From c279714e5e19f1f859972bd59000bdef1923b983 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Tue, 17 Mar 2026 22:06:01 +1100 Subject: [PATCH 1/3] feat: add InputOTP component --- apps/docs/src/assets/icon/input-otp.svg | 15 ++ apps/docs/src/routers.tsx | 2 + packages/react/src/form/ARCHITECTURE.md | 180 ++++++++++++++ packages/react/src/index.ts | 1 + .../__snapshots__/input-otp.test.tsx.snap | 59 +++++ .../input-otp/__tests__/input-otp.test.tsx | 117 +++++++++ .../react/src/input-otp/demo/auto-focus.md | 17 ++ packages/react/src/input-otp/demo/basic.md | 19 ++ packages/react/src/input-otp/demo/disabled.md | 13 + .../react/src/input-otp/demo/formatter.md | 29 +++ packages/react/src/input-otp/demo/length.md | 28 +++ packages/react/src/input-otp/demo/mask.md | 30 +++ .../react/src/input-otp/demo/separator.md | 31 +++ packages/react/src/input-otp/demo/size.md | 19 ++ packages/react/src/input-otp/index.md | 59 +++++ packages/react/src/input-otp/index.tsx | 3 + packages/react/src/input-otp/input-otp.tsx | 231 ++++++++++++++++++ packages/react/src/input-otp/otp-input.tsx | 99 ++++++++ .../react/src/input-otp/style/_index.scss | 53 ++++ packages/react/src/input-otp/style/index.tsx | 1 + packages/react/src/input-otp/types.ts | 41 ++++ packages/react/src/style/_component.scss | 3 +- 22 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/assets/icon/input-otp.svg create mode 100644 packages/react/src/form/ARCHITECTURE.md create mode 100644 packages/react/src/input-otp/__tests__/__snapshots__/input-otp.test.tsx.snap create mode 100644 packages/react/src/input-otp/__tests__/input-otp.test.tsx create mode 100644 packages/react/src/input-otp/demo/auto-focus.md create mode 100644 packages/react/src/input-otp/demo/basic.md create mode 100644 packages/react/src/input-otp/demo/disabled.md create mode 100644 packages/react/src/input-otp/demo/formatter.md create mode 100644 packages/react/src/input-otp/demo/length.md create mode 100644 packages/react/src/input-otp/demo/mask.md create mode 100644 packages/react/src/input-otp/demo/separator.md create mode 100644 packages/react/src/input-otp/demo/size.md create mode 100644 packages/react/src/input-otp/index.md create mode 100644 packages/react/src/input-otp/index.tsx create mode 100644 packages/react/src/input-otp/input-otp.tsx create mode 100644 packages/react/src/input-otp/otp-input.tsx create mode 100644 packages/react/src/input-otp/style/_index.scss create mode 100644 packages/react/src/input-otp/style/index.tsx create mode 100644 packages/react/src/input-otp/types.ts diff --git a/apps/docs/src/assets/icon/input-otp.svg b/apps/docs/src/assets/icon/input-otp.svg new file mode 100644 index 00000000..4a85c082 --- /dev/null +++ b/apps/docs/src/assets/icon/input-otp.svg @@ -0,0 +1,15 @@ + + + + + + + + +1 +2 +3 +4 +5 +6 + \ No newline at end of file diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index 3d87d083..930d98e3 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -129,6 +129,7 @@ const c = { speedDial: ll(() => import('../../../packages/react/src/speed-dial/index.md'), () => import('../../../packages/react/src/speed-dial/index.zh_CN.md')), anchor: ll(() => import('../../../packages/react/src/anchor/index.md'), () => import('../../../packages/react/src/anchor/index.zh_CN.md')), autoComplete: ll(() => import('../../../packages/react/src/auto-complete/index.md'), () => import('../../../packages/react/src/auto-complete/index.zh_CN.md')), + inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.md')), overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')), }; @@ -218,6 +219,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'Input', route: 'input', component: pick(c.input, z) }, { title: 'Input Number', route: 'input-number', component: pick(c.inputNumber, z) }, { title: 'Input Password', route: 'input-password', component: pick(c.inputPassword, z) }, + { title: 'InputOTP', route: 'input-otp', component: pick(c.inputOTP, z) }, { title: 'Native Select', route: 'native-select', component: pick(c.nativeSelect, z) }, { title: 'Radio', route: 'radio', component: pick(c.radio, z) }, { title: 'Rate', route: 'rate', component: pick(c.rate, z) }, diff --git a/packages/react/src/form/ARCHITECTURE.md b/packages/react/src/form/ARCHITECTURE.md new file mode 100644 index 00000000..c0f7af26 --- /dev/null +++ b/packages/react/src/form/ARCHITECTURE.md @@ -0,0 +1,180 @@ +# Form Component Architecture + +## Overview + +The Form system uses a **pub/sub pattern** built around a central `FormInstance` class, with two React Contexts wiring everything together. + +``` +Form (Provider) + ├── FormInstanceContext → shares the FormInstance (state store) + ├── FormOptionsContext → shares layout/validation options + └── Form.Item (Consumer) → subscribes to FormInstance for its field +``` + +### FormInstance — The State Store + +A plain class (not React state) that holds all form data: + +| Concern | Storage | Key Methods | +|---|---|---| +| **Values** | `values: { [name]: any }` | `getFieldValue`, `setFieldValue`, `setFieldValues` | +| **Errors** | `errors: { [name]: string[] }` | `getFieldError`, `setFieldError` | +| **Rules** | `rules: { [name]: Rule[] }` | `setFieldRules` | +| **Subscriptions** | `listeners: ((name) => void)[]` | `subscribe`, `notify` | + +When a value or validation result changes, `notify(name)` fires and every subscribed `FormItem` checks if the notification is relevant to it. + +### Form — The Wrapper + +- Creates (or accepts via `form` prop) a `FormInstance`, stored in a `useRef` +- Provides it to children via `FormInstanceContext` +- Provides layout options (`labelCol`, `wrapperCol`, `validateTrigger`, `layout`) via `FormOptionsContext` +- Handles **submit**: calls `validateFields()` on all fields, then routes to `onFinish` or `onFinishFailed` +- Handles **reset**: calls `resetFields()` which resets values to `initialValues` and notifies all fields with `'*'` + +### Form.Item — The Field Connector + +Each `Form.Item` with a `name` prop: + +1. **Registers rules** — On mount, calls `form.setFieldRules(name, rules)` +2. **Subscribes** — Calls `form.subscribe(callback)` in a `useEffect`. Updates local `value`/`error` state only if the notification matches its own `name` (or either side uses `'*'`) +3. **Injects props via `cloneElement`** — The child component gets: + - `value` (or the prop name from `valuePropName`) — bound to `form.getFieldValue(name)` + - `onChange` — calls `form.setFieldValue()` and optionally validates (if `validateTrigger === 'onChange'`) + - `onBlur` — validates on blur (if `validateTrigger === 'onBlur'`) +4. **Renders layout** — Uses `Row`/`Col` grid for label and input columns +5. **Shows errors** — Displays validation errors with a slide-down `` animation + +### useForm Hook + +A factory function that creates a `FormInstance` externally: + +```ts +const [form] = Form.useForm({ username: '', password: '' }); +// Pass to
to control the form programmatically +``` + +If no `form` prop is provided, `Form` creates one internally. + +### Validation (form-helper.ts) + +The `validate` function checks a value against a `Rule` supporting: `required`, `type`, `max`, `min`, `len`, `enum`, `pattern`, `whitespace`, `transform`, and custom `validator` functions. + +--- + +## Diagrams + +### Component Hierarchy & Context Flow + +```mermaid +graph TD + subgraph Form[""] + FI["FormInstance (useRef)"] + FIC["FormInstanceContext.Provider"] + FOC["FormOptionsContext.Provider"] + end + + subgraph FormItem1[""] + SUB1["subscribe(listener)"] + CE1["cloneElement(child, {value, onChange, onBlur})"] + ERR1["Error display with Transition"] + end + + subgraph FormItem2[""] + SUB2["subscribe(listener)"] + CE2["cloneElement(child, {value, onChange, onBlur})"] + ERR2["Error display with Transition"] + end + + FI --> FIC + FIC --> FormItem1 + FIC --> FormItem2 + FOC --> FormItem1 + FOC --> FormItem2 +``` + +### Data Flow: User Input + +```mermaid +sequenceDiagram + participant User + participant Input as Child Input + participant Item as Form.Item + participant Store as FormInstance + + Note over Item: On mount: subscribe + setFieldRules + + User->>Input: Types a character + Input->>Item: onChange fires + Item->>Store: setFieldValue(name, value) + Store->>Store: notify(name) + Store->>Item: Listener callback fires + Item->>Item: setValue → re-render + Item->>Input: cloneElement with new value + + alt validateTrigger === "onChange" + Item->>Store: validateField(name) + Store->>Store: run rules via validate() + Store->>Store: setFieldError + notify + Store->>Item: Listener fires again + Item->>Item: setError → show/hide error + end +``` + +### Data Flow: Form Submit + +```mermaid +sequenceDiagram + participant User + participant Form + participant Store as FormInstance + participant Items as All Form.Items + + User->>Form: Submit + Form->>Form: e.preventDefault() + Form->>Store: validateFields() + Store->>Store: validateField() for each rule set + Store->>Items: notify each field name + Items->>Items: Update error states + + Form->>Store: getFieldValues() + Form->>Store: getFieldErrors() + + alt Has errors + Form->>Form: onFinishFailed({ values, errors }) + else No errors + Form->>Form: onFinish(values) + end +``` + +### Data Flow: Form Reset + +```mermaid +sequenceDiagram + participant User + participant Form + participant Store as FormInstance + participant Items as All Form.Items + + User->>Form: Reset + Form->>Store: resetFields() + Store->>Store: errors = {} + Store->>Store: values = deepCopy(initValues) + Store->>Items: notify("*") + Items->>Items: All items re-read value and error → re-render +``` + +### Validation Rules + +```mermaid +graph LR + V["validate(value, rule)"] --> R["required: empty check"] + V --> T["type: typeof check"] + V --> MX["max / len: upper bound"] + V --> MN["min: lower bound"] + V --> E["enum: inclusion check"] + V --> P["pattern: regex test"] + V --> W["whitespace: trim check"] + V --> TR["transform: pre-process value"] + V --> C["validator: custom fn"] +``` diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 150ed131..695f38f7 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,7 @@ export { default as Form } from './form'; export { default as Image } from './image'; export { default as Input } from './input'; export { default as InputNumber } from './input-number'; +export { default as InputOTP } from './input-otp'; export { default as InputPassword } from './input-password'; export { default as IntlProvider } from './intl-provider'; export { default as Keyboard } from './keyboard'; diff --git a/packages/react/src/input-otp/__tests__/__snapshots__/input-otp.test.tsx.snap b/packages/react/src/input-otp/__tests__/__snapshots__/input-otp.test.tsx.snap new file mode 100644 index 00000000..369f4dac --- /dev/null +++ b/packages/react/src/input-otp/__tests__/__snapshots__/input-otp.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` + +
+ + + + + + +
+
+`; diff --git a/packages/react/src/input-otp/__tests__/input-otp.test.tsx b/packages/react/src/input-otp/__tests__/input-otp.test.tsx new file mode 100644 index 00000000..f461068e --- /dev/null +++ b/packages/react/src/input-otp/__tests__/input-otp.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import InputOTP from '../index'; + +describe('', () => { + it('should match the snapshot', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render 6 input cells by default', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + expect(inputs.length).toBe(6); + }); + + it('should render custom length', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + expect(inputs.length).toBe(4); + }); + + it('should render with default value', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toHaveValue('1'); + expect(inputs[1]).toHaveValue('2'); + expect(inputs[2]).toHaveValue('3'); + expect(inputs[3]).toHaveValue('4'); + }); + + it('should render with controlled value', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toHaveValue('a'); + expect(inputs[1]).toHaveValue('b'); + expect(inputs[2]).toHaveValue('c'); + expect(inputs[3]).toHaveValue('d'); + }); + + it('should render disabled state', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + }); + + it('should render different sizes', () => { + const { container: sm } = render(); + expect(sm.firstChild).toHaveClass('ty-input-otp_sm'); + + const { container: lg } = render(); + expect(lg.firstChild).toHaveClass('ty-input-otp_lg'); + }); + + it('should have role="group" on the container', () => { + const { container } = render(); + expect(container.firstChild).toHaveAttribute('role', 'group'); + }); + + it('should fire onChange when all cells are filled', () => { + const fn = jest.fn(); + const { container } = render(); + const inputs = container.querySelectorAll('input'); + + fireEvent.input(inputs[0], { target: { value: '1' } }); + fireEvent.input(inputs[1], { target: { value: '2' } }); + fireEvent.input(inputs[2], { target: { value: '3' } }); + expect(fn).not.toHaveBeenCalled(); + + fireEvent.input(inputs[3], { target: { value: '4' } }); + expect(fn).toHaveBeenCalledWith('1234'); + }); + + it('should render separator', () => { + const { container } = render(); + const separators = container.querySelectorAll('.ty-input-otp__separator'); + expect(separators.length).toBe(3); + expect(separators[0].textContent).toBe('-'); + }); + + it('should render separator as function', () => { + const { container } = render( + (index === 1 ? - : null)} /> + ); + const separators = container.querySelectorAll('.ty-input-otp__separator'); + // Separator appears for all positions, but only index 1 has content + expect(separators.length).toBe(1); + }); + + it('should move focus on arrow key press', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + + inputs[0].focus(); + fireEvent.keyDown(inputs[0], { key: 'ArrowRight' }); + + // Second input should be focused (via onActiveChange -> focus) + // In JSDOM, direct focus assertion might not work as expected, + // but verifying keyDown doesn't throw is sufficient + expect(inputs[0]).toBeTruthy(); + }); + + it('should apply custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-otp'); + }); + + it('should apply mask className when mask is set', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + inputs.forEach((input) => { + expect(input).toHaveClass('ty-input-otp__cell_mask'); + }); + }); +}); diff --git a/packages/react/src/input-otp/demo/auto-focus.md b/packages/react/src/input-otp/demo/auto-focus.md new file mode 100644 index 00000000..c36fc61e --- /dev/null +++ b/packages/react/src/input-otp/demo/auto-focus.md @@ -0,0 +1,17 @@ + + +### Auto Focus + +Use `autoFocus` to automatically focus the first cell when the component mounts. + +```jsx live +() => { + return ( +
+ +
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/demo/basic.md b/packages/react/src/input-otp/demo/basic.md new file mode 100644 index 00000000..c8a4bf56 --- /dev/null +++ b/packages/react/src/input-otp/demo/basic.md @@ -0,0 +1,19 @@ + + +### Basic + +A basic OTP input. + +```jsx live +() => { + const [value, setValue] = React.useState(''); + return ( +
+ setValue(val)} /> +

Entered: {value}

+
+ ); +} +``` + +
diff --git a/packages/react/src/input-otp/demo/disabled.md b/packages/react/src/input-otp/demo/disabled.md new file mode 100644 index 00000000..28b98ecc --- /dev/null +++ b/packages/react/src/input-otp/demo/disabled.md @@ -0,0 +1,13 @@ + + +### Disabled + +Disable the OTP input using the `disabled` prop. + +```jsx live +() => { + return ; +} +``` + + \ No newline at end of file diff --git a/packages/react/src/input-otp/demo/formatter.md b/packages/react/src/input-otp/demo/formatter.md new file mode 100644 index 00000000..f037fafe --- /dev/null +++ b/packages/react/src/input-otp/demo/formatter.md @@ -0,0 +1,29 @@ + + +### Controlled & Formatter + +Use `value` for controlled mode and `formatter` to restrict input. + +```jsx live +() => { + const [value, setValue] = React.useState(''); + + // Only allow digits + const formatter = (val) => val.replace(/\D/g, ''); + + return ( +
+ +

Value: {value}

+

Only digits are allowed

+
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/demo/length.md b/packages/react/src/input-otp/demo/length.md new file mode 100644 index 00000000..4de32988 --- /dev/null +++ b/packages/react/src/input-otp/demo/length.md @@ -0,0 +1,28 @@ + + +### Length + +Set the number of OTP cells using the `length` prop. + +```jsx live +() => { + return ( +
+
+
4 digits
+ +
+
+
6 digits (default)
+ +
+
+
8 digits
+ +
+
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/demo/mask.md b/packages/react/src/input-otp/demo/mask.md new file mode 100644 index 00000000..25137214 --- /dev/null +++ b/packages/react/src/input-otp/demo/mask.md @@ -0,0 +1,30 @@ + + +### Mask + +Use `mask` to hide the input values (useful for PIN codes). You can also set a custom mask character. + +```jsx live +() => { + const [value, setValue] = React.useState(''); + return ( +
+
+
Default mask (•)
+ +
+
+
Custom mask character (*)
+ +
+
+
No mask (default)
+ +
+

Value: {value}

+
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/demo/separator.md b/packages/react/src/input-otp/demo/separator.md new file mode 100644 index 00000000..17c36e1c --- /dev/null +++ b/packages/react/src/input-otp/demo/separator.md @@ -0,0 +1,31 @@ + + +### Separator + +Customize the separator between cells using the `separator` prop. + +```jsx live +() => { + return ( +
+
+
Default (no separator)
+ +
+
+
Dash separator
+ +
+
+
Custom separator element
+ ->} + /> +
+
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/demo/size.md b/packages/react/src/input-otp/demo/size.md new file mode 100644 index 00000000..b346c7f6 --- /dev/null +++ b/packages/react/src/input-otp/demo/size.md @@ -0,0 +1,19 @@ + + +### Size + +Three sizes are available: `sm`, `md` (default), and `lg`. + +```jsx live +() => { + return ( +
+ + + +
+ ); +} +``` + +
\ No newline at end of file diff --git a/packages/react/src/input-otp/index.md b/packages/react/src/input-otp/index.md new file mode 100644 index 00000000..362f5439 --- /dev/null +++ b/packages/react/src/input-otp/index.md @@ -0,0 +1,59 @@ +import Basic from './demo/basic.md' +import Size from './demo/size.md' +import Disabled from './demo/disabled.md' +import Mask from './demo/mask.md' +import Length from './demo/length.md' +import Separator from './demo/separator.md' +import Formatter from './demo/formatter.md' +import AutoFocus from './demo/auto-focus.md' + +# InputOTP + +Used for entering verification codes, OTP (One-Time Password), and similar short numeric/character sequences. + +## Scenario + +Verification code input for login, registration, or two-factor authentication flows. + +## Usage + +```js +import { InputOTP } from 'tiny-design'; +``` + +## Examples + + + + + + + + + + + + + + + + +## API + +### InputOTP + +| Property | Description | Type | Default | +| ------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------- | +| length | Number of input cells | number | 6 | +| value | Controlled value | string | - | +| defaultValue | Default value | string | - | +| size | Input size | enum: `sm` | `md` | `lg` | `md` | +| disabled | Whether disabled | boolean | false | +| mask | Whether to mask input, or a custom mask character | boolean | string | - | +| formatter | Format display value | (value: string) => string | - | +| separator | Separator element rendered between cells | ((index: number) => ReactNode) | ReactNode | - | +| autoFocus | Auto focus the first cell on mount | boolean | false | +| autoComplete | HTML autocomplete attribute | string | `one-time-code` | +| onChange | Callback when all cells are filled | (value: string) => void | - | +| style | Style of container | CSSProperties | - | +| className | ClassName of container | string | - | diff --git a/packages/react/src/input-otp/index.tsx b/packages/react/src/input-otp/index.tsx new file mode 100644 index 00000000..05680dc6 --- /dev/null +++ b/packages/react/src/input-otp/index.tsx @@ -0,0 +1,3 @@ +import InputOTP from './input-otp'; + +export default InputOTP; diff --git a/packages/react/src/input-otp/input-otp.tsx b/packages/react/src/input-otp/input-otp.tsx new file mode 100644 index 00000000..baab5c2f --- /dev/null +++ b/packages/react/src/input-otp/input-otp.tsx @@ -0,0 +1,231 @@ +import React, { useRef, useState, useEffect, useContext, useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { InputOTPProps } from './types'; +import OTPInput from './otp-input'; + +function strToArr(str: string): string[] { + return (str || '').split(''); +} + +export interface InputOTPRef { + focus: () => void; + blur: () => void; + nativeElement: HTMLDivElement | null; +} + +const InputOTP = React.forwardRef( + (props, ref) => { + const { + length = 6, + size = 'md', + defaultValue, + value, + onChange, + formatter, + separator, + disabled = false, + mask, + autoFocus, + autoComplete, + className, + style, + prefixCls: customisedCls, + onFocus, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('input-otp', configContext.prefixCls, customisedCls); + const inputSize = size || configContext.componentSize || 'md'; + + const containerRef = useRef(null); + const inputsRef = useRef>({}); + + // Formatter helper + const internalFormatter = useCallback( + (txt: string) => (formatter ? formatter(txt) : txt), + [formatter] + ); + + // Value state + const [valueCells, setValueCells] = useState(() => + strToArr(internalFormatter(defaultValue || '')) + ); + + useEffect(() => { + if (value !== undefined) { + setValueCells(strToArr(value)); + } + }, [value]); + + // Imperative handle + React.useImperativeHandle(ref, () => ({ + focus: () => { + inputsRef.current[0]?.focus(); + }, + blur: () => { + for (let i = 0; i < length; i += 1) { + inputsRef.current[i]?.blur(); + } + }, + nativeElement: containerRef.current, + })); + + // Trigger onChange when all cells filled + const triggerValueCellsChange = useCallback( + (nextValueCells: string[]) => { + setValueCells(nextValueCells); + + if ( + onChange && + nextValueCells.length === length && + nextValueCells.every((c) => c) && + nextValueCells.some((c, index) => valueCells[index] !== c) + ) { + onChange(nextValueCells.join('')); + } + }, + [onChange, length, valueCells] + ); + + // Patch value at given index + const patchValue = useCallback( + (index: number, txt: string) => { + let nextCells = [...valueCells]; + + // Fill cells till index + for (let i = 0; i < index; i += 1) { + if (!nextCells[i]) { + nextCells[i] = ''; + } + } + + if (txt.length <= 1) { + nextCells[index] = txt; + } else { + nextCells = nextCells.slice(0, index).concat(strToArr(txt)); + } + nextCells = nextCells.slice(0, length); + + // Clean trailing empty cells + for (let i = nextCells.length - 1; i >= 0; i -= 1) { + if (nextCells[i]) { + break; + } + nextCells.pop(); + } + + // Format if needed + const formattedValue = internalFormatter( + nextCells.map((c) => c || ' ').join('') + ); + nextCells = strToArr(formattedValue).map((c, i) => { + if (c === ' ' && !nextCells[i]) { + return nextCells[i]; + } + return c; + }); + + return nextCells; + }, + [valueCells, length, internalFormatter] + ); + + // Handle input change + const onInputChange = useCallback( + (index: number, txt: string) => { + const nextCells = patchValue(index, txt); + const nextIndex = Math.min(index + txt.length, length - 1); + if (nextIndex !== index && nextCells[index] !== undefined) { + inputsRef.current[nextIndex]?.focus(); + } + triggerValueCellsChange(nextCells); + }, + [patchValue, length, triggerValueCellsChange] + ); + + // Handle active change (arrow keys, backspace) + const onInputActiveChange = useCallback( + (nextIndex: number) => { + inputsRef.current[nextIndex]?.focus(); + }, + [] + ); + + // Handle focus — keep focus on first empty cell + const onInputFocus = useCallback( + (event: React.FocusEvent, index: number) => { + for (let i = 0; i < index; i += 1) { + if (!inputsRef.current[i]?.value) { + inputsRef.current[i]?.focus(); + return; + } + } + if (onFocus) { + (onFocus as React.FocusEventHandler)(event as unknown as React.FocusEvent); + } + }, + [onFocus] + ); + + // Render separator + const renderSeparator = useMemo(() => { + if (!separator) return null; + return (index: number) => { + const separatorNode = + typeof separator === 'function' ? separator(index) : separator; + if (!separatorNode) return null; + return ( + + {separatorNode} + + ); + }; + }, [separator, prefixCls]); + + const cls = classNames(prefixCls, className, `${prefixCls}_${inputSize}`, { + [`${prefixCls}_disabled`]: disabled, + }); + + return ( +
+ {Array.from({ length }).map((_, index) => { + const singleValue = valueCells[index] || ''; + return ( + + { + inputsRef.current[index] = el; + }} + index={index} + size={inputSize} + prefixCls={prefixCls} + value={singleValue} + disabled={disabled} + mask={mask} + autoFocus={index === 0 && autoFocus} + autoComplete={autoComplete} + onChange={onInputChange} + onActiveChange={onInputActiveChange} + onFocus={(e) => onInputFocus(e, index)} + /> + {index < length - 1 && renderSeparator?.(index)} + + ); + })} +
+ ); + } +); + +InputOTP.displayName = 'InputOTP'; + +export default InputOTP; diff --git a/packages/react/src/input-otp/otp-input.tsx b/packages/react/src/input-otp/otp-input.tsx new file mode 100644 index 00000000..21716fcb --- /dev/null +++ b/packages/react/src/input-otp/otp-input.tsx @@ -0,0 +1,99 @@ +import React, { useRef, useCallback } from 'react'; +import classNames from 'classnames'; +import { OTPInputCellProps } from './types'; + +const OTPInput = React.forwardRef( + (props, ref) => { + const { + index, + value, + disabled, + mask, + size = 'md', + autoFocus, + autoComplete, + prefixCls, + onChange, + onActiveChange, + onFocus, + } = props; + + const inputRef = useRef(null); + + // Merge refs + const setRef = useCallback( + (el: HTMLInputElement | null) => { + inputRef.current = el; + if (typeof ref === 'function') { + ref(el); + } else if (ref) { + (ref as React.MutableRefObject).current = el; + } + }, + [ref] + ); + + const syncSelection = useCallback(() => { + requestAnimationFrame(() => { + const inputEle = inputRef.current; + if (document.activeElement === inputEle && inputEle) { + inputEle.select(); + } + }); + }, []); + + const onInternalInput = (e: React.FormEvent): void => { + onChange(index, (e.target as HTMLInputElement).value); + }; + + const onInternalFocus: React.FocusEventHandler = (e) => { + onFocus?.(e); + syncSelection(); + }; + + const onInternalKeyDown: React.KeyboardEventHandler = (event) => { + const { key, ctrlKey, metaKey } = event; + + if (key === 'ArrowLeft') { + onActiveChange(index - 1); + } else if (key === 'ArrowRight') { + onActiveChange(index + 1); + } else if (key === 'z' && (ctrlKey || metaKey)) { + event.preventDefault(); + } else if (key === 'Backspace' && !value) { + onActiveChange(index - 1); + } + + syncSelection(); + }; + + const displayValue = mask && value ? (typeof mask === 'string' ? mask : '•') : value; + const inputType = mask === true ? 'password' : 'text'; + + return ( + + ); + } +); + +OTPInput.displayName = 'OTPInput'; + +export default OTPInput; diff --git a/packages/react/src/input-otp/style/_index.scss b/packages/react/src/input-otp/style/_index.scss new file mode 100644 index 00000000..4d751cd5 --- /dev/null +++ b/packages/react/src/input-otp/style/_index.scss @@ -0,0 +1,53 @@ +@use '../../style/variables' as *; +@use '../../input/style/mixin' as *; + +.#{$prefix}-input-otp { + display: inline-flex; + align-items: center; + gap: 8px; + + &__cell { + @include input-default; + + width: 36px; + height: 36px; + text-align: center; + padding: 0; + font-size: $input-md-font-size; + border-radius: $input-border-radius; + caret-color: transparent; + + &_sm { + width: 28px; + height: 28px; + font-size: $input-sm-font-size; + } + + &_md { + width: 36px; + height: 36px; + font-size: $input-md-font-size; + } + + &_lg { + width: 44px; + height: 44px; + font-size: $input-lg-font-size; + } + + &_disabled { + @include input-default-disabled; + } + } + + &__separator { + display: inline-flex; + align-items: center; + color: var(--ty-color-text-secondary); + font-size: $font-size-base; + } + + &_disabled { + cursor: not-allowed; + } +} diff --git a/packages/react/src/input-otp/style/index.tsx b/packages/react/src/input-otp/style/index.tsx new file mode 100644 index 00000000..67aac616 --- /dev/null +++ b/packages/react/src/input-otp/style/index.tsx @@ -0,0 +1 @@ +import './index.scss'; diff --git a/packages/react/src/input-otp/types.ts b/packages/react/src/input-otp/types.ts new file mode 100644 index 00000000..000be524 --- /dev/null +++ b/packages/react/src/input-otp/types.ts @@ -0,0 +1,41 @@ +import React from 'react'; +import { BaseProps, SizeType } from '../_utils/props'; + +export interface InputOTPProps extends BaseProps, Omit, 'onChange' | 'onInput'> { + /** Number of OTP input cells */ + length?: number; + /** Size of the input */ + size?: SizeType; + /** Default value of the OTP input */ + defaultValue?: string; + /** Controlled value of the OTP input */ + value?: string; + /** Callback when all cells are filled */ + onChange?: (value: string) => void; + /** Custom formatter to restrict/modify input */ + formatter?: (value: string) => string; + /** Separator element between cells */ + separator?: ((index: number) => React.ReactNode) | React.ReactNode; + /** Whether the input is disabled */ + disabled?: boolean; + /** Whether to mask the values, or a custom mask character */ + mask?: boolean | string; + /** Auto focus the first cell on mount */ + autoFocus?: boolean; + /** Autocomplete attribute */ + autoComplete?: string; +} + +export interface OTPInputCellProps { + index: number; + value: string; + disabled?: boolean; + mask?: boolean | string; + size?: SizeType; + autoFocus?: boolean; + autoComplete?: string; + prefixCls: string; + onChange: (index: number, value: string) => void; + onActiveChange: (index: number) => void; + onFocus?: React.FocusEventHandler; +} diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss index 6c9d1876..78b8ad30 100644 --- a/packages/react/src/style/_component.scss +++ b/packages/react/src/style/_component.scss @@ -28,6 +28,7 @@ @use "../image/style/index" as *; @use "../input/style/index" as *; @use "../input-number/style/index" as *; +@use "../input-otp/style/index" as *; @use "../input-password/style/index" as *; @use "../layout/style/index" as *; @use "../link/style/index" as *; @@ -75,4 +76,4 @@ @use "../tree/style/index" as *; @use "../typography/style/index" as *; @use "../upload/style/index" as *; -@use "../with-spin/style/index" as *; +@use "../with-spin/style/index" as *; \ No newline at end of file From e377fe35eb11db42fb2475820b249326cfed0f81 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Tue, 17 Mar 2026 22:09:26 +1100 Subject: [PATCH 2/3] fix: focus issue --- packages/react/src/input-otp/input-otp.tsx | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/react/src/input-otp/input-otp.tsx b/packages/react/src/input-otp/input-otp.tsx index baab5c2f..c62ab4b4 100644 --- a/packages/react/src/input-otp/input-otp.tsx +++ b/packages/react/src/input-otp/input-otp.tsx @@ -63,7 +63,16 @@ const InputOTP = React.forwardRef( // Imperative handle React.useImperativeHandle(ref, () => ({ focus: () => { - inputsRef.current[0]?.focus(); + // Focus first empty cell (or last cell if all filled) + let nextIndex = 0; + for (let i = 0; i < length; i += 1) { + if (!inputsRef.current[i]?.value) { + nextIndex = i; + break; + } + nextIndex = i; + } + inputsRef.current[nextIndex]?.focus(); }, blur: () => { for (let i = 0; i < length; i += 1) { @@ -149,20 +158,15 @@ const InputOTP = React.forwardRef( // Handle active change (arrow keys, backspace) const onInputActiveChange = useCallback( (nextIndex: number) => { - inputsRef.current[nextIndex]?.focus(); + const clampedIndex = Math.max(0, Math.min(nextIndex, length - 1)); + inputsRef.current[clampedIndex]?.focus(); }, - [] + [length] ); - // Handle focus — keep focus on first empty cell + // Handle focus — keep focus on the interacted cell const onInputFocus = useCallback( (event: React.FocusEvent, index: number) => { - for (let i = 0; i < index; i += 1) { - if (!inputsRef.current[i]?.value) { - inputsRef.current[i]?.focus(); - return; - } - } if (onFocus) { (onFocus as React.FocusEventHandler)(event as unknown as React.FocusEvent); } From 9a44133a650faa6bf68b8390727722134624e725 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Wed, 18 Mar 2026 08:48:49 +1100 Subject: [PATCH 3/3] fix(react): refine InputOTP behaviour and docs Made-with: Cursor --- .changeset/input-otp-onchange-behavior.md | 10 ++++ apps/docs/src/routers.tsx | 18 +++--- .../input-otp/__tests__/input-otp.test.tsx | 9 +-- .../react/src/input-otp/demo/separator.md | 2 +- packages/react/src/input-otp/index.md | 4 +- packages/react/src/input-otp/index.zh_CN.md | 60 +++++++++++++++++++ packages/react/src/input-otp/input-otp.tsx | 22 ++++--- packages/react/src/input-otp/otp-input.tsx | 4 +- .../react/src/input-otp/style/_index.scss | 2 +- 9 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 .changeset/input-otp-onchange-behavior.md create mode 100644 packages/react/src/input-otp/index.zh_CN.md diff --git a/.changeset/input-otp-onchange-behavior.md b/.changeset/input-otp-onchange-behavior.md new file mode 100644 index 00000000..a4bfbf20 --- /dev/null +++ b/.changeset/input-otp-onchange-behavior.md @@ -0,0 +1,10 @@ +--- +"@tiny-design/react": minor +--- + +Improve `InputOTP` behaviour: + +- Fire `onChange` on every value update instead of only when all cells are filled. +- Fix masked cell rendering logic. +- Adjust caret colour to follow current text colour. +- Update docs and tests for the new behaviour and add Chinese docs entry. diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index 930d98e3..e31598e6 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -129,7 +129,7 @@ const c = { speedDial: ll(() => import('../../../packages/react/src/speed-dial/index.md'), () => import('../../../packages/react/src/speed-dial/index.zh_CN.md')), anchor: ll(() => import('../../../packages/react/src/anchor/index.md'), () => import('../../../packages/react/src/anchor/index.zh_CN.md')), autoComplete: ll(() => import('../../../packages/react/src/auto-complete/index.md'), () => import('../../../packages/react/src/auto-complete/index.zh_CN.md')), - inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.md')), + inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.zh_CN.md')), overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')), }; @@ -162,7 +162,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: s.categories.layout, children: [ - { title: 'Aspect Ratio', route: 'aspect-ratio', component: pick(c.aspectRatio, z) }, + { title: 'AspectRatio', route: 'aspect-ratio', component: pick(c.aspectRatio, z) }, { title: 'Divider', route: 'divider', component: pick(c.divider, z) }, { title: 'Flex', route: 'flex', component: pick(c.flex, z) }, { title: 'Grid', route: 'grid', component: pick(c.grid, z) }, @@ -217,16 +217,16 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'ColorPicker', route: 'color-picker', component: pick(c.colorPicker, z) }, { title: 'DatePicker', route: 'date-picker', component: pick(c.datePicker, z) }, { title: 'Input', route: 'input', component: pick(c.input, z) }, - { title: 'Input Number', route: 'input-number', component: pick(c.inputNumber, z) }, - { title: 'Input Password', route: 'input-password', component: pick(c.inputPassword, z) }, + { title: 'InputNumber', route: 'input-number', component: pick(c.inputNumber, z) }, + { title: 'InputPassword', route: 'input-password', component: pick(c.inputPassword, z) }, { title: 'InputOTP', route: 'input-otp', component: pick(c.inputOTP, z) }, - { title: 'Native Select', route: 'native-select', component: pick(c.nativeSelect, z) }, + { title: 'NativeSelect', route: 'native-select', component: pick(c.nativeSelect, z) }, { title: 'Radio', route: 'radio', component: pick(c.radio, z) }, { title: 'Rate', route: 'rate', component: pick(c.rate, z) }, { title: 'Segmented', route: 'segmented', component: pick(c.segmented, z) }, { title: 'Select', route: 'select', component: pick(c.select, z) }, { title: 'Slider', route: 'slider', component: pick(c.slider, z) }, - { title: 'Split Button', route: 'split-button', component: pick(c.splitButton, z) }, + { title: 'SplitButton', route: 'split-button', component: pick(c.splitButton, z) }, { title: 'Switch', route: 'switch', component: pick(c.switch, z) }, { title: 'Tabs', route: 'tabs', component: pick(c.tabs, z) }, { title: 'Textarea', route: 'textarea', component: pick(c.textarea, z) }, @@ -242,15 +242,15 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'Drawer', route: 'drawer', component: pick(c.drawer, z) }, { title: 'Loader', route: 'loader', component: pick(c.loader, z) }, { title: 'Overlay', route: 'overlay', component: pick(c.overlay, z) }, - { title: 'Loading Bar', route: 'loading-bar', component: pick(c.loadingBar, z) }, + { title: 'LoadingBar', route: 'loading-bar', component: pick(c.loadingBar, z) }, { title: 'Message', route: 'message', component: pick(c.message, z) }, { title: 'Modal', route: 'modal', component: pick(c.modal, z) }, { title: 'Notification', route: 'notification', component: pick(c.notification, z) }, { title: 'PopConfirm', route: 'pop-confirm', component: pick(c.popConfirm, z) }, { title: 'Result', route: 'result', component: pick(c.result, z) }, - { title: 'Scroll Indicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) }, + { title: 'ScrollIndicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) }, { title: 'Skeleton', route: 'skeleton', component: pick(c.skeleton, z) }, - { title: 'Strength Indicator', route: 'strength-indicator', component: pick(c.strengthIndicator, z) }, + { title: 'StrengthIndicator', route: 'strength-indicator', component: pick(c.strengthIndicator, z) }, ], }, { diff --git a/packages/react/src/input-otp/__tests__/input-otp.test.tsx b/packages/react/src/input-otp/__tests__/input-otp.test.tsx index f461068e..4f7efba0 100644 --- a/packages/react/src/input-otp/__tests__/input-otp.test.tsx +++ b/packages/react/src/input-otp/__tests__/input-otp.test.tsx @@ -59,7 +59,7 @@ describe('', () => { expect(container.firstChild).toHaveAttribute('role', 'group'); }); - it('should fire onChange when all cells are filled', () => { + it('should fire onChange on every input change', () => { const fn = jest.fn(); const { container } = render(); const inputs = container.querySelectorAll('input'); @@ -67,10 +67,11 @@ describe('', () => { fireEvent.input(inputs[0], { target: { value: '1' } }); fireEvent.input(inputs[1], { target: { value: '2' } }); fireEvent.input(inputs[2], { target: { value: '3' } }); - expect(fn).not.toHaveBeenCalled(); - fireEvent.input(inputs[3], { target: { value: '4' } }); - expect(fn).toHaveBeenCalledWith('1234'); + expect(fn).toHaveBeenNthCalledWith(1, '1'); + expect(fn).toHaveBeenNthCalledWith(2, '12'); + expect(fn).toHaveBeenNthCalledWith(3, '123'); + expect(fn).toHaveBeenNthCalledWith(4, '1234'); }); it('should render separator', () => { diff --git a/packages/react/src/input-otp/demo/separator.md b/packages/react/src/input-otp/demo/separator.md index 17c36e1c..d0ff2cf0 100644 --- a/packages/react/src/input-otp/demo/separator.md +++ b/packages/react/src/input-otp/demo/separator.md @@ -20,7 +20,7 @@ Customize the separator between cells using the `separator` prop.
Custom separator element
->} + separator={/} /> diff --git a/packages/react/src/input-otp/index.md b/packages/react/src/input-otp/index.md index 362f5439..a6d0abc0 100644 --- a/packages/react/src/input-otp/index.md +++ b/packages/react/src/input-otp/index.md @@ -7,7 +7,7 @@ import Separator from './demo/separator.md' import Formatter from './demo/formatter.md' import AutoFocus from './demo/auto-focus.md' -# InputOTP +# Input OTP Used for entering verification codes, OTP (One-Time Password), and similar short numeric/character sequences. @@ -54,6 +54,6 @@ import { InputOTP } from 'tiny-design'; | separator | Separator element rendered between cells | ((index: number) => ReactNode) | ReactNode | - | | autoFocus | Auto focus the first cell on mount | boolean | false | | autoComplete | HTML autocomplete attribute | string | `one-time-code` | -| onChange | Callback when all cells are filled | (value: string) => void | - | +| onChange | Callback when the value changes | (value: string) => void | - | | style | Style of container | CSSProperties | - | | className | ClassName of container | string | - | diff --git a/packages/react/src/input-otp/index.zh_CN.md b/packages/react/src/input-otp/index.zh_CN.md new file mode 100644 index 00000000..d022a44b --- /dev/null +++ b/packages/react/src/input-otp/index.zh_CN.md @@ -0,0 +1,60 @@ +import Basic from './demo/basic.md' +import Size from './demo/size.md' +import Disabled from './demo/disabled.md' +import Mask from './demo/mask.md' +import Length from './demo/length.md' +import Separator from './demo/separator.md' +import Formatter from './demo/formatter.md' +import AutoFocus from './demo/auto-focus.md' + +# Input OTP + +用于输入验证码、一次性密码(OTP / One-Time Password)等短字符/数字序列。 + +## 使用场景 + +登录、注册或双因素认证流程中的验证码输入。 + +## 引入方式 + +```js +import { InputOTP } from 'tiny-design'; +``` + +## 代码示例 + + + + + + + + + + + + + + + + +## API + +### InputOTP + +| 属性 | 说明 | 类型 | 默认值 | +| ------------ | -------------------------------------- | ----------------------------------------------- | ------ | +| length | 输入单元格数量 | number | 6 | +| value | 受控值 | string | - | +| defaultValue | 默认值 | string | - | +| size | 输入尺寸 | enum: `sm` | `md` | `lg` | `md` | +| disabled | 是否禁用 | boolean | false | +| mask | 是否遮罩输入,或自定义遮罩字符 | boolean | string | - | +| formatter | 格式化展示值 | (value: string) => string | - | +| separator | 单元格之间的分隔内容 | ((index: number) => ReactNode) | ReactNode | - | +| autoFocus | 组件挂载后自动聚焦第一个单元格 | boolean | false | +| autoComplete | HTML autocomplete 属性 | string | `one-time-code` | +| onChange | 当值发生变化时触发的回调 | (value: string) => void | - | +| style | 容器样式 | CSSProperties | - | +| className | 容器类名 | string | - | + diff --git a/packages/react/src/input-otp/input-otp.tsx b/packages/react/src/input-otp/input-otp.tsx index c62ab4b4..dbb4fd3d 100644 --- a/packages/react/src/input-otp/input-otp.tsx +++ b/packages/react/src/input-otp/input-otp.tsx @@ -82,21 +82,19 @@ const InputOTP = React.forwardRef( nativeElement: containerRef.current, })); - // Trigger onChange when all cells filled + // Trigger onChange when value cells change const triggerValueCellsChange = useCallback( (nextValueCells: string[]) => { - setValueCells(nextValueCells); - - if ( - onChange && - nextValueCells.length === length && - nextValueCells.every((c) => c) && - nextValueCells.some((c, index) => valueCells[index] !== c) - ) { - onChange(nextValueCells.join('')); - } + setValueCells((prev) => { + const prevValue = prev.join(''); + const nextValue = nextValueCells.join(''); + if (onChange && prevValue !== nextValue) { + onChange(nextValue); + } + return nextValueCells; + }); }, - [onChange, length, valueCells] + [onChange] ); // Patch value at given index diff --git a/packages/react/src/input-otp/otp-input.tsx b/packages/react/src/input-otp/otp-input.tsx index 21716fcb..d4edc77d 100644 --- a/packages/react/src/input-otp/otp-input.tsx +++ b/packages/react/src/input-otp/otp-input.tsx @@ -67,7 +67,7 @@ const OTPInput = React.forwardRef( syncSelection(); }; - const displayValue = mask && value ? (typeof mask === 'string' ? mask : '•') : value; + const displayValue = mask && typeof mask === 'string' && value ? mask : value; const inputType = mask === true ? 'password' : 'text'; return ( @@ -81,7 +81,7 @@ const OTPInput = React.forwardRef( [`${prefixCls}__cell_disabled`]: disabled, [`${prefixCls}__cell_mask`]: mask, })} - value={mask && typeof mask === 'string' && value ? mask : value} + value={displayValue} disabled={disabled} autoFocus={autoFocus} onInput={onInternalInput} diff --git a/packages/react/src/input-otp/style/_index.scss b/packages/react/src/input-otp/style/_index.scss index 4d751cd5..352df410 100644 --- a/packages/react/src/input-otp/style/_index.scss +++ b/packages/react/src/input-otp/style/_index.scss @@ -15,7 +15,7 @@ padding: 0; font-size: $input-md-font-size; border-radius: $input-border-radius; - caret-color: transparent; + caret-color: currentcolor; &_sm { width: 28px;