mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
2 Commits
sp/pnpm-an
...
FW-2573
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918edf2f72 | ||
|
|
ba894d05a8 |
@@ -977,7 +977,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
|
|||||||
|
|
||||||
|
|
||||||
@ProxyCmp({
|
@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']
|
methods: ['setFocus', 'getInputElement']
|
||||||
})
|
})
|
||||||
@Component({
|
@Component({
|
||||||
@@ -985,7 +985,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: '<ng-content></ng-content>',
|
template: '<ng-content></ng-content>',
|
||||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
// 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 {
|
export class IonInput {
|
||||||
protected el: HTMLElement;
|
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,label,string | undefined,undefined,false,false
|
||||||
ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start",'start',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,legacy,boolean | undefined,undefined,false,false
|
||||||
|
ion-input,prop,mask,(string | RegExp)[] | RegExp | 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,max,number | string | undefined,undefined,false,false
|
||||||
ion-input,prop,maxlength,number | undefined,undefined,false,false
|
ion-input,prop,maxlength,number | undefined,undefined,false,false
|
||||||
ion-input,prop,min,number | string | 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 { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
|
||||||
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
||||||
import { SpinnerTypes } from "./components/spinner/spinner-configs";
|
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 { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||||
import { CounterFormatter } from "./components/item/item-interface";
|
import { CounterFormatter } from "./components/item/item-interface";
|
||||||
import { MenuChangeEventDetail, Side } from "./components/menu/menu-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 { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
|
||||||
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
||||||
export { SpinnerTypes } from "./components/spinner/spinner-configs";
|
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 { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||||
export { CounterFormatter } from "./components/item/item-interface";
|
export { CounterFormatter } from "./components/item/item-interface";
|
||||||
export { MenuChangeEventDetail, Side } from "./components/menu/menu-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.
|
* 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;
|
"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;
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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.
|
* 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;
|
"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;
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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 { createLegacyFormController } from '../../utils/forms';
|
||||||
import type { Attributes } from '../../utils/helpers';
|
import type { Attributes } from '../../utils/helpers';
|
||||||
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '../../utils/helpers';
|
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '../../utils/helpers';
|
||||||
|
import { MaskController } from '../../utils/input-masking';
|
||||||
|
import type { MaskExpression, MaskPlaceholder, MaskVisibility } from '../../utils/input-masking/public-api';
|
||||||
import { printIonWarning } from '../../utils/logging';
|
import { printIonWarning } from '../../utils/logging';
|
||||||
import { createColorClasses, hostContext } from '../../utils/theme';
|
import { createColorClasses, hostContext } from '../../utils/theme';
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export class Input implements ComponentInterface {
|
|||||||
private inheritedAttributes: Attributes = {};
|
private inheritedAttributes: Attributes = {};
|
||||||
private isComposing = false;
|
private isComposing = false;
|
||||||
private legacyFormController!: LegacyFormController;
|
private legacyFormController!: LegacyFormController;
|
||||||
|
private maskController?: MaskController;
|
||||||
|
|
||||||
// This flag ensures we log the deprecation warning at most once.
|
// This flag ensures we log the deprecation warning at most once.
|
||||||
private hasLoggedDeprecationWarning = false;
|
private hasLoggedDeprecationWarning = false;
|
||||||
@@ -272,6 +275,35 @@ export class Input implements ComponentInterface {
|
|||||||
*/
|
*/
|
||||||
@Prop({ mutable: true }) value?: string | number | null = '';
|
@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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* The `ionInput` event fires when the `value` of an `<ion-input>` element
|
||||||
* has been changed.
|
* has been changed.
|
||||||
@@ -343,9 +375,11 @@ export class Input implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillLoad() {
|
componentWillLoad() {
|
||||||
|
const { el } = this;
|
||||||
|
|
||||||
this.inheritedAttributes = {
|
this.inheritedAttributes = {
|
||||||
...inheritAriaAttributes(this.el),
|
...inheritAriaAttributes(el),
|
||||||
...inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type']),
|
...inheritAttributes(el, ['tabindex', 'title', 'data-form-type']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +400,15 @@ export class Input implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidLoad() {
|
componentDidLoad() {
|
||||||
|
const { mask, nativeInput } = this;
|
||||||
|
|
||||||
this.originalIonInput = this.ionInput;
|
this.originalIonInput = this.ionInput;
|
||||||
|
|
||||||
|
if (mask !== undefined && nativeInput) {
|
||||||
|
this.maskController = new MaskController(nativeInput, {
|
||||||
|
mask,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -377,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { newSpecPage } from '@stencil/core/testing';
|
import { newSpecPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
|
|
||||||
it('should inherit attributes', async () => {
|
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>',
|
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('title')).toBe('my title');
|
||||||
expect(nativeEl.getAttribute('tabindex')).toBe('-1');
|
expect(nativeEl.getAttribute('tabindex')).toBe('-1');
|
||||||
expect(nativeEl.getAttribute('data-form-type')).toBe('password');
|
expect(nativeEl.getAttribute('data-form-type')).toBe('password');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { newSpecPage } from '@stencil/core/testing';
|
import { newSpecPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
|
|
||||||
it('should render bottom content when helper text is defined', async () => {
|
it('should render bottom content when helper text is defined', async () => {
|
||||||
|
|||||||
81
core/src/components/input/test/mask/index.html
Normal file
81
core/src/components/input/test/mask/index.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!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 - Item</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>
|
||||||
|
</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/,
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
core/src/interface.d.ts
vendored
1
core/src/interface.d.ts
vendored
@@ -43,6 +43,7 @@ export {
|
|||||||
AnimationKeyFrames,
|
AnimationKeyFrames,
|
||||||
AnimationLifecycle,
|
AnimationLifecycle,
|
||||||
} from './utils/animation/animation-interface';
|
} from './utils/animation/animation-interface';
|
||||||
|
export { MaskExpression, MaskPlaceholder, MaskVisibility } from './utils/input-masking/public-api';
|
||||||
export { HTMLIonOverlayElement, OverlayController, OverlayInterface } from './utils/overlays-interface';
|
export { HTMLIonOverlayElement, OverlayController, OverlayInterface } from './utils/overlays-interface';
|
||||||
export { Config, config } from './global/config';
|
export { Config, config } from './global/config';
|
||||||
export { Gesture, GestureConfig, GestureDetail } from './utils/gesture';
|
export { Gesture, GestureConfig, GestureDetail } from './utils/gesture';
|
||||||
|
|||||||
1
core/src/utils/input-masking/classes/index.ts
Normal file
1
core/src/utils/input-masking/classes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MaskHistory } from './mask-history';
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
1
core/src/utils/input-masking/index.ts
Normal file
1
core/src/utils/input-masking/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MaskController } from './mask-controller';
|
||||||
158
core/src/utils/input-masking/mask-controller.ts
Normal file
158
core/src/utils/input-masking/mask-controller.ts
Normal file
@@ -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<MaskOptions> = {
|
||||||
|
...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<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 {
|
||||||
|
// 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<TypedInputEvent, 'data' | 'inputType'> = {
|
||||||
|
inputType: 'insertText',
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
// TODO implementation
|
||||||
|
console.debug('dispatchInputEvent', eventInit);
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
53
core/src/utils/input-masking/types/mask-interface.ts
Normal file
53
core/src/utils/input-masking/types/mask-interface.ts
Normal file
@@ -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;
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
2
core/src/utils/input-masking/utils/index.ts
Normal file
2
core/src/utils/input-masking/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './get-not-empty-selection';
|
||||||
|
export * from './identity';
|
||||||
@@ -428,6 +428,9 @@ export const IonInput = /*@__PURE__*/ defineContainer<JSX.IonInput, JSX.IonInput
|
|||||||
'size',
|
'size',
|
||||||
'type',
|
'type',
|
||||||
'value',
|
'value',
|
||||||
|
'mask',
|
||||||
|
'maskVisibility',
|
||||||
|
'maskPlaceholder',
|
||||||
'ionInput',
|
'ionInput',
|
||||||
'ionChange',
|
'ionChange',
|
||||||
'ionBlur',
|
'ionBlur',
|
||||||
|
|||||||
Reference in New Issue
Block a user