Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/input-otp-onchange-behavior.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions apps/docs/src/assets/icon/input-otp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 10 additions & 8 deletions apps/docs/src/routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.zh_CN.md')),
overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')),
};

Expand Down Expand Up @@ -161,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) },
Expand Down Expand Up @@ -216,15 +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: 'Native Select', route: 'native-select', component: pick(c.nativeSelect, 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: '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) },
Expand All @@ -240,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) },
],
},
{
Expand Down
180 changes: 180 additions & 0 deletions packages/react/src/form/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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 `<Transition>` animation

### useForm Hook

A factory function that creates a `FormInstance` externally:

```ts
const [form] = Form.useForm({ username: '', password: '' });
// Pass to <Form form={form}> 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["<Form>"]
FI["FormInstance (useRef)"]
FIC["FormInstanceContext.Provider"]
FOC["FormOptionsContext.Provider"]
end

subgraph FormItem1["<Form.Item name='username'>"]
SUB1["subscribe(listener)"]
CE1["cloneElement(child, {value, onChange, onBlur})"]
ERR1["Error display with Transition"]
end

subgraph FormItem2["<Form.Item name='password'>"]
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"]
```
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<InputOTP /> should match the snapshot 1`] = `
<DocumentFragment>
<div
class="ty-input-otp ty-input-otp_md"
role="group"
>
<input
aria-label="OTP Input 1"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
<input
aria-label="OTP Input 2"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
<input
aria-label="OTP Input 3"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
<input
aria-label="OTP Input 4"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
<input
aria-label="OTP Input 5"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
<input
aria-label="OTP Input 6"
autocomplete="one-time-code"
class="ty-input-otp__cell ty-input-otp__cell_md"
inputmode="numeric"
type="text"
value=""
/>
</div>
</DocumentFragment>
`;
Loading
Loading