From bc09f52e116ceb6ec3a4f6a25c1726f281ee9cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Tue, 6 Oct 2026 15:57:51 +0200 Subject: [PATCH] feat: add input masking support --- README.md | 79 ++- README.zh-CN.md | 79 ++- docs/demo/mask.md | 14 + docs/examples/mask.tsx | 64 +++ src/Input.tsx | 99 +++- src/hooks/useCountExceed.ts | 4 +- src/hooks/useMask.ts | 279 ++++++++++ src/index.tsx | 4 + src/interface.ts | 24 +- src/utils/maskUtil.ts | 515 ++++++++++++++++++ tests/mask.test.tsx | 1012 +++++++++++++++++++++++++++++++++++ 11 files changed, 2117 insertions(+), 56 deletions(-) create mode 100644 docs/demo/mask.md create mode 100644 docs/examples/mask.tsx create mode 100644 src/hooks/useMask.ts create mode 100644 src/utils/maskUtil.ts create mode 100644 tests/mask.test.tsx diff --git a/README.md b/README.md index 1ed89cf..764d16f 100644 --- a/README.md +++ b/README.md @@ -58,28 +58,63 @@ Then open `http://localhost:8000`. ### Input -| Property | Type | Default | Description | -| ------------ | ---------------------------------------------------------- | ---------- | --------------------------------------------------------------- | -| addonAfter | `ReactNode` | - | Element displayed after the input. | -| addonBefore | `ReactNode` | - | Element displayed before the input. | -| allowClear | `boolean \| { disabled?: boolean; clearIcon?: ReactNode }` | `false` | Show a clear button for the current value. | -| className | `string` | - | Class name for the input element. | -| classNames | `InputProps['classNames']` | - | Semantic class names for input slots. | -| count | `CountConfig` | - | Custom count strategy, limit, visibility, and exceed formatter. | -| defaultValue | `string \| number \| readonly string[] \| bigint` | - | Initial input value. | -| disabled | `boolean` | `false` | Disable the input. | -| htmlSize | `number` | - | Native input `size` attribute. | -| maxLength | `number` | - | Native input `maxLength` attribute. | -| prefix | `ReactNode` | - | Prefix content inside the input wrapper. | -| prefixCls | `string` | `rc-input` | Class name prefix. | -| showCount | `boolean \| { formatter: ShowCountFormatter }` | `false` | Show character count. Prefer `count.show` for new code. | -| styles | `InputProps['styles']` | - | Semantic styles for input slots. | -| suffix | `ReactNode` | - | Suffix content inside the input wrapper. | -| type | `InputProps['type']` | `text` | Native input type. Use `TextArea` for textarea behavior. | -| value | `string \| number \| readonly string[] \| bigint` | - | Controlled input value. | -| onChange | `React.ChangeEventHandler` | - | Triggered when the value changes. | -| onClear | `() => void` | - | Triggered when the clear button is clicked. | -| onPressEnter | `React.KeyboardEventHandler` | - | Triggered when Enter is pressed. | +| Property | Type | Default | Description | +| --------------- | ---------------------------------------------------------- | ---------- | --------------------------------------------------------------- | +| addonAfter | `ReactNode` | - | Element displayed after the input. | +| addonBefore | `ReactNode` | - | Element displayed before the input. | +| allowClear | `boolean \| { disabled?: boolean; clearIcon?: ReactNode }` | `false` | Show a clear button for the current value. | +| className | `string` | - | Class name for the input element. | +| classNames | `InputProps['classNames']` | - | Semantic class names for input slots. | +| count | `CountConfig` | - | Custom count strategy, limit, visibility, and exceed formatter. | +| defaultValue | `string \| number \| readonly string[] \| bigint` | - | Initial input value. | +| disabled | `boolean` | `false` | Disable the input. | +| htmlSize | `number` | - | Native input `size` attribute. | +| maxLength | `number` | - | Native input `maxLength` attribute. | +| mask | `InputMask` | - | String, array or dynamic input mask. | +| maskDefinitions | `InputMaskDefinitions` | - | Custom character rules that override or extend the defaults. | +| maskPlaceholder | `string \| null` | - | Placeholder for unfilled mask positions. | +| prefix | `ReactNode` | - | Prefix content inside the input wrapper. | +| prefixCls | `string` | `rc-input` | Class name prefix. | +| showCount | `boolean \| { formatter: ShowCountFormatter }` | `false` | Show character count. Prefer `count.show` for new code. | +| styles | `InputProps['styles']` | - | Semantic styles for input slots. | +| suffix | `ReactNode` | - | Suffix content inside the input wrapper. | +| type | `InputProps['type']` | `text` | Native input type. Use `TextArea` for textarea behavior. | +| value | `string \| number \| readonly string[] \| bigint` | - | Controlled input value. | +| onChange | `React.ChangeEventHandler` | - | Triggered when the value changes. | +| onClear | `() => void` | - | Triggered when the clear button is clicked. | +| onPressEnter | `React.KeyboardEventHandler` | - | Triggered when Enter is pressed. | + +#### Input masks + +`mask` supports a string pattern, an array of literal strings and single-character regular expressions, or a pure function `(state: InputMaskState) => InputMaskPattern`. + +- String tokens: `0` accepts an ASCII digit, `X` a Latin letter (`a-z`, `A-Z`), and `*` either. Other characters are fixed separators. Escape a token with a backslash to use it literally, for example `mask="\X000"`. +- `maskDefinitions` maps single-character keys to single-character regular expressions. Custom rules extend the defaults and override matching keys; `null` makes that token literal. Backslash is reserved for escaping. Rules also apply to string patterns returned by dynamic masks. Regular expressions are not mutated; stateful `g` and `y` flags are ignored, while other flags are preserved. +- Array strings are always literals, including `0`, `X` and `*`. Use regular expressions for custom character rules, including non-Latin letters. +- Dynamic functions receive the proposed input text and `selection: { start, end }`. Selection offsets refer to that text; `selection` is `null` when formatting an initial or externally updated value. Keep the function pure. +- No placeholders are shown when `maskPlaceholder` is omitted, `undefined`, `null` or `''`. Set a character such as `_` to show unfilled positions. A single character is repeated; a longer string supplies placeholders at the corresponding pattern positions, counted after escaping, for example `dd/mm/yyyy` for `00/00/0000`. Positions beyond a shorter string use `_`. Placeholder characters are reserved for empty positions: a character accepted by its own position cannot be entered, and a development warning is shown. +- Separators in typed or pasted text end the current group only after one of its positions is filled; for example, `(000) 000-0000` formats `555-123-4567` as `(555) 123-4567`. Typed characters that do not fit the next position are discarded, while pasted, dropped and autofilled text is read like a value, so a copied value keeps its characters. Without visible placeholders, edits that cannot be displayed unambiguously are ignored. +- An empty input displays its fixed prefix and any configured mask placeholders while focused and becomes empty again on blur. `value` and `defaultValue` may contain raw or formatted text. `onChange` keeps the usual event contract: both `target.value` and `currentTarget.value` contain formatted text, including visible placeholders. Clearing emits `''`. +- Masking supports `text`, `search`, `tel`, `url` and `password` inputs. Other input types ignore `mask`. During IME composition, input and change events stay unformatted until composition ends. +- With a mask, `maxLength` limits the pattern's displayed length, including literals and placeholders. Character counting uses the displayed text, including placeholders. `count.exceedFormatter` receives the formatted value, and its result is formatted again. + +```tsx | pure +import Input from '@rc-component/input'; + +; +; +; +; + + value.replace(/\D/g, '').length > 4 ? '000-000' : '00-00' + } + maskPlaceholder={null} +/>; +``` ### TextArea diff --git a/README.zh-CN.md b/README.zh-CN.md index 24a3aea..aaaac2b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,28 +58,63 @@ npm start ### Input -| 参数 | 类型 | 默认值 | 说明 | -| ------------ | ---------------------------------------------------------- | ---------- | ---------------------------------------------------- | -| addonAfter | `ReactNode` | - | 输入后显示的元素。 | -| addonBefore | `ReactNode` | - | 输入之前显示的元素。 | -| allowClear | `boolean \| { disabled?: boolean; clearIcon?: ReactNode }` | `false` | 显示当前值的清除按钮。 | -| className | `string` | - | 输入元素的 className。 | -| classNames | `InputProps['classNames']` | - | 输入槽的语义 className。 | -| count | `CountConfig` | - | 自定义计数策略、限制、可见性和超出格式化程序。 | -| defaultValue | `string \| number \| readonly string[] \| bigint` | - | 初始输入值。 | -| disabled | `boolean` | `false` | 禁用输入。 | -| htmlSize | `number` | - | 原生 input `size` 属性。 | -| maxLength | `number` | - | 原生 input `maxLength` 属性。 | -| prefix | `ReactNode` | - | 输入包装器内的前缀内容。 | -| prefixCls | `string` | `rc-input` | className 前缀。 | -| showCount | `boolean \| { formatter: ShowCountFormatter }` | `false` | 显示字符数。新代码首选 `count.show`。 | -| styles | `InputProps['styles']` | - | 输入槽的语义样式。 | -| suffix | `ReactNode` | - | 输入包装器内的后缀内容。 | -| type | `InputProps['type']` | `text` | 原生 input 类型。需要文本域行为时请使用 `TextArea`。 | -| value | `string \| number \| readonly string[] \| bigint` | - | 受控输入值。 | -| onChange | `React.ChangeEventHandler` | - | 当值改变时触发。 | -| onClear | `() => void` | - | 单击清除按钮时触发。 | -| onPressEnter | `React.KeyboardEventHandler` | - | 当按下 Enter 时触发。 | +| 参数 | 类型 | 默认值 | 说明 | +| --------------- | ---------------------------------------------------------- | ---------- | ---------------------------------------------------- | +| addonAfter | `ReactNode` | - | 输入后显示的元素。 | +| addonBefore | `ReactNode` | - | 输入之前显示的元素。 | +| allowClear | `boolean \| { disabled?: boolean; clearIcon?: ReactNode }` | `false` | 显示当前值的清除按钮。 | +| className | `string` | - | 输入元素的 className。 | +| classNames | `InputProps['classNames']` | - | 输入槽的语义 className。 | +| count | `CountConfig` | - | 自定义计数策略、限制、可见性和超出格式化程序。 | +| defaultValue | `string \| number \| readonly string[] \| bigint` | - | 初始输入值。 | +| disabled | `boolean` | `false` | 禁用输入。 | +| htmlSize | `number` | - | 原生 input `size` 属性。 | +| maxLength | `number` | - | 原生 input `maxLength` 属性。 | +| mask | `InputMask` | - | 字符串、数组或动态输入掩码。 | +| maskDefinitions | `InputMaskDefinitions` | - | 扩展或覆盖默认标记的自定义字符规则。 | +| maskPlaceholder | `string \| null` | - | 未填充掩码位置的占位字符。 | +| prefix | `ReactNode` | - | 输入包装器内的前缀内容。 | +| prefixCls | `string` | `rc-input` | className 前缀。 | +| showCount | `boolean \| { formatter: ShowCountFormatter }` | `false` | 显示字符数。新代码首选 `count.show`。 | +| styles | `InputProps['styles']` | - | 输入槽的语义样式。 | +| suffix | `ReactNode` | - | 输入包装器内的后缀内容。 | +| type | `InputProps['type']` | `text` | 原生 input 类型。需要文本域行为时请使用 `TextArea`。 | +| value | `string \| number \| readonly string[] \| bigint` | - | 受控输入值。 | +| onChange | `React.ChangeEventHandler` | - | 当值改变时触发。 | +| onClear | `() => void` | - | 单击清除按钮时触发。 | +| onPressEnter | `React.KeyboardEventHandler` | - | 当按下 Enter 时触发。 | + +#### 输入掩码 + +`mask` 支持字符串、由固定字符串和单字符正则表达式组成的数组,或纯函数 `(state: InputMaskState) => InputMaskPattern`。 + +- 字符串中的 `0` 表示 ASCII 数字,`X` 表示拉丁字母(`a-z`、`A-Z`),`*` 表示数字或拉丁字母。其他字符为固定分隔符。使用反斜杠转义标记,例如 `mask="\X000"`。 +- `maskDefinitions` 将单字符标记映射为单字符正则表达式。自定义规则扩展默认规则,并覆盖同名标记;`null` 将该标记视为固定字符。反斜杠保留用于转义。规则同样适用于动态掩码返回的字符串。正则表达式不会被修改;有状态的 `g` 和 `y` 标志会被忽略,其他标志会保留。 +- 数组中的字符串始终为固定文本,包括 `0`、`X` 和 `*`。正则表达式可定义自定义字符规则,包括非拉丁字母。 +- 动态函数接收待处理文本及 `selection: { start, end }`,选区位置对应该文本。格式化初始值或外部更新的值时,`selection` 为 `null`。函数应保持纯净。 +- `maskPlaceholder` 未传入或设为 `undefined`、`null` 或 `''` 时不显示占位符。设置为 `_` 等字符可显示未填充位置。单字符会重复使用;多字符字符串按转义后的掩码位置提供占位符,例如 `00/00/0000` 可使用 `dd/mm/yyyy`,超出字符串长度的位置使用 `_`。占位字符保留用于空位置:若某位置的规则接受其占位字符,则无法输入该字符,开发环境下会给出警告。 +- 输入或粘贴文本中的分隔符仅在当前分组已有字符时结束该分组,例如 `(000) 000-0000` 会将 `555-123-4567` 格式化为 `(555) 123-4567`。输入时不符合下一位置的字符会被丢弃;粘贴、拖放和自动填充的文本按值解析,因此复制的值会保留其字符。不显示占位符时,无法明确显示的编辑会被忽略。 +- 空输入框聚焦时显示固定前缀及已配置的掩码占位符,失焦后恢复为空。`value` 和 `defaultValue` 支持原始或格式化文本。`onChange` 保持事件参数形式,`target.value` 和 `currentTarget.value` 均包含格式化文本及可见占位符。清除时传递 `''`。 +- 支持 `text`、`search`、`tel`、`url` 和 `password` 类型,其他类型忽略 `mask`。输入法组合输入期间,文本及变更事件保持未格式化,组合输入结束后才应用掩码。 +- 使用掩码时,`maxLength` 限制显示长度,包括固定字符和占位符。字符计数包含显示的占位符。`count.exceedFormatter` 接收格式化后的值,其返回值会再次格式化。 + +```tsx | pure +import Input from '@rc-component/input'; + +; +; +; +; + + value.replace(/\D/g, '').length > 4 ? '000-000' : '00-00' + } + maskPlaceholder={null} +/>; +``` ### TextArea diff --git a/docs/demo/mask.md b/docs/demo/mask.md new file mode 100644 index 0000000..37b5751 --- /dev/null +++ b/docs/demo/mask.md @@ -0,0 +1,14 @@ +--- +title: Mask +nav: + title: Demo + path: /demo +--- + +Input masks support digits (`0`), Latin letters (`X`), alphanumeric characters (`*`), custom regular expressions and dynamic patterns. `onChange` receives the formatted value. + +Use `maskDefinitions` to configure your own tokens or override the built-in rules. Set a definition to `null` to treat that token as a literal. + +Unfilled positions are hidden by default. Set `maskPlaceholder` to `_`, another character or a descriptive string to show them. + + diff --git a/docs/examples/mask.tsx b/docs/examples/mask.tsx new file mode 100644 index 0000000..154cde4 --- /dev/null +++ b/docs/examples/mask.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Input from '@rc-component/input'; +import type { InputMask } from '@rc-component/input'; +import '../../assets/index.less'; + +const cardMask: InputMask = ({ value }) => + value.replace(/\D/g, '').startsWith('3') + ? '0000 000000 00000' + : '0000 0000 0000 0000'; + +export default function Demo() { + const [card, setCard] = React.useState(''); + + return ( +
+
+ + setCard(event.target.value)} + allowClear + inputMode="numeric" + /> +
+
Formatted value: {card || '(empty)'}
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/src/Input.tsx b/src/Input.tsx index 1e5fa14..d3f5f82 100644 --- a/src/Input.tsx +++ b/src/Input.tsx @@ -13,9 +13,12 @@ import useCount from './hooks/useCount'; import useCountDisplay from './hooks/useCountDisplay'; import useCountExceed from './hooks/useCountExceed'; import useMergedValue from './hooks/useMergedValue'; +import useMask from './hooks/useMask'; import type { ChangeEventInfo, InputProps, InputRef } from './interface'; import { resolveOnChange } from './utils/commonUtils'; +const maskInputTypes = ['text', 'search', 'tel', 'url', 'password']; + const Input = forwardRef((props, ref) => { const { autoComplete, @@ -39,6 +42,11 @@ const Input = forwardRef((props, ref) => { styles, onCompositionStart, onCompositionEnd, + onBeforeInput, + onSelect, + mask, + maskDefinitions, + maskPlaceholder, ...rest } = props; @@ -48,6 +56,7 @@ const Input = forwardRef((props, ref) => { const inputRef = useRef(null); const holderRef = useRef(null); + const compositionEndValueRef = useRef(undefined); const focus = (option?: InputFocusOptions) => { if (inputRef.current) { @@ -61,10 +70,21 @@ const Input = forwardRef((props, ref) => { props.value, ); + const maskConfig = useMask({ + mask: maskInputTypes.includes(type) ? mask : undefined, + maskDefinitions, + maskPlaceholder, + value: formatValue, + focused: focused && !disabled && !props.readOnly, + isComposing: compositionRef.current, + inputRef, + maxLength, + }); + const countConfig = useCount(count, showCount); const { isOutOfRange, dataCount } = useCountDisplay({ countConfig, - value: formatValue, + value: maskConfig.hasMask ? maskConfig.displayValue : formatValue, maxLength, }); const getExceedValue = useCountExceed({ @@ -106,21 +126,45 @@ const Input = forwardRef((props, ref) => { currentValue: string, info: ChangeEventInfo, ) => { - const cutValue = getExceedValue(currentValue, compositionRef.current); + const nextValue = + maskConfig.hasMask && !compositionRef.current + ? maskConfig.getMaskedValue( + currentValue, + (e.nativeEvent as InputEvent).inputType, + // Apply `count.exceedFormatter` to the formatted value, keeping the mask caret. + (value) => getExceedValue(value, false, false), + ) + : getExceedValue(currentValue, compositionRef.current); - if (info.source === 'compositionEnd' && currentValue === cutValue) { + if (info.source === 'compositionEnd' && currentValue === nextValue) { // Avoid triggering twice // https://github.com/ant-design/ant-design/issues/46587 - return; + return nextValue; + } + if ( + maskConfig.hasMask && + !compositionRef.current && + nextValue === (maskConfig.hasInput ? maskConfig.displayValue : '') + ) { + return nextValue; } - setValue(cutValue); + setValue(nextValue); if (inputRef.current) { - resolveOnChange(inputRef.current, e, onChange, cutValue); + resolveOnChange(inputRef.current, e, onChange, nextValue); } + return nextValue; }; const onInternalChange: React.ChangeEventHandler = (e) => { + if ( + maskConfig.hasMask && + compositionEndValueRef.current === e.target.value + ) { + compositionEndValueRef.current = undefined; + return; + } + compositionEndValueRef.current = undefined; triggerChange(e, e.target.value, { source: 'change', }); @@ -130,13 +174,26 @@ const Input = forwardRef((props, ref) => { e: React.CompositionEvent, ) => { compositionRef.current = false; - triggerChange(e, e.currentTarget.value, { + const nextValue = triggerChange(e, e.currentTarget.value, { source: 'compositionEnd', }); + if (maskConfig.hasMask) { + compositionEndValueRef.current = nextValue; + } onCompositionEnd?.(e); }; const handleKeyDown = (e: React.KeyboardEvent) => { + compositionEndValueRef.current = undefined; + if (maskConfig.hasMask) { + maskConfig.recordSelection( + e.key === 'Backspace' + ? 'backward' + : e.key === 'Delete' + ? 'forward' + : '', + ); + } if ( onPressEnter && e.key === 'Enter' && @@ -156,6 +213,9 @@ const Input = forwardRef((props, ref) => { }; const handleFocus: React.FocusEventHandler = (e) => { + if (maskConfig.hasMask && !maskConfig.hasInput) { + maskConfig.reset(); + } setFocused(true); onFocus?.(e); }; @@ -169,6 +229,8 @@ const Input = forwardRef((props, ref) => { }; const handleReset = (e: React.MouseEvent) => { + compositionEndValueRef.current = undefined; + maskConfig.reset(); setValue(''); focus(); if (inputRef.current) { @@ -203,6 +265,9 @@ const Input = forwardRef((props, ref) => { 'styles', 'classNames', 'onClear', + 'mask', + 'maskDefinitions', + 'maskPlaceholder', ], ); return ( @@ -214,6 +279,14 @@ const Input = forwardRef((props, ref) => { onBlur={handleBlur} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} + onBeforeInput={(e) => { + maskConfig.recordSelection(); + onBeforeInput?.(e); + }} + onSelect={(e) => { + maskConfig.recordSelection(); + onSelect?.(e); + }} className={clsx( prefixCls, { @@ -225,7 +298,9 @@ const Input = forwardRef((props, ref) => { ref={inputRef} size={htmlSize} type={type} + maxLength={maskConfig.hasMask ? undefined : maxLength} onCompositionStart={(e) => { + maskConfig.recordSelection(); compositionRef.current = true; onCompositionStart?.(e); }} @@ -269,7 +344,15 @@ const Input = forwardRef((props, ref) => { prefixCls={prefixCls} className={clsx(className, outOfRangeCls)} handleReset={handleReset} - value={formatValue} + value={maskConfig.displayValue} + allowClear={ + maskConfig.hasMask && !maskConfig.hasInput && rest.allowClear + ? { + ...(typeof rest.allowClear === 'object' ? rest.allowClear : {}), + disabled: true, + } + : rest.allowClear + } focused={focused} triggerFocus={focus} suffix={getSuffix()} diff --git a/src/hooks/useCountExceed.ts b/src/hooks/useCountExceed.ts index 7de71c7..e690f9c 100644 --- a/src/hooks/useCountExceed.ts +++ b/src/hooks/useCountExceed.ts @@ -30,7 +30,7 @@ export default function useCountExceed< }, [selection]); const getExceedValue = React.useCallback( - (currentValue: string, isComposing: boolean) => { + (currentValue: string, isComposing: boolean, restoreSelection = true) => { let nextValue = currentValue; if ( @@ -43,7 +43,7 @@ export default function useCountExceed< max: countConfig.max, }); - if (currentValue !== nextValue) { + if (restoreSelection && currentValue !== nextValue) { setSelection([ getTargetRef.current()?.selectionStart || 0, getTargetRef.current()?.selectionEnd || 0, diff --git a/src/hooks/useMask.ts b/src/hooks/useMask.ts new file mode 100644 index 0000000..adb405e --- /dev/null +++ b/src/hooks/useMask.ts @@ -0,0 +1,279 @@ +import { useLayoutEffect, warning } from '@rc-component/util'; +import { useMemo, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import type { + InputMask, + InputMaskDefinitions, + InputMaskPattern, + InputMaskState, +} from '../interface'; +import { + editMask, + formatMask, + hasPlaceholderConflict, + parseMask, +} from '../utils/maskUtil'; + +interface MaskRequest { + /** Value emitted for this request. */ + value: string; + /** Text rendered for this request, including visible placeholders. */ + display: string; + pattern: InputMaskPattern; + mask: InputMask; + placeholder: string | null; + /** State passed to a dynamic mask to select `pattern`. */ + state: InputMaskState; + selection: InputMaskState['selection']; +} + +function getTokens( + pattern: InputMaskPattern, + definitions?: InputMaskDefinitions, + maxLength?: number, +) { + const tokens = parseMask(pattern, definitions); + return maxLength !== undefined && maxLength >= 0 + ? tokens.slice(0, maxLength) + : tokens; +} + +function isFocused(input: HTMLInputElement) { + // `document.activeElement` is the shadow host when the input is inside a shadow root. + const root = (input.getRootNode?.() ?? input.ownerDocument) as + Document | ShadowRoot; + return root.activeElement === input; +} + +export default function useMask({ + mask, + maskDefinitions, + maskPlaceholder = null, + value, + focused, + isComposing, + inputRef, + maxLength, +}: { + mask?: InputMask; + maskDefinitions?: InputMaskDefinitions; + maskPlaceholder?: string | null; + value: string; + focused: boolean; + isComposing: boolean; + inputRef: RefObject; + maxLength?: number; +}) { + const [request, setRequest] = useState(); + const consumedRequest = useRef(undefined); + const currentRequest = + request && + request.value === value && + request.placeholder === maskPlaceholder + ? request + : undefined; + const pattern = useMemo(() => { + if (typeof mask !== 'function') { + return mask; + } + if (currentRequest) { + // Inline functions change on every parent render: re-evaluate the state + // that selected the pattern instead of the formatted value. + return currentRequest.mask === mask + ? currentRequest.pattern + : mask(currentRequest.state); + } + return mask({ value, selection: null }); + }, [mask, value, currentRequest]); + // Recompile rules separately so inline definitions do not reselect a dynamic mask. + const tokens = useMemo( + () => (pattern ? getTokens(pattern, maskDefinitions, maxLength) : []), + [pattern, maskDefinitions, maxLength], + ); + const formatted = useMemo( + () => + tokens.length + ? formatMask(value, tokens, maskPlaceholder, focused) + : undefined, + [value, tokens, maskPlaceholder, focused], + ); + const displayValue = formatted && !isComposing ? formatted.value : value; + const snapshotRef = useRef<{ + state: InputMaskState; + tokens: ReturnType; + direction: string; + }>(undefined); + + if (process.env.NODE_ENV !== 'production') { + warning( + !hasPlaceholderConflict(tokens, maskPlaceholder), + '[rc-input] `maskPlaceholder` contains characters accepted by their mask positions. They are treated as empty positions and cannot be entered.', + ); + } + + const getSelection = (): InputMaskState['selection'] => { + const input = inputRef.current; + return input && input.selectionStart !== null && input.selectionEnd !== null + ? { start: input.selectionStart, end: input.selectionEnd } + : null; + }; + + useLayoutEffect(() => { + const input = inputRef.current; + if (!mask || isComposing || !input) { + return; + } + const snapshot = snapshotRef.current; + if (request && consumedRequest.current !== request) { + consumedRequest.current = request; + // Keep the previous selection when a controlled parent rejects the request. + const selection = + snapshot && + request.display !== displayValue && + snapshot.state.value === displayValue + ? snapshot.state.selection + : request.selection; + if (selection && isFocused(input)) { + input.setSelectionRange( + Math.min(selection.start, displayValue.length), + Math.min(selection.end, displayValue.length), + ); + } + } + snapshotRef.current = { + state: { value: displayValue, selection: getSelection() }, + tokens, + direction: '', + }; + }); + + const recordSelection = (direction?: string) => { + const snapshot = snapshotRef.current; + if (snapshot && inputRef.current?.value === snapshot.state.value) { + snapshot.state.selection = getSelection(); + if (direction !== undefined) { + snapshot.direction = direction; + } + } + }; + + const getMaskedValue = ( + nextValue: string, + inputType = '', + limitValue?: (value: string) => string, + ) => { + if (!mask) { + return nextValue; + } + const state = { value: nextValue, selection: getSelection() }; + const nextPattern = typeof mask === 'function' ? mask(state) : mask; + const nextTokens = + typeof mask === 'function' + ? getTokens(nextPattern, maskDefinitions, maxLength) + : tokens; + const previous = snapshotRef.current; + const direction = inputType.includes('Backward') + ? 'backward' + : inputType.includes('Forward') + ? 'forward' + : previous?.direction; + let result: { + value: string; + filled: boolean; + selection: InputMaskState['selection']; + rejected: boolean; + }; + if (nextTokens.length) { + result = editMask( + previous?.state ?? { value: displayValue, selection: null }, + state, + previous?.tokens ?? tokens, + nextTokens, + maskPlaceholder, + direction, + // Typed characters are validated one by one; other insertions are read as values. + inputType !== 'insertText', + ); + } else { + // An empty pattern disables formatting, but `maxLength` still applies. + const limited = + maxLength !== undefined && maxLength >= 0 + ? nextValue.slice(0, maxLength) + : nextValue; + result = { + value: limited, + filled: !!limited, + selection: state.selection, + rejected: false, + }; + } + + let display = result.value; + let maskedValue = result.filled ? display : ''; + let { selection } = result; + if (limitValue && maskedValue && !result.rejected) { + // `count.exceedFormatter` receives the formatted value; format its result again. + const limited = limitValue(maskedValue); + if (limited !== maskedValue) { + const limitedMask = nextTokens.length + ? formatMask(limited, nextTokens, maskPlaceholder, true) + : { value: limited, filled: !!limited }; + display = limitedMask.value; + maskedValue = limitedMask.filled ? display : ''; + // Keep the previous caret when the formatter discards the edit. + selection = + previous && display === previous.state.value + ? previous.state.selection + : selection && { + start: Math.min(selection.start, display.length), + end: Math.min(selection.end, display.length), + }; + } + } + + setRequest({ + value: maskedValue, + display, + // A rejected edit keeps the pattern of the rendered value. + pattern: result.rejected && pattern ? pattern : nextPattern, + mask, + placeholder: maskPlaceholder, + state: result.rejected + ? (currentRequest?.state ?? { value, selection: null }) + : state, + selection, + }); + return maskedValue; + }; + + const reset = () => { + if (mask) { + const state = { value: '', selection: { start: 0, end: 0 } }; + const nextPattern = typeof mask === 'function' ? mask(state) : mask; + const nextTokens = getTokens(nextPattern, maskDefinitions, maxLength); + const result = formatMask('', nextTokens, maskPlaceholder, true); + const firstSlot = nextTokens.findIndex( + (token) => typeof token !== 'string', + ); + const position = result.positions[Math.max(firstSlot, 0)]; + setRequest({ + value: '', + display: result.value, + pattern: nextPattern, + mask, + placeholder: maskPlaceholder, + state, + selection: { start: position, end: position }, + }); + } + }; + + return { + displayValue, + hasMask: !!mask, + hasInput: !formatted || isComposing ? !!value : formatted.filled, + recordSelection, + getMaskedValue, + reset, + }; +} diff --git a/src/index.tsx b/src/index.tsx index eaa2458..f36eafe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,10 @@ import TextArea from './TextArea'; export type { AutoSizeType, BaseInputProps, + InputMask, + InputMaskDefinitions, + InputMaskPattern, + InputMaskState, InputProps, InputRef, ResizableTextAreaRef, diff --git a/src/interface.ts b/src/interface.ts index 0c87668..6dca350 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -88,6 +88,18 @@ export interface CountConfig { exceedFormatter?: ExceedFormatter; } +export interface InputMaskState { + value: string; + selection: { start: number; end: number } | null; +} + +export type InputMaskPattern = string | readonly (string | RegExp)[]; + +export type InputMask = + InputMaskPattern | ((state: InputMaskState) => InputMaskPattern); + +export type InputMaskDefinitions = Readonly>; + export interface InputProps extends CommonInputProps, @@ -142,6 +154,15 @@ export interface InputProps }; count?: CountConfig; onClear?: () => void; + /** `0`: digit, `X`: Latin letter, `*`: alphanumeric. Escape literals with `\\`. */ + mask?: InputMask; + /** Custom single-character rules. Use `null` to make a default token literal. */ + maskDefinitions?: InputMaskDefinitions; + /** + * Placeholder for unfilled positions. + * Omit or use `undefined`, `null` or `''` to hide placeholders. + */ + maskPlaceholder?: string | null; } export interface InputRef { @@ -172,8 +193,7 @@ export interface ResizableTextAreaRef { } export type ValueOrTextAreaValue = - | TextareaHTMLAttributes['value'] - | bigint; + TextareaHTMLAttributes['value'] | bigint; export type TextAreaProps = Omit< TextareaHTMLAttributes, diff --git a/src/utils/maskUtil.ts b/src/utils/maskUtil.ts new file mode 100644 index 0000000..1794161 --- /dev/null +++ b/src/utils/maskUtil.ts @@ -0,0 +1,515 @@ +import type { + InputMaskDefinitions, + InputMaskPattern, + InputMaskState, +} from '../interface'; + +type MaskToken = string | RegExp; + +const defaultDefinitions: Record = { + '0': /[0-9]/, + X: /[a-zA-Z]/, + '*': /[a-zA-Z0-9]/, +}; + +function getRule(token: RegExp) { + // Stateful expressions must not change between keystrokes or mutate the caller's regex. + return token.global || token.sticky + ? new RegExp(token.source, token.flags.replace(/[gy]/g, '')) + : token; +} + +export function parseMask( + mask: InputMaskPattern, + definitions?: InputMaskDefinitions, +): MaskToken[] { + const tokens: MaskToken[] = []; + + if (typeof mask === 'string') { + for (let index = 0; index < mask.length; index += 1) { + const character = mask[index]; + if (character === '\\' && index + 1 < mask.length) { + tokens.push(mask[++index]); + } else { + const definition = definitions?.[character]; + const token = + definition === undefined ? defaultDefinitions[character] : definition; + tokens.push(token ? getRule(token) : character); + } + } + } else { + mask.forEach((token) => { + if (typeof token === 'string') { + tokens.push(...token.split('')); + } else { + tokens.push(getRule(token)); + } + }); + } + + return tokens; +} + +function isSlot(token?: MaskToken): token is RegExp { + return token !== undefined && typeof token !== 'string'; +} + +function getPlaceholder(placeholder: string | null, index: number) { + return placeholder + ? placeholder.length === 1 + ? placeholder + : placeholder[index] || '_' + : ''; +} + +export function hasPlaceholderConflict( + tokens: MaskToken[], + placeholder: string | null, +) { + return ( + !!placeholder && + tokens.some( + (token, index) => + isSlot(token) && token.test(getPlaceholder(placeholder, index)), + ) + ); +} + +function matchesLiteral(tokens: MaskToken[], start: number, character: string) { + for (let index = start; index < tokens.length; index += 1) { + if (typeof tokens[index] === 'string' && tokens[index] === character) { + return true; + } + } + return false; +} + +function matchesLaterSlot( + tokens: MaskToken[], + start: number, + character: string, +) { + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]; + if (typeof token !== 'string' && token.test(character)) { + return true; + } + } + return false; +} + +function findLastMatch(value: string, token: RegExp) { + for (let index = value.length - 1; index >= 0; index -= 1) { + if (token.test(value[index])) { + return index; + } + } + return -1; +} + +/** + * Whether `value` from `offset` is consumed by the tokens from `index`, + * leaving positions empty instead of discarding characters. + */ +function fitsRemainingTokens( + value: string, + offset: number, + tokens: MaskToken[], + index: number, +) { + let current = offset; + for ( + let tokenIndex = index; + tokenIndex < tokens.length && current < value.length; + tokenIndex += 1 + ) { + const token = tokens[tokenIndex]; + if (typeof token === 'string') { + let literal = token; + while (typeof tokens[tokenIndex + 1] === 'string') { + literal += tokens[++tokenIndex]; + } + if (value.startsWith(literal, current)) { + current += literal.length; + } + } else if (token.test(value[current])) { + current += 1; + } + } + return current === value.length; +} + +/** + * Whether a hidden position stays empty instead of discarding `text[offset]`: + * the rest of the text fills the following positions, or the character belongs + * to a later position and no later character fits this one. + */ +function leavesHiddenPosition( + text: string, + offset: number, + tokens: MaskToken[], + index: number, + cache: { lastMatch?: number }, +) { + if (fitsRemainingTokens(text, offset, tokens, index + 1)) { + return true; + } + if (!matchesLaterSlot(tokens, index + 1, text[offset])) { + return false; + } + if (cache.lastMatch === undefined) { + cache.lastMatch = findLastMatch(text, tokens[index] as RegExp); + } + return cache.lastMatch < offset; +} + +/** Length of the leading literal prefix found at the start of `text`. */ +function getPrefixLength(text: string, tokens: MaskToken[]) { + let prefix = ''; + for (let index = 0; typeof tokens[index] === 'string'; index += 1) { + prefix += tokens[index]; + } + if (text.startsWith(prefix)) { + return prefix.length; + } + // Accept a partially written prefix, e.g. `+1 555` for `+1 (000)`, only when + // its first character cannot be entered in the first editable position. + const firstSlot = tokens[prefix.length]; + if (text[0] !== prefix[0] || (isSlot(firstSlot) && firstSlot.test(text[0]))) { + return 0; + } + let length = 0; + for (let index = 0; index < prefix.length; index += 1) { + if (text[length] === prefix[index]) { + length += 1; + } + } + return length; +} + +function renderMask( + tokens: MaskToken[], + characters: (string | null)[], + placeholder: string | null, + showEmpty: boolean, +) { + let lastFilled = -1; + characters.forEach((character, index) => { + if (character !== null) { + lastFilled = index; + } + }); + + let end = tokens.length; + if (lastFilled === -1 && !showEmpty) { + end = 0; + } else if (!placeholder) { + end = lastFilled + 1; + while (typeof tokens[end] === 'string') { + end += 1; + } + } + + let value = ''; + const positions: number[] = []; + tokens.forEach((token, index) => { + positions.push(value.length); + if (index < end) { + value += + typeof token === 'string' + ? token + : (characters[index] ?? getPlaceholder(placeholder, index)); + } + }); + positions.push(value.length); + + return { value, characters, positions, filled: lastFilled !== -1 }; +} + +export function formatMask( + value: string, + tokens: MaskToken[], + placeholder: string | null, + showEmpty = false, +) { + const characters: (string | null)[] = tokens.map(() => null); + let offset = 0; + // A separator can only end a group of editable positions that received input. + let groupHasInput = false; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (typeof token === 'string') { + const literalStart = index; + let literal = token; + while (typeof tokens[index + 1] === 'string') { + literal += tokens[++index]; + } + if (literalStart === 0) { + offset = getPrefixLength(value, tokens); + } else if (value.slice(offset, offset + literal.length) === literal) { + offset += literal.length; + } + groupHasInput = false; + } else { + const cache = {}; + while (offset < value.length) { + const character = value[offset]; + if (character === getPlaceholder(placeholder, index)) { + offset += 1; + groupHasInput = true; + break; + } + if (token.test(character)) { + characters[index] = character; + offset += 1; + groupHasInput = true; + break; + } + if ( + (groupHasInput && matchesLiteral(tokens, index + 1, character)) || + (!placeholder && + leavesHiddenPosition(value, offset, tokens, index, cache)) + ) { + break; + } + offset += 1; + } + } + } + + return renderMask(tokens, characters, placeholder, showEmpty); +} + +function findChange( + previous: InputMaskState, + current: InputMaskState, + direction: string, +) { + const oldValue = previous.value; + const value = current.value; + let start = previous.selection?.start ?? 0; + let end = previous.selection?.end ?? start; + + if (previous.selection) { + const removed = oldValue.length - value.length; + if (start === end && removed > 0) { + if (direction === 'backward') { + start = Math.max(0, start - removed); + } else if (direction === 'forward') { + end += removed; + } + } + const insertedLength = value.length - oldValue.length + end - start; + if ( + insertedLength >= 0 && + oldValue.slice(0, start) === value.slice(0, start) && + oldValue.slice(end) === value.slice(start + insertedLength) + ) { + return { + start, + end, + inserted: value.slice(start, start + insertedLength), + fromSelection: true, + }; + } + } + + start = 0; + while ( + start < oldValue.length && + start < value.length && + oldValue[start] === value[start] + ) { + start += 1; + } + end = oldValue.length; + let nextEnd = value.length; + while ( + end > start && + nextEnd > start && + oldValue[end - 1] === value[nextEnd - 1] + ) { + end -= 1; + nextEnd -= 1; + } + return { + start, + end, + inserted: value.slice(start, nextEnd), + fromSelection: false, + }; +} + +export function editMask( + previous: InputMaskState, + current: InputMaskState, + previousTokens: MaskToken[], + tokens: MaskToken[], + placeholder: string | null, + direction = '', + /** + * Read inserted text like a value, as for paste, drop or autofill, instead of + * discarding characters that do not fit the next position, as when typing. + */ + parse = false, +) { + const oldMask = formatMask(previous.value, previousTokens, placeholder, true); + const change = findChange(previous, current, direction); + const oldSlots = previousTokens.flatMap((token, index) => + typeof token === 'string' ? [] : [index], + ); + const slots = tokens.flatMap((token, index) => + typeof token === 'string' ? [] : [index], + ); + let start = oldSlots.findIndex( + (index) => oldMask.positions[index] >= change.start, + ); + if (start === -1) { + start = oldSlots.length; + } + if (change.inserted && start === oldSlots.length) { + const firstEmpty = oldSlots.findIndex( + (index) => oldMask.characters[index] === null, + ); + if (firstEmpty !== -1) { + start = firstEmpty; + } + } + const removed = new Set( + oldSlots.flatMap((index, slot) => + oldMask.positions[index] >= change.start && + oldMask.positions[index] < change.end && + (oldMask.characters[index] !== null || !!placeholder) + ? [slot] + : [], + ), + ); + + // Deleting a separator should delete the adjacent editable character instead of reinserting it. + // Only edits at the recorded caret qualify, not programmatic replacements. + if ( + change.fromSelection && + !change.inserted && + change.end > change.start && + removed.size === 0 && + previous.selection?.start === previous.selection?.end + ) { + const slot = direction === 'forward' ? start : start - 1; + if (slot >= 0 && slot < oldSlots.length) { + removed.add(slot); + start = slot; + } + } + + const characters: (string | null)[] = tokens.map(() => null); + const tail: string[] = []; + oldSlots.forEach((index, slot) => { + const character = oldMask.characters[index]; + if (character !== null && !removed.has(slot)) { + if (slot < start && slots[slot] !== undefined) { + const token = tokens[slots[slot]] as RegExp; + if (token.test(character)) { + characters[slots[slot]] = character; + } + } else { + tail.push(character); + } + } + }); + + let insertedOffset = + start === 0 ? getPrefixLength(change.inserted, tokens) : 0; + let tailOffset = 0; + let caret = slots[start] ?? tokens.length; + let groupHasInput = false; + for (let index = caret - 1; isSlot(tokens[index]); index -= 1) { + if (characters[index] !== null) { + groupHasInput = true; + } + } + + for (let index = caret; index < tokens.length; index += 1) { + const token = tokens[index]; + if (typeof token === 'string') { + if (change.inserted[insertedOffset] === token) { + insertedOffset += 1; + caret = index + 1; + } + groupHasInput = false; + continue; + } + + let inserted = false; + const cache = {}; + while (insertedOffset < change.inserted.length) { + const character = change.inserted[insertedOffset]; + // Placeholder characters are reserved for empty positions, as in `formatMask`. + if (character === getPlaceholder(placeholder, index)) { + insertedOffset += 1; + caret = index + 1; + inserted = true; + break; + } + if (token.test(character)) { + characters[index] = character; + insertedOffset += 1; + caret = index + 1; + inserted = true; + break; + } + if ( + (groupHasInput && matchesLiteral(tokens, index + 1, character)) || + (parse && + !placeholder && + leavesHiddenPosition( + change.inserted, + insertedOffset, + tokens, + index, + cache, + )) + ) { + break; + } + insertedOffset += 1; + } + if (inserted) { + groupHasInput = true; + } + + if ( + !inserted && + characters[index] === null && + insertedOffset === change.inserted.length + ) { + if (tailOffset < tail.length && token.test(tail[tailOffset])) { + characters[index] = tail[tailOffset++]; + } + } + } + + while (typeof tokens[caret] === 'string') { + caret += 1; + } + const result = renderMask(tokens, characters, placeholder, true); + // Reject results that would be read back differently, e.g. hidden empty + // positions followed by characters that also fit them. + if ( + formatMask(result.value, tokens, placeholder, true).value !== result.value + ) { + return { + value: previous.value, + filled: oldMask.filled, + selection: previous.selection, + rejected: true, + }; + } + const position = result.positions[caret]; + return { + value: result.value, + filled: result.filled, + selection: { start: position, end: position }, + rejected: false, + }; +} diff --git a/tests/mask.test.tsx b/tests/mask.test.tsx new file mode 100644 index 0000000..cd6a17b --- /dev/null +++ b/tests/mask.test.tsx @@ -0,0 +1,1012 @@ +import { resetWarned } from '@rc-component/util'; +import { act, fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import Input from '../src'; +import type { + InputMask, + InputMaskDefinitions, + InputMaskState, + InputProps, + InputRef, +} from '../src'; + +function renderInput(props: InputProps & React.RefAttributes = {}) { + const result = render(); + return { ...result, input: result.container.querySelector('input')! }; +} + +describe('Input.maskDefinitions', () => { + it('adds custom tokens while preserving the defaults and the event contract', async () => { + const maskDefinitions: InputMaskDefinitions = Object.freeze({ + a: /[a-zA-Z]/, + '9': /[0-9]/, + '#': /[a-zA-Z0-9]/, + }); + const onChange = jest.fn(); + const { input } = renderInput({ + mask: 'aa-99-##-0X*', + maskDefinitions, + onChange, + }); + await userEvent.type(input, 'AB12C34Dz'); + expect(input.value).toBe('AB-12-C3-4Dz'); + const event = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(event.target.value).toBe('AB-12-C3-4Dz'); + expect(event.currentTarget.value).toBe('AB-12-C3-4Dz'); + expect(input.hasAttribute('maskDefinitions')).toBe(false); + }); + + it('overrides the rules of default tokens', async () => { + const { input } = renderInput({ + mask: 'XX-00', + maskDefinitions: { X: /[A-F]/, '0': /[1-3]/ }, + }); + await userEvent.type(input, 'zAF912'); + expect(input.value).toBe('AF-12'); + }); + + it('makes default tokens literal when their definition is null', async () => { + const { input } = renderInput({ + mask: '0X*-99', + maskDefinitions: { '0': null, X: null, '*': null, '9': /[0-9]/ }, + }); + await userEvent.type(input, '12'); + expect(input.value).toBe('0X*-12'); + }); + + it('escapes custom tokens as literals', () => { + const { input } = renderInput({ + mask: '\\H-HH', + maskDefinitions: { H: /[0-9A-F]/ }, + defaultValue: 'A1', + }); + expect(input.value).toBe('H-A1'); + }); + + it('preserves regex flags and does not mutate stateful definitions', async () => { + const digit = /[0-9]/gy; + const letter = /[a-z]/iy; + digit.lastIndex = 1; + letter.lastIndex = 2; + const { input } = renderInput({ + mask: 'dd-LL', + maskDefinitions: { d: digit, L: letter }, + }); + await userEvent.type(input, '12aB'); + expect(input.value).toBe('12-aB'); + expect(digit.lastIndex).toBe(1); + expect(letter.lastIndex).toBe(2); + expect(digit.flags).toBe('gy'); + expect(letter.flags).toBe('iy'); + }); + + it('keeps array strings literal and uses their explicit regular expressions', () => { + const { input } = renderInput({ + mask: ['H0X*-', /[A-F0-9]/], + maskDefinitions: { H: /[0-9]/, '0': null, X: /[0-9]/, '*': null }, + defaultValue: 'A', + }); + expect(input.value).toBe('H0X*-A'); + }); + + it('reformats controlled values when definitions change without emitting a change', () => { + const onChange = jest.fn(); + const { input, rerender } = renderInput({ + mask: 'HH', + maskDefinitions: { H: /[A-F0-9]/ }, + value: 'AB12', + onChange, + }); + expect(input.value).toBe('AB'); + rerender( + , + ); + expect(input.value).toBe('12'); + rerender(); + expect(input.value).toBe(''); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('uses updated definitions when editing an uncontrolled value', async () => { + const { input, rerender } = renderInput({ + mask: 'HH', + maskDefinitions: { H: /[0-9]/ }, + }); + await userEvent.type(input, '12'); + rerender(); + expect(input.value).toBe('12'); + input.setSelectionRange(0, 2); + await userEvent.keyboard('AB'); + expect(input.value).toBe('AB'); + expect(input.selectionStart).toBe(2); + }); + + it('uses custom tokens with dynamic masks and after clearing', async () => { + const mask: InputMask = ({ value }) => + value.replace(/\D/g, '').length > 4 ? '999-999' : '99-99'; + const { input, container } = renderInput({ + mask, + maskDefinitions: { '9': /[0-9]/ }, + maskPlaceholder: null, + allowClear: true, + }); + await userEvent.type(input, '12345'); + expect(input.value).toBe('123-45'); + expect(input.selectionStart).toBe(6); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('12-34'); + await userEvent.click(container.querySelector('.rc-input-clear-icon')!); + expect(input.value).toBe(''); + expect(input.selectionStart).toBe(0); + await userEvent.keyboard('67'); + expect(input.value).toBe('67-'); + }); + + it('uses custom tokens in dynamic masks based on selection', () => { + const mask = jest.fn(({ selection }: InputMaskState) => + selection?.start === 1 ? 'Hd' : 'dd', + ); + const { input } = renderInput({ + mask, + maskDefinitions: { H: /[A-Z]/, d: /[0-9]/ }, + }); + fireEvent.change(input, { + target: { value: 'A', selectionStart: 1, selectionEnd: 1 }, + }); + expect(mask).toHaveBeenCalledWith({ + value: 'A', + selection: { start: 1, end: 1 }, + }); + expect(input.value).toBe('A'); + }); + + it('retains the selected dynamic mask with inline definitions in controlled inputs', () => { + const mask: InputMask = ({ selection }) => + selection?.start === 1 ? 'H-dd' : 'dd'; + const Demo = () => { + const [value, setValue] = React.useState(''); + return ( + setValue(event.target.value)} + /> + ); + }; + const { container } = render(); + const input = container.querySelector('input')!; + fireEvent.change(input, { + target: { value: 'A', selectionStart: 1, selectionEnd: 1 }, + }); + expect(input.value).toBe('A-'); + }); + + it('retains the dynamic mask selected by an inline function in controlled inputs', () => { + const onChange = jest.fn(); + const Demo = () => { + const [value, setValue] = React.useState(''); + return ( + (selection?.start === 1 ? 'H-dd' : 'dd')} + maskDefinitions={{ H: /[A-Z]/, d: /[0-9]/ }} + value={value} + onChange={(event) => { + onChange(event.target.value); + setValue(event.target.value); + }} + /> + ); + }; + const { container } = render(); + const input = container.querySelector('input')!; + fireEvent.change(input, { + target: { value: 'A', selectionStart: 1, selectionEnd: 1 }, + }); + expect(onChange).toHaveBeenCalledWith('A-'); + expect(input.value).toBe('A-'); + }); + + it('ignores definitions without a mask and does not forward them to the DOM', () => { + const { container } = render( + , + ); + const input = container.querySelector('input')!; + expect(input.value).toBe('H12'); + expect(input.hasAttribute('maskDefinitions')).toBe(false); + }); +}); + +describe('Input.mask', () => { + it.each([ + ['00-00', '1234', '12-34'], + ['XX-XX', 'AbCd', 'Ab-Cd'], + ['**-**', 'a1B2', 'a1-B2'], + ['XX-00', 'AB-12', 'AB-12'], + ['\\X\\0-00', '42', 'X0-42'], + ['9a-00', '42', '9a-42'], + ['+39 0000', '1234', '+39 1234'], + ])('formats the initial value with %s', (mask, defaultValue, expected) => { + const { input } = renderInput({ mask, defaultValue }); + expect(input.value).toBe(expected); + }); + + it('supports array masks and does not mutate stateful regexes', async () => { + const digit = /[0-9]/g; + digit.lastIndex = 1; + const { input } = renderInput({ mask: ['ID: ', /[A-Z]/, digit, digit] }); + await userEvent.type(input, 'A12'); + expect(input.value).toBe('ID: A12'); + expect(digit.lastIndex).toBe(1); + }); + + it('accepts digits, letters and alphanumeric input and skips invalid characters', async () => { + const { input } = renderInput({ mask: '00-XX-**' }); + await userEvent.type(input, 'x12!Ab?C3'); + expect(input.value).toBe('12-Ab-C3'); + }); + + it.each([ + [undefined, '12'], + ['_', '12__'], + [null, '12'], + ])( + 'skips invalid initial characters with placeholder=%s', + (maskPlaceholder, expected) => { + const { input } = renderInput({ + mask: '00XX', + defaultValue: 'x12', + maskPlaceholder, + }); + expect(input.value).toBe(expected); + }, + ); + + it.each([ + [undefined, '12-'], + ['_', '12-__'], + ['#', '12-##'], + [null, '12-'], + ['', '12-'], + ])('supports maskPlaceholder=%s', (maskPlaceholder, expected) => { + const { input } = renderInput({ defaultValue: '12', maskPlaceholder }); + expect(input.value).toBe(expected); + }); + + it('hides placeholders by default when focusing, typing and clearing', async () => { + const { input, container } = renderInput({ + allowClear: true, + placeholder: 'Digits', + }); + await userEvent.click(input); + expect(input.value).toBe(''); + expect(input.placeholder).toBe('Digits'); + await userEvent.keyboard('12'); + expect(input.value).toBe('12-'); + await userEvent.click(container.querySelector('.rc-input-clear-icon')!); + expect(input.value).toBe(''); + expect(input.selectionStart).toBe(0); + }); + + it('hides placeholders when an explicit placeholder is removed', () => { + const onChange = jest.fn(); + const { input, rerender } = renderInput({ + value: '12', + maskPlaceholder: '_', + onChange, + }); + expect(input.value).toBe('12-__'); + rerender( + , + ); + expect(input.value).toBe('12-'); + rerender(); + expect(input.value).toBe('12-'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('supports a placeholder string aligned with the mask', () => { + const { input } = renderInput({ + mask: '00/00/0000', + defaultValue: '12', + maskPlaceholder: 'dd/mm/yyyy', + }); + expect(input.value).toBe('12/mm/yyyy'); + }); + + it('shows an empty mask on focus and hides it on blur', async () => { + const { input, container } = renderInput({ + allowClear: true, + maskPlaceholder: '_', + }); + expect(input.value).toBe(''); + await userEvent.click(input); + expect(input.value).toBe('__-__'); + expect(container.querySelector('.rc-input-clear-icon-hidden')).toBeTruthy(); + await userEvent.tab(); + expect(input.value).toBe(''); + }); + + it.each([ + { maskPlaceholder: undefined, values: ['1', '12-', '12-3', '12-34'] }, + { maskPlaceholder: '_', values: ['1_-__', '12-__', '12-3_', '12-34'] }, + ])( + 'emits formatted values in both event targets without mutating earlier events with placeholder=$maskPlaceholder', + async ({ maskPlaceholder, values }) => { + const onChange = jest.fn(); + const { input } = renderInput({ onChange, maskPlaceholder }); + await userEvent.type(input, '1234'); + const events = onChange.mock.calls.map(([event]) => event); + expect(events.map((event) => event.target.value)).toEqual(values); + expect(events[0].currentTarget.value).toBe(values[0]); + expect(events[3].currentTarget.value).toBe('12-34'); + }, + ); + + it('preserves controlled values while emitting the requested change', async () => { + const onChange = jest.fn(); + const { input, rerender } = renderInput({ + value: '12', + maskPlaceholder: '_', + onChange, + }); + await userEvent.click(input); + input.setSelectionRange(3, 3); + await userEvent.keyboard('3'); + expect(onChange.mock.calls[0][0].target.value).toBe('12-3_'); + expect(input.value).toBe('12-__'); + expect(input.selectionStart).toBe(3); + rerender( + , + ); + expect(input.value).toBe('12-34'); + }); + + it('supports controlled updates through onChange', async () => { + const Demo = () => { + const [value, setValue] = React.useState(''); + return ( + setValue(e.target.value)} + /> + ); + }; + const { container } = render(); + const input = container.querySelector('input')!; + await userEvent.type(input, '1234'); + expect(input.value).toBe('12-34'); + expect(input.selectionStart).toBe(5); + }); + + it('shifts the suffix when inserting in the middle and restores the caret', async () => { + const { input } = renderInput({ defaultValue: '1234' }); + await userEvent.click(input); + input.setSelectionRange(1, 1); + await userEvent.keyboard('5'); + expect(input.value).toBe('15-23'); + expect(input.selectionStart).toBe(3); + expect(input.selectionEnd).toBe(3); + }); + + it('deletes the preceding digit when backspacing over a separator', async () => { + const { input } = renderInput({ + defaultValue: '1234', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(3, 3); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('13-4_'); + expect(input.selectionStart).toBe(1); + }); + + it('deletes the next digit when deleting a separator', async () => { + const { input } = renderInput({ + defaultValue: '1234', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(2, 2); + await userEvent.keyboard('{Delete}'); + expect(input.value).toBe('12-4_'); + expect(input.selectionStart).toBe(3); + }); + + it('preserves later groups with different character rules when deleting', async () => { + const { input } = renderInput({ + mask: 'XX-00', + defaultValue: 'AB-12', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(0, 0); + await userEvent.keyboard('{Delete}'); + expect(input.value).toBe('B_-12'); + expect(input.selectionStart).toBe(0); + }); + + it('preserves adjacent groups with different rules without placeholders', async () => { + const { input } = renderInput({ + mask: 'X0', + maskPlaceholder: null, + defaultValue: 'A1', + }); + await userEvent.click(input); + input.setSelectionRange(0, 0); + await userEvent.keyboard('{Delete}'); + expect(input.value).toBe('1'); + expect(input.selectionStart).toBe(0); + await userEvent.keyboard('B'); + expect(input.value).toBe('B1'); + }); + + it.each([ + ['X0X 0X0', 'A1B 2C3', '1B 2C3', 'Z1B 2C3'], + ['0X0', '1A2', 'A2', '9A2'], + ])( + 'emits the displayed value when deleting from %s without placeholders', + async (mask, defaultValue, deleted, typed) => { + const onChange = jest.fn(); + const { input } = renderInput({ mask, defaultValue, onChange }); + await userEvent.click(input); + input.setSelectionRange(0, 0); + await userEvent.keyboard('{Delete}'); + expect(input.value).toBe(deleted); + expect(onChange.mock.calls[0][0].target.value).toBe(deleted); + await userEvent.keyboard(typed[0]); + expect(input.value).toBe(typed); + }, + ); + + it('emits the displayed value when deleting a range from mixed groups', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ + mask: '00X0', + defaultValue: '12A3', + onChange, + }); + await userEvent.click(input); + input.setSelectionRange(0, 2); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('A3'); + expect(onChange.mock.calls[0][0].target.value).toBe('A3'); + }); + + it('ignores edits that cannot be displayed without placeholders', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ + mask: '0X0 0', + defaultValue: 'A 1', + onChange, + }); + await userEvent.click(input); + input.setSelectionRange(2, 2); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('A 1'); + expect(input.selectionStart).toBe(2); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([ + ['00X0', '12A3', [0, 2], 'A3'], + ['X0X 0X0', 'A1B 2C3', [0, 1], '1B 2C3'], + ] as const)( + 'pastes a partial value of %s copied from another input without placeholders', + async (mask, defaultValue, [start, end], partial) => { + const user = userEvent.setup(); + const { input } = renderInput({ mask, defaultValue }); + await user.click(input); + input.setSelectionRange(start, end); + await user.keyboard('{Backspace}'); + expect(input.value).toBe(partial); + + const onChange = jest.fn(); + const { input: pastedInput } = renderInput({ mask, onChange }); + await user.click(pastedInput); + await user.paste(input.value); + expect(pastedInput.value).toBe(partial); + expect(onChange.mock.calls[0][0].target.value).toBe(partial); + }, + ); + + it('discards typed characters that do not fit the next position', async () => { + const { input } = renderInput({ mask: '00X0' }); + await userEvent.type(input, 'A3'); + expect(input.value).toBe('3'); + }); + + it.each([ + ['0X0', '12A3', '1A3'], + ['X0X', 'AB1C', 'A1C'], + ])( + 'keeps valid characters when formatting %s with %s without placeholders', + async (mask, text, expected) => { + const { input } = renderInput({ mask, defaultValue: text }); + expect(input.value).toBe(expected); + const { input: controlledInput } = renderInput({ + mask, + value: text, + onChange: () => {}, + }); + expect(controlledInput.value).toBe(expected); + const user = userEvent.setup(); + const { input: pastedInput } = renderInput({ mask }); + await user.click(pastedInput); + await user.paste(text); + expect(pastedInput.value).toBe(expected); + }, + ); + + it('preserves explicitly pasted empty positions', async () => { + const user = userEvent.setup(); + const { input } = renderInput({ + defaultValue: '1234', + maskPlaceholder: '_', + }); + await user.click(input); + input.setSelectionRange(0, 1); + await user.paste('_'); + expect(input.value).toBe('_2-34'); + }); + + it('replaces a selection spanning a separator', async () => { + const { input } = renderInput({ + mask: '00-0000', + defaultValue: '123456', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(1, 5); + await userEvent.keyboard('9'); + expect(input.value).toBe('19-56__'); + expect(input.selectionStart).toBe(3); + }); + + it('handles repeated digits using the selection before the edit', async () => { + const { input } = renderInput({ + mask: '0000', + defaultValue: '1111', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(1, 1); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('111_'); + expect(input.selectionStart).toBe(0); + }); + + it.each(['1234', '12-34', '1x2-3!4'])('supports pasting %s', async (text) => { + const user = userEvent.setup(); + const { input } = renderInput(); + await user.click(input); + await user.paste(text); + expect(input.value).toBe('12-34'); + expect(input.selectionStart).toBe(5); + }); + + it('supports editing without placeholders', async () => { + const { input } = renderInput({ maskPlaceholder: null }); + await userEvent.type(input, '1234'); + input.setSelectionRange(3, 3); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('13-4'); + expect(input.selectionStart).toBe(1); + }); + + it('continues a partially filled initial value when typing at the end', async () => { + const { input } = renderInput({ defaultValue: '12' }); + await userEvent.type(input, '34'); + expect(input.value).toBe('12-34'); + }); + + it('recognizes a pasted literal prefix containing digits', async () => { + const user = userEvent.setup(); + const { input } = renderInput({ mask: ['+39 ', /\d/, /\d/, /\d/, /\d/] }); + await user.click(input); + await user.paste('+39 1234'); + expect(input.value).toBe('+39 1234'); + expect(input.selectionStart).toBe(8); + }); + + it.each([ + ['555-123-4567', undefined, '(555) 123-4567'], + ['555-123-4567', '_', '(555) 123-4567'], + ['555 123 4567', undefined, '(555) 123-4567'], + ['(555) 123-4567', undefined, '(555) 123-4567'], + ])( + 'ignores separators %s before a group is started (placeholder=%s)', + async (text, maskPlaceholder, expected) => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const { input } = renderInput({ + mask: '(000) 000-0000', + maskPlaceholder, + onChange, + }); + await user.click(input); + await user.paste(text); + expect(input.value).toBe(expected); + expect(onChange.mock.calls[0][0].target.value).toBe(expected); + + const { input: initialInput } = renderInput({ + mask: '(000) 000-0000', + maskPlaceholder, + defaultValue: text, + }); + expect(initialInput.value).toBe(expected); + }, + ); + + it('recognizes a partially written literal prefix', async () => { + const user = userEvent.setup(); + const { input } = renderInput({ mask: '+1 (000) 000-0000' }); + await user.click(input); + await user.paste('+1 555-123-4567'); + expect(input.value).toBe('+1 (555) 123-4567'); + }); + + it('does not consume input matching a prefix that starts with an editable character', async () => { + const { input } = renderInput({ mask: '39 000' }); + await userEvent.type(input, '3'); + expect(input.value).toBe('39 3'); + }); + + it('handles replacing the value through autofill', () => { + const { input } = renderInput({ defaultValue: '1234' }); + fireEvent.input(input, { + inputType: 'insertReplacementText', + target: { value: '9876', selectionStart: 4, selectionEnd: 4 }, + }); + expect(input.value).toBe('98-76'); + }); + + it.each([ + ['00-00', '12-34', '1234'], + ['(000) 000-0000', '(555) 123-4567', '(555) 1234567'], + ])( + 'does not treat a programmatic replacement as separator deletion with %s', + (mask, defaultValue, nextValue) => { + const onChange = jest.fn(); + const { input } = renderInput({ mask, defaultValue, onChange }); + fireEvent.change(input, { target: { value: nextValue } }); + expect(input.value).toBe(defaultValue); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it('handles deletion input events without a keyboard event', async () => { + const { input } = renderInput({ + defaultValue: '1234', + maskPlaceholder: '_', + }); + await userEvent.click(input); + input.setSelectionRange(3, 3); + fireEvent.select(input); + fireEvent.input(input, { + inputType: 'deleteContentBackward', + target: { value: '1234', selectionStart: 2, selectionEnd: 2 }, + }); + expect(input.value).toBe('13-4_'); + expect(input.selectionStart).toBe(1); + }); + + it('keeps a zero value editable and clearable', async () => { + const { input, container } = renderInput({ + defaultValue: 0, + allowClear: true, + maskPlaceholder: '_', + }); + expect(input.value).toBe('0_-__'); + expect(container.querySelector('.rc-input-clear-icon-hidden')).toBeFalsy(); + await userEvent.type(input, '123'); + expect(input.value).toBe('01-23'); + }); + + it('does not emit changes for rejected input with a raw initial value', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ defaultValue: '1234', onChange }); + await userEvent.type(input, 'x'); + expect(input.value).toBe('12-34'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not emit changes for rejected input', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ onChange, maskPlaceholder: '_' }); + await userEvent.type(input, 'a'); + expect(input.value).toBe('__-__'); + expect(onChange).not.toHaveBeenCalled(); + expect(input.selectionStart).toBe(0); + }); + + it('treats placeholder characters as empty positions and warns about conflicts', async () => { + resetWarned(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const onChange = jest.fn(); + const { input } = renderInput({ + mask: '00:00', + maskPlaceholder: '0', + onChange, + }); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('`maskPlaceholder` contains characters'), + ); + await userEvent.type(input, '01'); + expect(input.value).toBe('01:00'); + expect(onChange.mock.calls.map(([event]) => event.target.value)).toEqual([ + '01:00', + ]); + + const onWordChange = jest.fn(); + const { input: wordInput } = renderInput({ + mask: 'aa-aa', + maskDefinitions: { a: /\w/ }, + maskPlaceholder: '_', + onChange: onWordChange, + }); + await userEvent.type(wordInput, '_'); + expect(wordInput.value).toBe('__-__'); + expect(onWordChange).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('resolves dynamic masks using the proposed value', async () => { + const mask: InputMask = ({ value }) => + value.replace(/\D/g, '').length > 4 ? '000-000' : '00-00'; + const { input } = renderInput({ mask, maskPlaceholder: null }); + await userEvent.type(input, '12345'); + expect(input.value).toBe('123-45'); + expect(input.selectionStart).toBe(6); + await userEvent.keyboard('{Backspace}'); + expect(input.value).toBe('12-34'); + }); + + it('passes selection to dynamic masks and retains the selected mask after rendering', () => { + const mask = jest.fn(({ selection }: InputMaskState) => + selection?.start === 1 ? 'X0' : '00', + ); + const { input } = renderInput({ mask, maskPlaceholder: '_' }); + fireEvent.change(input, { + target: { value: 'A', selectionStart: 1, selectionEnd: 1 }, + }); + expect(mask).toHaveBeenCalledWith({ + value: 'A', + selection: { start: 1, end: 1 }, + }); + expect(input.value).toBe('A_'); + }); + + it('supports changing the mask and placeholder through props', () => { + const { input, rerender } = renderInput({ defaultValue: '1234' }); + rerender(); + expect(input.value).toBe('1-234'); + rerender(); + expect(input.value).toBe('1234'); + }); + + it('clears the value and keeps focus at the first editable position', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onClear = jest.fn(); + const { input, container } = renderInput({ + mask: '+00', + defaultValue: '12', + maskPlaceholder: '_', + allowClear: true, + onChange, + onClear, + }); + await user.click(container.querySelector('.rc-input-clear-icon')!); + expect(onChange.mock.calls[0][0].target.value).toBe(''); + expect(onClear).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + expect(input.value).toBe('+__'); + expect(input.selectionStart).toBe(1); + expect(container.querySelector('.rc-input-clear-icon-hidden')).toBeTruthy(); + }); + + it.each(['disabled', 'readOnly'] as const)( + 'preserves %s behavior', + async (prop) => { + const onChange = jest.fn(); + const { input } = renderInput({ + [prop]: true, + defaultValue: '1234', + onChange, + }); + await userEvent.type(input, '5'); + expect(input.value).toBe('12-34'); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it('does not mask unsupported input types or forward mask props to the DOM', () => { + const { input } = renderInput({ + type: 'email', + defaultValue: 'test@example.com', + maskDefinitions: { H: /[0-9A-F]/ }, + }); + expect(input.value).toBe('test@example.com'); + expect(input.hasAttribute('mask')).toBe(false); + expect(input.hasAttribute('maskDefinitions')).toBe(false); + expect(input.hasAttribute('maskPlaceholder')).toBe(false); + }); + + it('supports native text input types with selection', () => { + const { input } = renderInput({ type: 'tel', defaultValue: '1234' }); + expect(input.value).toBe('12-34'); + }); + + it('supports maxLength without preventing input into placeholders', async () => { + const { input } = renderInput({ maxLength: 4, maskPlaceholder: '_' }); + await userEvent.type(input, '1234'); + expect(input.value).toBe('12-3'); + }); + + it('supports maxLength=0', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ maxLength: 0, onChange }); + await userEvent.type(input, '12'); + expect(input.value).toBe(''); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('applies maxLength when a dynamic mask returns an empty pattern', async () => { + const { input } = renderInput({ mask: () => '', maxLength: 3 }); + await userEvent.type(input, '123456'); + expect(input.value).toBe('123'); + }); + + it('applies count.exceedFormatter to the formatted value', async () => { + const onChange = jest.fn(); + const { input } = renderInput({ + maskPlaceholder: '_', + count: { + max: 3, + strategy: (value) => value.replace(/\D/g, '').length, + exceedFormatter: (value, { max }) => { + let digits = 0; + return value.replace(/\d/g, (digit) => + (digits += 1) > max ? '' : digit, + ); + }, + }, + onChange, + }); + await userEvent.click(input); + await userEvent.keyboard('12'); + expect(input.selectionStart).toBe(3); + await userEvent.keyboard('34'); + expect(input.value).toBe('12-3_'); + expect(input.selectionStart).toBe(4); + expect(onChange.mock.calls.map(([event]) => event.target.value)).toEqual([ + '1_-__', + '12-__', + '12-3_', + ]); + }); + + it('preserves count and semantic styling', () => { + const { input, container } = renderInput({ + defaultValue: '12', + maskPlaceholder: '_', + showCount: true, + classNames: { input: 'masked-input' }, + styles: { input: { color: 'red' } }, + }); + expect(input.className).toContain('masked-input'); + expect(input.style.color).toBe('red'); + expect( + container.querySelector('.rc-input-show-count-suffix')?.textContent, + ).toBe('5'); + }); + + it('preserves input refs and event callbacks', async () => { + const ref = React.createRef(); + const onSelect = jest.fn(); + const onBeforeInput = jest.fn(); + const { input } = renderInput({ + ref, + onSelect, + onBeforeInput, + maskPlaceholder: '_', + }); + expect(ref.current?.input).toBe(input); + await userEvent.click(input); + await userEvent.keyboard('{ArrowRight}'); + expect(onSelect).toHaveBeenCalled(); + fireEvent.keyPress(input, { key: '1', charCode: 49, which: 49 }); + expect(onBeforeInput).toHaveBeenCalled(); + }); + + it('defers masking during composition and normalizes once at composition end', () => { + const onChange = jest.fn(); + const onCompositionStart = jest.fn(); + const onCompositionEnd = jest.fn(); + const { input } = renderInput({ + mask: [/[\u4e00-\u9fff]/, /[\u4e00-\u9fff]/, '-', /\d/, /\d/], + maskPlaceholder: '_', + onChange, + onCompositionStart, + onCompositionEnd, + }); + fireEvent.compositionStart(input); + fireEvent.change(input, { + target: { value: '你', selectionStart: 1, selectionEnd: 1 }, + }); + expect(input.value).toBe('你'); + fireEvent.compositionEnd(input, { + target: { value: '你', selectionStart: 1, selectionEnd: 1 }, + }); + expect(input.value).toBe('你_-__'); + fireEvent.input(input, { target: { value: '你_-__' } }); + expect(onChange.mock.calls.map(([event]) => event.target.value)).toEqual([ + '你', + '你_-__', + ]); + expect(onCompositionStart).toHaveBeenCalledTimes(1); + expect(onCompositionEnd).toHaveBeenCalledTimes(1); + }); + + it('does not restore the old selection on unrelated rerenders', async () => { + const { input, rerender } = renderInput(); + await userEvent.type(input, '12'); + input.setSelectionRange(0, 1); + rerender(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(1); + }); + + it('restores the caret inside a shadow root', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const container = host + .attachShadow({ mode: 'open' }) + .appendChild(document.createElement('div')); + const { unmount } = render(, { + container, + }); + const input = container.querySelector('input')!; + act(() => input.focus()); + input.setSelectionRange(1, 1); + fireEvent.keyDown(input, { key: '5' }); + fireEvent.change(input, { + target: { value: '152-34', selectionStart: 2, selectionEnd: 2 }, + }); + expect(input.value).toBe('15-23'); + expect(input.selectionStart).toBe(3); + unmount(); + host.remove(); + }); + + it('formats long raw values in linear time', () => { + const letter = /[a-z]/; + const test = jest.spyOn(letter, 'test'); + const { input } = renderInput({ + mask: 'X-0', + maskDefinitions: { X: letter }, + value: `${'1'.repeat(2000)}a`, + onChange: () => {}, + }); + expect(input.value).toBe('a-'); + expect(test.mock.calls.length).toBeLessThan(20000); + }); +});