diff --git a/projects/core/src/lib/utils/pipe.ts b/projects/core/src/lib/utils/pipe.ts index 3415c7593..4938101b0 100644 --- a/projects/core/src/lib/utils/pipe.ts +++ b/projects/core/src/lib/utils/pipe.ts @@ -1,16 +1,19 @@ import {MaskPostprocessor, MaskPreprocessor} from '../types'; -export function maskitoPipe(...processors: readonly MaskPreprocessor[]): MaskPreprocessor; +export function maskitoPipe( + ...processors: ReadonlyArray +): MaskPreprocessor; export function maskitoPipe( - ...processors: readonly MaskPostprocessor[] + ...processors: ReadonlyArray ): MaskPostprocessor; -// eslint-disable-next-line @typescript-eslint/ban-types -export function maskitoPipe(...processors: readonly Function[]): Function { +/* eslint-disable @typescript-eslint/ban-types */ +export function maskitoPipe( + ...processors: ReadonlyArray +): Function { return (initialData: object, ...readonlyArgs: unknown[]) => - processors.reduce( - (data, fn) => ({...data, ...fn(data, ...readonlyArgs)}), - initialData, - ); + processors + .filter((x: T | null | undefined): x is T => Boolean(x)) + .reduce((data, fn) => ({...data, ...fn(data, ...readonlyArgs)}), initialData); } diff --git a/projects/demo-integrations/cypress/tests/recipes/placeholder/date.cy.ts b/projects/demo-integrations/cypress/tests/recipes/placeholder/date.cy.ts new file mode 100644 index 000000000..f424beb2d --- /dev/null +++ b/projects/demo-integrations/cypress/tests/recipes/placeholder/date.cy.ts @@ -0,0 +1,64 @@ +import {DemoPath} from '@demo/constants'; + +describe('Placeholder | Date', () => { + beforeEach(() => { + cy.visit(DemoPath.Placeholder); + cy.get('#date input') + .should('be.visible') + .first() + .should('have.value', '') + .focus() + .should('have.value', 'dd/mm/yyyy') + .should('have.prop', 'selectionStart', 0) + .should('have.prop', 'selectionEnd', 0) + .as('input'); + }); + + describe('basic typing (1 character per keydown)', () => { + const tests = [ + // [Typed value, Masked value, caretIndex] + ['1', '1d/mm/yyyy', 1], + ['16', '16/mm/yyyy', '16'.length], + ['160', '16/0m/yyyy', '16/0'.length], + ['1605', '16/05/yyyy', '16/05'.length], + ['16052', '16/05/2yyy', '16/05/2'.length], + ['160520', '16/05/20yy', '16/05/20'.length], + ['1605202', '16/05/202y', '16/05/202'.length], + ['16052023', '16/05/2023', '16/05/2023'.length], + ] as const; + + tests.forEach(([typed, masked, caretIndex]) => { + it(`Type ${typed} => ${masked}`, () => { + cy.get('@input') + .type(typed) + .should('have.value', masked) + .should('have.prop', 'selectionStart', caretIndex) + .should('have.prop', 'selectionEnd', caretIndex); + }); + }); + }); + + it('Type 999 => 09/09/9yyy', () => { + cy.get('@input') + .type('999') + .should('have.value', '09/09/9yyy') + .should('have.prop', 'selectionStart', '09/09/9'.length) + .should('have.prop', 'selectionEnd', '09/09/9'.length); + }); + + it('Type 39 => 3d/mm/yyyy', () => { + cy.get('@input') + .type('39') + .should('have.value', '3d/mm/yyyy') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + + it('Type 31/13 => 31/1m/yyyy', () => { + cy.get('@input') + .type('3113') + .should('have.value', '31/1m/yyyy') + .should('have.prop', 'selectionStart', '31/1'.length) + .should('have.prop', 'selectionEnd', '31/1'.length); + }); +}); diff --git a/projects/demo-integrations/cypress/tests/recipes/placeholder/us-phone.cy.ts b/projects/demo-integrations/cypress/tests/recipes/placeholder/us-phone.cy.ts new file mode 100644 index 000000000..8153c6f7b --- /dev/null +++ b/projects/demo-integrations/cypress/tests/recipes/placeholder/us-phone.cy.ts @@ -0,0 +1,62 @@ +import {DemoPath} from '@demo/constants'; + +describe('Placeholder | US phone', () => { + beforeEach(() => { + cy.visit(DemoPath.Placeholder); + cy.get('#phone input') + .should('be.visible') + .first() + .should('have.value', '') + .focus() + .should('have.value', '+1 (   ) ___-____') + .should('have.prop', 'selectionStart', '+1 ('.length) + .should('have.prop', 'selectionEnd', '+1 ('.length) + .as('input'); + }); + + describe('basic typing (1 character per keydown)', () => { + const tests = [ + // [Typed value, Masked value, caretIndex] + ['2', '+1 (2  ) ___-____', '+1 (2'.length], + ['21', '+1 (21 ) ___-____', '+1 (21'.length], + ['212', '+1 (212) ___-____', '+1 (212'.length], + ['2125', '+1 (212) 5__-____', '+1 (212) 5'.length], + ['21255', '+1 (212) 55_-____', '+1 (212) 55'.length], + ['212555', '+1 (212) 555-____', '+1 (212) 555'.length], + ['2125552', '+1 (212) 555-2___', '+1 (212) 555-2'.length], + ['21255523', '+1 (212) 555-23__', '+1 (212) 555-23'.length], + ['212555236', '+1 (212) 555-236_', '+1 (212) 555-236'.length], + ['2125552368', '+1 (212) 555-2368', '+1 (212) 555-2368'.length], + ] as const; + + tests.forEach(([typed, masked, caretIndex]) => { + it(`Type ${typed} => ${masked}`, () => { + cy.get('@input') + .type(typed) + .should('have.value', masked) + .should('have.prop', 'selectionStart', caretIndex) + .should('have.prop', 'selectionEnd', caretIndex); + }); + }); + }); + + it('Can type 1 after country code +1', () => { + cy.get('@input') + .type('1') + .should('have.value', '+1 (1  ) ___-____') + .should('have.prop', 'selectionStart', '+1 (1'.length) + .should('have.prop', 'selectionEnd', '+1 (1'.length); + }); + + it('cannot erase country code +1', () => { + cy.get('@input') + .type('{backspace}'.repeat(10)) + .should('have.value', '+1 (   ) ___-____') + .type('{selectAll}{backspace}') + .should('have.value', '+1 (   ) ___-____') + .type('{selectAll}{del}') + .should('have.value', '+1 (   ) ___-____') + .should('have.prop', 'selectionStart', '+1'.length) + .should('have.prop', 'selectionEnd', '+1'.length); + }); +}); diff --git "a/projects/demo-integrations/cypress/tests/recipes/placeholder/\321\201vc-code.cy.ts" "b/projects/demo-integrations/cypress/tests/recipes/placeholder/\321\201vc-code.cy.ts" new file mode 100644 index 000000000..3cdc8747d --- /dev/null +++ "b/projects/demo-integrations/cypress/tests/recipes/placeholder/\321\201vc-code.cy.ts" @@ -0,0 +1,87 @@ +import {DemoPath} from '@demo/constants'; + +describe('Placeholder | CVC code', () => { + beforeEach(() => { + cy.visit(DemoPath.Placeholder); + cy.get('#cvc input') + .should('be.visible') + .first() + .focus() + .should('have.value', 'xxx') + .should('have.prop', 'selectionStart', 0) + .should('have.prop', 'selectionEnd', 0) + .as('input'); + }); + + it('Type 1 => 1|xx', () => { + cy.get('@input') + .type('1') + .should('have.value', '1xx') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + + it('Type 12 => 12|x', () => { + cy.get('@input') + .type('12') + .should('have.value', '12x') + .should('have.prop', 'selectionStart', 2) + .should('have.prop', 'selectionEnd', 2); + }); + + it('Type 123 => 123|', () => { + cy.get('@input') + .type('123') + .should('have.value', '123') + .should('have.prop', 'selectionStart', 3) + .should('have.prop', 'selectionEnd', 3); + }); + + it('12|3 => Backspace => 1|3x', () => { + cy.get('@input') + .type('123') + .type('{leftArrow}{backspace}') + .should('have.value', '13x') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + + it('1|3x => Type 0 => 10|3', () => { + cy.get('@input') + .type('13') + .type('{leftArrow}0') + .should('have.value', '103') + .should('have.prop', 'selectionStart', 2) + .should('have.prop', 'selectionEnd', 2); + }); + + it('1xx => select all => backspace => xxx', () => { + cy.get('@input') + .type('1') + .type('{selectAll}{backspace}') + .should('have.value', 'xxx') + .should('have.prop', 'selectionStart', 0) + .should('have.prop', 'selectionEnd', 0); + }); + + it('1xx => select all => delete => xxx', () => { + cy.get('@input') + .type('1') + .type('{selectAll}{del}') + .should('have.value', 'xxx') + .should('have.prop', 'selectionStart', 0) + .should('have.prop', 'selectionEnd', 0); + }); + + it('12x| => Backspace => 12|x', () => { + cy.get('@input') + .type('12') + .type('{rightArrow}') + .should('have.prop', 'selectionStart', 3) + .should('have.prop', 'selectionEnd', 3) + .type('{backspace}') + .should('have.value', '12x') + .should('have.prop', 'selectionStart', 2) + .should('have.prop', 'selectionEnd', 2); + }); +}); diff --git a/projects/demo/src/app/app.providers.ts b/projects/demo/src/app/app.providers.ts index 29b4d021a..95a9dec63 100644 --- a/projects/demo/src/app/app.providers.ts +++ b/projects/demo/src/app/app.providers.ts @@ -74,9 +74,12 @@ export const APP_PROVIDERS: Provider[] = [ }, tuiDocExampleOptionsProvider({ codeEditorVisibilityHandler: files => { - const primaryTabs: string[] = Object.values(DocExamplePrimaryTab); + const fileNames = Object.keys(files); - return Object.keys(files).every(fileName => primaryTabs.includes(fileName)); + return ( + fileNames.includes(DocExamplePrimaryTab.MaskitoOptions) && + fileNames.includes(DocExamplePrimaryTab.JavaScript) + ); }, tabTitles: new Map([ [DocExamplePrimaryTab.JavaScript, JAVASCRIPT_LOGO], diff --git a/projects/demo/src/app/app.routes.ts b/projects/demo/src/app/app.routes.ts index 3dbd7e464..776cc360d 100644 --- a/projects/demo/src/app/app.routes.ts +++ b/projects/demo/src/app/app.routes.ts @@ -204,6 +204,16 @@ export const appRoutes: Routes = [ title: `With postfix`, }, }, + { + path: DemoPath.Placeholder, + loadChildren: async () => + import(`../pages/recipes/placeholder/placeholder-doc.module`).then( + m => m.PlaceholderDocModule, + ), + data: { + title: `With placeholder`, + }, + }, // Other { path: DemoPath.BrowserSupport, diff --git a/projects/demo/src/app/constants/demo-path.ts b/projects/demo/src/app/constants/demo-path.ts index c4c07e6a5..2ee65cdd2 100644 --- a/projects/demo/src/app/constants/demo-path.ts +++ b/projects/demo/src/app/constants/demo-path.ts @@ -16,9 +16,10 @@ export const enum DemoPath { DateTime = 'kit/date-time', Card = 'recipes/card', Phone = 'recipes/phone', + Textarea = 'recipes/textarea', Prefix = 'recipes/prefix', Postfix = 'recipes/postfix', - Textarea = 'recipes/textarea', + Placeholder = 'recipes/placeholder', BrowserSupport = 'browser-support', Changelog = 'changelog', Stackblitz = 'stackblitz', diff --git a/projects/demo/src/pages/kit/number/examples/3-postfix/component.ts b/projects/demo/src/pages/kit/number/examples/3-postfix/component.ts index f367bbff1..59abf5589 100644 --- a/projects/demo/src/pages/kit/number/examples/3-postfix/component.ts +++ b/projects/demo/src/pages/kit/number/examples/3-postfix/component.ts @@ -1,10 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - NgZone, - ViewChild, -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; import mask from './mask'; @@ -37,8 +31,6 @@ export class NumberMaskDocExample3 { value = `97${this.postfix}`; maskitoOptions = mask; - constructor(private readonly ngZone: NgZone) {} - onFocus(): void { if (!this.value) { this.value = this.postfix; @@ -46,14 +38,9 @@ export class NumberMaskDocExample3 { const newCaretIndex = this.value.length - this.postfix.length; - this.ngZone.runOutsideAngular(() => { - setTimeout(() => { - // To put cursor before postfix - this.inputRef.nativeElement.setSelectionRange( - newCaretIndex, - newCaretIndex, - ); - }); + setTimeout(() => { + // To put cursor before postfix + this.inputRef.nativeElement.setSelectionRange(newCaretIndex, newCaretIndex); }); } diff --git a/projects/demo/src/pages/kit/number/number-mask-doc.component.ts b/projects/demo/src/pages/kit/number/number-mask-doc.component.ts index 98deb9177..d6ca8ff53 100644 --- a/projects/demo/src/pages/kit/number/number-mask-doc.component.ts +++ b/projects/demo/src/pages/kit/number/number-mask-doc.component.ts @@ -1,10 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - NgZone, - ViewChild, -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; import {FormControl} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoOptions} from '@maskito/core'; @@ -56,7 +50,9 @@ export class NumberMaskDocComponent implements GeneratorOptions { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/5-dynamic-decimal-zero-padding/mask.ts?raw' ), - Component: import('./examples/5-dynamic-decimal-zero-padding/component.ts?raw'), + [DocExamplePrimaryTab.Angular]: import( + './examples/5-dynamic-decimal-zero-padding/component.ts?raw' + ), }; apiPageControl = new FormControl(''); @@ -76,8 +72,6 @@ export class NumberMaskDocComponent implements GeneratorOptions { prefix = ''; postfix = ''; - constructor(private readonly ngZone: NgZone) {} - updateOptions(): void { this.maskitoOptions = maskitoNumberOptionsGenerator(this); } @@ -93,14 +87,12 @@ export class NumberMaskDocComponent implements GeneratorOptions { if (this.postfix) { const newCaretIndex = value.length - this.postfix.length; - this.ngZone.runOutsideAngular(() => { - setTimeout(() => { - // To put cursor before postfix - this.apiPageInput.nativeElement.setSelectionRange( - newCaretIndex, - newCaretIndex, - ); - }); + setTimeout(() => { + // To put cursor before postfix + this.apiPageInput.nativeElement.setSelectionRange( + newCaretIndex, + newCaretIndex, + ); }); } } diff --git a/projects/demo/src/pages/pages.ts b/projects/demo/src/pages/pages.ts index 43cb19e39..63fa54d92 100644 --- a/projects/demo/src/pages/pages.ts +++ b/projects/demo/src/pages/pages.ts @@ -123,6 +123,12 @@ export const DEMO_PAGES: TuiDocPages = [ route: DemoPath.Postfix, keywords: `postfix, after, percent, am, pm, recipe`, }, + { + section: 'Recipes', + title: 'With placeholder', + route: DemoPath.Placeholder, + keywords: `guide, placeholder, fill, recipe`, + }, { section: 'Other', title: 'Browser support', diff --git a/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/component.ts b/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/component.ts new file mode 100644 index 000000000..866a5f12f --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/component.ts @@ -0,0 +1,42 @@ +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; + +import mask from './mask'; + +@Component({ + selector: 'placeholder-doc-example-1', + template: ` + + Enter CVC code + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaceholderDocExample1 { + @ViewChild('inputRef', {read: ElementRef}) + inputRef!: ElementRef; + + readonly maskitoOptions = mask; + value = 'xxx'; + + onFocus(): void { + const beforePlaceholder = this.value.indexOf('x'); + + setTimeout(() => { + this.inputRef.nativeElement.setSelectionRange( + beforePlaceholder, + beforePlaceholder, + ); + }); + } +} diff --git a/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/mask.ts b/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/mask.ts new file mode 100644 index 000000000..cf38e3a22 --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/mask.ts @@ -0,0 +1,7 @@ +import {MaskitoOptions} from '@maskito/core'; +import {maskitoWithPlaceholder} from '@maskito/kit'; + +export default { + ...maskitoWithPlaceholder('xxx'), + mask: /^\d{0,3}$/, +} as MaskitoOptions; diff --git a/projects/demo/src/pages/recipes/placeholder/examples/2-phone/component.ts b/projects/demo/src/pages/recipes/placeholder/examples/2-phone/component.ts new file mode 100644 index 000000000..fd96609ad --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/2-phone/component.ts @@ -0,0 +1,59 @@ +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; + +import mask, {PLACEHOLDER, removePlaceholder} from './mask'; + +@Component({ + selector: 'placeholder-doc-example-2', + template: ` + + Enter US phone number + + + + Flag of the United States + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaceholderDocExample2 { + @ViewChild('inputRef', {read: ElementRef}) + inputRef!: ElementRef; + + readonly maskitoOptions = mask; + value = ''; + + onBlur(): void { + const cleanValue = removePlaceholder(this.value); + + this.value = cleanValue === '+1' ? '' : cleanValue; + } + + onFocus(): void { + const initialValue = this.value || '+1 ('; + + this.value = initialValue + PLACEHOLDER.slice(initialValue.length); + + setTimeout(() => { + this.inputRef.nativeElement.setSelectionRange( + initialValue.length, + initialValue.length, + ); + }); + } +} diff --git a/projects/demo/src/pages/recipes/placeholder/examples/2-phone/mask.ts b/projects/demo/src/pages/recipes/placeholder/examples/2-phone/mask.ts new file mode 100644 index 000000000..6d5b8ba67 --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/2-phone/mask.ts @@ -0,0 +1,49 @@ +import {MaskitoOptions, maskitoPipe} from '@maskito/core'; +import {maskitoPrefixPostprocessorGenerator, maskitoWithPlaceholder} from '@maskito/kit'; + +/** + * It is better to use en quad for placeholder characters + * instead of simple space. + * @see https://symbl.cc/en/2000 + */ +export const PLACEHOLDER = '+  (   ) ___-____'; +export const { + /** + * Use this utility to remove placeholder characters + * ___ + * @example + * inputRef.addEventListener('blur', () => { + * // removePlaceholder('+1 (212) 555-____') => '+1 (212) 555' + * inputRef.value = removePlaceholder(inputRef.value); + * }); + */ + removePlaceholder, + ...placeholderOptions +} = maskitoWithPlaceholder(PLACEHOLDER); + +export default { + preprocessor: placeholderOptions.preprocessor, + postprocessor: maskitoPipe( + maskitoPrefixPostprocessorGenerator('+1'), + placeholderOptions.postprocessor, + ), + mask: [ + '+', + '1', + ' ', + '(', + /\d/, + /\d/, + /\d/, + ')', + ' ', + /\d/, + /\d/, + /\d/, + '-', + /\d/, + /\d/, + /\d/, + /\d/, + ], +} as MaskitoOptions; diff --git a/projects/demo/src/pages/recipes/placeholder/examples/3-date/component.ts b/projects/demo/src/pages/recipes/placeholder/examples/3-date/component.ts new file mode 100644 index 000000000..392e49cc3 --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/3-date/component.ts @@ -0,0 +1,49 @@ +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; + +import mask, {PLACEHOLDER, removePlaceholder} from './mask'; + +@Component({ + selector: 'placeholder-doc-example-3', + template: ` + + Enter date + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaceholderDocExample3 { + @ViewChild('inputRef', {read: ElementRef}) + inputRef!: ElementRef; + + readonly maskitoOptions = mask; + value = ''; + + onBlur(): void { + this.value = removePlaceholder(this.value); + } + + onFocus(): void { + const initialValue = this.value; + + this.value = initialValue + PLACEHOLDER.slice(this.value.length); + + setTimeout(() => { + this.inputRef.nativeElement.setSelectionRange( + initialValue.length, + initialValue.length, + ); + }); + } +} diff --git a/projects/demo/src/pages/recipes/placeholder/examples/3-date/mask.ts b/projects/demo/src/pages/recipes/placeholder/examples/3-date/mask.ts new file mode 100644 index 000000000..17fd3388f --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/examples/3-date/mask.ts @@ -0,0 +1,29 @@ +import {MaskitoOptions, maskitoPipe} from '@maskito/core'; +import {maskitoDateOptionsGenerator, maskitoWithPlaceholder} from '@maskito/kit'; + +export const PLACEHOLDER = 'dd/mm/yyyy'; + +const dateOptions = maskitoDateOptionsGenerator({ + mode: 'dd/mm/yyyy', + separator: '/', +}); + +export const { + // Use this utility to remove placeholder characters + removePlaceholder, // removePlaceholder('31/12/yyyy') => '31/12' + ...placeholderOptions +} = maskitoWithPlaceholder(PLACEHOLDER); + +export default { + ...dateOptions, + preprocessor: maskitoPipe( + // Always put it BEFORE all other preprocessors + placeholderOptions.preprocessor, + dateOptions.preprocessor, + ), + postprocessor: maskitoPipe( + dateOptions.postprocessor, + // Always put it AFTER all other postprocessors + placeholderOptions.postprocessor, + ), +} as Required; diff --git a/projects/demo/src/pages/recipes/placeholder/placeholder-doc.component.ts b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.component.ts new file mode 100644 index 000000000..c99082ebb --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.component.ts @@ -0,0 +1,30 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; +import {TuiDocExample} from '@taiga-ui/addon-doc'; + +@Component({ + selector: 'placeholder-doc', + templateUrl: './placeholder-doc.template.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaceholderDocComponent { + readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; + readonly processorsDocPage = `/${DemoPath.Processors}`; + readonly prefixDocPage = `/${DemoPath.Prefix}`; + + readonly cvcExample1: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import( + './examples/1-cvc-code/mask.ts?raw' + ), + }; + + readonly phoneExample2: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/2-phone/mask.ts?raw'), + [DocExamplePrimaryTab.Angular]: import('./examples/2-phone/component.ts?raw'), + }; + + readonly dateExample3: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-date/mask.ts?raw'), + [DocExamplePrimaryTab.Angular]: import('./examples/3-date/component.ts?raw'), + }; +} diff --git a/projects/demo/src/pages/recipes/placeholder/placeholder-doc.module.ts b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.module.ts new file mode 100644 index 000000000..3a7034998 --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.module.ts @@ -0,0 +1,39 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; +import {MaskitoModule} from '@maskito/angular'; +import {TuiAddonDocModule, tuiGenerateRoutes} from '@taiga-ui/addon-doc'; +import { + TuiFlagPipeModule, + TuiLinkModule, + TuiTextfieldControllerModule, +} from '@taiga-ui/core'; +import {TuiInputModule} from '@taiga-ui/kit'; + +import {PlaceholderDocExample1} from './examples/1-cvc-code/component'; +import {PlaceholderDocExample2} from './examples/2-phone/component'; +import {PlaceholderDocExample3} from './examples/3-date/component'; +import {PlaceholderDocComponent} from './placeholder-doc.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MaskitoModule, + TuiAddonDocModule, + TuiFlagPipeModule, + TuiInputModule, + TuiLinkModule, + TuiTextfieldControllerModule, + RouterModule.forChild(tuiGenerateRoutes(PlaceholderDocComponent)), + ], + declarations: [ + PlaceholderDocComponent, + PlaceholderDocExample1, + PlaceholderDocExample2, + PlaceholderDocExample3, + ], + exports: [PlaceholderDocComponent], +}) +export class PlaceholderDocModule {} diff --git a/projects/demo/src/pages/recipes/placeholder/placeholder-doc.template.html b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.template.html new file mode 100644 index 000000000..2efb1274b --- /dev/null +++ b/projects/demo/src/pages/recipes/placeholder/placeholder-doc.template.html @@ -0,0 +1,92 @@ + +

