From af961b84f8765e7d2147c80210e3a8ac6ed30597 Mon Sep 17 00:00:00 2001 From: Stanislav Zaycev <102649815+KrollikRoddzer@users.noreply.github.com> Date: Mon, 20 May 2024 11:36:34 +0300 Subject: [PATCH] feat(kit): `Time` & `DateTime` support increment / decrement of time segment via `ArrowUp` / `ArrowDown` (#1223) --- .../kit/date-time/date-time-time-step.cy.ts | 150 ++++++++++ .../src/tests/kit/time/time-step.cy.ts | 258 ++++++++++++++++++ .../date-time/date-time-mask-doc.component.ts | 9 + .../date-time-mask-doc.template.html | 42 +++ .../examples/4-time-step/component.ts | 39 +++ .../date-time/examples/4-time-step/mask.ts | 7 + .../kit/time/examples/3-step/component.ts | 38 +++ .../pages/kit/time/examples/3-step/mask.ts | 6 + .../pages/kit/time/time-mask-doc.component.ts | 8 + .../kit/time/time-mask-doc.template.html | 41 +++ .../src/lib/masks/date-time/date-time-mask.ts | 12 +- projects/kit/src/lib/masks/time/time-mask.ts | 10 + projects/kit/src/lib/plugins/index.ts | 1 + .../src/lib/plugins/time-segments-stepping.ts | 120 ++++++++ 14 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 projects/demo-integrations/src/tests/kit/date-time/date-time-time-step.cy.ts create mode 100644 projects/demo-integrations/src/tests/kit/time/time-step.cy.ts create mode 100644 projects/demo/src/pages/kit/date-time/examples/4-time-step/component.ts create mode 100644 projects/demo/src/pages/kit/date-time/examples/4-time-step/mask.ts create mode 100644 projects/demo/src/pages/kit/time/examples/3-step/component.ts create mode 100644 projects/demo/src/pages/kit/time/examples/3-step/mask.ts create mode 100644 projects/kit/src/lib/plugins/time-segments-stepping.ts diff --git a/projects/demo-integrations/src/tests/kit/date-time/date-time-time-step.cy.ts b/projects/demo-integrations/src/tests/kit/date-time/date-time-time-step.cy.ts new file mode 100644 index 000000000..6bff0b062 --- /dev/null +++ b/projects/demo-integrations/src/tests/kit/date-time/date-time-time-step.cy.ts @@ -0,0 +1,150 @@ +import {DemoPath} from '@demo/constants'; +import {BROWSER_SUPPORTS_REAL_EVENTS} from 'projects/demo-integrations/src/support/constants'; + +describe('DateTime | timeStep', () => { + describe('yy/mm;HH:MM:SS.MSS', () => { + describe('timeStep = 1, initial state = 22.12;', () => { + beforeEach(() => { + cy.visit( + `/${DemoPath.DateTime}/API?dateTimeSeparator=;&dateMode=yy%2Fmm&timeStep=1&timeMode=HH:MM:SS.MSS`, + ); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .as('input'); + + cy.get('@input') + .type('2212.') + .should('have.value', '22.12;') + .should('have.a.prop', 'selectionStart', '22.12;'.length) + .should('have.a.prop', 'selectionEnd', '22.12;'.length); + }); + + it('decrements hours segment by pressing ArrowDown at different places of the segment', () => { + cy.get('@input') + .type('{downArrow}') + .should('have.value', '22.12;23') + .should('have.a.prop', 'selectionStart', '22.12;'.length) + .should('have.a.prop', 'selectionEnd', '22.12;'.length) + .type('{rightArrow}{downArrow}') + .should('have.value', '22.12;22') + .should('have.a.prop', 'selectionStart', '22.12;2'.length) + .should('have.a.prop', 'selectionEnd', '22.12;2'.length) + .type('{rightArrow}') + .type('{upArrow}'.repeat(12)) + .should('have.value', '22.12;10') + .should('have.a.prop', 'selectionStart', '22.12;10'.length) + .should('have.a.prop', 'selectionEnd', '22.12;10'.length); + }); + + it('increments/decrements minutes segment by pressing keyboard arrows at different places of the segment', () => { + cy.get('@input') + .type('12:') + .should('have.value', '22.12;12:') + .should('have.a.prop', 'selectionStart', '22.12;12:'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:'.length) + .type('{upArrow}') + .should('have.value', '22.12;12:01') + .should('have.a.prop', 'selectionStart', '22.12;12:'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:'.length) + .type('{rightArrow}') + .type('{upArrow}'.repeat(9)) + .should('have.value', '22.12;12:10') + .should('have.a.prop', 'selectionStart', '22.12;12:1'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:1'.length) + .type('{rightArrow}') + .type('{downArrow}'.repeat(34)) + .should('have.value', '22.12;12:36') + .should('have.a.prop', 'selectionStart', '22.12;12:36'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:36'.length); + }); + + it('changes seconds segment by pressing keyboard arrow up/down', () => { + cy.get('@input') + .type('12:10:') + .should('have.value', '22.12;12:10:') + .should('have.a.prop', 'selectionStart', '22.12;12:10:'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:10:'.length) + .type('{downArrow}'.repeat(6)) + .should('have.value', '22.12;12:10:54') + .should('have.a.prop', 'selectionStart', '22.12;12:10:'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:10:'.length) + .type('{rightArrow}{upArrow}'.repeat(2)) + .should('have.value', '22.12;12:10:56') + .should('have.a.prop', 'selectionStart', '22.12;12:10:56'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:10:56'.length); + }); + + it('changes milliseconds segment by pressing keyboard arrow up/down', () => { + cy.get('@input') + .type('213212.') + .should('have.value', '22.12;21:32:12.') + .should('have.a.prop', 'selectionStart', '22.12;21:32:12.'.length) + .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.'.length) + .type('{upArrow}{rightArrow}'.repeat(3)) + .type('{downArrow}') + .should('have.value', '22.12;21:32:12.002') + .should('have.a.prop', 'selectionStart', '22.12;21:32:12.002'.length) + .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.002'.length); + }); + + it('type 213212. => 22.12;21:32:12.| => type ({downArrow}{rightArrow}) * 3 + {downArrow} => 22.12:21:32:12.995|', () => { + cy.get('@input') + .type('213212.') + .should('have.value', '22.12;21:32:12.') + .should('have.a.prop', 'selectionStart', '22.12;21:32:12.'.length) + .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.'.length) + .type('{downArrow}{rightArrow}'.repeat(3)) + .type('{downArrow}') + .should('have.value', '22.12;21:32:12.996') + .should('have.a.prop', 'selectionStart', '22.12;21:32:12.996'.length) + .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.996'.length); + }); + + it('should affect only time segments', () => { + cy.get('@input') + .type('123456111') + .should('have.value', '22.12;12:34:56.111') + .should('have.a.prop', 'selectionStart', '22.12;12:34:56.111'.length) + .should('have.a.prop', 'selectionEnd', '22.12;12:34:56.111'.length) + .type('{upArrow}{leftArrow}'.repeat('22.12;12:34:56.111'.length)) + .should('have.value', '22.12;15:37:59.115') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0); + }); + }); + + describe('timeStep = 0 (disabled time stepping)', () => { + beforeEach(() => { + cy.visit( + `/${DemoPath.DateTime}/API?dateTimeSeparator=;&dateMode=yy%2Fmm&timeStep=0&timeMode=HH:MM:SS.MSS`, + ); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .as('input'); + + cy.get('@input') + .type('1202123456000') + .should('have.value', '12.02;12:34:56.000') + .should('have.a.prop', 'selectionStart', '12.02;12:34:56.000'.length) + .should('have.a.prop', 'selectionEnd', '12.02;12:34:56.000'.length); + }); + + it('should be disabled', BROWSER_SUPPORTS_REAL_EVENTS, () => { + cy.get('@input').realPress('ArrowUp'); + + cy.get('@input') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0) + .realPress('ArrowDown'); + + cy.get('@input') + .should('have.a.prop', 'selectionStart', '12.02;12:34:56.000'.length) + .should('have.a.prop', 'selectionEnd', '12.02;12:34:56.000'.length); + }); + }); + }); +}); diff --git a/projects/demo-integrations/src/tests/kit/time/time-step.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-step.cy.ts new file mode 100644 index 000000000..c2d236f1d --- /dev/null +++ b/projects/demo-integrations/src/tests/kit/time/time-step.cy.ts @@ -0,0 +1,258 @@ +import {DemoPath} from '@demo/constants'; +import {BROWSER_SUPPORTS_REAL_EVENTS} from 'projects/demo-integrations/src/support/constants'; + +describe('Time', () => { + describe('Mode', () => { + describe('HH:MM', () => { + describe('step = 1', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&step=1`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .clear() + .as('input'); + }); + + it('empty field => {upArrow} => 01', () => { + cy.get('@input').type('{upArrow}').should('have.value', '01'); + }); + + it('type 2 + type {upArrow} => 21', () => { + cy.get('@input').type('2{upArrow}').should('have.value', '21'); + }); + + it('{downArrow} => |23 => 23| => type {upArrow} => 23:01', () => { + cy.get('@input') + .type('{downArrow}') + .should('have.value', '23') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0) + .type('{rightArrow}'.repeat(2)) + .type(':{upArrow}') + .should('have.value', '23:01'); + }); + + it('{downArrow} => |23 => 23| => type :{downArrow} => 23:59', () => { + cy.get('@input') + .type('{downArrow}') + .should('have.value', '23') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0) + .type('{rightArrow}'.repeat(2)) + .type(':{downArrow}') + .should('have.value', '23:59'); + }); + + it('{upArrow}*24 => 00', () => { + cy.get('@input') + .type('{upArrow}'.repeat(24)) + .should('have.value', '00'); + }); + + it('type {upArrow}*12 => |12 => 1|2 => type {upArrow} => 1|3 => 13| => type {downArrow} => 12|', () => { + cy.get('@input') + .type('{upArrow}'.repeat(12)) + .should('have.value', '12') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0) + .type('{rightArrow}') + .should('have.a.prop', 'selectionStart', 1) + .should('have.a.prop', 'selectionStart', 1) + .type('{upArrow}') + .should('have.value', '13') + .should('have.a.prop', 'selectionStart', 1) + .should('have.a.prop', 'selectionStart', 1) + .type('{rightArrow}') + .should('have.a.prop', 'selectionStart', 2) + .should('have.a.prop', 'selectionStart', 2) + .type('{downArrow}') + .should('have.value', '12') + .should('have.a.prop', 'selectionStart', 2) + .should('have.a.prop', 'selectionStart', 2); + }); + + it('type 12:{upArrow}{rightArrow}{downArrow}{rightArrow}{downArrow} => 12:59', () => { + cy.get('@input') + .type('12:{upArrow}') + .type('{rightArrow}{downArrow}'.repeat(2)) + .should('have.value', '12:59'); + }); + }); + + describe('step = 0', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&step=0`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .clear() + .as('input'); + }); + + it('should be disabled', BROWSER_SUPPORTS_REAL_EVENTS, () => { + cy.get('@input').type('1212').realPress('ArrowUp'); + + cy.get('@input') + .should('have.a.prop', 'selectionStart', 0) + .should('have.a.prop', 'selectionEnd', 0) + .realPress('ArrowDown'); + + cy.get('@input') + .should('have.a.prop', 'selectionStart', '12:12'.length) + .should('have.a.prop', 'selectionEnd', '12:12'.length); + }); + }); + }); + + describe('HH:MM:SS.MSS', () => { + describe('step = 1', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS.MSS&step=1`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .clear() + .as('input'); + }); + + it('correctly works for hour segments', () => { + cy.get('@input') + .type('{upArrow}') + .should('have.value', '01') + .type('{rightArrow}{upArrow}') + .should('have.value', '02') + .should('have.a.prop', 'selectionStart', 1) + .should('have.a.prop', 'selectionEnd', 1) + .type('{rightArrow}') + .type('{downArrow}'.repeat(3)) + .should('have.value', '23') + .should('have.a.prop', 'selectionStart', 2) + .should('have.a.prop', 'selectionEnd', 2); + }); + + it('correctly works for minute segments', () => { + cy.get('@input') + .type('12:') + .should('have.value', '12:') + .should('have.a.prop', 'selectionStart', '12:'.length) + .should('have.a.prop', 'selectionEnd', '12:'.length) + .type('{upArrow}') + .should('have.a.prop', 'selectionStart', '12:'.length) + .should('have.a.prop', 'selectionEnd', '12:'.length) + .should('have.value', '12:01') + .type('{rightArrow}') + .type('{downArrow}'.repeat(2)) + .should('have.a.prop', 'selectionStart', '12:5'.length) + .should('have.a.prop', 'selectionEnd', '12:5'.length) + .should('have.value', '12:59') + .type('{rightArrow}') + .type('{downArrow}'.repeat(4)) + .should('have.a.prop', 'selectionStart', '12:55'.length) + .should('have.a.prop', 'selectionEnd', '12:55'.length) + .should('have.value', '12:55'); + }); + + it('correctly works for second segments', () => { + cy.get('@input') + .type('1234:') + .should('have.value', '12:34:') + .should('have.a.prop', 'selectionStart', '12:34:'.length) + .should('have.a.prop', 'selectionEnd', '12:34:'.length) + .type('{upArrow}'.repeat(5)) + .should('have.a.prop', 'selectionStart', '12:34:'.length) + .should('have.a.prop', 'selectionEnd', '12:34:'.length) + .should('have.value', '12:34:05') + .type('{rightArrow}') + .type('{downArrow}'.repeat(8)) + .should('have.a.prop', 'selectionStart', '12:34:5'.length) + .should('have.a.prop', 'selectionEnd', '12:34:5'.length) + .should('have.value', '12:34:57') + .type('{rightArrow}') + .type('{upArrow}'.repeat(3)) + .should('have.a.prop', 'selectionStart', '12:34:00'.length) + .should('have.a.prop', 'selectionEnd', '12:34:00'.length) + .should('have.value', '12:34:00'); + }); + + it('correctly works for millisecond segments', () => { + cy.get('@input') + .type('123456.') + .should('have.value', '12:34:56.') + .should('have.a.prop', 'selectionStart', '12:34:56.'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.'.length) + .type('{upArrow}'.repeat(23)) + .should('have.value', '12:34:56.023') + .should('have.a.prop', 'selectionStart', '12:34:56.'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.'.length) + .type('{rightArrow}{downArrow}') + .should('have.value', '12:34:56.022') + .should('have.a.prop', 'selectionStart', '12:34:56.0'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.0'.length) + .type('{rightArrow}') + .type('{upArrow}'.repeat(3)) + .should('have.value', '12:34:56.025') + .should('have.a.prop', 'selectionStart', '12:34:56.02'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.02'.length) + .type('{rightArrow}') + .type('{downArrow}'.repeat(29)) + .should('have.value', '12:34:56.996') + .should('have.a.prop', 'selectionStart', '12:34:56.996'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.996'.length); + }); + }); + + describe('step = 10', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS.MSS&step=10`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .clear() + .as('input'); + }); + + it('correctly works for millisecond segments', () => { + cy.get('@input') + .type('123456.') + .should('have.value', '12:34:56.') + .type('{upArrow}'.repeat(20)) + .should('have.value', '12:34:56.200') + .type('{downArrow}'.repeat(30)) + .should('have.value', '12:34:56.900'); + }); + + it('correctly works for each time segment', () => { + cy.get('@input') + .type('123456000') + .should('have.value', '12:34:56.000') + .should('have.a.prop', 'selectionStart', '12:34:56.000'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.000'.length) + .type('{downArrow}') + .should('have.value', '12:34:56.990') + .should('have.a.prop', 'selectionStart', '12:34:56.990'.length) + .should('have.a.prop', 'selectionEnd', '12:34:56.990'.length) + .type('{leftArrow}'.repeat(4)) + .type('{downArrow}') + .should('have.value', '12:34:46.990') + .should('have.a.prop', 'selectionStart', '12:34:46'.length) + .should('have.a.prop', 'selectionEnd', '12:34:46'.length) + .type('{leftArrow}'.repeat(3)) + .type('{upArrow}') + .should('have.value', '12:44:46.990') + .should('have.a.prop', 'selectionStart', '12:44'.length) + .should('have.a.prop', 'selectionEnd', '12:44'.length) + .type('{leftArrow}'.repeat(3)) + .type('{upArrow}') + .should('have.value', '22:44:46.990') + .should('have.a.prop', 'selectionStart', '22'.length) + .should('have.a.prop', 'selectionEnd', '22'.length); + }); + }); + }); + }); +}); diff --git a/projects/demo/src/pages/kit/date-time/date-time-mask-doc.component.ts b/projects/demo/src/pages/kit/date-time/date-time-mask-doc.component.ts index ead832f3a..8c7c0cfe3 100644 --- a/projects/demo/src/pages/kit/date-time/date-time-mask-doc.component.ts +++ b/projects/demo/src/pages/kit/date-time/date-time-mask-doc.component.ts @@ -14,6 +14,7 @@ import {DATE_TIME_SEPARATOR, TuiInputModule} from '@taiga-ui/kit'; import {DateTimeMaskDocExample1} from './examples/1-date-time-localization/component'; import {DateTimeMaskDocExample2} from './examples/2-date-time-separator/component'; import {DateTimeMaskDocExample3} from './examples/3-min-max/component'; +import {DateTimeMaskDocExample4} from './examples/4-time-step/component'; type GeneratorOptions = Required< NonNullable[0]> @@ -32,6 +33,7 @@ type GeneratorOptions = Required< DateTimeMaskDocExample1, DateTimeMaskDocExample2, DateTimeMaskDocExample3, + DateTimeMaskDocExample4, ], templateUrl: './date-time-mask-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -53,6 +55,12 @@ export class DateTimeMaskDocComponent implements GeneratorOptions { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-min-max/mask.ts?raw'), }; + protected readonly dateTimeTimeStepExample: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import( + './examples/4-time-step/mask.ts?raw' + ), + }; + protected apiPageControl = new FormControl(''); protected readonly dateModeOptions: MaskitoDateMode[] = [ @@ -83,6 +91,7 @@ export class DateTimeMaskDocComponent implements GeneratorOptions { public dateSeparator = '.'; public min = new Date(this.minStr); public max = new Date(this.maxStr); + public timeStep = 0; protected maskitoOptions: MaskitoOptions = maskitoDateTimeOptionsGenerator(this); diff --git a/projects/demo/src/pages/kit/date-time/date-time-mask-doc.template.html b/projects/demo/src/pages/kit/date-time/date-time-mask-doc.template.html index bb3b68c01..b317d2387 100644 --- a/projects/demo/src/pages/kit/date-time/date-time-mask-doc.template.html +++ b/projects/demo/src/pages/kit/date-time/date-time-mask-doc.template.html @@ -63,6 +63,32 @@ + + + +

+ Property + timeStep + allows you to increment / decrement time segments by pressing + ArrowUp + / + ArrowDown + . +

+ +

+ Use + step === 0 + (default value) to disable this feature. +

+
+ +
@@ -137,6 +163,22 @@

+ + The value by which the keyboard arrows increment/decrement time segments + +

+ Default: + 0 + (disable stepping) +

+
+ + Time Stepping + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DateTimeMaskDocExample4 { + protected value = '09.01.2018, 15:30'; + protected readonly filler = 'dd.mm.yyyy, hh:mm'; + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/kit/date-time/examples/4-time-step/mask.ts b/projects/demo/src/pages/kit/date-time/examples/4-time-step/mask.ts new file mode 100644 index 000000000..ffa092fe3 --- /dev/null +++ b/projects/demo/src/pages/kit/date-time/examples/4-time-step/mask.ts @@ -0,0 +1,7 @@ +import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoDateTimeOptionsGenerator({ + dateMode: 'dd/mm/yyyy', + timeMode: 'HH:MM', + timeStep: 1, +}); diff --git a/projects/demo/src/pages/kit/time/examples/3-step/component.ts b/projects/demo/src/pages/kit/time/examples/3-step/component.ts new file mode 100644 index 000000000..3883654f1 --- /dev/null +++ b/projects/demo/src/pages/kit/time/examples/3-step/component.ts @@ -0,0 +1,38 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MaskitoDirective} from '@maskito/angular'; +import {TuiTextfieldControllerModule} from '@taiga-ui/core'; +import {TuiInputModule} from '@taiga-ui/kit'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'time-mask-doc-example-3', + imports: [ + TuiInputModule, + TuiTextfieldControllerModule, + FormsModule, + MaskitoDirective, + ], + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TimeMaskDocExample3 { + protected value = '11:59:59'; + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/kit/time/examples/3-step/mask.ts b/projects/demo/src/pages/kit/time/examples/3-step/mask.ts new file mode 100644 index 000000000..6e2c445ef --- /dev/null +++ b/projects/demo/src/pages/kit/time/examples/3-step/mask.ts @@ -0,0 +1,6 @@ +import {maskitoTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoTimeOptionsGenerator({ + mode: 'HH:MM:SS', + step: 1, +}); diff --git a/projects/demo/src/pages/kit/time/time-mask-doc.component.ts b/projects/demo/src/pages/kit/time/time-mask-doc.component.ts index 994666a61..65a148b43 100644 --- a/projects/demo/src/pages/kit/time/time-mask-doc.component.ts +++ b/projects/demo/src/pages/kit/time/time-mask-doc.component.ts @@ -12,6 +12,7 @@ import {TuiInputModule} from '@taiga-ui/kit'; import {TimeMaskDocExample1} from './examples/1-modes/component'; import {TimeMaskDocExample2} from './examples/2-twelve-hour-format/component'; +import {TimeMaskDocExample3} from './examples/3-step/component'; type GeneratorOptions = Required[0]>; @@ -26,6 +27,7 @@ type GeneratorOptions = Required[ TuiTextfieldControllerModule, TimeMaskDocExample1, TimeMaskDocExample2, + TimeMaskDocExample3, ], templateUrl: './time-mask-doc.template.html', styleUrls: ['./time-mask-doc.style.less'], @@ -42,6 +44,10 @@ export class TimeMaskDocComponent implements GeneratorOptions { ), }; + protected readonly stepExample3: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-step/mask.ts?raw'), + }; + protected apiPageControl = new FormControl(''); protected readonly modeOptions: MaskitoTimeMode[] = [ @@ -61,6 +67,8 @@ export class TimeMaskDocComponent implements GeneratorOptions { public timeSegmentMaxValues: Partial> = this.timeSegmentMaxValuesOptions[0]; + public step = 0; + protected maskitoOptions: MaskitoOptions = maskitoTimeOptionsGenerator(this); protected updateOptions(): void { diff --git a/projects/demo/src/pages/kit/time/time-mask-doc.template.html b/projects/demo/src/pages/kit/time/time-mask-doc.template.html index 8443f0477..d74b36e0e 100644 --- a/projects/demo/src/pages/kit/time/time-mask-doc.template.html +++ b/projects/demo/src/pages/kit/time/time-mask-doc.template.html @@ -55,6 +55,32 @@ + + + +

+ Property + step + allows you to increment/decrement time segments by pressing + ArrowUp + / + ArrowDown + . +

+ +

+ Use + step === 0 + (default value) to disable this feature. +

+
+ +
@@ -96,6 +122,21 @@ > Max value for every time segment + + The value by which the keyboard arrows increment/decrement time segments + +

+ Default: + 0 + (disable stepping) +

+
diff --git a/projects/kit/src/lib/masks/date-time/date-time-mask.ts b/projects/kit/src/lib/masks/date-time/date-time-mask.ts index a30500f19..88d9cff0c 100644 --- a/projects/kit/src/lib/masks/date-time/date-time-mask.ts +++ b/projects/kit/src/lib/masks/date-time/date-time-mask.ts @@ -1,7 +1,8 @@ import type {MaskitoOptions} from '@maskito/core'; import {MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; -import {TIME_FIXED_CHARACTERS} from '../../constants'; +import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; +import {createTimeSegmentsSteppingPlugin} from '../../plugins'; import { createColonConvertPreprocessor, createDateSegmentsZeroPaddingPostprocessor, @@ -23,6 +24,7 @@ export function maskitoDateTimeOptionsGenerator({ min, max, dateTimeSeparator = DATE_TIME_SEPARATOR, + timeStep = 0, }: { dateMode: MaskitoDateMode; timeMode: MaskitoTimeMode; @@ -30,6 +32,7 @@ export function maskitoDateTimeOptionsGenerator({ max?: Date; min?: Date; dateTimeSeparator?: string; + timeStep?: number; }): Required { const dateModeTemplate = dateMode.split('/').join(dateSeparator); @@ -91,5 +94,12 @@ export function maskitoDateTimeOptionsGenerator({ dateTimeSeparator, }), ], + plugins: [ + createTimeSegmentsSteppingPlugin({ + step: timeStep, + fullMode: `${dateModeTemplate}${dateTimeSeparator}${timeMode}`, + timeSegmentMaxValues: DEFAULT_TIME_SEGMENT_MAX_VALUES, + }), + ], }; } diff --git a/projects/kit/src/lib/masks/time/time-mask.ts b/projects/kit/src/lib/masks/time/time-mask.ts index d017f19cf..c212232b1 100644 --- a/projects/kit/src/lib/masks/time/time-mask.ts +++ b/projects/kit/src/lib/masks/time/time-mask.ts @@ -2,6 +2,7 @@ import type {MaskitoOptions} from '@maskito/core'; import {MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; +import {createTimeSegmentsSteppingPlugin} from '../../plugins'; import { createColonConvertPreprocessor, createFullWidthToHalfWidthPreprocessor, @@ -13,9 +14,11 @@ import {createMaxValidationPreprocessor} from './processors'; export function maskitoTimeOptionsGenerator({ mode, timeSegmentMaxValues = {}, + step = 0, }: { mode: MaskitoTimeMode; timeSegmentMaxValues?: Partial>; + step?: number; }): Required { const enrichedTimeSegmentMaxValues = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, @@ -33,6 +36,13 @@ export function maskitoTimeOptionsGenerator({ createZeroPlaceholdersPreprocessor(), createMaxValidationPreprocessor(enrichedTimeSegmentMaxValues, mode), ], + plugins: [ + createTimeSegmentsSteppingPlugin({ + fullMode: mode, + step, + timeSegmentMaxValues: enrichedTimeSegmentMaxValues, + }), + ], overwriteMode: 'replace', }; } diff --git a/projects/kit/src/lib/plugins/index.ts b/projects/kit/src/lib/plugins/index.ts index cbc8b4325..9c65bcdbd 100644 --- a/projects/kit/src/lib/plugins/index.ts +++ b/projects/kit/src/lib/plugins/index.ts @@ -3,3 +3,4 @@ export {maskitoCaretGuard} from './caret-guard'; export {maskitoEventHandler} from './event-handler'; export {maskitoRejectEvent} from './reject-event'; export {maskitoRemoveOnBlurPlugin} from './remove-on-blur'; +export {createTimeSegmentsSteppingPlugin} from './time-segments-stepping'; diff --git a/projects/kit/src/lib/plugins/time-segments-stepping.ts b/projects/kit/src/lib/plugins/time-segments-stepping.ts new file mode 100644 index 000000000..85ed6f422 --- /dev/null +++ b/projects/kit/src/lib/plugins/time-segments-stepping.ts @@ -0,0 +1,120 @@ +import type {MaskitoPlugin} from '@maskito/core'; +import {maskitoUpdateElement} from '@maskito/core'; + +import type {MaskitoTimeSegments} from '../types'; + +const noop = (): void => {}; + +export function createTimeSegmentsSteppingPlugin({ + step, + fullMode, + timeSegmentMaxValues, +}: { + step: number; + fullMode: string; + timeSegmentMaxValues: MaskitoTimeSegments; +}): MaskitoPlugin { + const segmentsIndexes = createTimeSegmentsIndexes(fullMode); + + return step <= 0 + ? noop + : element => { + const listener = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { + return; + } + + event.preventDefault(); + const selectionStart = element.selectionStart || 0; + const activeSegment = getActiveSegment({ + segmentsIndexes, + selectionStart, + }); + + if (!activeSegment) { + return; + } + + const updatedValue = updateSegmentValue({ + selection: segmentsIndexes.get(activeSegment)!, + value: element.value, + toAdd: event.key === 'ArrowUp' ? step : -step, + max: timeSegmentMaxValues[activeSegment], + }); + + maskitoUpdateElement(element, { + value: updatedValue, + selection: [selectionStart, selectionStart], + }); + }; + + element.addEventListener('keydown', listener); + + return () => element.removeEventListener('keydown', listener); + }; +} + +function createTimeSegmentsIndexes( + fullMode: string, +): Map { + return new Map([ + ['hours', getSegmentRange(fullMode, 'HH')], + ['minutes', getSegmentRange(fullMode, 'MM')], + ['seconds', getSegmentRange(fullMode, 'SS')], + ['milliseconds', getSegmentRange(fullMode, 'MSS')], + ]); +} + +function getSegmentRange(mode: string, segment: string): [number, number] { + const index = mode.indexOf(segment); + + return index === -1 ? [-1, -1] : [index, index + segment.length]; +} + +function getActiveSegment({ + segmentsIndexes, + selectionStart, +}: { + segmentsIndexes: Map; + selectionStart: number; +}): keyof MaskitoTimeSegments | null { + for (const [segmentName, segmentRange] of segmentsIndexes.entries()) { + const [from, to] = segmentRange; + + if (from <= selectionStart && selectionStart <= to) { + return segmentName; + } + } + + return null; +} + +function updateSegmentValue({ + selection, + value, + toAdd, + max, +}: { + selection: readonly [number, number]; + value: string; + toAdd: number; + max: number; +}): string { + const [from, to] = selection; + const segmentValue = Number(value.slice(from, to).padEnd(to - from, '0')); + const newSegmentValue = mod(segmentValue + toAdd, max + 1); + + return ( + value.slice(0, from) + + String(newSegmentValue).padStart(to - from, '0') + + value.slice(to, value.length) + ); +} + +function mod(value: number, max: number): number { + if (value < 0) { + value += Math.floor(Math.abs(value) / max + 1) * max; + } + + return value % max; +}