Skip to content

feat: add input masking support - #206

Open
nikzanda wants to merge 4 commits into
react-component:masterfrom
nikzanda:feat/input-mask
Open

nikzanda wants to merge 4 commits into
react-component:masterfrom
nikzanda:feat/input-mask

Conversation

@nikzanda

@nikzanda nikzanda commented Oct 6, 2026 •

Copy link
Copy Markdown

Add native masks to Input so applications can format card numbers, phone numbers and mixed character codes while users type, with caret and selection handling built into the component. For example, mask="0000 0000 0000 0000" displays 1234567890123456 as 1234 5678 9012 3456 without an additional runtime dependency.

Related to ant-design/ant-design#54442. This provides the native foundation; exposing it through Ant Design requires a separate integration after release.

API

  • mask?: InputMask: a string pattern, a readonly array of literal strings and single-character regular expressions, or a pure function receiving { value, selection } and returning a pattern. Dynamic masks receive the proposed input text and its selection offsets; selection is null for initial or externally updated values.
  • maskDefinitions?: Readonly<Record<string, RegExp | null>>: extends or overrides the default string tokens. 0 accepts ASCII digits, X accepts Latin letters, and * accepts alphanumeric characters. A null definition makes a token literal; backslash escapes tokens. Array strings remain literals. Stateful g and y regex flags are ignored without mutating caller-provided expressions.
  • maskPlaceholder?: string | null: unfilled positions are hidden when omitted, undefined, null or ''. An explicit character repeats across empty positions; a longer string supplies placeholders at the corresponding pattern positions, with _ used beyond its length. Placeholder characters are reserved for empty positions, and conflicting rules produce a development warning.

Exports InputMaskState, InputMaskPattern, InputMask, and InputMaskDefinitions.

import Input from '@rc-component/input';

<Input mask="XX-00-**" maskPlaceholder="_" />;

<Input mask="HH-00" maskDefinitions={{ H: /[A-Z]/ }} />;

<Input
  mask={({ value }) =>
    value.replace(/\D/g, '').startsWith('3')
      ? '0000 000000 00000'
      : '0000 0000 0000 0000'
  }
/>;

Behavior and coverage

  • Supports raw or formatted controlled and uncontrolled values, inline dynamic masks and definition updates.
  • Handles insertion, deletion around separators, range replacement, paste, autofill and caret restoration, including shadow roots. Copied partial values retain their characters when pasted into the same mask without placeholders. Typed characters are validated against the next position; edits that cannot be displayed unambiguously without placeholders are ignored.
  • Defers formatting during IME composition and avoids duplicate change events when composition ends.
  • Preserves the React event contract: target.value and currentTarget.value contain formatted text, including separators and visible placeholders. Clearing emits ''. No generic formatter/parser or automatic unmasked-value API is added.
  • Integrates with allowClear, refs, focus, disabled/read-only state, maxLength and character counting. Placeholder-only values hide the clear button through a new hiddenClear prop on BaseInput: since feat: unify allowClear configuration #208, allowClear.disabled removes the affix wrapper, which would remount the input and lose focus. Explicitly disabled clearing keeps the upstream behavior. maxLength limits displayed pattern length; count.exceedFormatter receives formatted text and its result is formatted again.
  • Applies to text, search, tel, url and password inputs. Mask props are excluded from DOM attributes.
  • Adds 99 mask tests, a demo covering static and dynamic patterns, and English and Chinese API documentation.

Summary by CodeRabbit

  • 新功能
    • 输入框支持固定或动态输入掩码、自定义字符规则及占位符。
    • 掩码输入支持格式化、编辑、粘贴和清除,并兼容长度限制、字符计数及输入法组合。
    • 提供银行卡号掩码示例,展示多种配置方式。
  • 文档
    • 补充中英文输入掩码说明、参数介绍及使用示例。

@vercel

vercel Bot commented Oct 6, 2026

Copy link
Copy Markdown

@nikzanda is attempting to deploy a commit to the afc163's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 00bb1d7f-2876-436c-ab8b-9f272316abaf
Commits

Reviewing files that changed from the base of the PR and between e5cb0b2 and 1ba1cc4.

Files selected for processing: 4
  • src/Input.tsx
  • src/hooks/useMask.ts
  • src/utils/maskUtil.ts
  • tests/mask.test.tsx

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

Input 新增掩码配置和公开类型。掩码支持静态及动态模式、自定义规则、占位符和选区编辑,并接入计数、IME 组合及清除流程。此次变更还新增文档、演示示例和测试。

