From bc330990810ba0065df5432847be9798a68f80fc Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Wed, 25 Sep 2024 12:46:26 +0300 Subject: [PATCH 1/5] feat(kit): `Time` & `DateTime` support `AM` / `PM` formats --- .eslintrc.js | 1 + .../kit/date-time/date-time-meridiem.cy.ts | 415 ++++++++++++++++++ .../kit/date-time/date-time-separator.cy.ts | 4 +- .../src/tests/kit/time/time-meridiem.cy.ts | 375 ++++++++++++++++ .../src/tests/kit/time/time-mode.cy.ts | 2 +- .../kit/time/time-segment-max-values.cy.ts | 8 +- projects/demo-integrations/src/tests/utils.ts | 7 + .../supported-input-types.component.ts | 1 - .../date-time/date-time-mask-doc.component.ts | 9 + .../date-time-mask-doc.template.html | 16 + .../date-time/examples/5-am-pm/component.ts | 38 ++ .../kit/date-time/examples/5-am-pm/mask.ts | 7 + .../kit/time/examples/1-modes/component.ts | 2 +- .../component.ts | 6 +- .../pages/kit/time/examples/2-am-pm/mask.ts | 18 + .../examples/2-twelve-hour-format/mask.ts | 23 - .../pages/kit/time/examples/3-step/mask.ts | 20 +- .../4-time-segments-min-max/component.ts | 39 ++ .../examples/4-time-segments-min-max/mask.ts | 7 + .../pages/kit/time/time-mask-doc.component.ts | 39 +- .../kit/time/time-mask-doc.template.html | 105 +++-- ...lues.ts => default-time-segment-bounds.ts} | 7 + projects/kit/src/lib/constants/index.ts | 3 +- projects/kit/src/lib/constants/meridiem.ts | 4 + .../src/lib/masks/date-time/date-time-mask.ts | 37 +- .../min-max-date-time-postprocessor.ts | 13 +- .../valid-date-time-preprocessor.ts | 19 +- projects/kit/src/lib/masks/time/index.ts | 2 +- projects/kit/src/lib/masks/time/time-mask.ts | 35 +- .../time/{time-options.ts => time-params.ts} | 1 + .../src/lib/masks/time/utils/parse-time.ts | 2 +- .../lib/masks/time/utils/stringify-time.ts | 2 +- projects/kit/src/lib/plugins/index.ts | 3 +- .../src/lib/plugins/time/meridiem-stepping.ts | 47 ++ .../{ => time}/time-segments-stepping.ts | 2 +- .../first-date-end-separator-preprocessor.ts | 19 +- projects/kit/src/lib/processors/index.ts | 4 + ...lid-time-segment-insertion-preprocessor.ts | 19 +- .../src/lib/processors/meridiem-processors.ts | 85 ++++ .../zero-placeholders-preprocessor.ts | 4 + projects/kit/src/lib/types/time-mode.ts | 4 + .../utils/time/create-time-mask-expression.ts | 8 + .../time/enrich-time-segments-with-zeroes.ts | 6 +- projects/kit/src/lib/utils/time/index.ts | 1 + .../src/lib/utils/time/parse-time-string.ts | 16 +- 45 files changed, 1348 insertions(+), 137 deletions(-) create mode 100644 projects/demo-integrations/src/tests/kit/date-time/date-time-meridiem.cy.ts create mode 100644 projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts create mode 100644 projects/demo-integrations/src/tests/utils.ts create mode 100644 projects/demo/src/pages/kit/date-time/examples/5-am-pm/component.ts create mode 100644 projects/demo/src/pages/kit/date-time/examples/5-am-pm/mask.ts rename projects/demo/src/pages/kit/time/examples/{2-twelve-hour-format => 2-am-pm}/component.ts (88%) create mode 100644 projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts delete mode 100644 projects/demo/src/pages/kit/time/examples/2-twelve-hour-format/mask.ts create mode 100644 projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts create mode 100644 projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/mask.ts rename projects/kit/src/lib/constants/{default-time-segment-max-values.ts => default-time-segment-bounds.ts} (57%) create mode 100644 projects/kit/src/lib/constants/meridiem.ts rename projects/kit/src/lib/masks/time/{time-options.ts => time-params.ts} (77%) create mode 100644 projects/kit/src/lib/plugins/time/meridiem-stepping.ts rename projects/kit/src/lib/plugins/{ => time}/time-segments-stepping.ts (98%) create mode 100644 projects/kit/src/lib/processors/meridiem-processors.ts create mode 100644 projects/kit/src/lib/utils/time/create-time-mask-expression.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3deb202e2..93b36d412 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { rules: { 'react/display-name': 'off', 'react/react-in-jsx-scope': 'off', + 'no-irregular-whitespace': 'off', }, }, ], diff --git a/projects/demo-integrations/src/tests/kit/date-time/date-time-meridiem.cy.ts b/projects/demo-integrations/src/tests/kit/date-time/date-time-meridiem.cy.ts new file mode 100644 index 000000000..0ac586a80 --- /dev/null +++ b/projects/demo-integrations/src/tests/kit/date-time/date-time-meridiem.cy.ts @@ -0,0 +1,415 @@ +import {DemoPath} from '@demo/constants'; + +import {range, withCaretLabel} from '../../utils'; + +describe('DateTime | time modes with meridiem', () => { + describe('HH:MM AA', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.DateTime}/API?timeMode=HH:MM%20AA`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .as('textfield'); + }); + + describe('basic text insertion works', () => { + it('Empty textfield => Type 1234AM => 12:34 AM', () => { + cy.get('@textfield') + .type('9920001234AM') + .should('have.value', '09.09.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '09.09.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '09.09.2000, 12:34 AM'.length); + }); + + it('12:34| => Type lowercase `a` => 12:34 AM', () => { + cy.get('@textfield') + .type('01.01.20001234a') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + + it('12:34| => Type uppercase `A` => 12:34 AM', () => { + cy.get('@textfield') + .type('01.01.20001234A') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + + it('12:34| => Type lowercase `p` => 12:34 AM', () => { + cy.get('@textfield') + .type('01.01.20001234p') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34| => Type uppercase `P` => 12:34 AM', () => { + cy.get('@textfield') + .type('01.01.20001234P') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34| => Type lowercase `m` => 12:34', () => { + cy.get('@textfield') + .type('01.01.20001234m') + .should('have.value', '01.01.2000, 12:34 ') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 '.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 '.length); + }); + + it('12:34| => Type uppercase `M` => 12:34', () => { + cy.get('@textfield') + .type('01.01.20001234M') + .should('have.value', '01.01.2000, 12:34 ') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 '.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 '.length); + }); + }); + + describe('deletion of any meridiem characters deletes all meridiem character', () => { + [ + {caretIndex: '01.01.2000, 12:34 AM'.length, action: '{backspace}'}, + {caretIndex: '01.01.2000, 12:34 A'.length, action: '{backspace}'}, + {caretIndex: '01.01.2000, 12:34 '.length, action: '{del}'}, + {caretIndex: '01.01.2000, 12:34 A'.length, action: '{del}'}, + ].forEach(({caretIndex, action}) => { + const initialValue = '01.01.2000, 12:34 AM'; + + it(`${withCaretLabel(initialValue, caretIndex)} => ${action} => 12:34|`, () => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', initialValue) + .type('{moveToStart}') + .type('{rightArrow}'.repeat(caretIndex)) + .type(action) + .should('have.value', '01.01.2000, 12:34') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34'.length); + }); + }); + }); + + describe('type new meridiem value when textfield already has another one', () => { + it('12:34 AM| => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', '01.01.2000, 12:34 AM') + .type('{moveToEnd}') + .type('p') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34 A|M => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', '01.01.2000, 12:34 AM') + .type('{moveToEnd}{leftArrow}') + .type('p') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34 |AM => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', '01.01.2000, 12:34 AM') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(2)) + .type('p') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34| AM => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', '01.01.2000, 12:34 AM') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(' AM'.length)) + .type('p') + .should('have.value', '01.01.2000, 12:34 PM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); + }); + + it('12:34 PM| => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234p') + .should('have.value', '01.01.2000, 12:34 PM') + .type('{moveToEnd}') + .type('a') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + + it('12:34 P|M => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234p') + .should('have.value', '01.01.2000, 12:34 PM') + .type('{moveToEnd}{leftArrow}') + .type('A') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + + it('12:34 |PM => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234p') + .should('have.value', '01.01.2000, 12:34 PM') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(2)) + .type('a') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + + it('12:34| PM => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('01.01.2000 1234p') + .should('have.value', '01.01.2000, 12:34 PM') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(' PM'.length)) + .type('a') + .should('have.value', '01.01.2000, 12:34 AM') + .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) + .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); + }); + }); + + describe('hour segment bounds', () => { + const beforeTimeValue = '01.01.2000, '; + + beforeEach(() => { + cy.get('@textfield') + .type('01.01.2000 ') + .should('have.value', beforeTimeValue); + }); + + it('cannot be less than 01 (rejects zero as the 2nd hour segment)', () => { + cy.get('@textfield') + .type('00') + .should('have.value', `${beforeTimeValue}0`) + .should('have.prop', 'selectionStart', beforeTimeValue.length + 1) + .should('have.prop', 'selectionEnd', beforeTimeValue.length + 1); + }); + + it('can be 1 (as the 1st digit segment)', () => { + cy.get('@textfield') + .type('1') + .should('have.value', `${beforeTimeValue}1`) + .should('have.prop', 'selectionStart', beforeTimeValue.length + 1) + .should('have.prop', 'selectionEnd', beforeTimeValue.length + 1); + }); + + describe('automatically pads with zero', () => { + range(2, 9).forEach((x) => { + it(`on attempt to enter ${x} as the first hour segment`, () => { + cy.get('@textfield') + .type(String(x)) + .should('have.value', `${beforeTimeValue}0${x}`) + .should( + 'have.prop', + 'selectionStart', + beforeTimeValue.length + 2, + ) + .should( + 'have.prop', + 'selectionEnd', + beforeTimeValue.length + 2, + ); + }); + }); + }); + + range(10, 12).forEach((x) => { + const value = String(x); + + it(`can be ${x}`, () => { + cy.get('@textfield') + .type(value) + .should('have.value', beforeTimeValue + value) + .should('have.prop', 'selectionStart', beforeTimeValue.length + 2) + .should('have.prop', 'selectionEnd', beforeTimeValue.length + 2); + }); + }); + + describe('rejects insertion', () => { + range(13, 19).forEach((x) => { + it(`on attempt to enter ${x} as the last hour segment`, () => { + cy.get('@textfield') + .type(String(x)) + .should('have.value', `${beforeTimeValue}1`) + .should( + 'have.prop', + 'selectionStart', + beforeTimeValue.length + 1, + ) + .should( + 'have.prop', + 'selectionEnd', + beforeTimeValue.length + 1, + ); + }); + }); + }); + }); + + describe('toggle meridiem value on ArrowUp / ArrowDown', () => { + describe('Initial value === "12:34|"', () => { + const beforeMeridiemValue = '01.01.2000, 12:34'; + + beforeEach(() => { + cy.get('@textfield') + .type('01.01.2000 1234') + .should('have.value', beforeMeridiemValue); + }); + + it('↑ --- 12:34| AM', () => { + cy.get('@textfield') + .type('{upArrow}') + .should('have.value', `${beforeMeridiemValue} AM`) + .should('have.prop', 'selectionStart', beforeMeridiemValue.length) + .should('have.prop', 'selectionEnd', beforeMeridiemValue.length); + }); + + it('↓ --- 12:34| PM', () => { + cy.get('@textfield') + .type('{downArrow}') + .should('have.value', `${beforeMeridiemValue} PM`) + .should('have.prop', 'selectionStart', beforeMeridiemValue.length) + .should('have.prop', 'selectionEnd', beforeMeridiemValue.length); + }); + }); + + describe('Initial value === "12:34 AM"', () => { + const beforeTimeValue = '01.01.2000, '; + const initialValue = `${beforeTimeValue}12:34 AM`; + const toggledValue = `${beforeTimeValue}12:34 PM`; + + beforeEach(() => { + cy.get('@textfield') + .type('01.01.2000 1234a') + .should('have.value', initialValue) + .type('{moveToStart}'); + }); + + [ + `${beforeTimeValue}12:34`.length, + `${beforeTimeValue}12:34 `.length, + `${beforeTimeValue}12:34 A`.length, + `${beforeTimeValue}12:34 AM`.length, + ].forEach((initialCaretIndex) => { + const initialValueWithCaretLabel = withCaretLabel( + initialValue, + initialCaretIndex, + ); + const toggledValueWithCaretLabel = withCaretLabel( + toggledValue, + initialCaretIndex, + ); + + it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{upArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + + it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{downArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + }); + }); + + describe('Initial value === "01:01 PM"', () => { + const beforeTimeValue = '01.01.2000, '; + const initialValue = `${beforeTimeValue}01:01 PM`; + const toggledValue = `${beforeTimeValue}01:01 AM`; + + beforeEach(() => { + cy.get('@textfield') + .type('01.01.2000 0101p') + .should('have.value', initialValue) + .type('{moveToStart}'); + }); + + [ + `${beforeTimeValue}01:01`.length, + `${beforeTimeValue}01:01 `.length, + `${beforeTimeValue}01:01 P`.length, + `${beforeTimeValue}01:01 PM`.length, + ].forEach((initialCaretIndex) => { + const initialValueWithCaretLabel = withCaretLabel( + initialValue, + initialCaretIndex, + ); + const toggledValueWithCaretLabel = withCaretLabel( + toggledValue, + initialCaretIndex, + ); + + it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{upArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + + it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{downArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + }); + }); + + describe('do nothing for partially completed time string', () => { + it('Empty textfield --- ↑↓ --- Empty textfield', () => { + cy.get('@textfield') + .type('01.01.2000 ') + .should('have.value', '01.01.2000, ') + .type('{upArrow}') + .should('have.value', '01.01.2000, ') + .type('{downArrow}') + .should('have.value', '01.01.2000, '); + }); + + ['1', '12', '12:', '12:3'].forEach((textfieldValue) => { + it(`${textfieldValue} --- ↑↓ --- ${textfieldValue}`, () => { + cy.get('@textfield') + .type(`01.01.2000 ${textfieldValue}`) + .should('have.value', `01.01.2000, ${textfieldValue}`) + .type('{upArrow}') + .should('have.value', `01.01.2000, ${textfieldValue}`) + .type('{downArrow}') + .should('have.value', `01.01.2000, ${textfieldValue}`); + }); + }); + }); + }); + }); +}); diff --git a/projects/demo-integrations/src/tests/kit/date-time/date-time-separator.cy.ts b/projects/demo-integrations/src/tests/kit/date-time/date-time-separator.cy.ts index f025b8e3c..b79978364 100644 --- a/projects/demo-integrations/src/tests/kit/date-time/date-time-separator.cy.ts +++ b/projects/demo-integrations/src/tests/kit/date-time/date-time-separator.cy.ts @@ -18,7 +18,7 @@ describe('DateTime | Separator', () => { it('rejects dot as separator', () => { cy.get('@input') .type('1412.') - .should('have.value', '14/12') + .should('have.value', '14/12/') .type('2000') .should('have.value', '14/12/2000'); }); @@ -57,7 +57,7 @@ describe('DateTime | Separator', () => { .type('14') .should('have.value', '14') .type('12.') - .should('have.value', '14-12'); + .should('have.value', '14-12-'); }); }); }); diff --git a/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts new file mode 100644 index 000000000..962c0c539 --- /dev/null +++ b/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts @@ -0,0 +1,375 @@ +import {DemoPath} from '@demo/constants'; + +import {range, withCaretLabel} from '../../utils'; + +describe('Time | modes with meridiem', () => { + describe('HH:MM AA', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM%20AA`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .as('textfield'); + }); + + describe('basic text insertion works', () => { + it('Empty textfield => Type 1234AM => 12:34 AM', () => { + cy.get('@textfield') + .type('1234AM') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34| => Type lowercase `a` => 12:34 AM', () => { + cy.get('@textfield') + .type('1234a') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34| => Type uppercase `A` => 12:34 AM', () => { + cy.get('@textfield') + .type('1234A') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34| => Type lowercase `p` => 12:34 AM', () => { + cy.get('@textfield') + .type('1234p') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34| => Type uppercase `P` => 12:34 AM', () => { + cy.get('@textfield') + .type('1234P') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34| => Type lowercase `m` => 12:34', () => { + cy.get('@textfield') + .type('1234m') + .should('have.value', '12:34 ') + .should('have.prop', 'selectionStart', '12:34 '.length) + .should('have.prop', 'selectionEnd', '12:34 '.length); + }); + + it('12:34| => Type uppercase `M` => 12:34', () => { + cy.get('@textfield') + .type('1234M') + .should('have.value', '12:34 ') + .should('have.prop', 'selectionStart', '12:34 '.length) + .should('have.prop', 'selectionEnd', '12:34 '.length); + }); + }); + + describe('deletion of any meridiem characters deletes all meridiem character', () => { + [ + {caretIndex: '12:34 AM'.length, action: '{backspace}'}, + {caretIndex: '12:34 A'.length, action: '{backspace}'}, + {caretIndex: '12:34 '.length, action: '{del}'}, + {caretIndex: '12:34 A'.length, action: '{del}'}, + ].forEach(({caretIndex, action}) => { + const initialValue = '12:34 AM'; + + it(`${withCaretLabel(initialValue, caretIndex)} => ${action} => 12:34|`, () => { + cy.get('@textfield') + .type('1234a') + .type('{moveToStart}') + .type('{rightArrow}'.repeat(caretIndex)) + .type(action) + .should('have.value', '12:34') + .should('have.prop', 'selectionStart', '12:34'.length) + .should('have.prop', 'selectionEnd', '12:34'.length); + }); + }); + }); + + describe('type new meridiem value when textfield already has another one', () => { + it('12:34 AM| => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('1234a') + .type('{moveToEnd}') + .type('p') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34 A|M => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('1234a') + .type('{moveToEnd}{leftArrow}') + .type('p') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34 |AM => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('1234a') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(2)) + .type('p') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34| AM => Type P => 12:34 PM|', () => { + cy.get('@textfield') + .type('1234a') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(' AM'.length)) + .type('p') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34 PM'.length) + .should('have.prop', 'selectionEnd', '12:34 PM'.length); + }); + + it('12:34 PM| => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('1234p') + .type('{moveToEnd}') + .type('a') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34 P|M => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('1234p') + .type('{moveToEnd}{leftArrow}') + .type('A') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34 |PM => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('1234p') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(2)) + .type('a') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34| PM => Type A => 12:34 AM|', () => { + cy.get('@textfield') + .type('1234p') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(' PM'.length)) + .type('a') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + }); + + describe('hour segment bounds', () => { + it('cannot be less than 01 (rejects zero as the 2nd hour segment)', () => { + cy.get('@textfield') + .type('00') + .should('have.value', '0') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + + it('can be 1 (as the 1st digit segment)', () => { + cy.get('@textfield') + .type('1') + .should('have.value', '1') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + + describe('automatically pads with zero', () => { + range(2, 9).forEach((x) => { + it(`on attempt to enter ${x} as the first hour segment`, () => { + cy.get('@textfield') + .type(String(x)) + .should('have.value', `0${x}`) + .should('have.prop', 'selectionStart', 2) + .should('have.prop', 'selectionEnd', 2); + }); + }); + }); + + range(10, 12).forEach((x) => { + const value = String(x); + + it(`can be ${x}`, () => { + cy.get('@textfield') + .type(value) + .should('have.value', value) + .should('have.prop', 'selectionStart', 2) + .should('have.prop', 'selectionEnd', 2); + }); + }); + + describe('rejects insertion', () => { + range(13, 19).forEach((x) => { + it(`on attempt to enter ${x} as the last hour segment`, () => { + cy.get('@textfield') + .type(String(x)) + .should('have.value', '1') + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + }); + }); + }); + + describe('toggle meridiem value on ArrowUp / ArrowDown', () => { + describe('Initial value === "12:34|"', () => { + beforeEach(() => { + cy.get('@textfield').type('1234'); + }); + + it('↑ --- 12:34| AM', () => { + cy.get('@textfield') + .type('{upArrow}') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34'.length) + .should('have.prop', 'selectionEnd', '12:34'.length); + }); + + it('↓ --- 12:34| PM', () => { + cy.get('@textfield') + .type('{downArrow}') + .should('have.value', '12:34 PM') + .should('have.prop', 'selectionStart', '12:34'.length) + .should('have.prop', 'selectionEnd', '12:34'.length); + }); + }); + + describe('Initial value === "12:34 AM"', () => { + const initialValue = '12:34 AM'; + const toggledValue = '12:34 PM'; + + beforeEach(() => { + cy.get('@textfield') + .type('1234a') + .should('have.value', initialValue) + .type('{moveToStart}'); + }); + + [ + '12:34'.length, + '12:34 '.length, + '12:34 A'.length, + '12:34 AM'.length, + ].forEach((initialCaretIndex) => { + const initialValueWithCaretLabel = withCaretLabel( + initialValue, + initialCaretIndex, + ); + const toggledValueWithCaretLabel = withCaretLabel( + toggledValue, + initialCaretIndex, + ); + + it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{upArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + + it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{downArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + }); + }); + + describe('Initial value === "01:01 PM"', () => { + const initialValue = '01:01 PM'; + const toggledValue = '01:01 AM'; + + beforeEach(() => { + cy.get('@textfield') + .type('0101p') + .should('have.value', initialValue) + .type('{moveToStart}'); + }); + + [ + '01:01'.length, + '01:01 '.length, + '01:01 P'.length, + '01:01 PM'.length, + ].forEach((initialCaretIndex) => { + const initialValueWithCaretLabel = withCaretLabel( + initialValue, + initialCaretIndex, + ); + const toggledValueWithCaretLabel = withCaretLabel( + toggledValue, + initialCaretIndex, + ); + + it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{upArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + + it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { + cy.get('@textfield') + .type('{rightArrow}'.repeat(initialCaretIndex)) + .type('{downArrow}') + .should('have.value', toggledValue) + .should('have.prop', 'selectionStart', initialCaretIndex) + .should('have.prop', 'selectionEnd', initialCaretIndex); + }); + }); + }); + + describe('do nothing for partially completed time string', () => { + it('Empty textfield --- ↑↓ --- Empty textfield', () => { + cy.get('@textfield') + .should('have.value', '') + .type('{upArrow}') + .should('have.value', '') + .type('{downArrow}') + .should('have.value', ''); + }); + + ['1', '12', '12:', '12:3'].forEach((textfieldValue) => { + it(`${textfieldValue} --- ↑↓ --- ${textfieldValue}`, () => { + cy.get('@textfield') + .type(textfieldValue) + .should('have.value', textfieldValue) + .type('{upArrow}') + .should('have.value', textfieldValue) + .type('{downArrow}') + .should('have.value', textfieldValue); + }); + }); + }); + }); + }); +}); diff --git a/projects/demo-integrations/src/tests/kit/time/time-mode.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-mode.cy.ts index 5465474a5..a7dabab71 100644 --- a/projects/demo-integrations/src/tests/kit/time/time-mode.cy.ts +++ b/projects/demo-integrations/src/tests/kit/time/time-mode.cy.ts @@ -394,7 +394,7 @@ describe('Time', () => { describe('max hours 11', () => { beforeEach(() => { - cy.visit(`/${DemoPath.Time}/API?mode=HH&timeSegmentMaxValues$=1`); + cy.visit(`/${DemoPath.Time}/API?mode=HH&timeSegmentMaxValues$=2`); cy.get('#demo-content input') .should('be.visible') .first() diff --git a/projects/demo-integrations/src/tests/kit/time/time-segment-max-values.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-segment-max-values.cy.ts index 6f6cfdb83..15d0dc7c2 100644 --- a/projects/demo-integrations/src/tests/kit/time/time-segment-max-values.cy.ts +++ b/projects/demo-integrations/src/tests/kit/time/time-segment-max-values.cy.ts @@ -1,9 +1,11 @@ import {DemoPath} from '@demo/constants'; +import {range} from '../../utils'; + describe('Time | [timeSegmentMaxValues] property', () => { describe('{hours: 5, minutes: 5, seconds: 5, milliseconds: 5}', () => { beforeEach(() => { - cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&timeSegmentMaxValues$=2`); + cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&timeSegmentMaxValues$=3`); cy.get('#demo-content input') .should('be.visible') .first() @@ -152,7 +154,3 @@ describe('Time | [timeSegmentMaxValues] property', () => { }); }); }); - -function range(from: number, to: number): number[] { - return new Array(to - from + 1).fill(null).map((_, i) => from + i); -} diff --git a/projects/demo-integrations/src/tests/utils.ts b/projects/demo-integrations/src/tests/utils.ts new file mode 100644 index 000000000..bac768d70 --- /dev/null +++ b/projects/demo-integrations/src/tests/utils.ts @@ -0,0 +1,7 @@ +export function range(from: number, to: number): number[] { + return new Array(to - from + 1).fill(null).map((_, i) => from + i); +} + +export function withCaretLabel(value: string, caretIndex: number): string { + return `${value.slice(0, caretIndex)}|${value.slice(caretIndex)}`; +} diff --git a/projects/demo/src/pages/documentation/supported-input-types/supported-input-types.component.ts b/projects/demo/src/pages/documentation/supported-input-types/supported-input-types.component.ts index 559b655ec..576e83089 100644 --- a/projects/demo/src/pages/documentation/supported-input-types/supported-input-types.component.ts +++ b/projects/demo/src/pages/documentation/supported-input-types/supported-input-types.component.ts @@ -50,7 +50,6 @@ export default class SupportedInputTypesDocPageComponent { }; protected getInput(type: HTMLInputElement['type']): string { - // eslint-disable-next-line no-irregular-whitespace return ``; } } 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 9c7f93906..132d4f0a4 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 @@ -15,6 +15,7 @@ import {DateTimeMaskDocExample1} from './examples/1-date-time-localization/compo 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'; +import {DateTimeMaskDocExample5} from './examples/5-am-pm/component'; type GeneratorOptions = Required< NonNullable[0]> @@ -28,6 +29,7 @@ type GeneratorOptions = Required< DateTimeMaskDocExample2, DateTimeMaskDocExample3, DateTimeMaskDocExample4, + DateTimeMaskDocExample5, MaskitoDirective, ReactiveFormsModule, TuiAddonDoc, @@ -62,6 +64,10 @@ export default class DateTimeMaskDocComponent implements GeneratorOptions { ), }; + protected readonly amPmExample: Record = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/5-am-pm/mask.ts?raw'), + }; + protected apiPageControl = new FormControl(''); protected readonly dateModeOptions = [ @@ -72,8 +78,11 @@ export default class DateTimeMaskDocComponent implements GeneratorOptions { protected readonly timeModeOptions = [ 'HH:MM', + 'HH:MM AA', 'HH:MM:SS', + 'HH:MM:SS AA', 'HH:MM:SS.MSS', + 'HH:MM:SS.MSS AA', ] as const satisfies readonly MaskitoTimeMode[]; protected readonly minMaxOptions = [ 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 a3733a14a..75491f663 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 @@ -89,6 +89,22 @@ + + + + Any + timeMode + ending with + AA + is 12-hour time format with meridiem part. + + + diff --git a/projects/demo/src/pages/kit/date-time/examples/5-am-pm/component.ts b/projects/demo/src/pages/kit/date-time/examples/5-am-pm/component.ts new file mode 100644 index 000000000..1cb4ce33a --- /dev/null +++ b/projects/demo/src/pages/kit/date-time/examples/5-am-pm/component.ts @@ -0,0 +1,38 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MaskitoDirective} from '@maskito/angular'; +import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'date-time-mask-doc-example-5', + imports: [ + FormsModule, + MaskitoDirective, + TuiInputModule, + TuiTextfieldControllerModule, + ], + template: ` + + With 12-hour time format + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DateTimeMaskDocExample5 { + protected value = '20/09/2020, 03:30 PM'; + protected readonly filler = 'mm/dd/yyyy, hh:mm aa'; + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/kit/date-time/examples/5-am-pm/mask.ts b/projects/demo/src/pages/kit/date-time/examples/5-am-pm/mask.ts new file mode 100644 index 000000000..cdc9b7608 --- /dev/null +++ b/projects/demo/src/pages/kit/date-time/examples/5-am-pm/mask.ts @@ -0,0 +1,7 @@ +import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoDateTimeOptionsGenerator({ + dateMode: 'dd/mm/yyyy', + timeMode: 'HH:MM AA', + dateSeparator: '/', +}); diff --git a/projects/demo/src/pages/kit/time/examples/1-modes/component.ts b/projects/demo/src/pages/kit/time/examples/1-modes/component.ts index 25c6ceb52..1ea87557b 100644 --- a/projects/demo/src/pages/kit/time/examples/1-modes/component.ts +++ b/projects/demo/src/pages/kit/time/examples/1-modes/component.ts @@ -21,7 +21,7 @@ import mask from './mask'; [style.max-width.rem]="20" [(ngModel)]="value" > - HH:MM:SS + Enter 24-hour time format + Enter 12-hour time format { + if (element.value.length >= 'HH:MM'.length && !element.value.endsWith('M')) { + maskitoUpdateElement(element, `${element.value} AM`); + } + }), + ], +} satisfies MaskitoOptions; diff --git a/projects/demo/src/pages/kit/time/examples/2-twelve-hour-format/mask.ts b/projects/demo/src/pages/kit/time/examples/2-twelve-hour-format/mask.ts deleted file mode 100644 index 021d68b0f..000000000 --- a/projects/demo/src/pages/kit/time/examples/2-twelve-hour-format/mask.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {MaskitoOptions} from '@maskito/core'; -import {maskitoUpdateElement} from '@maskito/core'; -import {maskitoEventHandler, maskitoTimeOptionsGenerator} from '@maskito/kit'; - -const timeOptions = maskitoTimeOptionsGenerator({ - mode: 'HH:MM', - timeSegmentMaxValues: {hours: 12}, -}); - -export default { - ...timeOptions, - plugins: [ - ...timeOptions.plugins, - maskitoEventHandler('blur', (element) => { - const [hours = '', minutes = ''] = element.value.split(':'); - - maskitoUpdateElement( - element, - [hours, minutes].map((segment) => segment.padEnd(2, '0')).join(':'), - ); - }), - ], -} satisfies MaskitoOptions; 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 index 6e2c445ef..b9c8de0d8 100644 --- a/projects/demo/src/pages/kit/time/examples/3-step/mask.ts +++ b/projects/demo/src/pages/kit/time/examples/3-step/mask.ts @@ -1,6 +1,22 @@ -import {maskitoTimeOptionsGenerator} from '@maskito/kit'; +import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; +import {maskitoEventHandler, maskitoTimeOptionsGenerator} from '@maskito/kit'; -export default maskitoTimeOptionsGenerator({ +const timeOptions = maskitoTimeOptionsGenerator({ mode: 'HH:MM:SS', step: 1, }); + +export default { + ...timeOptions, + plugins: [ + ...timeOptions.plugins, + maskitoEventHandler('blur', (element) => { + const [hh = '', mm = '', ss = ''] = element.value.split(':'); + + maskitoUpdateElement( + element, + [hh, mm, ss].map((segment) => segment.padEnd(2, '0')).join(':'), + ); + }), + ], +} satisfies MaskitoOptions; diff --git a/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts new file mode 100644 index 000000000..55e87d2a5 --- /dev/null +++ b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts @@ -0,0 +1,39 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MaskitoDirective} from '@maskito/angular'; +import {TuiTextfield} from '@taiga-ui/core'; +import {TuiSegmented} from '@taiga-ui/kit'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'time-mask-doc-example-4', + imports: [FormsModule, MaskitoDirective, TuiSegmented, TuiTextfield], + template: ` + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TimeMaskDocExample4 { + protected value = '03:30'; + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/mask.ts b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/mask.ts new file mode 100644 index 000000000..cfd087eb0 --- /dev/null +++ b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/mask.ts @@ -0,0 +1,7 @@ +import {maskitoTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoTimeOptionsGenerator({ + mode: 'HH:MM', + timeSegmentMaxValues: {hours: 12}, + timeSegmentMinValues: {hours: 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 22b06be9a..d4e5c0800 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 @@ -1,18 +1,20 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; -import {DocExamplePrimaryTab} from '@demo/constants'; +import {RouterLink} from '@angular/router'; +import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import type {MaskitoTimeMode, MaskitoTimeSegments} from '@maskito/kit'; import {maskitoTimeOptionsGenerator} from '@maskito/kit'; import type {TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; -import {TuiNotification} from '@taiga-ui/core'; +import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {TimeMaskDocExample1} from './examples/1-modes/component'; -import {TimeMaskDocExample2} from './examples/2-twelve-hour-format/component'; +import {TimeMaskDocExample2} from './examples/2-am-pm/component'; import {TimeMaskDocExample3} from './examples/3-step/component'; +import {TimeMaskDocExample4} from './examples/4-time-segments-min-max/component'; type GeneratorOptions = Required[0]>; @@ -22,11 +24,14 @@ type GeneratorOptions = Required[ imports: [ MaskitoDirective, ReactiveFormsModule, + RouterLink, TimeMaskDocExample1, TimeMaskDocExample2, TimeMaskDocExample3, + TimeMaskDocExample4, TuiAddonDoc, TuiInputModule, + TuiLink, TuiNotification, TuiTextfieldControllerModule, ], @@ -35,6 +40,8 @@ type GeneratorOptions = Required[ changeDetection: ChangeDetectionStrategy.OnPush, }) export default class TimeMaskDocComponent implements GeneratorOptions { + protected pages = DemoPath; + protected readonly maskitoParseStringifyTimeDemo = import( './examples/maskito-parse-stringify-time-demo.md?raw' ); @@ -43,36 +50,50 @@ export default class TimeMaskDocComponent implements GeneratorOptions { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-modes/mask.ts?raw'), }; - protected readonly modeExample2: Record = { - [DocExamplePrimaryTab.MaskitoOptions]: import( - './examples/2-twelve-hour-format/mask.ts?raw' - ), + protected readonly amPmExample2: Record = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/2-am-pm/mask.ts?raw'), }; protected readonly stepExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-step/mask.ts?raw'), }; + protected readonly timeSegmentsMinMaxExample4: Record = { + [DocExamplePrimaryTab.MaskitoOptions]: import( + './examples/4-time-segments-min-max/mask.ts?raw' + ), + }; + protected apiPageControl = new FormControl(''); protected readonly modeOptions = [ 'HH:MM', + 'HH:MM AA', 'HH:MM:SS', + 'HH:MM:SS AA', 'HH:MM:SS.MSS', + 'HH:MM:SS.MSS AA', 'HH', + 'HH AA', 'MM:SS.MSS', 'SS.MSS', ] as const satisfies readonly MaskitoTimeMode[]; protected readonly timeSegmentMaxValuesOptions = [ + {}, {hours: 23, minutes: 59, seconds: 59, milliseconds: 999}, {hours: 11}, {hours: 5, minutes: 5, seconds: 5, milliseconds: 5}, ] as const satisfies ReadonlyArray>>; + protected readonly timeSegmentMinValuesOptions = [ + {}, + {hours: 1}, + ] as const satisfies ReadonlyArray>>; + public mode: MaskitoTimeMode = this.modeOptions[0]; - public timeSegmentMaxValues: Partial> = - this.timeSegmentMaxValuesOptions[0]; + public timeSegmentMinValues = this.timeSegmentMinValuesOptions[0]; + public timeSegmentMaxValues = this.timeSegmentMaxValuesOptions[0]; public step = 0; public maskitoOptions: MaskitoOptions = maskitoTimeOptionsGenerator(this); 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 59a0959b0..ba7b6547f 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 @@ -39,41 +39,31 @@

Use mode - property to set time format. -

-

- Available options - : - HH:MM - , - HH:MM:SS - or - HH:MM:SS.MSS - . + property to set time format. See the full list of available mode on + + API page + + of the documentation.

- -

- Property - timeSegmentMaxValues - allows you to set max value for every time segment. -

- -

- Time segments - are units of the time which form time string. For example, - HH:MM - consists of two time segments: hours and minutes. -

+ + Any + mode + ending with + AA + is 12-hour time format with meridiem part.
@@ -103,6 +93,31 @@ + + + +

+ Property + timeSegmentMinValues + / + timeSegmentMaxValues + allows you to set min/max value for every time segment. +

+ +

+ Time segments + are units of the time which form time string. For example, + HH:MM + consists of two time segments: hours and minutes. +

+
+ +
@@ -134,6 +149,27 @@ > Time format mode + + Minimum value for each time segment + +

+ Default: +
+ {hours: 0} +  /  + {hours: 1} + for + mode + without / with meridiem period +

+
- Max value for every time segment + Maximum value for each time segment + +

+ Default: +
+ {hours: 24} +  /  + {hours: 12} + for + mode + without / with meridiem period +

= { seconds: 59, milliseconds: 999, }; + +export const DEFAULT_TIME_SEGMENT_MIN_VALUES: MaskitoTimeSegments = { + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, +}; diff --git a/projects/kit/src/lib/constants/index.ts b/projects/kit/src/lib/constants/index.ts index 677346588..42f55a24d 100644 --- a/projects/kit/src/lib/constants/index.ts +++ b/projects/kit/src/lib/constants/index.ts @@ -1,7 +1,8 @@ export * from './date-segment-max-values'; export * from './default-decimal-pseudo-separators'; export * from './default-min-max-dates'; -export * from './default-time-segment-max-values'; +export * from './default-time-segment-bounds'; +export * from './meridiem'; export * from './time-fixed-characters'; export * from './time-segment-value-lengths'; export * from './unicode-characters'; diff --git a/projects/kit/src/lib/constants/meridiem.ts b/projects/kit/src/lib/constants/meridiem.ts new file mode 100644 index 000000000..9648b067a --- /dev/null +++ b/projects/kit/src/lib/constants/meridiem.ts @@ -0,0 +1,4 @@ +import {CHAR_NO_BREAK_SPACE} from './unicode-characters'; + +export const ANY_MERIDIEM_CHARACTER_RE = new RegExp(`[${CHAR_NO_BREAK_SPACE}APM]+$`, 'g'); +export const ALL_MERIDIEM_CHARACTERS_RE = new RegExp(`${CHAR_NO_BREAK_SPACE}[AP]M$`, 'g'); 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 9539e0b25..cdf253ad6 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,18 +1,27 @@ 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 { + DEFAULT_TIME_SEGMENT_MAX_VALUES, + DEFAULT_TIME_SEGMENT_MIN_VALUES, +} from '../../constants'; +import { + createMeridiemSteppingPlugin, + createTimeSegmentsSteppingPlugin, +} from '../../plugins'; import { createColonConvertPreprocessor, createDateSegmentsZeroPaddingPostprocessor, createFirstDateEndSeparatorPreprocessor, createFullWidthToHalfWidthPreprocessor, createInvalidTimeSegmentInsertionPreprocessor, + createMeridiemPostprocessor, + createMeridiemPreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; -import type {MaskitoDateMode, MaskitoTimeMode} from '../../types'; +import type {MaskitoDateMode, MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; +import {createTimeMaskExpression} from '../../utils/time'; import {DATE_TIME_SEPARATOR} from './constants'; import {createMinMaxDateTimePostprocessor} from './postprocessors'; import {createValidDateTimePreprocessor} from './preprocessors'; @@ -35,7 +44,17 @@ export function maskitoDateTimeOptionsGenerator({ dateTimeSeparator?: string; timeStep?: number; }): Required { + const hasMeridiem = timeMode.includes('AA'); const dateModeTemplate = dateMode.split('/').join(dateSeparator); + const timeSegmentMaxValues: MaskitoTimeSegments = { + ...DEFAULT_TIME_SEGMENT_MAX_VALUES, + ...(hasMeridiem ? {hours: 12} : {}), + }; + const timeSegmentMinValues: MaskitoTimeSegments = { + ...DEFAULT_TIME_SEGMENT_MIN_VALUES, + ...(hasMeridiem ? {hours: 1} : {}), + }; + const fullMode = `${dateModeTemplate}${dateTimeSeparator}${timeMode}`; return { ...MASKITO_DEFAULT_OPTIONS, @@ -44,9 +63,7 @@ export function maskitoDateTimeOptionsGenerator({ dateSeparator.includes(char) ? char : /\d/, ), ...dateTimeSeparator.split(''), - ...Array.from(timeMode).map((char) => - TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/, - ), + ...createTimeMaskExpression(timeMode), ], overwriteMode: 'replace', preprocessors: [ @@ -59,6 +76,7 @@ export function maskitoDateTimeOptionsGenerator({ pseudoFirstDateEndSeparators: dateTimeSeparator.split(''), }), createZeroPlaceholdersPreprocessor(), + createMeridiemPreprocessor(timeMode), normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: dateSeparator, @@ -66,6 +84,8 @@ export function maskitoDateTimeOptionsGenerator({ }), createInvalidTimeSegmentInsertionPreprocessor({ timeMode, + timeSegmentMinValues, + timeSegmentMaxValues, parseValue: (x) => { const [dateString, timeString] = parseDateTimeString(x, { dateModeTemplate, @@ -80,9 +100,11 @@ export function maskitoDateTimeOptionsGenerator({ dateSegmentsSeparator: dateSeparator, dateTimeSeparator, timeMode, + timeSegmentMaxValues, }), ], postprocessors: [ + createMeridiemPostprocessor(timeMode), createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator: dateSeparator, @@ -109,9 +131,10 @@ export function maskitoDateTimeOptionsGenerator({ plugins: [ createTimeSegmentsSteppingPlugin({ step: timeStep, - fullMode: `${dateModeTemplate}${dateTimeSeparator}${timeMode}`, + fullMode, timeSegmentMaxValues: DEFAULT_TIME_SEGMENT_MAX_VALUES, }), + createMeridiemSteppingPlugin(fullMode.indexOf(' AA')), ], }; } diff --git a/projects/kit/src/lib/masks/date-time/postprocessors/min-max-date-time-postprocessor.ts b/projects/kit/src/lib/masks/date-time/postprocessors/min-max-date-time-postprocessor.ts index d52fbc8d8..cf82026c2 100644 --- a/projects/kit/src/lib/masks/date-time/postprocessors/min-max-date-time-postprocessor.ts +++ b/projects/kit/src/lib/masks/date-time/postprocessors/min-max-date-time-postprocessor.ts @@ -66,12 +66,15 @@ export function createMinMaxDateTimePostprocessor({ const date = segmentsToDate(parsedDate, parsedTime); const clampedDate = clamp(date, min, max); + // trailing segment separators or meridiem characters + const [trailingNonDigitCharacters = ''] = value.match(/\D+$/g) || []; - const validatedValue = toDateString(dateToSegments(clampedDate), { - dateMode: dateModeTemplate, - dateTimeSeparator, - timeMode, - }); + const validatedValue = + toDateString(dateToSegments(clampedDate), { + dateMode: dateModeTemplate, + dateTimeSeparator, + timeMode, + }) + trailingNonDigitCharacters; return { selection, diff --git a/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts b/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts index 43efa8185..cd15e1d22 100644 --- a/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts +++ b/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts @@ -1,8 +1,7 @@ import type {MaskitoPreprocessor} from '@maskito/core'; -import {TIME_FIXED_CHARACTERS} from '../../../constants'; -import type {MaskitoTimeMode} from '../../../types'; -import {escapeRegExp, validateDateString} from '../../../utils'; +import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../../types'; +import {validateDateString} from '../../../utils'; import {enrichTimeSegmentsWithZeroes} from '../../../utils/time'; import {parseDateTimeString} from '../utils'; @@ -11,18 +10,14 @@ export function createValidDateTimePreprocessor({ dateSegmentsSeparator, dateTimeSeparator, timeMode, + timeSegmentMaxValues, }: { dateModeTemplate: string; dateSegmentsSeparator: string; dateTimeSeparator: string; timeMode: MaskitoTimeMode; + timeSegmentMaxValues: MaskitoTimeSegments; }): MaskitoPreprocessor { - const invalidCharsRegExp = new RegExp( - `[^\\d${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}${escapeRegExp( - dateSegmentsSeparator, - )}]+`, - ); - return ({elementState, data}) => { const {value, selection} = elementState; @@ -33,10 +28,10 @@ export function createValidDateTimePreprocessor({ }; } - const newCharacters = data.replace(invalidCharsRegExp, ''); + const newCharacters = data.replaceAll(/\D/g, ''); if (!newCharacters) { - return {elementState, data: ''}; + return {elementState, data}; } const [from, rawTo] = selection; @@ -68,7 +63,7 @@ export function createValidDateTimePreprocessor({ const updatedTimeState = enrichTimeSegmentsWithZeroes( {value: timeString, selection: [from, to]}, - {mode: timeMode}, + {mode: timeMode, timeSegmentMaxValues}, ); to = updatedTimeState.selection[1]; diff --git a/projects/kit/src/lib/masks/time/index.ts b/projects/kit/src/lib/masks/time/index.ts index b18ac29e2..3298879de 100644 --- a/projects/kit/src/lib/masks/time/index.ts +++ b/projects/kit/src/lib/masks/time/index.ts @@ -1,3 +1,3 @@ export {maskitoTimeOptionsGenerator} from './time-mask'; -export type {MaskitoTimeParams} from './time-options'; +export type {MaskitoTimeParams} from './time-params'; export {maskitoParseTime, maskitoStringifyTime} from './utils'; diff --git a/projects/kit/src/lib/masks/time/time-mask.ts b/projects/kit/src/lib/masks/time/time-mask.ts index 841579727..6c1e53ed0 100644 --- a/projects/kit/src/lib/masks/time/time-mask.ts +++ b/projects/kit/src/lib/masks/time/time-mask.ts @@ -1,40 +1,58 @@ import type {MaskitoOptions} from '@maskito/core'; -import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; -import {createTimeSegmentsSteppingPlugin} from '../../plugins'; +import { + DEFAULT_TIME_SEGMENT_MAX_VALUES, + DEFAULT_TIME_SEGMENT_MIN_VALUES, +} from '../../constants'; +import { + createMeridiemSteppingPlugin, + createTimeSegmentsSteppingPlugin, +} from '../../plugins'; import { createColonConvertPreprocessor, createFullWidthToHalfWidthPreprocessor, createInvalidTimeSegmentInsertionPreprocessor, + createMeridiemPostprocessor, + createMeridiemPreprocessor, createZeroPlaceholdersPreprocessor, } from '../../processors'; -import {enrichTimeSegmentsWithZeroes} from '../../utils/time'; -import type {MaskitoTimeParams} from './time-options'; +import type {MaskitoTimeSegments} from '../../types'; +import {createTimeMaskExpression, enrichTimeSegmentsWithZeroes} from '../../utils/time'; +import type {MaskitoTimeParams} from './time-params'; export function maskitoTimeOptionsGenerator({ mode, timeSegmentMaxValues = {}, + timeSegmentMinValues = {}, step = 0, }: MaskitoTimeParams): Required { - const enrichedTimeSegmentMaxValues = { + const hasMeridiem = mode.includes('AA'); + const enrichedTimeSegmentMaxValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, + ...(hasMeridiem ? {hours: 12} : {}), ...timeSegmentMaxValues, }; + const enrichedTimeSegmentMinValues: MaskitoTimeSegments = { + ...DEFAULT_TIME_SEGMENT_MIN_VALUES, + ...(hasMeridiem ? {hours: 1} : {}), + ...timeSegmentMinValues, + }; return { - mask: Array.from(mode).map((char) => - TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/, - ), + mask: createTimeMaskExpression(mode), preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createColonConvertPreprocessor(), createZeroPlaceholdersPreprocessor(), + createMeridiemPreprocessor(mode), createInvalidTimeSegmentInsertionPreprocessor({ timeMode: mode, + timeSegmentMinValues: enrichedTimeSegmentMinValues, timeSegmentMaxValues: enrichedTimeSegmentMaxValues, }), ], postprocessors: [ + createMeridiemPostprocessor(mode), (elementState) => enrichTimeSegmentsWithZeroes(elementState, { mode, @@ -47,6 +65,7 @@ export function maskitoTimeOptionsGenerator({ step, timeSegmentMaxValues: enrichedTimeSegmentMaxValues, }), + createMeridiemSteppingPlugin(mode.indexOf(' AA')), ], overwriteMode: 'replace', }; diff --git a/projects/kit/src/lib/masks/time/time-options.ts b/projects/kit/src/lib/masks/time/time-params.ts similarity index 77% rename from projects/kit/src/lib/masks/time/time-options.ts rename to projects/kit/src/lib/masks/time/time-params.ts index 2fce2e60c..16f573d52 100644 --- a/projects/kit/src/lib/masks/time/time-options.ts +++ b/projects/kit/src/lib/masks/time/time-params.ts @@ -3,5 +3,6 @@ import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; export interface MaskitoTimeParams { readonly mode: MaskitoTimeMode; readonly timeSegmentMaxValues?: Partial>; + readonly timeSegmentMinValues?: Partial>; readonly step?: number; } diff --git a/projects/kit/src/lib/masks/time/utils/parse-time.ts b/projects/kit/src/lib/masks/time/utils/parse-time.ts index b1af086a4..29f074142 100644 --- a/projects/kit/src/lib/masks/time/utils/parse-time.ts +++ b/projects/kit/src/lib/masks/time/utils/parse-time.ts @@ -1,7 +1,7 @@ import {DEFAULT_TIME_SEGMENT_MAX_VALUES} from '../../../constants'; import type {MaskitoTimeSegments} from '../../../types'; import {padEndTimeSegments, parseTimeString} from '../../../utils/time'; -import type {MaskitoTimeParams} from '../time-options'; +import type {MaskitoTimeParams} from '../time-params'; /** * Converts a formatted time string to milliseconds based on the given `options.mode`. diff --git a/projects/kit/src/lib/masks/time/utils/stringify-time.ts b/projects/kit/src/lib/masks/time/utils/stringify-time.ts index f2c13add0..23e635ea3 100644 --- a/projects/kit/src/lib/masks/time/utils/stringify-time.ts +++ b/projects/kit/src/lib/masks/time/utils/stringify-time.ts @@ -1,7 +1,7 @@ import {DEFAULT_TIME_SEGMENT_MAX_VALUES} from '../../../constants'; import type {MaskitoTimeSegments} from '../../../types'; import {padStartTimeSegments} from '../../../utils/time'; -import type {MaskitoTimeParams} from '../time-options'; +import type {MaskitoTimeParams} from '../time-params'; /** * Converts milliseconds to a formatted time string based on the given `options.mode`. diff --git a/projects/kit/src/lib/plugins/index.ts b/projects/kit/src/lib/plugins/index.ts index 9c65bcdbd..8e1571190 100644 --- a/projects/kit/src/lib/plugins/index.ts +++ b/projects/kit/src/lib/plugins/index.ts @@ -3,4 +3,5 @@ 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'; +export {createMeridiemSteppingPlugin} from './time/meridiem-stepping'; +export {createTimeSegmentsSteppingPlugin} from './time/time-segments-stepping'; diff --git a/projects/kit/src/lib/plugins/time/meridiem-stepping.ts b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts new file mode 100644 index 000000000..0f0cf7cd8 --- /dev/null +++ b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts @@ -0,0 +1,47 @@ +import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; + +import {ANY_MERIDIEM_CHARACTER_RE, CHAR_NO_BREAK_SPACE} from '../../constants'; + +export function createMeridiemSteppingPlugin(meridiemStartIndex: number): MaskitoPlugin { + if (meridiemStartIndex < 0) { + return () => {}; + } + + return (element) => { + const listener = (event: KeyboardEvent): void => { + const caretIndex = Number(element.selectionStart); + const value = element.value.toUpperCase(); + + if ( + (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || + caretIndex < meridiemStartIndex + ) { + return; + } + + event.preventDefault(); + + /* eslint-disable no-nested-ternary */ + const meridiemMainCharacter = value.includes('A') + ? 'P' + : value.includes('P') + ? 'A' + : event.key === 'ArrowDown' + ? 'P' + : 'A'; + const newMeridiem = `${CHAR_NO_BREAK_SPACE}${meridiemMainCharacter}M`; + + maskitoUpdateElement(element, { + value: + value.length === meridiemStartIndex + ? value + newMeridiem + : value.replace(ANY_MERIDIEM_CHARACTER_RE, newMeridiem), + selection: [caretIndex, caretIndex], + }); + }; + + element.addEventListener('keydown', listener); + + return () => element.removeEventListener('keydown', listener); + }; +} diff --git a/projects/kit/src/lib/plugins/time-segments-stepping.ts b/projects/kit/src/lib/plugins/time/time-segments-stepping.ts similarity index 98% rename from projects/kit/src/lib/plugins/time-segments-stepping.ts rename to projects/kit/src/lib/plugins/time/time-segments-stepping.ts index 37135c832..280686602 100644 --- a/projects/kit/src/lib/plugins/time-segments-stepping.ts +++ b/projects/kit/src/lib/plugins/time/time-segments-stepping.ts @@ -1,7 +1,7 @@ import type {MaskitoPlugin} from '@maskito/core'; import {maskitoUpdateElement} from '@maskito/core'; -import type {MaskitoTimeSegments} from '../types'; +import type {MaskitoTimeSegments} from '../../types'; const noop = (): void => {}; diff --git a/projects/kit/src/lib/processors/first-date-end-separator-preprocessor.ts b/projects/kit/src/lib/processors/first-date-end-separator-preprocessor.ts index 41287aff3..87258c3af 100644 --- a/projects/kit/src/lib/processors/first-date-end-separator-preprocessor.ts +++ b/projects/kit/src/lib/processors/first-date-end-separator-preprocessor.ts @@ -20,22 +20,25 @@ export function createFirstDateEndSeparatorPreprocessor({ }): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; + const [from, to] = selection; const firstCompleteDate = getFirstCompleteDate(value, dateModeTemplate); const pseudoSeparators = pseudoFirstDateEndSeparators.filter( (x) => !firstDateEndSeparator.includes(x) && x !== dateSegmentSeparator, ); const pseudoSeparatorsRE = new RegExp(`[${pseudoSeparators.join('')}]`, 'gi'); + const newValue = + firstCompleteDate && value.length > firstCompleteDate.length + ? firstCompleteDate + + value + .slice(firstCompleteDate.length) + .replace(/^[\D\s]*/, firstDateEndSeparator) + : value; + const caretShift = newValue.length - value.length; return { elementState: { - selection, - value: - firstCompleteDate && value.length > firstCompleteDate.length - ? firstCompleteDate + - value - .slice(firstCompleteDate.length) - .replace(/^[\D\s]*/, firstDateEndSeparator) - : value, + selection: [from + caretShift, to + caretShift], + value: newValue, }, data: data.replace(pseudoSeparatorsRE, firstDateEndSeparator), }; diff --git a/projects/kit/src/lib/processors/index.ts b/projects/kit/src/lib/processors/index.ts index f40f4c169..af4a73c37 100644 --- a/projects/kit/src/lib/processors/index.ts +++ b/projects/kit/src/lib/processors/index.ts @@ -3,6 +3,10 @@ export {createDateSegmentsZeroPaddingPostprocessor} from './date-segments-zero-p export {createFirstDateEndSeparatorPreprocessor} from './first-date-end-separator-preprocessor'; export {createFullWidthToHalfWidthPreprocessor} from './fullwidth-to-halfwidth-preprocessor'; export {createInvalidTimeSegmentInsertionPreprocessor} from './invalid-time-segment-insertion-preprocessor'; +export { + createMeridiemPostprocessor, + createMeridiemPreprocessor, +} from './meridiem-processors'; export {createMinMaxDatePostprocessor} from './min-max-date-postprocessor'; export {normalizeDatePreprocessor} from './normalize-date-preprocessor'; export {maskitoPostfixPostprocessorGenerator} from './postfix-postprocessor'; diff --git a/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts b/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts index a9a656e41..88171310a 100644 --- a/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts +++ b/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts @@ -3,11 +3,12 @@ import type {MaskitoTimeMode, MaskitoTimeSegments} from '@maskito/kit'; import { DEFAULT_TIME_SEGMENT_MAX_VALUES, + DEFAULT_TIME_SEGMENT_MIN_VALUES, TIME_FIXED_CHARACTERS, TIME_SEGMENT_VALUE_LENGTHS, } from '../constants'; -import {escapeRegExp} from '../utils'; -import {padStartTimeSegments, parseTimeString} from '../utils/time'; +import {clamp, escapeRegExp} from '../utils'; +import {parseTimeString} from '../utils/time'; /** * Prevent insertion if any time segment will become invalid @@ -16,14 +17,15 @@ import {padStartTimeSegments, parseTimeString} from '../utils/time'; */ export function createInvalidTimeSegmentInsertionPreprocessor({ timeMode, + timeSegmentMinValues = DEFAULT_TIME_SEGMENT_MIN_VALUES, timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, parseValue = (x) => ({timeString: x}), }: { timeMode: MaskitoTimeMode; + timeSegmentMinValues?: MaskitoTimeSegments; timeSegmentMaxValues?: MaskitoTimeSegments; parseValue?: (value: string) => {timeString: string; restValue?: string}; }): MaskitoPreprocessor { - const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); const invalidCharsRegExp = new RegExp( `[^\\d${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]+`, ); @@ -45,21 +47,24 @@ export function createInvalidTimeSegmentInsertionPreprocessor({ let offset = restValue.length; - for (const [segmentName, segmentValue] of timeSegments) { - const maxSegmentValue = paddedMaxValues[segmentName]; + for (const [segmentName, stringifiedSegmentValue] of timeSegments) { + const minSegmentValue = timeSegmentMinValues[segmentName]; + const maxSegmentValue = timeSegmentMaxValues[segmentName]; + const segmentValue = Number(stringifiedSegmentValue); + const lastSegmentDigitIndex = offset + TIME_SEGMENT_VALUE_LENGTHS[segmentName]; if ( lastSegmentDigitIndex >= from && lastSegmentDigitIndex <= to && - Number(segmentValue) > Number(maxSegmentValue) + segmentValue !== clamp(segmentValue, minSegmentValue, maxSegmentValue) ) { return {elementState, data: ''}; // prevent insertion } offset += - segmentValue.length + + stringifiedSegmentValue.length + // any time segment separator 1; } diff --git a/projects/kit/src/lib/processors/meridiem-processors.ts b/projects/kit/src/lib/processors/meridiem-processors.ts new file mode 100644 index 000000000..ca5187dc0 --- /dev/null +++ b/projects/kit/src/lib/processors/meridiem-processors.ts @@ -0,0 +1,85 @@ +import type {MaskitoPostprocessor, MaskitoPreprocessor} from '@maskito/core'; + +import { + ALL_MERIDIEM_CHARACTERS_RE, + ANY_MERIDIEM_CHARACTER_RE, + CHAR_NO_BREAK_SPACE, +} from '../constants'; +import type {MaskitoTimeMode} from '../types'; +import {identity} from '../utils'; + +export function createMeridiemPreprocessor( + timeMode: MaskitoTimeMode, +): MaskitoPreprocessor { + if (!timeMode.includes('AA')) { + return identity; + } + + const mainMeridiemCharRE = /^[AP]$/gi; + + return ({elementState, data}) => { + const {value, selection} = elementState; + const newValue = value.toUpperCase(); + const newData = data.toUpperCase(); + + if ( + newValue.match(ALL_MERIDIEM_CHARACTERS_RE) && + newData.match(mainMeridiemCharRE) + ) { + return { + elementState: { + value: newValue.replaceAll(ALL_MERIDIEM_CHARACTERS_RE, ''), + selection, + }, + data: `${newData}M`, + }; + } + + return {elementState: {selection, value: newValue}, data: newData}; + }; +} + +export function createMeridiemPostprocessor( + timeMode: MaskitoTimeMode, +): MaskitoPostprocessor { + if (!timeMode.includes('AA')) { + return identity; + } + + return ({value, selection}, initialElementState) => { + if ( + !value.match(ANY_MERIDIEM_CHARACTER_RE) || + value.match(ALL_MERIDIEM_CHARACTERS_RE) + ) { + return {value, selection}; + } + + const [from, to] = selection; + + // any meridiem character was deleted + if (initialElementState.value.match(ALL_MERIDIEM_CHARACTERS_RE)) { + const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, ''); + + return { + value: newValue, + selection: [ + Math.min(from, newValue.length), + Math.min(to, newValue.length), + ], + }; + } + + const fullMeridiem = `${CHAR_NO_BREAK_SPACE}${value.includes('P') ? 'P' : 'A'}M`; + const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, (x) => + x !== CHAR_NO_BREAK_SPACE ? fullMeridiem : x, + ); + + return { + value: newValue, + selection: + to >= newValue.indexOf(fullMeridiem) + ? [newValue.length, newValue.length] + : selection, + }; + }; +} diff --git a/projects/kit/src/lib/processors/zero-placeholders-preprocessor.ts b/projects/kit/src/lib/processors/zero-placeholders-preprocessor.ts index ce3241f87..653865aaa 100644 --- a/projects/kit/src/lib/processors/zero-placeholders-preprocessor.ts +++ b/projects/kit/src/lib/processors/zero-placeholders-preprocessor.ts @@ -13,6 +13,10 @@ export function createZeroPlaceholdersPreprocessor(): MaskitoPreprocessor { const zeroes = value.slice(from, to).replaceAll(/\d/g, '0'); const newValue = value.slice(0, from) + zeroes + value.slice(to); + if (!zeroes.replaceAll(/\D/g, '')) { + return {elementState}; + } + if (actionType === 'validation' || (actionType === 'insert' && from === to)) { return { elementState: {selection, value: newValue}, diff --git a/projects/kit/src/lib/types/time-mode.ts b/projects/kit/src/lib/types/time-mode.ts index 64a4d9e17..8dce07a3f 100644 --- a/projects/kit/src/lib/types/time-mode.ts +++ b/projects/kit/src/lib/types/time-mode.ts @@ -1,4 +1,8 @@ export type MaskitoTimeMode = + | 'HH AA' + | 'HH:MM AA' + | 'HH:MM:SS AA' + | 'HH:MM:SS.MSS AA' | 'HH:MM:SS.MSS' | 'HH:MM:SS' | 'HH:MM' diff --git a/projects/kit/src/lib/utils/time/create-time-mask-expression.ts b/projects/kit/src/lib/utils/time/create-time-mask-expression.ts new file mode 100644 index 000000000..42c651ed2 --- /dev/null +++ b/projects/kit/src/lib/utils/time/create-time-mask-expression.ts @@ -0,0 +1,8 @@ +import {CHAR_NO_BREAK_SPACE, TIME_FIXED_CHARACTERS} from '../../constants'; +import type {MaskitoTimeMode} from '../../types'; + +export function createTimeMaskExpression(mode: MaskitoTimeMode): Array { + return Array.from(mode.replace(' AA', '')) + .map((char) => (TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/)) + .concat(mode.includes('AA') ? [CHAR_NO_BREAK_SPACE, /[AP]/i, /M/i] : []); +} diff --git a/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts b/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts index c3013bfd8..a470aedbf 100644 --- a/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts +++ b/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts @@ -48,10 +48,10 @@ export function enrichTimeSegmentsWithZeroes( validatedTimeSegments[segmentName] = validatedSegmentValue; } - const [trailingSegmentSeparator = ''] = - value.match(TRAILING_TIME_SEGMENT_SEPARATOR_REG) || []; + // trailing segment separators or meridiem characters + const [trailingNonDigitCharacters = ''] = value.match(/\D+$/g) || []; const validatedTimeString = - toTimeString(validatedTimeSegments) + trailingSegmentSeparator; + toTimeString(validatedTimeSegments) + trailingNonDigitCharacters; const addedDateSegmentSeparators = Math.max( validatedTimeString.length - value.length, 0, diff --git a/projects/kit/src/lib/utils/time/index.ts b/projects/kit/src/lib/utils/time/index.ts index 1a00cd250..0c3d0a352 100644 --- a/projects/kit/src/lib/utils/time/index.ts +++ b/projects/kit/src/lib/utils/time/index.ts @@ -1,3 +1,4 @@ +export * from './create-time-mask-expression'; export * from './enrich-time-segments-with-zeroes'; export * from './pad-end-time-segments'; export * from './pad-start-time-segments'; diff --git a/projects/kit/src/lib/utils/time/parse-time-string.ts b/projects/kit/src/lib/utils/time/parse-time-string.ts index 0447fd287..ea0713941 100644 --- a/projects/kit/src/lib/utils/time/parse-time-string.ts +++ b/projects/kit/src/lib/utils/time/parse-time-string.ts @@ -19,12 +19,18 @@ export function parseTimeString( let offset = 0; return Object.fromEntries( - timeMode.split(/\W/).map((segmentAbbr) => { - const segmentValue = onlyDigits.slice(offset, offset + segmentAbbr.length); + timeMode + .split(/\W/) + .filter((segmentAbbr) => SEGMENT_FULL_NAME[segmentAbbr]) + .map((segmentAbbr) => { + const segmentValue = onlyDigits.slice( + offset, + offset + segmentAbbr.length, + ); - offset += segmentAbbr.length; + offset += segmentAbbr.length; - return [SEGMENT_FULL_NAME[segmentAbbr], segmentValue]; - }), + return [SEGMENT_FULL_NAME[segmentAbbr], segmentValue]; + }), ); } From ba7f2af8a88dcc4643ef96a1e0274f76a6651af5 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 27 Sep 2024 13:11:38 +0300 Subject: [PATCH 2/5] chore: fix review comment --- projects/kit/src/lib/plugins/time/meridiem-stepping.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/projects/kit/src/lib/plugins/time/meridiem-stepping.ts b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts index 0f0cf7cd8..f66b1e1db 100644 --- a/projects/kit/src/lib/plugins/time/meridiem-stepping.ts +++ b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts @@ -21,14 +21,13 @@ export function createMeridiemSteppingPlugin(meridiemStartIndex: number): Maskit event.preventDefault(); - /* eslint-disable no-nested-ternary */ + // eslint-disable-next-line no-nested-ternary const meridiemMainCharacter = value.includes('A') ? 'P' - : value.includes('P') + : value.includes('P') || event.key === 'ArrowUp' ? 'A' - : event.key === 'ArrowDown' - ? 'P' - : 'A'; + : 'P'; + const newMeridiem = `${CHAR_NO_BREAK_SPACE}${meridiemMainCharacter}M`; maskitoUpdateElement(element, { From ef2e04991558c7657dca090d07bd22a36692e70d Mon Sep 17 00:00:00 2001 From: taiga-family-bot Date: Tue, 1 Oct 2024 10:58:20 +0000 Subject: [PATCH 3/5] chore: apply changes after linting [bot] --- projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts | 3 ++- projects/demo/src/pages/kit/time/examples/3-step/mask.ts | 3 ++- projects/kit/src/lib/plugins/time/meridiem-stepping.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts b/projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts index e5713e1b6..b9a6f9fc6 100644 --- a/projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts +++ b/projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts @@ -1,4 +1,5 @@ -import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; +import type {MaskitoOptions} from '@maskito/core'; +import {maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler, maskitoTimeOptionsGenerator} from '@maskito/kit'; const timeOptions = maskitoTimeOptionsGenerator({ 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 index b9c8de0d8..ba30e1044 100644 --- a/projects/demo/src/pages/kit/time/examples/3-step/mask.ts +++ b/projects/demo/src/pages/kit/time/examples/3-step/mask.ts @@ -1,4 +1,5 @@ -import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; +import type {MaskitoOptions} from '@maskito/core'; +import {maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler, maskitoTimeOptionsGenerator} from '@maskito/kit'; const timeOptions = maskitoTimeOptionsGenerator({ diff --git a/projects/kit/src/lib/plugins/time/meridiem-stepping.ts b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts index f66b1e1db..95af4c587 100644 --- a/projects/kit/src/lib/plugins/time/meridiem-stepping.ts +++ b/projects/kit/src/lib/plugins/time/meridiem-stepping.ts @@ -1,4 +1,5 @@ -import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; +import type {MaskitoPlugin} from '@maskito/core'; +import {maskitoUpdateElement} from '@maskito/core'; import {ANY_MERIDIEM_CHARACTER_RE, CHAR_NO_BREAK_SPACE} from '../../constants'; From 78f6c561759becbc198ceddc3c352ed6e8a06706 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Wed, 9 Oct 2024 13:19:14 +0300 Subject: [PATCH 4/5] chore: remove `(input)=(0)` workaround --- .../kit/time/examples/4-time-segments-min-max/component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts index 55e87d2a5..18a14cb2e 100644 --- a/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts +++ b/projects/demo/src/pages/kit/time/examples/4-time-segments-min-max/component.ts @@ -11,12 +11,10 @@ import mask from './mask'; selector: 'time-mask-doc-example-4', imports: [FormsModule, MaskitoDirective, TuiSegmented, TuiTextfield], template: ` - Date: Wed, 9 Oct 2024 15:39:17 +0300 Subject: [PATCH 5/5] chore(demo-integrations): some new tests --- .../src/tests/kit/time/time-meridiem.cy.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts index 962c0c539..5057832c9 100644 --- a/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts +++ b/projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts @@ -179,6 +179,53 @@ describe('Time | modes with meridiem', () => { }); }); + describe('press any characters (except part of meridiem ones) when cursor is placed near already existing meridiem', () => { + beforeEach(() => { + cy.get('@textfield') + .type('1234a') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34 AM| => Press 1 => Nothing changed', () => { + cy.get('@textfield') + .type('{moveToEnd}') + .type('1') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 AM'.length) + .should('have.prop', 'selectionEnd', '12:34 AM'.length); + }); + + it('12:34 A|M => Press 1 => Nothing changed', () => { + cy.get('@textfield') + .type('{moveToEnd}{leftArrow}') + .type('1') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 A'.length) + .should('have.prop', 'selectionEnd', '12:34 A'.length); + }); + + it('12:34 A|M => Press T => Nothing changed', () => { + cy.get('@textfield') + .type('{moveToEnd}{leftArrow}') + .type('t') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 A'.length) + .should('have.prop', 'selectionEnd', '12:34 A'.length); + }); + + it('12:34 |AM => Press T => Nothing changed', () => { + cy.get('@textfield') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat(2)) + .type('t') + .should('have.value', '12:34 AM') + .should('have.prop', 'selectionStart', '12:34 '.length) + .should('have.prop', 'selectionEnd', '12:34 '.length); + }); + }); + describe('hour segment bounds', () => { it('cannot be less than 01 (rejects zero as the 2nd hour segment)', () => { cy.get('@textfield')