mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
5 Commits
fix/dateti
...
FW-2573-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29fd7ef90 | ||
|
|
2dc4ae57dc | ||
|
|
18c9ca141b | ||
|
|
918edf2f72 | ||
|
|
ba894d05a8 |
@@ -977,7 +977,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
|
||||
|
||||
|
||||
@ProxyCmp({
|
||||
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
|
||||
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'mask', 'maskPlaceholder', 'maskVisibility', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
|
||||
methods: ['setFocus', 'getInputElement']
|
||||
})
|
||||
@Component({
|
||||
@@ -985,7 +985,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: '<ng-content></ng-content>',
|
||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
|
||||
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'mask', 'maskPlaceholder', 'maskVisibility', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
|
||||
})
|
||||
export class IonInput {
|
||||
protected el: HTMLElement;
|
||||
|
||||
@@ -553,6 +553,9 @@ ion-input,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "
|
||||
ion-input,prop,label,string | undefined,undefined,false,false
|
||||
ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start",'start',false,false
|
||||
ion-input,prop,legacy,boolean | undefined,undefined,false,false
|
||||
ion-input,prop,mask,(string | RegExp)[] | RegExp | string | undefined,undefined,false,false
|
||||
ion-input,prop,maskPlaceholder,null | string | undefined,'_',false,false
|
||||
ion-input,prop,maskVisibility,"always" | "focus" | "never" | undefined,'always',false,false
|
||||
ion-input,prop,max,number | string | undefined,undefined,false,false
|
||||
ion-input,prop,maxlength,number | undefined,undefined,false,false
|
||||
ion-input,prop,min,number | string | undefined,undefined,false,false
|
||||
|
||||
26
core/src/components.d.ts
vendored
26
core/src/components.d.ts
vendored
@@ -17,6 +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/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";
|
||||
@@ -53,6 +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/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";
|
||||
@@ -1225,6 +1227,18 @@ export namespace Components {
|
||||
* Set the `legacy` property to `true` to forcibly use the legacy form control markup. Ionic will only opt components in to the modern form markup when they are using either the `aria-label` attribute or the `label` property. As a result, the `legacy` property should only be used as an escape hatch when you want to avoid this automatic opt-in behavior. Note that this property will be removed in an upcoming major release of Ionic, and all form components will be opted-in to using the modern form markup.
|
||||
*/
|
||||
"legacy"?: boolean;
|
||||
/**
|
||||
* The predefined format of the user's input. For example, you can set a mask that only accepts digits, or you can configure a more complex pattern like a phone number or credit card number. The mask supports two formats: 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) 2. An array containing regular expression and fixed character patterns The fixed characters in the mask cannot be erased or replaced by the user. For example in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
|
||||
*/
|
||||
"mask"?: MaskExpression | string;
|
||||
/**
|
||||
* Character or string to cover unfilled parts of the mask. The default character is `_`. If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
|
||||
*/
|
||||
"maskPlaceholder"?: MaskPlaceholder;
|
||||
/**
|
||||
* The visibility of the mask placeholder. With `always`, the placeholder will be visible even when the control does not have focus. With `focus`, the placeholder will only be visible when the control has focus. With `never`, the placeholder will never be visibly displayed.
|
||||
*/
|
||||
"maskVisibility"?: MaskVisibility;
|
||||
/**
|
||||
* The maximum value, which must not be less than its minimum (min attribute) value.
|
||||
*/
|
||||
@@ -5255,6 +5269,18 @@ declare namespace LocalJSX {
|
||||
* Set the `legacy` property to `true` to forcibly use the legacy form control markup. Ionic will only opt components in to the modern form markup when they are using either the `aria-label` attribute or the `label` property. As a result, the `legacy` property should only be used as an escape hatch when you want to avoid this automatic opt-in behavior. Note that this property will be removed in an upcoming major release of Ionic, and all form components will be opted-in to using the modern form markup.
|
||||
*/
|
||||
"legacy"?: boolean;
|
||||
/**
|
||||
* The predefined format of the user's input. For example, you can set a mask that only accepts digits, or you can configure a more complex pattern like a phone number or credit card number. The mask supports two formats: 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) 2. An array containing regular expression and fixed character patterns The fixed characters in the mask cannot be erased or replaced by the user. For example in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
|
||||
*/
|
||||
"mask"?: MaskExpression | string;
|
||||
/**
|
||||
* Character or string to cover unfilled parts of the mask. The default character is `_`. If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
|
||||
*/
|
||||
"maskPlaceholder"?: MaskPlaceholder;
|
||||
/**
|
||||
* The visibility of the mask placeholder. With `always`, the placeholder will be visible even when the control does not have focus. With `focus`, the placeholder will only be visible when the control has focus. With `never`, the placeholder will never be visibly displayed.
|
||||
*/
|
||||
"maskVisibility"?: MaskVisibility;
|
||||
/**
|
||||
* The maximum value, which must not be less than its minimum (min attribute) value.
|
||||
*/
|
||||
|
||||
@@ -8,6 +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 { MaskController, formatMask } 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';
|
||||
|
||||
@@ -31,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;
|
||||
@@ -272,6 +275,35 @@ export class Input implements ComponentInterface {
|
||||
*/
|
||||
@Prop({ mutable: true }) value?: string | number | null = '';
|
||||
|
||||
/**
|
||||
* The predefined format of the user's input. For example, you can set a mask
|
||||
* that only accepts digits, or you can configure a more complex pattern like
|
||||
* a phone number or credit card number.
|
||||
*
|
||||
* The mask supports two formats:
|
||||
* 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
|
||||
* 2. An array containing regular expression and fixed character patterns
|
||||
*
|
||||
* The fixed characters in the mask cannot be erased or replaced by the user. For example
|
||||
* in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
|
||||
*
|
||||
*/
|
||||
@Prop() mask?: MaskExpression | string;
|
||||
|
||||
/**
|
||||
* The visibility of the mask placeholder. With `always`, the placeholder will be
|
||||
* visible even when the control does not have focus. With `focus`, the placeholder
|
||||
* will only be visible when the control has focus. With `never`, the placeholder will
|
||||
* never be visibly displayed.
|
||||
*/
|
||||
@Prop() maskVisibility?: MaskVisibility = 'always';
|
||||
|
||||
/**
|
||||
* Character or string to cover unfilled parts of the mask. The default character is `_`.
|
||||
* If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
|
||||
*/
|
||||
@Prop() maskPlaceholder?: MaskPlaceholder = '_';
|
||||
|
||||
/**
|
||||
* The `ionInput` event fires when the `value` of an `<ion-input>` element
|
||||
* has been changed.
|
||||
@@ -342,10 +374,20 @@ export class Input implements ComponentInterface {
|
||||
this.emitStyle();
|
||||
}
|
||||
|
||||
@Watch('mask')
|
||||
protected maskChanged() {
|
||||
if (this.maskController) {
|
||||
this.maskController.destroy();
|
||||
}
|
||||
this.initInputMask();
|
||||
}
|
||||
|
||||
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']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,6 +409,8 @@ export class Input implements ComponentInterface {
|
||||
|
||||
componentDidLoad() {
|
||||
this.originalIonInput = this.ionInput;
|
||||
|
||||
this.initInputMask();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -377,6 +421,9 @@ export class Input implements ComponentInterface {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Todo - need to evaluate if I need to recreate this in connectedCallback after first load
|
||||
this.maskController?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,6 +483,18 @@ export class Input implements ComponentInterface {
|
||||
return clearOnEdit === undefined ? type === 'password' : clearOnEdit;
|
||||
}
|
||||
|
||||
private initInputMask() {
|
||||
const { mask, nativeInput } = this;
|
||||
if (mask !== undefined && nativeInput) {
|
||||
const formattedMask = formatMask(mask);
|
||||
if (formattedMask) {
|
||||
this.maskController = new MaskController(nativeInput, {
|
||||
mask: formattedMask,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getValue(): string {
|
||||
return typeof this.value === 'number' ? this.value.toString() : (this.value || '').toString();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { Input } from '../input';
|
||||
|
||||
it('should inherit attributes', async () => {
|
||||
@@ -7,7 +8,7 @@ it('should inherit attributes', async () => {
|
||||
html: '<ion-input title="my title" tabindex="-1" data-form-type="password"></ion-input>',
|
||||
});
|
||||
|
||||
const nativeEl = page.body.querySelector('ion-input input');
|
||||
const nativeEl = page.body.querySelector('ion-input input')!;
|
||||
expect(nativeEl.getAttribute('title')).toBe('my title');
|
||||
expect(nativeEl.getAttribute('tabindex')).toBe('-1');
|
||||
expect(nativeEl.getAttribute('data-form-type')).toBe('password');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
110
core/src/components/input/test/mask/index.html
Normal file
110
core/src/components/input/test/mask/index.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Input - Mask</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(250px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: #6f7378;
|
||||
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Input - Mask</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content id="content" class="ion-padding" color="light">
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>US Phone Number</h2>
|
||||
|
||||
<ion-input id="input-phone-us" label="Phone"></ion-input>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Dynamic Mask</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-input id="dynamicMaskInput" label="Enter a mask"></ion-input>
|
||||
<ion-input id="dynamicExample" label="Example"></ion-input>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const inputPhoneUS = document.querySelector('#input-phone-us');
|
||||
inputPhoneUS.mask = [
|
||||
'+',
|
||||
'1',
|
||||
' ',
|
||||
'(',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
')',
|
||||
' ',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
'-',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
];
|
||||
|
||||
inputPhoneUS.addEventListener('ionInput', (ev) => {
|
||||
console.log('** ionInput event **', ev.detail);
|
||||
});
|
||||
|
||||
inputPhoneUS.addEventListener('ionChange', (ev) => {
|
||||
console.log('** ionChange event **', ev.detail);
|
||||
});
|
||||
|
||||
const dynamicMaskInput = document.querySelector('#dynamicMaskInput');
|
||||
dynamicMaskInput.addEventListener('ionChange', (ev) => {
|
||||
const mask = ev.detail.value;
|
||||
const exampleInput = document.querySelector('#dynamicExample');
|
||||
console.log('setting the mask to: ', mask);
|
||||
exampleInput.mask = mask;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
44
core/src/components/input/test/mask/input.e2e.ts
Normal file
44
core/src/components/input/test/mask/input.e2e.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { configs } from '@utils/test/playwright';
|
||||
|
||||
import { test } from './mask-fixture';
|
||||
|
||||
configs({
|
||||
modes: ['md'],
|
||||
directions: ['ltr'],
|
||||
}).forEach(({ title, config }) => {
|
||||
test.describe(title('input: mask'), () => {
|
||||
test('should mask the input', async ({ maskPage }) => {
|
||||
// US Phone number
|
||||
await maskPage.init(config, [
|
||||
'+',
|
||||
'1',
|
||||
' ',
|
||||
'(',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
')',
|
||||
' ',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
'-',
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
/\d/,
|
||||
]);
|
||||
|
||||
await maskPage.typeAndBlur('5555555555');
|
||||
await maskPage.expectValue('+1 (555) 555-5555');
|
||||
});
|
||||
|
||||
test('should mask the input with a string', async ({ maskPage }) => {
|
||||
// Only allow lowercase letters
|
||||
await maskPage.init(config, '[a-z]$');
|
||||
|
||||
await maskPage.typeAndBlur('5abc123d');
|
||||
await maskPage.expectValue('abcd');
|
||||
});
|
||||
});
|
||||
});
|
||||
57
core/src/components/input/test/mask/mask-fixture.ts
Normal file
57
core/src/components/input/test/mask/mask-fixture.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { E2ELocator, E2EPage, TestConfig } from '@utils/test/playwright';
|
||||
import { test as base } from '@utils/test/playwright';
|
||||
import type { MaskExpression } from 'src/interface';
|
||||
|
||||
const stringifyMask = (mask: MaskExpression | string): string => {
|
||||
if (typeof mask === 'string') {
|
||||
return `'${mask}'`;
|
||||
}
|
||||
if (Array.isArray(mask)) {
|
||||
return `[${mask.map(stringifyMask).join(', ')}]`;
|
||||
}
|
||||
return mask.toString();
|
||||
};
|
||||
|
||||
class MaskPage {
|
||||
ionInput!: E2ELocator;
|
||||
nativeInput!: E2ELocator;
|
||||
|
||||
constructor(private page: E2EPage) {}
|
||||
|
||||
async init(config: TestConfig, mask: MaskExpression | string) {
|
||||
await this.page.setContent(
|
||||
`<ion-input></ion-input>
|
||||
<script>
|
||||
const input = document.querySelector('ion-input');
|
||||
input.mask = ${stringifyMask(mask)};
|
||||
</script>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
this.ionInput = this.page.locator('ion-input');
|
||||
this.nativeInput = this.page.locator('ion-input input');
|
||||
}
|
||||
|
||||
async typeAndBlur(value: string) {
|
||||
await this.ionInput.click();
|
||||
|
||||
await this.ionInput.type(value, { delay: 50 });
|
||||
await this.ionInput.blur();
|
||||
}
|
||||
|
||||
async expectValue(value: string) {
|
||||
expect(await this.ionInput.evaluate((node: HTMLIonInputElement) => node.value)).toBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
type MaskFixtures = {
|
||||
maskPage: MaskPage;
|
||||
};
|
||||
|
||||
export const test = base.extend<MaskFixtures>({
|
||||
maskPage: async ({ page }, use) => {
|
||||
await use(new MaskPage(page));
|
||||
},
|
||||
});
|
||||
1
core/src/interface.d.ts
vendored
1
core/src/interface.d.ts
vendored
@@ -43,6 +43,7 @@ export {
|
||||
AnimationKeyFrames,
|
||||
AnimationLifecycle,
|
||||
} from './utils/animation/animation-interface';
|
||||
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';
|
||||
|
||||
2
core/src/utils/input-masking/classes/index.ts
Normal file
2
core/src/utils/input-masking/classes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MaskHistory } from './mask-history';
|
||||
export { MaskModel } from './mask-model/mask-model';
|
||||
63
core/src/utils/input-masking/classes/mask-history.ts
Normal file
63
core/src/utils/input-masking/classes/mask-history.ts
Normal file
@@ -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<TypedInputEvent, 'data' | 'inputType'>
|
||||
): 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 });
|
||||
}
|
||||
}
|
||||
141
core/src/utils/input-masking/classes/mask-model/mask-model.ts
Normal file
141
core/src/utils/input-masking/classes/mask-model/mask-model.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ElementState, MaskExpression, MaskOptions, SelectionRange } from '../../types/mask-interface';
|
||||
import { areElementStatesEqual } from '../../utils';
|
||||
|
||||
import { applyOverwriteMode } from './utils/apply-overwrite-mode';
|
||||
import { calibrateValueByMask } from './utils/calibrate-value-by-mask';
|
||||
import { removeFixedMaskCharacters } from './utils/remove-fixed-mask-characters';
|
||||
|
||||
export class MaskModel implements ElementState {
|
||||
value = '';
|
||||
selection: SelectionRange = [0, 0];
|
||||
|
||||
constructor(readonly initialElementState: ElementState, private readonly maskOptions: Required<MaskOptions>) {
|
||||
const { value, selection } = calibrateValueByMask(initialElementState, this.getMaskExpression(initialElementState));
|
||||
|
||||
this.value = value;
|
||||
this.selection = selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts new characters into the input value at the specified selection range.
|
||||
*
|
||||
* @param selectionRange - An array containing the start and end indices of the selection range where new characters should be inserted.
|
||||
* @param newCharacters - The string of new characters to insert into the input value.
|
||||
* @throws An error if the resulting masked value is invalid or the new characters do not change the value.
|
||||
*/
|
||||
addCharacters([from, to]: SelectionRange, newCharacters: string): void {
|
||||
const { value } = this;
|
||||
|
||||
// Get the mask expression for the updated value with the new characters inserted.
|
||||
const maskExpression = this.getMaskExpression({
|
||||
value: value.slice(0, from) + newCharacters + value.slice(to),
|
||||
selection: [from + newCharacters.length, from + newCharacters.length],
|
||||
});
|
||||
|
||||
// Create an initial element state with the original value and selection.
|
||||
const initialElementState = { value, selection: [from, to] } as ElementState;
|
||||
|
||||
// Remove fixed mask characters from the value within the selection range.
|
||||
const unmaskedElementState = removeFixedMaskCharacters(initialElementState, maskExpression);
|
||||
|
||||
// Apply the overwrite mode to the new characters and get the updated selection range within the unmasked value.
|
||||
const [unmaskedFrom, unmaskedTo] = applyOverwriteMode(
|
||||
unmaskedElementState,
|
||||
newCharacters,
|
||||
this.maskOptions.overwriteMode
|
||||
).selection;
|
||||
|
||||
// Create a new unmasked value with the new characters inserted.
|
||||
const newUnmaskedLeadingValuePart = unmaskedElementState.value.slice(0, unmaskedFrom) + newCharacters;
|
||||
|
||||
// Set the new caret index to the end of the new leading value part.
|
||||
const newCaretIndex = newUnmaskedLeadingValuePart.length;
|
||||
|
||||
// Calibrate the new unmasked value by the mask expression to get the new masked value and selection.
|
||||
const maskedElementState = calibrateValueByMask(
|
||||
{
|
||||
value: newUnmaskedLeadingValuePart + unmaskedElementState.value.slice(unmaskedTo),
|
||||
selection: [newCaretIndex, newCaretIndex],
|
||||
},
|
||||
maskExpression,
|
||||
initialElementState
|
||||
);
|
||||
|
||||
// Check if the insertion of new characters is invalid.
|
||||
const isInvalidCharsInsertion =
|
||||
value.slice(0, unmaskedFrom) ===
|
||||
calibrateValueByMask(
|
||||
{
|
||||
value: newUnmaskedLeadingValuePart,
|
||||
selection: [newCaretIndex, newCaretIndex],
|
||||
},
|
||||
maskExpression,
|
||||
initialElementState
|
||||
).value;
|
||||
|
||||
// Throw an error if the insertion is invalid or the new characters do not change the value.
|
||||
if (isInvalidCharsInsertion || areElementStatesEqual(this, maskedElementState)) {
|
||||
throw new Error('Invalid mask value');
|
||||
}
|
||||
|
||||
// Set the component's value and selection to the masked value and selection.
|
||||
this.value = maskedElementState.value;
|
||||
this.selection = maskedElementState.selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes characters from the input value within the given selection range.
|
||||
* The characters to be deleted are replaced with empty space characters.
|
||||
*
|
||||
* @param selectionRange - An array containing the start and end indices of the text to delete.
|
||||
*/
|
||||
deleteCharacters([from, to]: SelectionRange): void {
|
||||
// If the selection range is empty or undefined, do nothing.
|
||||
if (from === to || to === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { value } = this;
|
||||
|
||||
// Get the mask expression for the updated value with the selected text deleted.
|
||||
const maskExpression = this.getMaskExpression({
|
||||
value: value.slice(0, from) + value.slice(to),
|
||||
selection: [from, from],
|
||||
});
|
||||
|
||||
// Create an initial element state with the original value and selection.
|
||||
const initialElementState = { value, selection: [from, to] } as ElementState;
|
||||
|
||||
// Remove fixed mask characters from the value within the selection range.
|
||||
const unmaskedElementState = removeFixedMaskCharacters(initialElementState, maskExpression);
|
||||
|
||||
// Get the new selection range within the unmasked value.
|
||||
const [unmaskedFrom, unmaskedTo] = unmaskedElementState.selection;
|
||||
|
||||
// Create a new unmasked value within selected text deleted.
|
||||
const newUnmaskedValue =
|
||||
unmaskedElementState.value.slice(0, unmaskedFrom) + unmaskedElementState.value.slice(unmaskedTo);
|
||||
|
||||
// Calibrate the unmasked value by the mask expression to get the final masked value.
|
||||
const maskedElementState = calibrateValueByMask(
|
||||
{ value: newUnmaskedValue, selection: [unmaskedFrom, unmaskedFrom] },
|
||||
maskExpression,
|
||||
initialElementState
|
||||
);
|
||||
|
||||
// Set the component's value and selection to the masked value and selection.
|
||||
this.value = maskedElementState.value;
|
||||
this.selection = maskedElementState.selection;
|
||||
}
|
||||
|
||||
private getMaskExpression(elementState: ElementState): MaskExpression {
|
||||
const { mask } = this.maskOptions;
|
||||
/**
|
||||
* Ionic Framework does not currently allow developers to use
|
||||
* a function as a mask, e.g.: (elementState) => maskExpression.
|
||||
*
|
||||
* However, we are keeping this code here for future reference.
|
||||
*/
|
||||
return typeof mask === 'function' ? mask(elementState) : mask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ElementState, MaskOptions, SelectionRange } from '../../../types/mask-interface';
|
||||
|
||||
/**
|
||||
* Applies the specified overwrite mode to the selection range within the given element state.
|
||||
*
|
||||
* @param elementState - An object containing the current input value and selection range.
|
||||
* @param newCharacters - The string of new characters to insert into the input value.
|
||||
* @param mode - The overwrite mode to apply. This can be either a string ('replace' or 'preserve'), or a function that takes the element state as input and returns a string.
|
||||
* @returns A new element state object with the updated selection range based on the overwrite mode.
|
||||
*/
|
||||
export function applyOverwriteMode(
|
||||
{ value, selection }: ElementState,
|
||||
newCharacters: string,
|
||||
mode: MaskOptions['overwriteMode']
|
||||
): ElementState {
|
||||
const [from, to] = selection;
|
||||
|
||||
// Compute the overwrite mode based on the mode argument
|
||||
const computedMode = typeof mode === 'function' ? mode({ value, selection }) : mode;
|
||||
|
||||
// Determine the updated selection range based on the overwrite mode.
|
||||
const newSelection: SelectionRange = computedMode === 'replace' ? [from, from + newCharacters.length] : [from, to];
|
||||
|
||||
// Return a new element state object with the updated selection range
|
||||
return {
|
||||
value,
|
||||
selection: newSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ElementState, MaskExpression } from '../../../types/mask-interface';
|
||||
|
||||
import { guessValidValueByPattern } from './guess-valid-value-by-pattern';
|
||||
import { guessValidValueByRegExp } from './guess-valid-value-by-reg-exp';
|
||||
import { validateValueWithMask } from './validate-value-with-mask';
|
||||
|
||||
export function calibrateValueByMask(
|
||||
elementState: ElementState,
|
||||
mask: MaskExpression,
|
||||
initialElementState: ElementState | null = null
|
||||
): ElementState {
|
||||
if (validateValueWithMask(elementState.value, mask)) {
|
||||
// If the value is valid with the mask, then we don't need to calibrate it.
|
||||
return elementState;
|
||||
}
|
||||
|
||||
const { value, selection } = Array.isArray(mask)
|
||||
? guessValidValueByPattern(elementState, mask, initialElementState)
|
||||
: guessValidValueByRegExp(elementState, mask);
|
||||
|
||||
return {
|
||||
selection,
|
||||
value: Array.isArray(mask) ? value.slice(0, mask.length) : value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ElementState } from '../../../types/mask-interface';
|
||||
|
||||
import { isFixedCharacter } from './is-fixed-character';
|
||||
|
||||
/**
|
||||
* Returns a string of fixed characters from the beginning of the mask.
|
||||
*
|
||||
* @param mask - An array of mask constraints, where each constraint can be either a string or a regular expression.
|
||||
* @param validatedValuePart - The part of the input value that has already been validated against the mask.
|
||||
* @param newCharacter - The new character being added to the input value.
|
||||
* @param initialElementState - The initial state of the input element, containing the original value and selection range.
|
||||
* @returns A string of fixed characters from the beginning of the mask.
|
||||
*/
|
||||
export function getLeadingFixedCharacters(
|
||||
mask: (RegExp | string)[],
|
||||
validatedValuePart: string,
|
||||
newCharacter: string,
|
||||
initialElementState: ElementState | null
|
||||
): string {
|
||||
let leadingFixedCharacters = '';
|
||||
|
||||
for (let i = validatedValuePart.length; i < mask.length; i++) {
|
||||
const charConstraint = mask[i];
|
||||
// Check if the character constraint exists in the initial element state.
|
||||
const isInitiallyExisted = initialElementState?.value[i] === charConstraint;
|
||||
|
||||
if (!isFixedCharacter(charConstraint) || (charConstraint === newCharacter && !isInitiallyExisted)) {
|
||||
return leadingFixedCharacters;
|
||||
}
|
||||
|
||||
leadingFixedCharacters += charConstraint;
|
||||
}
|
||||
|
||||
return leadingFixedCharacters;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ElementState } from '../../../types/mask-interface';
|
||||
|
||||
import { getLeadingFixedCharacters } from './get-leading-fixed-characters';
|
||||
import { isFixedCharacter } from './is-fixed-character';
|
||||
import { validateValueWithMask } from './validate-value-with-mask';
|
||||
|
||||
/**
|
||||
* Attempts to guess a valid input value by matching the input against a mask pattern.
|
||||
*
|
||||
* @param elementState - An object containing the current input value and selection range.
|
||||
* @param mask - An array of mask constraints, where each constraint can be either a string or a regular expression.
|
||||
* @param initialElementState - The initial state of the input element, containing the original value and selection range.
|
||||
* @returns An element state object containing a potentially valid input value and updated selection range.
|
||||
*/
|
||||
export function guessValidValueByPattern(
|
||||
elementState: ElementState,
|
||||
mask: (RegExp | string)[],
|
||||
initialElementState: ElementState | null
|
||||
): ElementState {
|
||||
let maskedFrom: number | null = null;
|
||||
let maskedTo: number | null = null;
|
||||
|
||||
const maskedValue = Array.from(elementState.value).reduce((validatedCharacters, char, charIndex) => {
|
||||
// Leading fixed characters may be a + or - sign, or a prefix like $.
|
||||
const leadingFixedCharacters = getLeadingFixedCharacters(mask, validatedCharacters, char, initialElementState);
|
||||
const newValidatedChars = validatedCharacters + leadingFixedCharacters;
|
||||
const charConstraint = mask[newValidatedChars.length];
|
||||
|
||||
if (isFixedCharacter(charConstraint)) {
|
||||
// If the character constraint is a fixed character, then we don't need to check it.
|
||||
return newValidatedChars + charConstraint;
|
||||
}
|
||||
|
||||
if (!char.match(charConstraint)) {
|
||||
return newValidatedChars;
|
||||
}
|
||||
|
||||
if (maskedFrom === null && charIndex >= elementState.selection[0]) {
|
||||
maskedFrom = newValidatedChars.length;
|
||||
}
|
||||
|
||||
if (maskedTo === null && charIndex >= elementState.selection[1]) {
|
||||
maskedTo = newValidatedChars.length;
|
||||
}
|
||||
|
||||
return newValidatedChars + char;
|
||||
}, '');
|
||||
|
||||
// Get the trailing fixed characters to add to the potentially valid value.
|
||||
const trailingFixedCharacters = getLeadingFixedCharacters(mask, maskedValue, '', initialElementState);
|
||||
|
||||
// Return a new element state with the updated value and selection.
|
||||
return {
|
||||
value: validateValueWithMask(maskedValue + trailingFixedCharacters, mask)
|
||||
? maskedValue + trailingFixedCharacters
|
||||
: maskedValue,
|
||||
selection: [maskedFrom ?? maskedValue.length, maskedTo ?? maskedValue.length],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ElementState } from '../../../types/mask-interface';
|
||||
|
||||
/**
|
||||
* Attempts to guess a valid input value by matching the value against a mask regular expression.
|
||||
*
|
||||
* @param elementState - An object containing the current input value and selection range.
|
||||
* @param maskRegExp - A regular expression used to validate the input value.
|
||||
* @returns An element state object containing a potentially valid input value and updated selection range.
|
||||
*/
|
||||
export function guessValidValueByRegExp({ value, selection }: ElementState, maskRegExp: RegExp): ElementState {
|
||||
const [from, to] = selection;
|
||||
let newFrom = from;
|
||||
let newTo = to;
|
||||
|
||||
// Checks the value one character at a time against the mask regular expression.
|
||||
const validatedValue = Array.from(value).reduce((validatedValuePart, char, i) => {
|
||||
const newPossibleValue = validatedValuePart + char;
|
||||
|
||||
if (from === i) {
|
||||
newFrom = validatedValuePart.length;
|
||||
}
|
||||
|
||||
if (to === i) {
|
||||
newTo = validatedValuePart.length;
|
||||
}
|
||||
|
||||
return newPossibleValue.match(maskRegExp)
|
||||
? // If the new possible value matches the mask regular expression, then we return it.
|
||||
newPossibleValue
|
||||
: // Otherwise, we return the previous validated value part.
|
||||
validatedValuePart;
|
||||
}, '');
|
||||
|
||||
// Return a new element state with the valid value and updated selection range.
|
||||
return { value: validatedValue, selection: [newFrom, newTo] };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isFixedCharacter } from './is-fixed-character';
|
||||
|
||||
describe('isFixedCharacter()', () => {
|
||||
it('should return true if the given character is a string', () => {
|
||||
expect(isFixedCharacter('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the given character is a RegExp', () => {
|
||||
expect(isFixedCharacter(/[a-z]/)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Checks if the given character is a fixed character.
|
||||
* @param char The character to check.
|
||||
* @returns `true` if the given character is a fixed character, `false` otherwise.
|
||||
*/
|
||||
export function isFixedCharacter(char: RegExp | string): char is string {
|
||||
return typeof char === 'string';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ElementState, MaskExpression } from '../../../types/mask-interface';
|
||||
|
||||
import { isFixedCharacter } from './is-fixed-character';
|
||||
|
||||
/**
|
||||
* Removes all fixed characters from the value of the given element state.
|
||||
* A fixed character is a character in the mask that is not allowed to change.
|
||||
* @param initialElementState The initial element state containing the value and the selection.
|
||||
* @param mask The mask expression defining the fixed and variable characters allowed at each position of the value.
|
||||
* @returns A new element state with the unmasked value and updated selection indices.
|
||||
*/
|
||||
export function removeFixedMaskCharacters(initialElementState: ElementState, mask: MaskExpression): ElementState {
|
||||
// If a mask is not an array, it cannot contain fixed characters.
|
||||
if (!Array.isArray(mask)) {
|
||||
return initialElementState;
|
||||
}
|
||||
|
||||
// Get the start and end indices of the selection.
|
||||
const [from, to] = initialElementState.selection;
|
||||
const selection: number[] = [];
|
||||
|
||||
// Loop over each character in the initial value and remove all fixed characters.
|
||||
const unmaskedValue = Array.from(initialElementState.value).reduce((rawValue, char, i) => {
|
||||
const charConstraint = mask[i];
|
||||
|
||||
// Add selection index to array if the character is at the start or end of the selection.
|
||||
if (i === from) {
|
||||
selection.push(rawValue.length);
|
||||
}
|
||||
|
||||
if (i === to) {
|
||||
selection.push(rawValue.length);
|
||||
}
|
||||
|
||||
// Add character to the unmasked value if it is not a fixed character or does not match a mask constraint.
|
||||
return isFixedCharacter(charConstraint) && charConstraint === char ? rawValue : rawValue + char;
|
||||
}, '');
|
||||
|
||||
// Append the length of the unmasked value to the selection array if it has less than two values.
|
||||
if (selection.length < 2) {
|
||||
selection.push(...Array(2 - selection.length).fill(unmaskedValue.length));
|
||||
}
|
||||
|
||||
// Return the new element state with the unmasked value and updated selection indices.
|
||||
return {
|
||||
value: unmaskedValue,
|
||||
selection: [selection[0], selection[1]],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MaskExpression } from '../../../types/mask-interface';
|
||||
|
||||
import { isFixedCharacter } from './is-fixed-character';
|
||||
|
||||
/**
|
||||
* Validates the given value with the given mask expression.
|
||||
* @param value The value to validate.
|
||||
* @param maskExpression The mask expression to validate the value with.
|
||||
* @returns `true` if the given value is valid with the given mask expression, `false` otherwise.
|
||||
*/
|
||||
export function validateValueWithMask(value: string, maskExpression: MaskExpression): boolean {
|
||||
if (Array.isArray(maskExpression)) {
|
||||
/**
|
||||
* If the mask expression is an array, check if the the length of the value matches the length of the mask,
|
||||
* and if each character in the values matches the corresponding mask constraint.
|
||||
*/
|
||||
return (
|
||||
value.length === maskExpression.length &&
|
||||
Array.from(value).every((char, i) => {
|
||||
const charConstraint = maskExpression[i];
|
||||
|
||||
return isFixedCharacter(charConstraint) ? char === charConstraint : char.match(charConstraint);
|
||||
})
|
||||
);
|
||||
}
|
||||
// If the mask expression is a regular expression, test whether the value matches the expression.
|
||||
return maskExpression.test(value);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MaskOptions } from '../types/mask-interface';
|
||||
import { identity } from '../utils';
|
||||
|
||||
export const MASK_DEFAULT_OPTIONS: Required<MaskOptions> = {
|
||||
mask: /^.*$/,
|
||||
preprocessor: identity,
|
||||
postprocessor: identity,
|
||||
overwriteMode: 'shift',
|
||||
};
|
||||
1
core/src/utils/input-masking/constants/index.ts
Normal file
1
core/src/utils/input-masking/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './default-options';
|
||||
30
core/src/utils/input-masking/dom/event-listener.ts
Normal file
30
core/src/utils/input-masking/dom/event-listener.ts
Normal file
@@ -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<E extends keyof HTMLElementEventMap>(
|
||||
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<E>(eventType, untypedFn, options);
|
||||
this.listeners.push(() => this.element.removeEventListener(eventType, untypedFn));
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.listeners.forEach((removeListener) => removeListener());
|
||||
}
|
||||
}
|
||||
3
core/src/utils/input-masking/dom/index.ts
Normal file
3
core/src/utils/input-masking/dom/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './event-listener';
|
||||
export * from './is-event-producing-character';
|
||||
export * from './is-before-input-event-supported';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
2
core/src/utils/input-masking/index.ts
Normal file
2
core/src/utils/input-masking/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MaskController } from './mask-controller';
|
||||
export { formatMask } from './utils';
|
||||
273
core/src/utils/input-masking/mask-controller.ts
Normal file
273
core/src/utils/input-masking/mask-controller.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { MaskHistory, MaskModel } 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 { areElementValuesEqual, getLineSelection, getNotEmptySelection, getWordSelection } from './utils';
|
||||
import { maskTransform } from './utils/transform';
|
||||
|
||||
/**
|
||||
* 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<MaskOptions> = {
|
||||
...MASK_DEFAULT_OPTIONS,
|
||||
...this.maskOptions,
|
||||
};
|
||||
|
||||
constructor(private readonly element: HTMLInputElement, private readonly maskOptions: MaskOptions) {
|
||||
super();
|
||||
this.ensureValueFitsMask();
|
||||
this.updateHistory(this.elementState);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
this.eventListener.listen('keydown', (_event) => {
|
||||
// TODO handle undo and redo
|
||||
});
|
||||
|
||||
if (isBeforeInputEventSupported(element)) {
|
||||
this.eventListener.listen('beforeinput', (event) => {
|
||||
const isForward = event.inputType.includes('Forward');
|
||||
|
||||
this.updateHistory(this.elementState);
|
||||
|
||||
switch (event.inputType) {
|
||||
case 'deleteByCut':
|
||||
case 'deleteContentBackward':
|
||||
case 'deleteContentForward':
|
||||
return this.handleDelete({
|
||||
event,
|
||||
isForward,
|
||||
selection: getNotEmptySelection(this.elementState, isForward),
|
||||
});
|
||||
case 'deleteWordForward':
|
||||
case 'deleteWordBackward':
|
||||
return this.handleDelete({
|
||||
event,
|
||||
isForward,
|
||||
selection: getWordSelection(this.elementState, isForward),
|
||||
force: true,
|
||||
});
|
||||
case 'deleteSoftLineBackward':
|
||||
case 'deleteSoftLineForward':
|
||||
case 'deleteHardLineBackward':
|
||||
case 'deleteHardLineForward':
|
||||
return this.handleDelete({
|
||||
event,
|
||||
isForward,
|
||||
selection: getLineSelection(this.elementState, isForward),
|
||||
force: true,
|
||||
});
|
||||
case 'insertText':
|
||||
default:
|
||||
return this.handleInsert(event, event.data || '');
|
||||
}
|
||||
});
|
||||
} 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', () => {
|
||||
this.ensureValueFitsMask();
|
||||
this.updateHistory(this.elementState);
|
||||
});
|
||||
}
|
||||
|
||||
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<TypedInputEvent, 'data' | 'inputType'> = {
|
||||
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 {
|
||||
this.updateElementState(maskTransform(this.elementState, this.options));
|
||||
}
|
||||
|
||||
private handleDelete({
|
||||
event,
|
||||
selection,
|
||||
isForward,
|
||||
force = false,
|
||||
}: {
|
||||
event: Event | TypedInputEvent;
|
||||
selection: SelectionRange;
|
||||
isForward: boolean;
|
||||
force?: boolean;
|
||||
}): void {
|
||||
const initialState: ElementState = {
|
||||
value: this.elementState.value,
|
||||
selection,
|
||||
};
|
||||
const [initialFrom, initialTo] = initialState.selection;
|
||||
const { elementState } = this.options.preprocessor(
|
||||
{
|
||||
elementState: initialState,
|
||||
data: '',
|
||||
},
|
||||
isForward ? 'deleteForward' : 'deleteBackward',
|
||||
);
|
||||
const maskModel = new MaskModel(elementState, this.options);
|
||||
const [from, to] = elementState.selection;
|
||||
|
||||
maskModel.deleteCharacters([from, to]);
|
||||
|
||||
const newElementState = this.options.postprocessor(maskModel, initialState);
|
||||
const newPossibleValue =
|
||||
initialState.value.slice(0, initialFrom) +
|
||||
initialState.value.slice(initialTo);
|
||||
|
||||
if (newPossibleValue === newElementState.value && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (areElementValuesEqual(initialState, elementState, maskModel, newElementState)) {
|
||||
// User presses Backspace/Delete for the fixed value
|
||||
return this.updateSelectionRange(isForward ? [to, to] : [from, from]);
|
||||
}
|
||||
|
||||
// TODO: drop it when `event: Event | TypedInputEvent` => `event: TypedInputEvent`
|
||||
const inputTypeFallback = isForward
|
||||
? 'deleteContentForward'
|
||||
: 'deleteContentBackward';
|
||||
|
||||
this.updateElementState(newElementState, {
|
||||
inputType: 'inputType' in event ? event.inputType : inputTypeFallback,
|
||||
data: null,
|
||||
});
|
||||
this.updateHistory(newElementState);
|
||||
}
|
||||
|
||||
private handleInsert(event: Event | TypedInputEvent, data: string): void {
|
||||
// Store the initial element state before processing the input.
|
||||
const initialElementState = this.elementState;
|
||||
|
||||
// Preprocess the input using the preprocessor function.
|
||||
const { elementState, data: insertedText = data } = this.options.preprocessor(
|
||||
{
|
||||
data,
|
||||
elementState: initialElementState,
|
||||
},
|
||||
'insert'
|
||||
);
|
||||
|
||||
// Create a new MaskModel object and attempt to add new characters to the input.
|
||||
const maskModel = new MaskModel(elementState, this.options);
|
||||
try {
|
||||
maskModel.addCharacters(elementState.selection, insertedText);
|
||||
} catch {
|
||||
// If adding new characters fails, prevent the default behavior of the input event.
|
||||
return event.preventDefault();
|
||||
}
|
||||
|
||||
// Compute the new input value and element state after adding the new characters.
|
||||
const [from, to] = elementState.selection;
|
||||
const newPossibleValue = elementState.value.slice(0, from) + data + elementState.value.slice(to);
|
||||
|
||||
// Postprocess the input using the postprocessor function.
|
||||
const newElementState = this.options.postprocessor(maskModel, initialElementState);
|
||||
|
||||
// If the new input value is different from the computed value, update the input element and history.
|
||||
if (newPossibleValue !== newElementState.value) {
|
||||
event.preventDefault();
|
||||
|
||||
// Update the element state and trigger a new input event with updated data.
|
||||
this.updateElementState(newElementState, {
|
||||
data,
|
||||
inputType: 'inputType' in event ? event.inputType : 'insertText',
|
||||
});
|
||||
this.updateHistory(newElementState);
|
||||
}
|
||||
}
|
||||
|
||||
private updateSelectionRange([from, to]: SelectionRange): void {
|
||||
const { element } = this;
|
||||
if (element.selectionStart !== from || element.selectionEnd !== to) {
|
||||
element.setSelectionRange?.(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
private updateValue(newValue: string): void {
|
||||
if (this.element.value !== newValue) {
|
||||
this.element.value = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchInputEvent(
|
||||
eventInit: Pick<TypedInputEvent, 'data' | 'inputType'> = {
|
||||
inputType: 'insertText',
|
||||
data: null,
|
||||
}
|
||||
): void {
|
||||
if (globalThis?.InputEvent !== undefined) {
|
||||
this.element.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
...eventInit,
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
core/src/utils/input-masking/public-api.ts
Normal file
1
core/src/utils/input-masking/public-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MaskVisibility, MaskExpression, MaskPlaceholder } from './types/mask-interface';
|
||||
62
core/src/utils/input-masking/types/mask-interface.ts
Normal file
62
core/src/utils/input-masking/types/mask-interface.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly preprocessor?: MaskPreprocessor;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly postprocessor?: MaskPostprocessor;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly overwriteMode?: 'replace' | 'shift' | ((elementState: ElementState) => 'replace' | 'shift');
|
||||
}
|
||||
|
||||
export type MaskVisibility = 'always' | 'focus' | 'never';
|
||||
|
||||
export type MaskPlaceholder = string | null | undefined;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ElementState } from '../types/mask-interface';
|
||||
|
||||
import { areElementStatesEqual, areElementValuesEqual } from './element-states-equality';
|
||||
|
||||
describe('areElementValuesEqual', () => {
|
||||
it('should return true if the values of the given states are equal', () => {
|
||||
const sampleState = { value: 'a', selection: [0, 0] } as ElementState;
|
||||
const states = [{ value: 'a', selection: [0, 0] }] as ElementState[];
|
||||
|
||||
expect(areElementValuesEqual(sampleState, ...states)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the values of the given states are not equal', () => {
|
||||
const sampleState = { value: 'a', selection: [0, 0] } as ElementState;
|
||||
const states = [{ value: 'b', selection: [0, 0] }] as ElementState[];
|
||||
|
||||
expect(areElementValuesEqual(sampleState, ...states)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areElementStatesEqual', () => {
|
||||
it('should return true if the states are equal', () => {
|
||||
const sampleState = { value: 'a', selection: [0, 0] } as ElementState;
|
||||
const states = [{ value: 'a', selection: [0, 0] }] as ElementState[];
|
||||
|
||||
expect(areElementStatesEqual(sampleState, ...states)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the state values are not equal', () => {
|
||||
const sampleState = { value: 'a', selection: [0, 0] } as ElementState;
|
||||
const states = [{ value: 'b', selection: [0, 0] }] as ElementState[];
|
||||
|
||||
expect(areElementStatesEqual(sampleState, ...states)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the state selections are not equal', () => {
|
||||
const sampleState = { value: 'a', selection: [0, 0] } as ElementState;
|
||||
const states = [{ value: 'a', selection: [0, 1] }] as ElementState[];
|
||||
|
||||
expect(areElementStatesEqual(sampleState, ...states)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ElementState } from '../types/mask-interface';
|
||||
|
||||
/**
|
||||
* Checks if the values of the given states are equal.
|
||||
* @param sampleState The state to compare with.
|
||||
* @param states The states to compare.
|
||||
* @returns `true` if the values of the given states are equal, `false` otherwise.
|
||||
*/
|
||||
export function areElementValuesEqual(sampleState: ElementState, ...states: ElementState[]): boolean {
|
||||
return states.every(({ value }) => value === sampleState.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the states are equal (value and selection).
|
||||
* @param sampleState The state to compare with.
|
||||
* @param states The states to compare.
|
||||
* @returns `true` if the states are equal, `false` otherwise.
|
||||
*/
|
||||
export function areElementStatesEqual(sampleState: ElementState, ...states: ElementState[]): boolean {
|
||||
return states.every(
|
||||
({ value, selection }) =>
|
||||
value === sampleState.value &&
|
||||
selection[0] === sampleState.selection[0] &&
|
||||
selection[1] === sampleState.selection[1]
|
||||
);
|
||||
}
|
||||
17
core/src/utils/input-masking/utils/format-mask.spec.ts
Normal file
17
core/src/utils/input-masking/utils/format-mask.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { formatMask } from './format-mask';
|
||||
|
||||
describe('formatMask', () => {
|
||||
it('should return a RegExp if the given mask is a string', () => {
|
||||
expect(formatMask('a')).toBeInstanceOf(RegExp);
|
||||
expect(formatMask('a')).toEqual(/a/);
|
||||
});
|
||||
|
||||
it('should return null if the given mask is an invalid regular expression', () => {
|
||||
const originalError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
expect(formatMask('[')).toBeNull();
|
||||
|
||||
console.error = originalError;
|
||||
});
|
||||
});
|
||||
26
core/src/utils/input-masking/utils/format-mask.ts
Normal file
26
core/src/utils/input-masking/utils/format-mask.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { printIonError } from '@utils/logging';
|
||||
|
||||
import type { MaskExpression } from '../public-api';
|
||||
|
||||
/**
|
||||
* Formats a mask expression into a supported format.
|
||||
* @param mask The mask to format.
|
||||
* @returns The formatted mask.
|
||||
*/
|
||||
export function formatMask(mask: string | MaskExpression): MaskExpression | null {
|
||||
if (typeof mask === 'string') {
|
||||
try {
|
||||
/**
|
||||
* Incoming masks can be a string, representing a regular expression.
|
||||
* If it is, we need to convert it to a RegExp object.
|
||||
*
|
||||
* e.g.: 'a' => /a/
|
||||
*/
|
||||
return new RegExp(mask);
|
||||
} catch (error) {
|
||||
printIonError('Failed to format mask.', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
21
core/src/utils/input-masking/utils/get-line-selection.ts
Normal file
21
core/src/utils/input-masking/utils/get-line-selection.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ElementState, SelectionRange } from "../types/mask-interface";
|
||||
|
||||
export function getLineSelection(
|
||||
{ value, selection }: ElementState,
|
||||
isForward: boolean,
|
||||
): SelectionRange {
|
||||
const [from, to] = selection;
|
||||
|
||||
if (from !== to) {
|
||||
return [from, to];
|
||||
}
|
||||
|
||||
const nearestBreak = isForward
|
||||
? value.slice(from).indexOf('\n') + 1 || value.length
|
||||
: value.slice(0, to).lastIndexOf('\n') + 1;
|
||||
|
||||
const selectFrom = isForward ? from : nearestBreak;
|
||||
const selectTo = isForward ? nearestBreak : to;
|
||||
|
||||
return [selectFrom, selectTo];
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
46
core/src/utils/input-masking/utils/get-word-selection.ts
Normal file
46
core/src/utils/input-masking/utils/get-word-selection.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ElementState, SelectionRange } from "../types/mask-interface";
|
||||
|
||||
const TRAILING_SPACES_REG = /\s+$/g;
|
||||
const LEADING_SPACES_REG = /^\s+/g;
|
||||
const SPACE_REG = /\s/;
|
||||
|
||||
export function getWordSelection(
|
||||
{ value, selection }: ElementState,
|
||||
isForward: boolean,
|
||||
): SelectionRange {
|
||||
const [from, to] = selection;
|
||||
|
||||
if (from !== to) {
|
||||
return [from, to];
|
||||
}
|
||||
|
||||
if (isForward) {
|
||||
const valueAfterSelectionStart = value.slice(from);
|
||||
const [leadingSpaces] = valueAfterSelectionStart.match(LEADING_SPACES_REG) || [
|
||||
'',
|
||||
];
|
||||
const nearestWordEndIndex = valueAfterSelectionStart
|
||||
.trimStart()
|
||||
.search(SPACE_REG);
|
||||
|
||||
return [
|
||||
from,
|
||||
nearestWordEndIndex !== -1
|
||||
? from + leadingSpaces.length + nearestWordEndIndex
|
||||
: value.length,
|
||||
];
|
||||
}
|
||||
|
||||
const valueBeforeSelectionEnd = value.slice(0, to);
|
||||
const [trailingSpaces] = valueBeforeSelectionEnd.match(TRAILING_SPACES_REG) || [''];
|
||||
const selectedWordLength = valueBeforeSelectionEnd
|
||||
.trimEnd()
|
||||
.split('')
|
||||
.reverse()
|
||||
.findIndex(char => char.match(SPACE_REG));
|
||||
|
||||
return [
|
||||
selectedWordLength !== -1 ? to - trailingSpaces.length - selectedWordLength : 0,
|
||||
to,
|
||||
];
|
||||
}
|
||||
3
core/src/utils/input-masking/utils/identity.ts
Normal file
3
core/src/utils/input-masking/utils/identity.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function identity<T>(x: T): T {
|
||||
return x;
|
||||
}
|
||||
6
core/src/utils/input-masking/utils/index.ts
Normal file
6
core/src/utils/input-masking/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './get-not-empty-selection';
|
||||
export * from './identity';
|
||||
export * from './element-states-equality';
|
||||
export * from './format-mask';
|
||||
export * from './get-word-selection';
|
||||
export * from './get-line-selection';
|
||||
21
core/src/utils/input-masking/utils/transform.ts
Normal file
21
core/src/utils/input-masking/utils/transform.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MaskModel } from '../classes';
|
||||
import { MASK_DEFAULT_OPTIONS } from '../constants';
|
||||
import type { ElementState, MaskOptions } from '../types/mask-interface';
|
||||
|
||||
export function maskTransform(value: string, maskOptions: MaskOptions): string;
|
||||
export function maskTransform(state: ElementState, maskOptions: MaskOptions): ElementState;
|
||||
|
||||
export function maskTransform(valueOrState: ElementState | string, maskOptions: MaskOptions): ElementState | string {
|
||||
const options: Required<MaskOptions> = {
|
||||
...MASK_DEFAULT_OPTIONS,
|
||||
...maskOptions,
|
||||
};
|
||||
const initialElementState: ElementState =
|
||||
typeof valueOrState === 'string' ? { value: valueOrState, selection: [0, 0] } : valueOrState;
|
||||
|
||||
const { elementState } = options.preprocessor({ elementState: initialElementState, data: '' }, 'validation');
|
||||
const maskModel = new MaskModel(elementState, options);
|
||||
const { value, selection } = options.postprocessor(maskModel, initialElementState);
|
||||
|
||||
return typeof valueOrState === 'string' ? value : { value, selection };
|
||||
}
|
||||
@@ -428,6 +428,9 @@ export const IonInput = /*@__PURE__*/ defineContainer<JSX.IonInput, JSX.IonInput
|
||||
'size',
|
||||
'type',
|
||||
'value',
|
||||
'mask',
|
||||
'maskVisibility',
|
||||
'maskPlaceholder',
|
||||
'ionInput',
|
||||
'ionChange',
|
||||
'ionBlur',
|
||||
|
||||
Reference in New Issue
Block a user