From 54abc1cc8c63626a8a4b5940d266d8b9ed321c46 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 9 Feb 2024 15:42:53 +0300 Subject: [PATCH 1/5] feat(core): add `contenteditable` support --- README.md | 2 +- projects/core/src/index.ts | 2 + .../constants/default-element-predicate.ts | 7 +- projects/core/src/lib/mask.ts | 25 +++- .../core/src/lib/types/element-predicate.ts | 7 +- projects/core/src/lib/types/index.ts | 1 + .../core/src/lib/types/maskito-element.ts | 5 + projects/core/src/lib/types/plugin.ts | 3 +- .../core/src/lib/types/typed-input-event.ts | 1 + .../core/src/lib/utils/content-editable.ts | 51 +++++++++ .../dom/get-content-editable-selection.ts | 11 ++ .../dom/set-content-editable-selection.ts | 24 ++++ .../core/src/lib/utils/dom/update-element.ts | 4 +- projects/core/src/lib/utils/index.ts | 3 + projects/demo/src/app/app.routes.ts | 8 ++ projects/demo/src/app/constants/demo-path.ts | 1 + .../what-is-maskito.template.html | 6 +- projects/demo/src/pages/pages.ts | 6 + .../content-editable-doc.component.ts | 50 ++++++++ .../content-editable-doc.template.html | 107 ++++++++++++++++++ .../examples/1-time/component.ts | 27 +++++ .../content-editable/examples/1-time/mask.ts | 5 + .../examples/2-multi-line/component.ts | 35 ++++++ .../examples/2-multi-line/mask.ts | 5 + .../examples/maskito-with-content-editable.md | 14 +++ .../examples/vanilla-js-tab.md | 11 ++ projects/kit/src/lib/plugins/event-handler.ts | 7 +- projects/kit/src/lib/plugins/reject-event.ts | 8 +- projects/react/src/lib/useMaskito.ts | 10 +- 29 files changed, 416 insertions(+), 30 deletions(-) create mode 100644 projects/core/src/lib/types/maskito-element.ts create mode 100644 projects/core/src/lib/utils/content-editable.ts create mode 100644 projects/core/src/lib/utils/dom/get-content-editable-selection.ts create mode 100644 projects/core/src/lib/utils/dom/set-content-editable-selection.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md create mode 100644 projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md diff --git a/README.md b/README.md index 716f4c00d..092a38d98 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ users type values according to predefined format. - Server Side Rendering and Shadow DOM support. -- You can use it with `HTMLInputElement` and `HTMLTextAreaElement`. +- You can use it with `HTMLInputElement` or `HTMLTextAreaElement` or even with `[contenteditable]` element. - **Maskito** core is zero-dependency package. You can mask input in your vanilla JavaScript project. However, we have separate packages for Angular, React and Vue as well. diff --git a/projects/core/src/index.ts b/projects/core/src/index.ts index c4c2b586c..ead10d94b 100644 --- a/projects/core/src/index.ts +++ b/projects/core/src/index.ts @@ -4,6 +4,7 @@ export { } from './lib/constants'; export {Maskito} from './lib/mask'; export { + MaskitoElement, MaskitoElementPredicate, MaskitoMask, MaskitoMaskExpression, @@ -13,6 +14,7 @@ export { MaskitoPreprocessor, } from './lib/types'; export { + maskitoAdaptContentEditable, maskitoInitialCalibrationPlugin, maskitoPipe, maskitoStrictCompositionPlugin, diff --git a/projects/core/src/lib/constants/default-element-predicate.ts b/projects/core/src/lib/constants/default-element-predicate.ts index edca6d3be..d2cfcedfb 100644 --- a/projects/core/src/lib/constants/default-element-predicate.ts +++ b/projects/core/src/lib/constants/default-element-predicate.ts @@ -1,5 +1,8 @@ import type {MaskitoElementPredicate} from '../types'; +import {maskitoAdaptContentEditable} from '../utils'; export const MASKITO_DEFAULT_ELEMENT_PREDICATE: MaskitoElementPredicate = e => - e.querySelector('input,textarea') || - (e as HTMLInputElement | HTMLTextAreaElement); + e.isContentEditable + ? maskitoAdaptContentEditable(e) + : e.querySelector('input,textarea') || + (e as HTMLInputElement | HTMLTextAreaElement); diff --git a/projects/core/src/lib/mask.ts b/projects/core/src/lib/mask.ts index d17db746c..a42ad675b 100644 --- a/projects/core/src/lib/mask.ts +++ b/projects/core/src/lib/mask.ts @@ -2,6 +2,7 @@ import {MaskHistory, MaskModel} from './classes'; import {MASKITO_DEFAULT_OPTIONS} from './constants'; import type { ElementState, + MaskitoElement, MaskitoOptions, SelectionRange, TypedInputEvent, @@ -35,7 +36,7 @@ export class Maskito extends MaskHistory { ); constructor( - private readonly element: HTMLInputElement | HTMLTextAreaElement, + private readonly element: MaskitoElement, private readonly maskitoOptions: MaskitoOptions, ) { super(); @@ -99,12 +100,19 @@ export class Maskito extends MaskHistory { case 'insertCompositionText': return; // will be handled inside `compositionend` event case 'insertLineBreak': + case 'insertParagraph': return this.handleEnter(event); case 'insertFromPaste': case 'insertText': case 'insertFromDrop': default: - return this.handleInsert(event, event.data || ''); + return this.handleInsert( + event, + event.data || + // `event.data` for `contentEditable` is always `null` for paste/drop events + event.dataTransfer?.getData('text/plain') || + '', + ); } }); @@ -229,7 +237,11 @@ export class Maskito extends MaskHistory { initialState.value.slice(0, initialFrom) + initialState.value.slice(initialTo); - if (newPossibleValue === newElementState.value && !force) { + if ( + newPossibleValue === newElementState.value && + !force && + !this.element.isContentEditable + ) { return; } @@ -277,7 +289,10 @@ export class Maskito extends MaskHistory { return event.preventDefault(); } - if (newPossibleValue !== newElementState.value) { + if ( + newPossibleValue !== newElementState.value || + this.element.isContentEditable + ) { event.preventDefault(); this.updateElementState(newElementState, { @@ -289,7 +304,7 @@ export class Maskito extends MaskHistory { } private handleEnter(event: TypedInputEvent): void { - if (this.isTextArea) { + if (this.isTextArea || this.element.isContentEditable) { this.handleInsert(event, '\n'); } } diff --git a/projects/core/src/lib/types/element-predicate.ts b/projects/core/src/lib/types/element-predicate.ts index 9e6bcd56a..a78d3a506 100644 --- a/projects/core/src/lib/types/element-predicate.ts +++ b/projects/core/src/lib/types/element-predicate.ts @@ -1,6 +1,5 @@ +import type {MaskitoElement} from './maskito-element'; + export type MaskitoElementPredicate = ( element: HTMLElement, -) => - | HTMLInputElement - | HTMLTextAreaElement - | Promise; +) => MaskitoElement | Promise; diff --git a/projects/core/src/lib/types/index.ts b/projects/core/src/lib/types/index.ts index 84254a051..30cb3ea9b 100644 --- a/projects/core/src/lib/types/index.ts +++ b/projects/core/src/lib/types/index.ts @@ -3,6 +3,7 @@ export * from './element-state'; export * from './mask'; export * from './mask-options'; export * from './mask-processors'; +export * from './maskito-element'; export * from './plugin'; export * from './selection-range'; export * from './typed-input-event'; diff --git a/projects/core/src/lib/types/maskito-element.ts b/projects/core/src/lib/types/maskito-element.ts new file mode 100644 index 000000000..df37daeb0 --- /dev/null +++ b/projects/core/src/lib/types/maskito-element.ts @@ -0,0 +1,5 @@ +export type TextfieldLike = Pick< + HTMLInputElement, + 'maxLength' | 'selectionEnd' | 'selectionStart' | 'setSelectionRange' | 'value' +>; +export type MaskitoElement = HTMLElement & TextfieldLike; diff --git a/projects/core/src/lib/types/plugin.ts b/projects/core/src/lib/types/plugin.ts index 896b02f82..bf08449e2 100644 --- a/projects/core/src/lib/types/plugin.ts +++ b/projects/core/src/lib/types/plugin.ts @@ -1,6 +1,7 @@ import type {MaskitoOptions} from './mask-options'; +import type {MaskitoElement} from './maskito-element'; export type MaskitoPlugin = ( - element: HTMLInputElement | HTMLTextAreaElement, + element: MaskitoElement, options: Required, ) => (() => void) | void; diff --git a/projects/core/src/lib/types/typed-input-event.ts b/projects/core/src/lib/types/typed-input-event.ts index e43a71821..49826d83f 100644 --- a/projects/core/src/lib/types/typed-input-event.ts +++ b/projects/core/src/lib/types/typed-input-event.ts @@ -15,6 +15,7 @@ export interface TypedInputEvent extends InputEvent { | 'insertFromDrop' | 'insertFromPaste' // Ctrl (Command) + V | 'insertLineBreak' + | 'insertParagraph' | 'insertReplacementText' | 'insertText'; } diff --git a/projects/core/src/lib/utils/content-editable.ts b/projects/core/src/lib/utils/content-editable.ts new file mode 100644 index 000000000..f42e99880 --- /dev/null +++ b/projects/core/src/lib/utils/content-editable.ts @@ -0,0 +1,51 @@ +import type {MaskitoElement, TextfieldLike} from '../types'; +import {getContentEditableSelection} from './dom/get-content-editable-selection'; +import {setContentEditableSelection} from './dom/set-content-editable-selection'; + +class ContentEditableAdapter implements TextfieldLike { + public maxLength = Infinity; + + constructor(private readonly element: HTMLElement) {} + + public get value(): string { + return this.element.innerText.replace(/\n\n$/, '\n'); + } + + public set value(value) { + // Setting into innerHTML of element with `white-space: pre;` style + this.element.innerHTML = value.replace(/\n$/, '\n\n'); + } + + public get selectionStart(): number | null { + return getContentEditableSelection(this.element)[0]; + } + + public get selectionEnd(): number | null { + return getContentEditableSelection(this.element)[1]; + } + + public setSelectionRange(from: number | null, to: number | null): void { + setContentEditableSelection(this.element, [from || 0, to || 0]); + } +} + +export function maskitoAdaptContentEditable(element: HTMLElement): MaskitoElement { + const adapter = new ContentEditableAdapter(element); + + return new Proxy(element, { + get(target, prop: keyof HTMLElement) { + if (prop in adapter) { + return adapter[prop as keyof ContentEditableAdapter]; + } + + const nativeProperty = target[prop]; + + return typeof nativeProperty === 'function' + ? nativeProperty.bind(target) + : nativeProperty; + }, + set(target, prop: keyof HTMLElement, val, receiver) { + return Reflect.set(prop in adapter ? adapter : target, prop, val, receiver); + }, + }) as MaskitoElement; +} diff --git a/projects/core/src/lib/utils/dom/get-content-editable-selection.ts b/projects/core/src/lib/utils/dom/get-content-editable-selection.ts new file mode 100644 index 000000000..670be08b5 --- /dev/null +++ b/projects/core/src/lib/utils/dom/get-content-editable-selection.ts @@ -0,0 +1,11 @@ +import type {SelectionRange} from '../../types'; + +export function getContentEditableSelection(element: HTMLElement): SelectionRange { + const {anchorOffset = 0, focusOffset = 0} = + element.ownerDocument.getSelection() || {}; + + const from = Math.min(anchorOffset, focusOffset); + const to = Math.max(anchorOffset, focusOffset); + + return [from, to]; +} diff --git a/projects/core/src/lib/utils/dom/set-content-editable-selection.ts b/projects/core/src/lib/utils/dom/set-content-editable-selection.ts new file mode 100644 index 000000000..75dc81209 --- /dev/null +++ b/projects/core/src/lib/utils/dom/set-content-editable-selection.ts @@ -0,0 +1,24 @@ +import type {SelectionRange} from '../../types'; + +export function setContentEditableSelection( + element: HTMLElement, + [from, to]: SelectionRange, +): void { + const document = element.ownerDocument; + const range = document.createRange(); + + range.setStart( + element.firstChild || element, + Math.min(from, element.textContent?.length || 0), + ); + range.setEnd( + element.lastChild || element, + Math.min(to, element.textContent?.length || 0), + ); + const selection = document.getSelection(); + + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } +} diff --git a/projects/core/src/lib/utils/dom/update-element.ts b/projects/core/src/lib/utils/dom/update-element.ts index 131611e59..389086234 100644 --- a/projects/core/src/lib/utils/dom/update-element.ts +++ b/projects/core/src/lib/utils/dom/update-element.ts @@ -1,4 +1,4 @@ -import type {ElementState} from '../../types'; +import type {ElementState, MaskitoElement} from '../../types'; /** * Sets value to element, and dispatches input event @@ -13,7 +13,7 @@ import type {ElementState} from '../../types'; * @return void */ export function maskitoUpdateElement( - element: HTMLInputElement | HTMLTextAreaElement, + element: MaskitoElement, valueOrElementState: ElementState | string, ): void { const initialValue = element.value; diff --git a/projects/core/src/lib/utils/index.ts b/projects/core/src/lib/utils/index.ts index caed4fe26..3849ab5e8 100644 --- a/projects/core/src/lib/utils/index.ts +++ b/projects/core/src/lib/utils/index.ts @@ -1,5 +1,8 @@ +export * from './content-editable'; export * from './dom/event-listener'; +export * from './dom/get-content-editable-selection'; export * from './dom/history-events'; +export * from './dom/set-content-editable-selection'; export * from './dom/update-element'; export * from './element-states-equality'; export * from './get-line-selection'; diff --git a/projects/demo/src/app/app.routes.ts b/projects/demo/src/app/app.routes.ts index f971f1558..0aeadfef1 100644 --- a/projects/demo/src/app/app.routes.ts +++ b/projects/demo/src/app/app.routes.ts @@ -170,6 +170,14 @@ export const appRoutes: Routes = [ title: 'Textarea', }, }, + { + path: DemoPath.ContentEditable, + loadComponent: () => + import('../pages/recipes/content-editable/content-editable-doc.component'), + data: { + title: 'ContentEditable', + }, + }, { path: DemoPath.Prefix, loadComponent: () => import('../pages/recipes/prefix/prefix-doc.component'), diff --git a/projects/demo/src/app/constants/demo-path.ts b/projects/demo/src/app/constants/demo-path.ts index 0cd61c22f..ee0fc015f 100644 --- a/projects/demo/src/app/constants/demo-path.ts +++ b/projects/demo/src/app/constants/demo-path.ts @@ -20,6 +20,7 @@ export const DemoPath = { Card: 'recipes/card', Phone: 'recipes/phone', Textarea: 'recipes/textarea', + ContentEditable: 'recipes/content-editable', Prefix: 'recipes/prefix', Postfix: 'recipes/postfix', Placeholder: 'recipes/placeholder', diff --git a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html index c5829476e..529bc2f4b 100644 --- a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html +++ b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html @@ -39,9 +39,11 @@

Why Maskito?

  • You can use it with HTMLInputElement - and + / HTMLTextAreaElement - . + or even with + [contenteditable] + element .
  • diff --git a/projects/demo/src/pages/pages.ts b/projects/demo/src/pages/pages.ts index 8208d557f..5fff8353a 100644 --- a/projects/demo/src/pages/pages.ts +++ b/projects/demo/src/pages/pages.ts @@ -130,6 +130,12 @@ export const DEMO_PAGES: TuiDocPages = [ route: DemoPath.Textarea, keywords: 'textarea, latin, mask, recipe', }, + { + section: 'Recipes', + title: 'ContentEditable', + route: DemoPath.ContentEditable, + keywords: 'content, editable, contenteditable, contentEditable, mask, recipe', + }, { section: 'Recipes', title: 'With prefix', diff --git a/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts new file mode 100644 index 000000000..02ae2020a --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts @@ -0,0 +1,50 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; +import type {TuiDocExample} from '@taiga-ui/addon-doc'; +import {TuiAddonDocModule} from '@taiga-ui/addon-doc'; +import {TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core'; + +import {ContentEditableDocExample1} from './examples/1-time/component'; +import {ContentEditableDocExample2} from './examples/2-multi-line/component'; + +@Component({ + standalone: true, + selector: 'content-editable-doc', + imports: [ + TuiAddonDocModule, + TuiLinkModule, + RouterLink, + ContentEditableDocExample1, + ContentEditableDocExample2, + TuiNotificationModule, + ], + templateUrl: './content-editable-doc.template.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ContentEditableDocComponent { + protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; + protected readonly timeMaskDocPage = `/${DemoPath.Time}`; + protected readonly angularDocPage = `/${DemoPath.Angular}`; + protected readonly reactDocPage = `/${DemoPath.React}`; + protected readonly vueDocPage = `/${DemoPath.Vue}`; + protected readonly maskitoWithContentEditableDemo = import( + './examples/maskito-with-content-editable.md?raw' + ); + + protected readonly contentEditableExample1: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-time/mask.ts?raw'), + [DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md?raw'), + [DocExamplePrimaryTab.Angular]: import('./examples/1-time/component.ts?raw'), + }; + + protected readonly contentEditableExample2: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import( + './examples/2-multi-line/mask.ts?raw' + ), + [DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md?raw'), + [DocExamplePrimaryTab.Angular]: import( + './examples/2-multi-line/component.ts?raw' + ), + }; +} diff --git a/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html new file mode 100644 index 000000000..16169a85c --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html @@ -0,0 +1,107 @@ + +
    +

    + You can use + Maskito + with + + contentEditable + + too. +

    +

    + Just wrap the element with + maskitoAdaptContentEditable + utility and use + Maskito + in the same way as + HTMLInputElement + / + HTMLTextAreaElement + . +

    + + + No need to use + maskitoAdaptContentEditable + if you use + + @maskito/angular + + , + + @maskito/react + + or + + @maskito/vue + + with the default element predicate (it will be wrapped automatically). + + + + +

    + Learn more in the + + "Core Concepts" + + section. +

    +
    + + + + With built-in + + Time + + mask + + + + + + + Use + white-space: pre + for multi-line mode + + + +
    diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts new file mode 100644 index 000000000..7bfbe7ad5 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts @@ -0,0 +1,27 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MaskitoDirective} from '@maskito/angular'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'content-editable-doc-example-1', + imports: [MaskitoDirective], + template: ` + Meeting time: + + 12:00 + + `, + styles: [ + ':host {font-size: 2.5rem}', + '[contenteditable] {border: 3px dashed lightgrey}', + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentEditableDocExample1 { + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts new file mode 100644 index 000000000..69f9b9c91 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts @@ -0,0 +1,5 @@ +import {maskitoTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoTimeOptionsGenerator({ + mode: 'HH:MM', +}); diff --git a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts new file mode 100644 index 000000000..b05cab710 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts @@ -0,0 +1,35 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MaskitoDirective} from '@maskito/angular'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'content-editable-doc-example-2', + imports: [MaskitoDirective], + template: ` + Enter message: +

    + `, + styles: [ + ` + [contenteditable] { + white-space: pre; + border: 3px dashed lightgrey; + max-width: 30rem; + padding: 1rem; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentEditableDocExample2 { + protected readonly mask = mask; + protected initialText = `Hello, world! +How are you today? +Do not forget to read description of this example!`; +} diff --git a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts new file mode 100644 index 000000000..42c172149 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts @@ -0,0 +1,5 @@ +import type {MaskitoOptions} from '@maskito/core'; + +export default { + mask: /^[a-z\s.,/!?]+$/i, +} as MaskitoOptions; diff --git a/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md b/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md new file mode 100644 index 000000000..f2fc6ed27 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md @@ -0,0 +1,14 @@ +```ts +import {Maskito, maskitoAdaptContentEditable, MaskitoOptions} from '@maskito/core'; + +const maskitoOptions: MaskitoOptions = { + mask: /^\d+$/, +}; + +const element = document.querySelector('[contenteditable]')!; + +const maskedInput = new Maskito( + maskitoAdaptContentEditable(element), // <-- This is the only difference + maskitoOptions, +); +``` diff --git a/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md b/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md new file mode 100644 index 000000000..e6ce712db --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md @@ -0,0 +1,11 @@ +```ts +import {Maskito, maskitoAdaptContentEditable} from '@maskito/core'; + +import maskitoOptions from './mask'; + +const element = document.querySelector('[contenteditable]')!; + +const maskedInput = new Maskito(maskitoAdaptContentEditable(element), maskitoOptions); + +console.info('Call this function when the element is detached from DOM', maskedInput.destroy); +``` diff --git a/projects/kit/src/lib/plugins/event-handler.ts b/projects/kit/src/lib/plugins/event-handler.ts index 2c86b8e9e..f21f0dcc8 100644 --- a/projects/kit/src/lib/plugins/event-handler.ts +++ b/projects/kit/src/lib/plugins/event-handler.ts @@ -1,11 +1,8 @@ -import type {MaskitoOptions, MaskitoPlugin} from '@maskito/core'; +import type {MaskitoElement, MaskitoOptions, MaskitoPlugin} from '@maskito/core'; export function maskitoEventHandler( name: string, - handler: ( - element: HTMLInputElement | HTMLTextAreaElement, - options: Required, - ) => void, + handler: (element: MaskitoElement, options: Required) => void, eventListenerOptions?: AddEventListenerOptions, ): MaskitoPlugin { return (element, maskitoOptions) => { diff --git a/projects/kit/src/lib/plugins/reject-event.ts b/projects/kit/src/lib/plugins/reject-event.ts index d5ed7407a..fc89a6c81 100644 --- a/projects/kit/src/lib/plugins/reject-event.ts +++ b/projects/kit/src/lib/plugins/reject-event.ts @@ -1,6 +1,6 @@ -export function maskitoRejectEvent( - element: HTMLInputElement | HTMLTextAreaElement, -): () => void { +import type {MaskitoPlugin} from '@maskito/core'; + +export const maskitoRejectEvent: MaskitoPlugin = element => { const listener = (): void => { const value = element.value; @@ -20,4 +20,4 @@ export function maskitoRejectEvent( element.addEventListener('beforeinput', listener, true); return () => element.removeEventListener('beforeinput', listener, true); -} +}; diff --git a/projects/react/src/lib/useMaskito.ts b/projects/react/src/lib/useMaskito.ts index 1dfdc555b..bc399b246 100644 --- a/projects/react/src/lib/useMaskito.ts +++ b/projects/react/src/lib/useMaskito.ts @@ -1,4 +1,8 @@ -import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; +import type { + MaskitoElement, + MaskitoElementPredicate, + MaskitoOptions, +} from '@maskito/core'; import {Maskito, MASKITO_DEFAULT_ELEMENT_PREDICATE} from '@maskito/core'; import type {RefCallback} from 'react'; import {useCallback, useRef, useState} from 'react'; @@ -31,9 +35,7 @@ export const useMaskito = ({ elementPredicate?: MaskitoElementPredicate; } = {}): RefCallback => { const [hostElement, setHostElement] = useState(null); - const [element, setElement] = useState( - null, - ); + const [element, setElement] = useState(null); const onRefChange: RefCallback = useCallback( (node: HTMLElement | null) => { From 6016524d55a19db7f8f978304b95750f2bf90e7e Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 2 Apr 2024 17:16:18 +0300 Subject: [PATCH 2/5] chore(demo-integrations): add tests for new `ContentEditable` doc page --- .../recipes/content-editable/multi-line.cy.ts | 48 +++++++++++ .../single-line-time-mask.cy.ts | 84 +++++++++++++++++++ .../examples/1-time/component.ts | 6 +- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts create mode 100644 projects/demo-integrations/src/tests/recipes/content-editable/single-line-time-mask.cy.ts diff --git a/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts b/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts new file mode 100644 index 000000000..07a424816 --- /dev/null +++ b/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts @@ -0,0 +1,48 @@ +import {DemoPath} from '@demo/constants'; + +describe('ContentEditable | Multi-line support', () => { + describe('Deletion', () => { + beforeEach(() => { + cy.visit(DemoPath.ContentEditable); + cy.get('#multi-line [contenteditable]') + .should('be.visible') + .first() + .focus() + .as('element'); + }); + + it('Select all + delete => Empty', () => { + cy.get('@element').type('{selectAll}{del}').should('have.text', ''); + }); + + it('Select all + Backspace => Empty', () => { + cy.get('@element').type('{selectAll}{backspace}').should('have.text', ''); + }); + }); + + describe('Rejects invalid symbols on EVERY line', () => { + beforeEach(() => { + cy.visit(DemoPath.ContentEditable); + cy.get('#multi-line [contenteditable]') + .should('be.visible') + .first() + .focus() + .clear() + .should('have.text', '') + .as('element'); + }); + + const tests = [ + // [Typed value, Masked value] + ['abc123 def', 'abc def'], + ['abc123 def{enter}1a2b3c 4d', 'abc def\nabc d'], + ['a{enter}b{enter}{enter}aa11bb', 'a\nb\n\naabb'], + ] as const; + + tests.forEach(([typed, masked]) => { + it(`Type ${typed} => ${masked}`, () => { + cy.get('@element').type(typed).should('have.text', masked); + }); + }); + }); +}); diff --git a/projects/demo-integrations/src/tests/recipes/content-editable/single-line-time-mask.cy.ts b/projects/demo-integrations/src/tests/recipes/content-editable/single-line-time-mask.cy.ts new file mode 100644 index 000000000..356d7327d --- /dev/null +++ b/projects/demo-integrations/src/tests/recipes/content-editable/single-line-time-mask.cy.ts @@ -0,0 +1,84 @@ +import {DemoPath} from '@demo/constants'; + +describe('ContentEditable | With Time mask', () => { + beforeEach(() => { + cy.visit(DemoPath.ContentEditable); + cy.get('#time [contenteditable]') + .should('be.visible') + .first() + .clear() + .focus() + .should('have.value', '') + .as('element'); + }); + + describe('basic typing (1 character per keydown)', () => { + const tests = [ + // [Typed value, Masked value] + ['1', '1'], + ['1.', '1'], + ['12', '12'], + ['12:', '12:'], + ['9', '09'], + ['99', '09:09'], + ['123', '12:3'], + ['128', '12:08'], + ['25', '2'], + ] as const; + + tests.forEach(([typed, masked]) => { + it(`Type ${typed} => ${masked}`, () => { + cy.get('@element').type(typed).should('have.text', masked); + }); + }); + }); + + describe('basic deletion via backspace', () => { + const tests = [ + // [initialValue, n-times backspace pressed, result] + ['23:59', 1, '23:5'], + ['23:59', 2, '23'], + ['23:59', 3, '2'], + ['23:59', 4, ''], + ] as const; + + tests.forEach(([initialValue, n, result]) => { + it(`${initialValue} => Backspace x${n} => ${result}`, () => { + cy.get('@element') + .type(initialValue) + .type('{backspace}'.repeat(n)) + .should('have.text', result); + }); + }); + }); + + describe('basic deletion via delete', () => { + const tests = [ + // [initialValue, n-times backspace pressed, result] + ['23:59', 1, '03:59'], + ['23:59', 2, '00:59'], + ['23:59', 3, '00:59'], + ['23:59', 4, '00:09'], + ['23:59', 5, '00:0'], + ] as const; + + tests.forEach(([initialValue, n, result]) => { + it(`${initialValue} => Move cursor to start => Delete x${n} => ${result}`, () => { + cy.get('@element') + .type(initialValue) + .type('{moveToStart}') + .type('{del}'.repeat(n)) + .should('have.text', result); + }); + }); + }); + + it('12:|36 => 12:09|', () => { + cy.get('@element') + .type('1236') + .should('have.text', '12:36') + .type('{leftArrow}'.repeat(2)) + .type('9') + .should('have.text', '12:09'); + }); +}); diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts index 7bfbe7ad5..1b6fdf2ac 100644 --- a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts @@ -12,9 +12,8 @@ import mask from './mask'; - 12:00 - + [textContent]="initialValue" + > `, styles: [ ':host {font-size: 2.5rem}', @@ -23,5 +22,6 @@ import mask from './mask'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentEditableDocExample1 { + protected initialValue = '12:00'; protected readonly mask = mask; } From fdfb0ac3c97e76aeb946f92fb753672d824e2b32 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 2 Apr 2024 18:06:39 +0300 Subject: [PATCH 3/5] chore: add test for deletion of multi-line texts --- .../tests/recipes/content-editable/multi-line.cy.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts b/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts index 07a424816..f95b36190 100644 --- a/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts +++ b/projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts @@ -18,6 +18,19 @@ describe('ContentEditable | Multi-line support', () => { it('Select all + Backspace => Empty', () => { cy.get('@element').type('{selectAll}{backspace}').should('have.text', ''); }); + + it('Long multi-line text', () => { + cy.get('@element') + .clear() + .type('a b{enter}cd ef{enter}aa11bb') + .should('have.text', 'a b\ncd ef\naabb') + .type('{backspace}'.repeat(5)) + .should('have.text', 'a b\ncd ef') + .type('{backspace}'.repeat(2)) + .should('have.text', 'a b\ncd ') + .type('{backspace}'.repeat(4)) + .should('have.text', 'a b'); + }); }); describe('Rejects invalid symbols on EVERY line', () => { From 216675a8cfb9406f0e41c465360dc914c66a6ace Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 2 Apr 2024 18:56:23 +0300 Subject: [PATCH 4/5] chore(demo): adaptive --- .../pages/recipes/content-editable/examples/1-time/component.ts | 2 +- .../recipes/content-editable/examples/2-multi-line/component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts index 1b6fdf2ac..eabecd509 100644 --- a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts @@ -16,7 +16,7 @@ import mask from './mask'; > `, styles: [ - ':host {font-size: 2.5rem}', + ':host {font-size: 1.75rem}', '[contenteditable] {border: 3px dashed lightgrey}', ], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts index b05cab710..845b5349f 100644 --- a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts +++ b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts @@ -31,5 +31,5 @@ export class ContentEditableDocExample2 { protected readonly mask = mask; protected initialText = `Hello, world! How are you today? -Do not forget to read description of this example!`; +Read description of this example!`; } From ed8939c530b1ed3f2020e46318aa8466f36b4436 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Sat, 6 Apr 2024 17:41:00 +0300 Subject: [PATCH 5/5] chore: apply review suggestion Co-authored-by: Stanislav Zaycev <102649815+KrollikRoddzer@users.noreply.github.com> --- .../documentation/what-is-maskito/what-is-maskito.template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html index 529bc2f4b..cc04c9e70 100644 --- a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html +++ b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html @@ -43,7 +43,7 @@

    Why Maskito?

    HTMLTextAreaElement or even with [contenteditable] - element . + element.