+ maskitoWithPlaceholder + helps to show placeholder mask characters. The placeholder character + represents the fillable spot in the mask. +

+ + + +

+ This example is the simplest demonstration how to create masked + input with + placeholder + . +

+

+ The only required prerequisite is basic understanding of + + "Mask expression" + + concept. +

+
+ +
+ + + +

+ The following example explains return type of + maskitoWithPlaceholder + utility — an object which partially implements + MaskitoOptions + interface. It contains its own + + processor and postprocessor + + . +

+

+

+ Also, this complex example uses built-in postprocessor + + maskitoPrefixPostprocessorGenerator + + from + @maskito/kit + . +

+
+ +
+ + + + This last example demonstrates how to integrate + maskitoWithPlaceholder + with any built-in mask from + @maskito/kit + . + + + +
diff --git a/projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/component.ts b/projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/component.ts index b3facffb9..ac71fdfac 100644 --- a/projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/component.ts +++ b/projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/component.ts @@ -1,10 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - NgZone, - ViewChild, -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; import mask from './mask'; @@ -35,18 +29,14 @@ export class PostfixDocExample2 { readonly maskitoOptions = mask; value = ''; - constructor(private readonly ngZone: NgZone) {} - onFocus(): void { if (!this.value) { this.value = '$.00'; - this.ngZone.runOutsideAngular(() => - setTimeout(() => { - // To put cursor after dollar ($|.00) - this.inputElement.nativeElement.setSelectionRange(1, 1); - }), - ); + setTimeout(() => { + // To put cursor after dollar ($|.00) + this.inputElement.nativeElement.setSelectionRange(1, 1); + }); } } diff --git a/projects/kit/src/index.ts b/projects/kit/src/index.ts index a3086aa9c..91da5377c 100644 --- a/projects/kit/src/index.ts +++ b/projects/kit/src/index.ts @@ -6,6 +6,7 @@ export {maskitoTimeOptionsGenerator} from './lib/masks/time'; export { maskitoPostfixPostprocessorGenerator, maskitoPrefixPostprocessorGenerator, + maskitoWithPlaceholder, } from './lib/processors'; export { MaskitoDateMode, diff --git a/projects/kit/src/lib/processors/index.ts b/projects/kit/src/lib/processors/index.ts index 14d67e0cf..c0916c57c 100644 --- a/projects/kit/src/lib/processors/index.ts +++ b/projects/kit/src/lib/processors/index.ts @@ -2,4 +2,5 @@ export {createMinMaxDatePostprocessor} from './min-max-date-postprocessor'; export {maskitoPostfixPostprocessorGenerator} from './postfix-postprocessor'; export {maskitoPrefixPostprocessorGenerator} from './prefix-postprocessor'; export {createValidDatePreprocessor} from './valid-date-preprocessor'; +export {maskitoWithPlaceholder} from './with-placeholder'; export {createZeroPlaceholdersPreprocessor} from './zero-placeholders-preprocessor'; diff --git a/projects/kit/src/lib/processors/tests/with-placeholder.spec.ts b/projects/kit/src/lib/processors/tests/with-placeholder.spec.ts new file mode 100644 index 000000000..0d9f95205 --- /dev/null +++ b/projects/kit/src/lib/processors/tests/with-placeholder.spec.ts @@ -0,0 +1,60 @@ +import {maskitoWithPlaceholder} from '@maskito/kit'; + +describe('maskitoWithPlaceholder("dd/mm/yyyy")', () => { + const {preprocessor, postprocessor} = maskitoWithPlaceholder('dd/mm/yyyy'); + + describe('preprocessor', () => { + const check = (valueBefore: string, valueAfter: string): void => { + const {elementState} = preprocessor( + { + elementState: { + value: valueBefore, + selection: [0, 0] as const, + }, + data: '', + }, + 'insert', + ); + + expect(elementState.value).toBe(valueAfter); + }; + + it('Empty', () => check('', '')); + it('2/mm/yyyy => 2', () => check('2d/mm/yyyy', '2')); + it('26/mm/yyyy => 26', () => check('26/mm/yyyy', '26')); + it('26/0m/yyyy => 26/0', () => check('26/0m/yyyy', '26/0')); + it('26/01/yyyy => 26/01', () => check('26/01/yyyy', '26/01')); + it('26/01/1yyy => 26/01/1', () => check('26/01/1yyy', '26/01/1')); + it('26/01/19yy => 26/01/19', () => check('26/01/19yy', '26/01/19')); + it('26/01/199y => 26/01/199', () => check('26/01/199y', '26/01/199')); + it('26/01/1997 => 26/01/1997', () => check('26/01/1997', '26/01/1997')); + }); + + describe('postprocessor', () => { + const check = (valueBefore: string, valueAfter: string): void => { + const INITIAL_ELEMENT_STATE = { + value: 'dd/mm/yyyy', + selection: [0, 0] as const, + }; + const {value} = postprocessor( + { + value: valueBefore, + selection: [0, 0] as const, + }, + INITIAL_ELEMENT_STATE, + ); + + expect(value).toBe(valueAfter); + }; + + it('Empty', () => check('', 'dd/mm/yyyy')); + it('1 => 1d/mm/yyyy', () => check('1', '1d/mm/yyyy')); + it('16 => 16/mm/yyyy', () => check('16', '16/mm/yyyy')); + it('16/0 => 16/0m/yyyy', () => check('16/0', '16/0m/yyyy')); + it('16/05 => 16/05/yyyy', () => check('16/05', '16/05/yyyy')); + it('16/05/2 => 16/05/2yyy', () => check('16/05/2', '16/05/2yyy')); + it('16/05/20 => 16/05/20yy', () => check('16/05/20', '16/05/20yy')); + it('16/05/202 => 16/05/202y', () => check('16/05/202', '16/05/202y')); + it('16/05/2023 => 16/05/2023', () => check('16/05/2023', '16/05/2023')); + }); +}); diff --git a/projects/kit/src/lib/processors/with-placeholder.ts b/projects/kit/src/lib/processors/with-placeholder.ts new file mode 100644 index 000000000..26f2a7050 --- /dev/null +++ b/projects/kit/src/lib/processors/with-placeholder.ts @@ -0,0 +1,40 @@ +import {MaskitoOptions} from '@maskito/core'; + +export function maskitoWithPlaceholder(placeholder: string): Pick< + Required, + 'postprocessor' | 'preprocessor' +> & { + removePlaceholder: (value: string) => string; +} { + const removePlaceholder = (value: string): string => { + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] !== placeholder[i]) { + return value.slice(0, i + 1); + } + } + + return ''; + }; + + return { + preprocessor: ({elementState, data}) => { + const {value, selection} = elementState; + + return { + elementState: { + selection, + value: removePlaceholder(value), + }, + data, + }; + }, + postprocessor: ({value, selection}, initialElementState) => + initialElementState.value + ? { + value: value + placeholder.slice(value.length), + selection, + } + : {value, selection}, + removePlaceholder, + }; +}