Skip to content

Commit 9a44133

Browse files
committed
fix(react): refine InputOTP behaviour and docs
Made-with: Cursor
1 parent e377fe3 commit 9a44133

9 files changed

Lines changed: 100 additions & 31 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@tiny-design/react": minor
3+
---
4+
5+
Improve `InputOTP` behaviour:
6+
7+
- Fire `onChange` on every value update instead of only when all cells are filled.
8+
- Fix masked cell rendering logic.
9+
- Adjust caret colour to follow current text colour.
10+
- Update docs and tests for the new behaviour and add Chinese docs entry.

apps/docs/src/routers.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const c = {
129129
speedDial: ll(() => import('../../../packages/react/src/speed-dial/index.md'), () => import('../../../packages/react/src/speed-dial/index.zh_CN.md')),
130130
anchor: ll(() => import('../../../packages/react/src/anchor/index.md'), () => import('../../../packages/react/src/anchor/index.zh_CN.md')),
131131
autoComplete: ll(() => import('../../../packages/react/src/auto-complete/index.md'), () => import('../../../packages/react/src/auto-complete/index.zh_CN.md')),
132-
inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.md')),
132+
inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.zh_CN.md')),
133133
overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')),
134134
};
135135

@@ -162,7 +162,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
162162
{
163163
title: s.categories.layout,
164164
children: [
165-
{ title: 'Aspect Ratio', route: 'aspect-ratio', component: pick(c.aspectRatio, z) },
165+
{ title: 'AspectRatio', route: 'aspect-ratio', component: pick(c.aspectRatio, z) },
166166
{ title: 'Divider', route: 'divider', component: pick(c.divider, z) },
167167
{ title: 'Flex', route: 'flex', component: pick(c.flex, z) },
168168
{ title: 'Grid', route: 'grid', component: pick(c.grid, z) },
@@ -217,16 +217,16 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
217217
{ title: 'ColorPicker', route: 'color-picker', component: pick(c.colorPicker, z) },
218218
{ title: 'DatePicker', route: 'date-picker', component: pick(c.datePicker, z) },
219219
{ title: 'Input', route: 'input', component: pick(c.input, z) },
220-
{ title: 'Input Number', route: 'input-number', component: pick(c.inputNumber, z) },
221-
{ title: 'Input Password', route: 'input-password', component: pick(c.inputPassword, z) },
220+
{ title: 'InputNumber', route: 'input-number', component: pick(c.inputNumber, z) },
221+
{ title: 'InputPassword', route: 'input-password', component: pick(c.inputPassword, z) },
222222
{ title: 'InputOTP', route: 'input-otp', component: pick(c.inputOTP, z) },
223-
{ title: 'Native Select', route: 'native-select', component: pick(c.nativeSelect, z) },
223+
{ title: 'NativeSelect', route: 'native-select', component: pick(c.nativeSelect, z) },
224224
{ title: 'Radio', route: 'radio', component: pick(c.radio, z) },
225225
{ title: 'Rate', route: 'rate', component: pick(c.rate, z) },
226226
{ title: 'Segmented', route: 'segmented', component: pick(c.segmented, z) },
227227
{ title: 'Select', route: 'select', component: pick(c.select, z) },
228228
{ title: 'Slider', route: 'slider', component: pick(c.slider, z) },
229-
{ title: 'Split Button', route: 'split-button', component: pick(c.splitButton, z) },
229+
{ title: 'SplitButton', route: 'split-button', component: pick(c.splitButton, z) },
230230
{ title: 'Switch', route: 'switch', component: pick(c.switch, z) },
231231
{ title: 'Tabs', route: 'tabs', component: pick(c.tabs, z) },
232232
{ title: 'Textarea', route: 'textarea', component: pick(c.textarea, z) },
@@ -242,15 +242,15 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
242242
{ title: 'Drawer', route: 'drawer', component: pick(c.drawer, z) },
243243
{ title: 'Loader', route: 'loader', component: pick(c.loader, z) },
244244
{ title: 'Overlay', route: 'overlay', component: pick(c.overlay, z) },
245-
{ title: 'Loading Bar', route: 'loading-bar', component: pick(c.loadingBar, z) },
245+
{ title: 'LoadingBar', route: 'loading-bar', component: pick(c.loadingBar, z) },
246246
{ title: 'Message', route: 'message', component: pick(c.message, z) },
247247
{ title: 'Modal', route: 'modal', component: pick(c.modal, z) },
248248
{ title: 'Notification', route: 'notification', component: pick(c.notification, z) },
249249
{ title: 'PopConfirm', route: 'pop-confirm', component: pick(c.popConfirm, z) },
250250
{ title: 'Result', route: 'result', component: pick(c.result, z) },
251-
{ title: 'Scroll Indicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) },
251+
{ title: 'ScrollIndicator', route: 'scroll-indicator', component: pick(c.scrollIndicator, z) },
252252
{ title: 'Skeleton', route: 'skeleton', component: pick(c.skeleton, z) },
253-
{ title: 'Strength Indicator', route: 'strength-indicator', component: pick(c.strengthIndicator, z) },
253+
{ title: 'StrengthIndicator', route: 'strength-indicator', component: pick(c.strengthIndicator, z) },
254254
],
255255
},
256256
{

packages/react/src/input-otp/__tests__/input-otp.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,19 @@ describe('<InputOTP />', () => {
5959
expect(container.firstChild).toHaveAttribute('role', 'group');
6060
});
6161

62-
it('should fire onChange when all cells are filled', () => {
62+
it('should fire onChange on every input change', () => {
6363
const fn = jest.fn();
6464
const { container } = render(<InputOTP length={4} onChange={fn} />);
6565
const inputs = container.querySelectorAll('input');
6666

6767
fireEvent.input(inputs[0], { target: { value: '1' } });
6868
fireEvent.input(inputs[1], { target: { value: '2' } });
6969
fireEvent.input(inputs[2], { target: { value: '3' } });
70-
expect(fn).not.toHaveBeenCalled();
71-
7270
fireEvent.input(inputs[3], { target: { value: '4' } });
73-
expect(fn).toHaveBeenCalledWith('1234');
71+
expect(fn).toHaveBeenNthCalledWith(1, '1');
72+
expect(fn).toHaveBeenNthCalledWith(2, '12');
73+
expect(fn).toHaveBeenNthCalledWith(3, '123');
74+
expect(fn).toHaveBeenNthCalledWith(4, '1234');
7475
});
7576

7677
it('should render separator', () => {

packages/react/src/input-otp/demo/separator.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Customize the separator between cells using the `separator` prop.
2020
<div style={{ marginBottom: 8 }}>Custom separator element</div>
2121
<InputOTP
2222
length={4}
23-
separator={<span style={{ color: '#999' }}>-&gt;</span>}
23+
separator={<span style={{ color: '#999' }}>/</span>}
2424
/>
2525
</div>
2626
</div>

packages/react/src/input-otp/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Separator from './demo/separator.md'
77
import Formatter from './demo/formatter.md'
88
import AutoFocus from './demo/auto-focus.md'
99

10-
# InputOTP
10+
# Input OTP
1111

1212
Used for entering verification codes, OTP (One-Time Password), and similar short numeric/character sequences.
1313

@@ -54,6 +54,6 @@ import { InputOTP } from 'tiny-design';
5454
| separator | Separator element rendered between cells | ((index: number) => ReactNode) &#124; ReactNode | - |
5555
| autoFocus | Auto focus the first cell on mount | boolean | false |
5656
| autoComplete | HTML autocomplete attribute | string | `one-time-code` |
57-
| onChange | Callback when all cells are filled | (value: string) => void | - |
57+
| onChange | Callback when the value changes | (value: string) => void | - |
5858
| style | Style of container | CSSProperties | - |
5959
| className | ClassName of container | string | - |
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Basic from './demo/basic.md'
2+
import Size from './demo/size.md'
3+
import Disabled from './demo/disabled.md'
4+
import Mask from './demo/mask.md'
5+
import Length from './demo/length.md'
6+
import Separator from './demo/separator.md'
7+
import Formatter from './demo/formatter.md'
8+
import AutoFocus from './demo/auto-focus.md'
9+
10+
# Input OTP
11+
12+
用于输入验证码、一次性密码(OTP / One-Time Password)等短字符/数字序列。
13+
14+
## 使用场景
15+
16+
登录、注册或双因素认证流程中的验证码输入。
17+
18+
## 引入方式
19+
20+
```js
21+
import { InputOTP } from 'tiny-design';
22+
```
23+
24+
## 代码示例
25+
26+
<Layout>
27+
<Column>
28+
<Basic/>
29+
<Size/>
30+
<Length/>
31+
<Mask/>
32+
</Column>
33+
<Column>
34+
<Separator/>
35+
<Disabled/>
36+
<Formatter/>
37+
<AutoFocus/>
38+
</Column>
39+
</Layout>
40+
41+
## API
42+
43+
### InputOTP
44+
45+
| 属性 | 说明 | 类型 | 默认值 |
46+
| ------------ | -------------------------------------- | ----------------------------------------------- | ------ |
47+
| length | 输入单元格数量 | number | 6 |
48+
| value | 受控值 | string | - |
49+
| defaultValue | 默认值 | string | - |
50+
| size | 输入尺寸 | enum: `sm` &#124; `md` &#124; `lg` | `md` |
51+
| disabled | 是否禁用 | boolean | false |
52+
| mask | 是否遮罩输入,或自定义遮罩字符 | boolean &#124; string | - |
53+
| formatter | 格式化展示值 | (value: string) => string | - |
54+
| separator | 单元格之间的分隔内容 | ((index: number) => ReactNode) &#124; ReactNode | - |
55+
| autoFocus | 组件挂载后自动聚焦第一个单元格 | boolean | false |
56+
| autoComplete | HTML autocomplete 属性 | string | `one-time-code` |
57+
| onChange | 当值发生变化时触发的回调 | (value: string) => void | - |
58+
| style | 容器样式 | CSSProperties | - |
59+
| className | 容器类名 | string | - |
60+

packages/react/src/input-otp/input-otp.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,19 @@ const InputOTP = React.forwardRef<InputOTPRef, InputOTPProps>(
8282
nativeElement: containerRef.current,
8383
}));
8484

85-
// Trigger onChange when all cells filled
85+
// Trigger onChange when value cells change
8686
const triggerValueCellsChange = useCallback(
8787
(nextValueCells: string[]) => {
88-
setValueCells(nextValueCells);
89-
90-
if (
91-
onChange &&
92-
nextValueCells.length === length &&
93-
nextValueCells.every((c) => c) &&
94-
nextValueCells.some((c, index) => valueCells[index] !== c)
95-
) {
96-
onChange(nextValueCells.join(''));
97-
}
88+
setValueCells((prev) => {
89+
const prevValue = prev.join('');
90+
const nextValue = nextValueCells.join('');
91+
if (onChange && prevValue !== nextValue) {
92+
onChange(nextValue);
93+
}
94+
return nextValueCells;
95+
});
9896
},
99-
[onChange, length, valueCells]
97+
[onChange]
10098
);
10199

102100
// Patch value at given index

packages/react/src/input-otp/otp-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const OTPInput = React.forwardRef<HTMLInputElement, OTPInputCellProps>(
6767
syncSelection();
6868
};
6969

70-
const displayValue = mask && value ? (typeof mask === 'string' ? mask : '•') : value;
70+
const displayValue = mask && typeof mask === 'string' && value ? mask : value;
7171
const inputType = mask === true ? 'password' : 'text';
7272

7373
return (
@@ -81,7 +81,7 @@ const OTPInput = React.forwardRef<HTMLInputElement, OTPInputCellProps>(
8181
[`${prefixCls}__cell_disabled`]: disabled,
8282
[`${prefixCls}__cell_mask`]: mask,
8383
})}
84-
value={mask && typeof mask === 'string' && value ? mask : value}
84+
value={displayValue}
8585
disabled={disabled}
8686
autoFocus={autoFocus}
8787
onInput={onInternalInput}

packages/react/src/input-otp/style/_index.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
padding: 0;
1616
font-size: $input-md-font-size;
1717
border-radius: $input-border-radius;
18-
caret-color: transparent;
18+
caret-color: currentcolor;
1919

2020
&_sm {
2121
width: 28px;

0 commit comments

Comments
 (0)