Changes

Input 掩码

Layer / File(s) Summary
掩码类型、解析与编辑
src/interface.ts, src/index.tsx, src/utils/maskUtil.ts
新增掩码类型和 Input 属性。新增令牌解析、格式化及编辑逻辑,处理字面量、占位符、正则规则、插入、删除和光标位置。
掩码输入处理与组件集成
src/hooks/useMask.ts, src/Input.tsx, src/BaseInput.tsx, src/hooks/useCountExceed.ts, tests/mask.test.tsx
新增 useMask,并将掩码值、选区和输入事件接入 Input。掩码显示值用于计数和渲染;BaseInput 支持隐藏清除按钮。测试覆盖格式化、编辑、粘贴、清除、maxLength、计数及 IME 行为。
文档与演示示例
README.md, README.zh-CN.md, docs/demo/mask.md, docs/examples/mask.tsx
新增中英文掩码说明、演示页面和示例组件,展示动态银行卡号掩码、自定义规则和占位符配置。

Priority: Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Input
  participant useMask
  participant editMask
  participant BaseInput
  Input->>useMask: 传入掩码配置和输入状态
  useMask->>editMask: 处理编辑值、选区和令牌
  useMask-->>Input: 返回格式化值和编辑结果
  Input->>BaseInput: 传入掩码显示值
Loading
Merge Risk: Minimal · up to 1ba1c

This change adds opt-in input masking to Input, which only takes effect when mask props are set. The summaries show the reported input-loss cases fixed and covered by regression tests, and I found no remaining merge-blocking risk.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed 标题“feat: add input masking support”准确概括了本次新增输入掩码支持的主要变更,内容清晰且简洁。
Docstring Coverage Passed Docstring coverage is inconclusive: files from earlier reviews still need to be rechecked within the check's analysis limits. Not blocking.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

兔子捧来掩码新,
数字字母排成行。
光标轻移随输入,
占位符在空格旁。
文档示例齐亮相,
兔耳欢摇庆更新。

Comment @coderabbitai help to get the list of available commands.

Preserve masked input focus with the unified allowClear configuration.
@nikzanda

Copy link
Copy Markdown
Author

Hi @zombieJ, what do you think about adding native input masking to rc-input, as proposed in ant-design/ant-design#54442? I'd appreciate your feedback on the approach and API in this PR.

@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.33925% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.82%. Comparing base (39f4ae6) to head (e5cb0b2).

Files with missing lines Patch % Lines
src/hooks/useMask.ts 95.79% 5 Missing ⚠️
src/utils/maskUtil.ts 98.20% 5 Missing ⚠️
src/Input.tsx 96.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #206      +/-   ##
==========================================
- Coverage   98.37%   97.82%   -0.56%     
==========================================
  Files          11       13       +2     
  Lines         430      872     +442     
  Branches      139      342     +203     
==========================================
+ Hits          423      853     +430     
- Misses          7       19      +12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zombieJ

zombieJ commented Oct 29, 2026

Copy link
Copy Markdown
Member

@MadCcc could you help to check this?

@zombieJ

zombieJ commented Oct 29, 2026

Copy link
Copy Markdown
Member

From Code Review Skill (cr)

Conclusion

❌ Please fix the input-loss cases below before merging. I reproduced all three against e5cb0b2 using additional React Testing Library/user-event tests. The existing 244 tests and 22 snapshots pass, as does the TypeScript check.

Main changes

This adds native input masks with custom character rules, placeholders and dynamic patterns, together with caret/selection handling, IME support, counting and clear-button integration.

+ mask?: InputMask;
+ maskDefinitions?: InputMaskDefinitions;
+ maskPlaceholder?: string | null;

Findings, ordered by importance

🔴 Blocking correctness issues

1. Activating a dynamic mask discards the existing text — src/hooks/useMask.ts:187

<Input mask={({ value }) => value.length > 4 ? '00-000' : ''} />

Type 12345 into the empty input. Expected: 12-345. Actual: 5. The onChange values are 1, 12, 123, 1234, then 5, so the loss also reaches application state. The same transition can be reproduced through composition events.

The previous token list is empty, so editMask cannot recover the existing 1234 from the previous slots and processes only the inserted 5. Please handle the transition from an empty pattern by formatting the complete proposed value and mapping its caret.

2. A literal prefix consumes a matching typed character — src/utils/maskUtil.ts:421

