From 918edf2f7269846404e20073f60ab2ad7c730f92 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 5 May 2023 13:38:36 -0400 Subject: [PATCH] feat(input): mask controller set-up (#27346) Issue number: N/A --------- ## What is the current behavior? N/A ## What is the new behavior? Introduces a `MaskController` class to handle the implementation details of input masking. The implementation is stubbed out to guide future PRs that will implement the underlying details. The design is implemented based on [maskito](https://github.com/Tinkoff/maskito). Ionic Framework supports newer versions of Firefox than this library targets, so we can diverge on the implementation and access modern APIs in portions of the implementation. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information In terms of testing, you could run the mask test template and verify that different `console.debug` messages are logging when interacting with the control. --- core/src/components.d.ts | 4 +- core/src/components/input/input.tsx | 21 ++- core/src/components/input/test/input.spec.tsx | 1 + .../src/components/input/test/mask/index.html | 81 +++++++++ core/src/interface.d.ts | 2 +- core/src/utils/input-masking/classes/index.ts | 1 + .../input-masking/classes/mask-history.ts | 63 +++++++ .../constants/default-options.ts | 9 + .../utils/input-masking/constants/index.ts | 1 + .../utils/input-masking/dom/event-listener.ts | 30 ++++ core/src/utils/input-masking/dom/index.ts | 3 + .../dom/is-before-input-event-supported.ts | 14 ++ .../dom/is-event-producing-character.ts | 9 + core/src/utils/input-masking/index.ts | 2 +- .../input-masking/input-masking-interface.ts | 5 - .../utils/input-masking/mask-controller.ts | 158 ++++++++++++++++++ core/src/utils/input-masking/public-api.ts | 1 + .../input-masking/types/mask-interface.ts | 53 ++++++ .../utils/get-not-empty-selection.spec.ts | 64 +++++++ .../utils/get-not-empty-selection.ts | 19 +++ .../src/utils/input-masking/utils/identity.ts | 3 + core/src/utils/input-masking/utils/index.ts | 2 + 22 files changed, 534 insertions(+), 12 deletions(-) create mode 100644 core/src/components/input/test/mask/index.html create mode 100644 core/src/utils/input-masking/classes/index.ts create mode 100644 core/src/utils/input-masking/classes/mask-history.ts create mode 100644 core/src/utils/input-masking/constants/default-options.ts create mode 100644 core/src/utils/input-masking/constants/index.ts create mode 100644 core/src/utils/input-masking/dom/event-listener.ts create mode 100644 core/src/utils/input-masking/dom/index.ts create mode 100644 core/src/utils/input-masking/dom/is-before-input-event-supported.ts create mode 100644 core/src/utils/input-masking/dom/is-event-producing-character.ts delete mode 100644 core/src/utils/input-masking/input-masking-interface.ts create mode 100644 core/src/utils/input-masking/mask-controller.ts create mode 100644 core/src/utils/input-masking/public-api.ts create mode 100644 core/src/utils/input-masking/types/mask-interface.ts create mode 100644 core/src/utils/input-masking/utils/get-not-empty-selection.spec.ts create mode 100644 core/src/utils/input-masking/utils/get-not-empty-selection.ts create mode 100644 core/src/utils/input-masking/utils/identity.ts create mode 100644 core/src/utils/input-masking/utils/index.ts diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 4943d8862a..d911a97431 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -17,7 +17,7 @@ import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interf import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; -import { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking"; +import { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking/public-api"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { CounterFormatter } from "./components/item/item-interface"; import { MenuChangeEventDetail, Side } from "./components/menu/menu-interface"; @@ -54,7 +54,7 @@ export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interf export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; -export { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking"; +export { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking/public-api"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { CounterFormatter } from "./components/item/item-interface"; export { MenuChangeEventDetail, Side } from "./components/menu/menu-interface"; diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index b05fd693ea..9acfcc246a 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -8,7 +8,8 @@ import type { LegacyFormController } from '../../utils/forms'; import { createLegacyFormController } from '../../utils/forms'; import type { Attributes } from '../../utils/helpers'; import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '../../utils/helpers'; -import type { MaskExpression, MaskPlaceholder, MaskVisibility } from '../../utils/input-masking'; +import { MaskController } from '../../utils/input-masking'; +import type { MaskExpression, MaskPlaceholder, MaskVisibility } from '../../utils/input-masking/public-api'; import { printIonWarning } from '../../utils/logging'; import { createColorClasses, hostContext } from '../../utils/theme'; @@ -32,6 +33,7 @@ export class Input implements ComponentInterface { private inheritedAttributes: Attributes = {}; private isComposing = false; private legacyFormController!: LegacyFormController; + private maskController?: MaskController; // This flag ensures we log the deprecation warning at most once. private hasLoggedDeprecationWarning = false; @@ -373,9 +375,11 @@ export class Input implements ComponentInterface { } componentWillLoad() { + const { el } = this; + this.inheritedAttributes = { - ...inheritAriaAttributes(this.el), - ...inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type']), + ...inheritAriaAttributes(el), + ...inheritAttributes(el, ['tabindex', 'title', 'data-form-type']), }; } @@ -396,7 +400,15 @@ export class Input implements ComponentInterface { } componentDidLoad() { + const { mask, nativeInput } = this; + this.originalIonInput = this.ionInput; + + if (mask !== undefined && nativeInput) { + this.maskController = new MaskController(nativeInput, { + mask, + }); + } } disconnectedCallback() { @@ -407,6 +419,9 @@ export class Input implements ComponentInterface { }) ); } + + // Todo - need to evaluate if I need to recreate this in connectedCallback after first load + this.maskController?.destroy(); } /** diff --git a/core/src/components/input/test/input.spec.tsx b/core/src/components/input/test/input.spec.tsx index 84637011b8..8eb6417fbc 100644 --- a/core/src/components/input/test/input.spec.tsx +++ b/core/src/components/input/test/input.spec.tsx @@ -1,4 +1,5 @@ import { newSpecPage } from '@stencil/core/testing'; + import { Input } from '../input'; it('should render bottom content when helper text is defined', async () => { diff --git a/core/src/components/input/test/mask/index.html b/core/src/components/input/test/mask/index.html new file mode 100644 index 0000000000..7a8354dcae --- /dev/null +++ b/core/src/components/input/test/mask/index.html @@ -0,0 +1,81 @@ + + + + + Input - Mask + + + + + + + + + + + + + + Input - Item + + + + +
+
+

US Phone Number

+ + +
+
+
+
+ + + + diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index be14e3f26a..7a7aed756d 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -43,7 +43,7 @@ export { AnimationKeyFrames, AnimationLifecycle, } from './utils/animation/animation-interface'; -export { MaskExpression, MaskPlaceholder, MaskVisibility } from './utils/input-masking'; +export { MaskExpression, MaskPlaceholder, MaskVisibility } from './utils/input-masking/public-api'; export { HTMLIonOverlayElement, OverlayController, OverlayInterface } from './utils/overlays-interface'; export { Config, config } from './global/config'; export { Gesture, GestureConfig, GestureDetail } from './utils/gesture'; diff --git a/core/src/utils/input-masking/classes/index.ts b/core/src/utils/input-masking/classes/index.ts new file mode 100644 index 0000000000..5ac33d2a44 --- /dev/null +++ b/core/src/utils/input-masking/classes/index.ts @@ -0,0 +1 @@ +export { MaskHistory } from './mask-history'; diff --git a/core/src/utils/input-masking/classes/mask-history.ts b/core/src/utils/input-masking/classes/mask-history.ts new file mode 100644 index 0000000000..e3bebc45b6 --- /dev/null +++ b/core/src/utils/input-masking/classes/mask-history.ts @@ -0,0 +1,63 @@ +import type { ElementState, TypedInputEvent } from '../types/mask-interface'; + +/** + * The mask history class. It is used to store the previous and next states of the element. + * @internal + * + * Original design by Tinkoff: + * @see https://github.com/Tinkoff/maskito/blob/main/projects/core/src/lib/classes/mask-history.ts + */ +export abstract class MaskHistory { + private now: ElementState | null = null; + private readonly past: ElementState[] = []; + private future: ElementState[] = []; + + protected abstract updateElementState( + state: ElementState, + eventInit: Pick + ): void; + + protected undo(): void { + const state = this.past.pop(); + + if (state && this.now) { + this.future.push(this.now); + this.updateElement(state, 'historyUndo'); + } + } + + protected redo(): void { + const state = this.future.pop(); + + if (state && this.now) { + this.past.push(this.now); + this.updateElement(state, 'historyRedo'); + } + } + + protected updateHistory(state: ElementState): void { + if (!this.now) { + this.now = state; + return; + } + + const isValueChanged = this.now.value !== state.value; + const isSelectionChanged = this.now.selection.some((item, index) => item !== state.selection[index]); + + if (!isValueChanged && !isSelectionChanged) { + return; + } + + if (isValueChanged) { + this.past.push(this.now); + this.future = []; + } + + this.now = state; + } + + private updateElement(state: ElementState, inputType: TypedInputEvent['inputType']): void { + this.now = state; + this.updateElementState(state, { inputType, data: null }); + } +} diff --git a/core/src/utils/input-masking/constants/default-options.ts b/core/src/utils/input-masking/constants/default-options.ts new file mode 100644 index 0000000000..31b276aad7 --- /dev/null +++ b/core/src/utils/input-masking/constants/default-options.ts @@ -0,0 +1,9 @@ +import type { MaskOptions } from '../types/mask-interface'; +import { identity } from '../utils'; + +export const MASK_DEFAULT_OPTIONS: Required = { + mask: /^.*$/, + preprocessor: identity, + postprocessor: identity, + overwriteMode: 'shift', +}; diff --git a/core/src/utils/input-masking/constants/index.ts b/core/src/utils/input-masking/constants/index.ts new file mode 100644 index 0000000000..2276938968 --- /dev/null +++ b/core/src/utils/input-masking/constants/index.ts @@ -0,0 +1 @@ +export * from './default-options'; diff --git a/core/src/utils/input-masking/dom/event-listener.ts b/core/src/utils/input-masking/dom/event-listener.ts new file mode 100644 index 0000000000..2f2d11ba7c --- /dev/null +++ b/core/src/utils/input-masking/dom/event-listener.ts @@ -0,0 +1,30 @@ +import type { TypedInputEvent } from '../types/mask-interface'; + +/** + * Event listener utility class that simplifies teardown of + * manually added event listeners. + * @internal + * + * Original design by Tinkoff: + * @see https://github.com/Tinkoff/maskito/blob/main/projects/core/src/lib/utils/dom/event-listener.ts + */ +export class EventListener { + private readonly listeners: (() => void)[] = []; + + constructor(private readonly element: HTMLElement) {} + + listen( + eventType: E, + fn: (event: E extends 'beforeinput' ? TypedInputEvent : HTMLElementEventMap[E]) => unknown, + options?: AddEventListenerOptions + ): void { + const untypedFn = fn as (event: HTMLElementEventMap[E]) => unknown; + + this.element.addEventListener(eventType, untypedFn, options); + this.listeners.push(() => this.element.removeEventListener(eventType, untypedFn)); + } + + destroy(): void { + this.listeners.forEach((removeListener) => removeListener()); + } +} diff --git a/core/src/utils/input-masking/dom/index.ts b/core/src/utils/input-masking/dom/index.ts new file mode 100644 index 0000000000..eef5f8c881 --- /dev/null +++ b/core/src/utils/input-masking/dom/index.ts @@ -0,0 +1,3 @@ +export * from './event-listener'; +export * from './is-event-producing-character'; +export * from './is-before-input-event-supported'; diff --git a/core/src/utils/input-masking/dom/is-before-input-event-supported.ts b/core/src/utils/input-masking/dom/is-before-input-event-supported.ts new file mode 100644 index 0000000000..0e394ac79e --- /dev/null +++ b/core/src/utils/input-masking/dom/is-before-input-event-supported.ts @@ -0,0 +1,14 @@ +/** + * "beforeinput" is the appropriate event for preprocessing of the input masking (rather than `keydown`): + * - `keydown` is not triggered by predictive text from native mobile keyboards. + * - `keydown` is triggered by system key combinations (we don't need them, and they should be manually filtered). + * - Dropping text inside input triggers `beforeinput` (but not `keydown`). + * ___ + * "beforeinput" is not supported by Chrome 49+ (only from 60+) and by Firefox 52+ (only from 87+). + * + * @see https://caniuse.com/?search=beforeinput + * @see https://ionicframework.com/docs/reference/browser-support + */ +export function isBeforeInputEventSupported(element: HTMLInputElement | HTMLTextAreaElement): boolean { + return 'onbeforeinput' in element; +} diff --git a/core/src/utils/input-masking/dom/is-event-producing-character.ts b/core/src/utils/input-masking/dom/is-event-producing-character.ts new file mode 100644 index 0000000000..51057eb958 --- /dev/null +++ b/core/src/utils/input-masking/dom/is-event-producing-character.ts @@ -0,0 +1,9 @@ +/** + * Checks if the event is a character that should be inserted into the input. + */ +export function isEventProducingCharacter({ key, ctrlKey, metaKey, altKey }: KeyboardEvent): boolean { + const isSystemKeyCombinations = ctrlKey || metaKey || altKey; + const isSingleUnicodeChar = /^.$/u.test(key); // 4-byte characters case (e.g. smile) + + return !isSystemKeyCombinations && key !== 'Backspace' && isSingleUnicodeChar; +} diff --git a/core/src/utils/input-masking/index.ts b/core/src/utils/input-masking/index.ts index 79c5527fd3..cbe72c6a33 100644 --- a/core/src/utils/input-masking/index.ts +++ b/core/src/utils/input-masking/index.ts @@ -1 +1 @@ -export * from './input-masking-interface'; +export { MaskController } from './mask-controller'; diff --git a/core/src/utils/input-masking/input-masking-interface.ts b/core/src/utils/input-masking/input-masking-interface.ts deleted file mode 100644 index 9f2fae70c0..0000000000 --- a/core/src/utils/input-masking/input-masking-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type MaskVisibility = 'always' | 'focus' | 'never'; - -export type MaskExpression = RegExp | (string | RegExp)[]; - -export type MaskPlaceholder = string | null | undefined; diff --git a/core/src/utils/input-masking/mask-controller.ts b/core/src/utils/input-masking/mask-controller.ts new file mode 100644 index 0000000000..ec30a6c136 --- /dev/null +++ b/core/src/utils/input-masking/mask-controller.ts @@ -0,0 +1,158 @@ +import { MaskHistory } from './classes'; +import { MASK_DEFAULT_OPTIONS } from './constants'; +import { isBeforeInputEventSupported, isEventProducingCharacter, EventListener } from './dom'; +import type { ElementState, MaskOptions, SelectionRange, TypedInputEvent } from './types/mask-interface'; +import { getNotEmptySelection } from './utils'; + +/** + * The mask controller class. It is used to control the mask of the element. + * + * @internal + * + * Original design by Tinkoff: + * @see https://github.com/Tinkoff/maskito/blob/main/projects/core/src/lib/mask.ts + */ +export class MaskController extends MaskHistory { + private readonly eventListener = new EventListener(this.element); + + private readonly options: Required = { + ...MASK_DEFAULT_OPTIONS, + ...this.maskOptions, + }; + + constructor(private readonly element: HTMLInputElement, private readonly maskOptions: MaskOptions) { + super(); + console.debug('MaskController', { + element, + maskOptions, + options: this.options, + }); + this.ensureValueFitsMask(); + this.updateHistory(this.elementState); + + this.eventListener.listen('keydown', (event) => { + console.debug('keydown', event); + }); + + if (isBeforeInputEventSupported(element)) { + this.eventListener.listen('beforeinput', (event) => { + console.debug('beforeinput', event); + }); + } else { + /** + * TODO: Remove once Firefox 87+ is the minimum supported version + * Also, replace union types `Event | TypedInputEvent` with `TypedInputEvent` inside: + *** {@link handleDelete} + *** {@link handleInsert} + */ + this.eventListener.listen('keydown', (event) => this.handleKeyDown(event)); + this.eventListener.listen('paste', (event) => + this.handleInsert(event, event.clipboardData?.getData('text/plain') || '') + ); + } + + this.eventListener.listen('input', () => { + console.debug('input'); + }); + } + + private get elementState(): ElementState { + const { value, selectionStart, selectionEnd } = this.element; + return { + value, + selection: [selectionStart ?? 0, selectionEnd ?? 0], + }; + } + + destroy(): void { + // Remove all event listeners + this.eventListener.destroy(); + } + + protected updateElementState( + { value, selection }: ElementState, + eventInit: Pick = { + inputType: 'insertText', + data: null, + } + ): void { + const initialValue = this.elementState.value; + + this.updateValue(value); + this.updateSelectionRange(selection); + + if (initialValue !== value) { + this.dispatchInputEvent(eventInit); + } + } + + private handleKeyDown(event: KeyboardEvent): void { + const pressedKey = event.key; + const isForward = pressedKey === 'Delete'; + + switch (pressedKey) { + case 'Backspace': + case 'Delete': + return this.handleDelete({ + event, + isForward, + selection: getNotEmptySelection(this.elementState, isForward), + }); + } + + if (!isEventProducingCharacter(event)) { + return; + } + + this.handleInsert(event, pressedKey); + } + + private ensureValueFitsMask(): void { + // TODO implementation + } + + private handleDelete({ + event, + selection, + isForward, + force = false, + }: { + event: Event | TypedInputEvent; + selection: SelectionRange; + isForward: boolean; + force?: boolean; + }): void { + // TODO implementation + console.debug('handleDelete', { + event, + selection, + isForward, + force, + }); + } + + private handleInsert(event: Event | TypedInputEvent, data: string): void { + // TODO implementation + console.debug('handleInsert', event, data); + } + + private updateSelectionRange([from, to]: SelectionRange): void { + // TODO implementation + console.debug('updateSelectionRange', [from, to]); + } + + private updateValue(newValue: string): void { + // TODO implementation + console.debug('updateValue', newValue); + } + + private dispatchInputEvent( + eventInit: Pick = { + inputType: 'insertText', + data: null, + } + ): void { + // TODO implementation + console.debug('dispatchInputEvent', eventInit); + } +} diff --git a/core/src/utils/input-masking/public-api.ts b/core/src/utils/input-masking/public-api.ts new file mode 100644 index 0000000000..565d9f4eb1 --- /dev/null +++ b/core/src/utils/input-masking/public-api.ts @@ -0,0 +1 @@ +export { MaskVisibility, MaskExpression, MaskPlaceholder } from './types/mask-interface'; diff --git a/core/src/utils/input-masking/types/mask-interface.ts b/core/src/utils/input-masking/types/mask-interface.ts new file mode 100644 index 0000000000..2ddb4c3ddd --- /dev/null +++ b/core/src/utils/input-masking/types/mask-interface.ts @@ -0,0 +1,53 @@ +export interface TypedInputEvent extends InputEvent { + inputType: + | 'deleteByCut' // Ctrl (Command) + X + | 'deleteContentBackward' // Backspace + | 'deleteContentForward' // Delete (Fn + Backspace) + | 'deleteHardLineBackward' // Ctrl (Command) + Backspace + | 'deleteHardLineForward' + | 'deleteSoftLineBackward' // Ctrl (Command) + Backspace + | 'deleteSoftLineForward' + | 'deleteWordBackward' // Alt (Option) + Backspace + | 'deleteWordForward' // Alt (Option) + Delete (Fn + Backspace) + | 'historyRedo' // Ctrl (Command) + Shift + Z + | 'historyUndo' // Ctrl (Command) + Z + | 'insertCompositionText' + | 'insertFromDrop' + | 'insertFromPaste' // Ctrl (Command) + V + | 'insertLineBreak' + | 'insertReplacementText' + | 'insertText'; +} + +export type SelectionRange = [from: number, to: number]; + +export interface ElementState { + readonly value: string; + readonly selection: SelectionRange; +} + +export type MaskExpression = (RegExp | string)[] | RegExp; + +export type MaskPreprocessor = ( + _: { + elementState: ElementState; + data: string; + }, + actionType: 'deleteBackward' | 'deleteForward' | 'insert' | 'validation' +) => { + elementState: ElementState; + data?: string; +}; + +export type MaskPostprocessor = (elementState: ElementState, initialElementState: ElementState) => ElementState; + +export interface MaskOptions { + readonly mask: MaskExpression | ((elementState: ElementState) => MaskExpression); + readonly preprocessor?: MaskPreprocessor; + readonly postprocessor?: MaskPostprocessor; + readonly overwriteMode?: 'replace' | 'shift' | ((elementState: ElementState) => 'replace' | 'shift'); +} + +export type MaskVisibility = 'always' | 'focus' | 'never'; + +export type MaskPlaceholder = string | null | undefined; diff --git a/core/src/utils/input-masking/utils/get-not-empty-selection.spec.ts b/core/src/utils/input-masking/utils/get-not-empty-selection.spec.ts new file mode 100644 index 0000000000..ea50b08049 --- /dev/null +++ b/core/src/utils/input-masking/utils/get-not-empty-selection.spec.ts @@ -0,0 +1,64 @@ +import type { ElementState } from '../types/mask-interface'; + +import { getNotEmptySelection } from './get-not-empty-selection'; + +describe('getNotEmptySelection', () => { + it('should return the same selection when selection positions are not equal', () => { + const elementStateStub: ElementState = { + value: 'testValue', + selection: [1, 3], + }; + + expect(getNotEmptySelection(elementStateStub, true)).toEqual(elementStateStub.selection); + expect(getNotEmptySelection(elementStateStub, false)).toEqual(elementStateStub.selection); + }); + + describe('backward direction', () => { + it('should not change when start value is 0', () => { + const elementStateStub: ElementState = { + value: 'testValue', + selection: [0, 4], + }; + + expect(getNotEmptySelection(elementStateStub, false)).toEqual([0, 4]); + }); + + it('should decrease by one start value', () => { + const elementStateStub: ElementState = { + value: 'testValue', + selection: [1, 1], + }; + + expect(getNotEmptySelection(elementStateStub, false)).toEqual([0, 1]); + }); + }); + + describe('forward direction', () => { + it('should increase by one end position, when value`s length is more than the end position', () => { + const elementStateStub: ElementState = { + value: 'testValue', + selection: [2, 2], + }; + + expect(getNotEmptySelection(elementStateStub, true)).toEqual([2, 3]); + }); + + it('should return value length as end position, when value`s length is less or equal to the end position ', () => { + const elementStateStub: ElementState = { + value: 'sx', + selection: [4, 4], + }; + + expect(getNotEmptySelection(elementStateStub, true)).toEqual([2, 2]); + }); + + it('should increase by one end position, when value`s length equal end position is increased by one', () => { + const elementStateStub: ElementState = { + value: 'test1', + selection: [4, 4], + }; + + expect(getNotEmptySelection(elementStateStub, true)).toEqual([4, 5]); + }); + }); +}); diff --git a/core/src/utils/input-masking/utils/get-not-empty-selection.ts b/core/src/utils/input-masking/utils/get-not-empty-selection.ts new file mode 100644 index 0000000000..f1f7e207cd --- /dev/null +++ b/core/src/utils/input-masking/utils/get-not-empty-selection.ts @@ -0,0 +1,19 @@ +import type { ElementState, SelectionRange } from '../types/mask-interface'; + +/** + * Returns a selection that is not empty from the given element state. + * @param elementState The element state to get the selection from. + * @param isForward Whether the selection is forward or not. + * @returns The non-empty selection. + */ +export function getNotEmptySelection({ value, selection }: ElementState, isForward: boolean): SelectionRange { + const [from, to] = selection; + + if (from !== to) { + return [from, to]; + } + + const notEmptySelection = isForward ? [from, to + 1] : [from - 1, to]; + + return notEmptySelection.map((index) => Math.min(Math.max(index, 0), value.length)) as [number, number]; +} diff --git a/core/src/utils/input-masking/utils/identity.ts b/core/src/utils/input-masking/utils/identity.ts new file mode 100644 index 0000000000..6589842c03 --- /dev/null +++ b/core/src/utils/input-masking/utils/identity.ts @@ -0,0 +1,3 @@ +export function identity(x: T): T { + return x; +} diff --git a/core/src/utils/input-masking/utils/index.ts b/core/src/utils/input-masking/utils/index.ts new file mode 100644 index 0000000000..52d5758636 --- /dev/null +++ b/core/src/utils/input-masking/utils/index.ts @@ -0,0 +1,2 @@ +export * from './get-not-empty-selection'; +export * from './identity';