With <Input mask="100" /> (literal 1, followed by two digit slots), type 12. Expected: 112. Actual: 12; the first typed 1 never fills a slot.

getPrefixLength(change.inserted, tokens) runs for typed text as well as complete-value parsing. It consumes the first keystroke as the literal prefix, even though the input already displays that prefix. Please distinguish typed slot input from parsing a value that may include its prefix.

3. Replacing a selected character targets a preceding hidden slot — src/utils/maskUtil.ts:365

  1. Render <Input mask="0X0" defaultValue="1A2" /> without placeholders.
  2. Place the caret at offset 0 and press Delete. The value becomes A2.
  3. Select offsets [0, 1] (the A) and type B.

Expected: B2. Actual: 2.

The hidden leading digit slot and the visible letter slot share the same display offset. findIndex selects the hidden digit slot, rejects B, and removes the selected A. Please map range replacements to the actual selected character slots, accounting for hidden empty positions.

🟡 Non-blocking recommendations

None beyond adding regression coverage for these cases alongside the fixes.

🟢 Optional improvements

None.

- Format the whole proposed value, mapping its caret, when a dynamic mask
  leaves an empty pattern or a mask is enabled during a composition.
- Match the literal prefix only when reading a value, not when typing.
- When typing over a selection, skip a hidden empty position that rejects
  the character.
- Ignore results that would be read back differently, including
  count.exceedFormatter results, restoring the previous value or an empty
  one.
- Remove unreachable composition and fallback code and cover the remaining
  branches.
@nikzanda

Copy link
Copy Markdown
Author

@zombieJ thanks for the review! The three cases are fixed in 1ba1cc4, each with a regression test:

  1. When a dynamic mask leaves an empty pattern, the whole proposed value is now formatted and the caret is mapped, so typing 12345 gives 12-345. The same applies when the switch happens at the end of a composition.
  2. The literal prefix is only matched when reading a value (paste, autofill), not when typing, so typing 12 with mask="100" gives 112.
  3. When typing over a selection, a hidden empty position that rejects the character is skipped, so replacing A with B in A2 (0X0) gives B2.

While working on these, I also made results that would be read back differently (for example when a literal could be taken as a digit) restore the previous value instead of emitting something else, and added tests for the lines Codecov reported as uncovered.

Comment thread src/interface.ts
count?: CountConfig;
onClear?: () => void;
/** `0`: digit, `X`: Latin letter, `*`: alphanumeric. Escape literals with `\\`. */
mask?: InputMask;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked through antd’s existing APIs and found two approaches to formatting: formatter callbacks for transforming values, as in InputNumber and Input.OTP, and string templates, as in DatePicker and the other Picker components. For consistency with the existing APIs, I think we could consolidate these under format:

format?: {
  mask?: string;
  formatter?: (value: string) => string;
};

Also, InputNumber’s built-in decimal formatting already handles caret positioning. Could we extract the caret calculations into a shared function and reuse it in both components?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking into the existing APIs, consolidating everything under format makes sense to me. Before reworking the PR, I'd like to check a few details:

  1. Should format.mask accept only strings? The current mask also accepts arrays of literals and regular expressions, and functions that choose the pattern from the value. For example, card numbers use 0000 000000 00000 for Amex and 0000 0000 0000 0000 for the others. There are also custom token rules and placeholders for empty positions. I could keep them in the same object:

    format?: {
      mask?: InputMask; // string, array of literals and RegExp, or a function returning one
      definitions?: InputMaskDefinitions;
      placeholder?: string;
      formatter?: (value: string) => string;
    };

    Or I could drop arrays and dynamic masks and keep only strings, as in your example. Which do you prefer?

  2. Can mask and formatter be used together? If so, I'd apply formatter first and the mask afterwards, so that, for example, formatter: (value) => value.toUpperCase() works with mask: 'XX-00'.

  3. For the caret, I'd move the useCursor logic from rc-input-number to @rc-component/util and use it for formatter, since it works with any transformation. For the mask, I'd keep the current calculation: the shared logic only compares the text before and after the caret, so it can't track editable positions. For example, pressing Backspace right after the separator in 12-34 gives 13-4: the mask places the caret after 1, while the shared logic would move it to the end. Does that work for you? If so, I'll open the @rc-component/util PR first and update this one afterwards.

@nikzanda
nikzanda requested a review from zombieJ October 30, 2026 14:59

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants