Skip to content

Commit d636365

Browse files
committed
refactor(pin-input): improve ux
1 parent 3007b2a commit d636365

File tree

20 files changed

+1066
-116
lines changed

20 files changed

+1066
-116
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@zag-js/pin-input": minor
3+
---
4+
5+
Major UX overhaul for Pin Input, making it feel polished and predictable for OTP and verification code flows.
6+
7+
- **No more holes**: Delete and Backspace now splice values left instead of leaving empty gaps. Deleting "2" from
8+
`[1, 2, 3]` yields `[1, 3, ""]` — not `[1, "", 3]`. Cut (`Ctrl+X`) behaves the same way.
9+
10+
- **Smarter focus management**
11+
- Backspace always moves back: previously it stayed in place on filled slots
12+
- Click and ArrowRight are clamped to the insertion point: no more accidentally focusing empty slots
13+
- Same-key skip: retyping the same character advances focus instead of getting stuck
14+
- Roving tabIndex: Tab/Shift+Tab treats the entire pin input as a single tab stop
15+
16+
- **New keyboard shortcuts**
17+
- Home / End: jump to the first slot or last filled slot
18+
- `enterKeyHint`: mobile keyboards show "next" on intermediate slots and "done" on the last
19+
20+
- **New props**
21+
- `autoSubmit`: automatically submits the owning form when all inputs are filled
22+
- `sanitizeValue`: sanitize pasted values before validation (e.g. strip dashes from "1-2-3")

e2e/models/pin-input.model.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { expect, type Page } from "@playwright/test"
2+
import { testid } from "../_utils"
3+
import { Model } from "./model"
4+
5+
export class PinInputModel extends Model {
6+
constructor(public page: Page) {
7+
super(page)
8+
}
9+
10+
goto(url = "/pin-input/basic") {
11+
return this.page.goto(url)
12+
}
13+
14+
private getInput(index: number) {
15+
return this.page.locator(testid(`input-${index}`))
16+
}
17+
18+
private get clearButton() {
19+
return this.page.locator(testid("clear-button"))
20+
}
21+
22+
private get allInputs() {
23+
return this.page.locator("[data-part=input]")
24+
}
25+
26+
// --- Actions ---
27+
28+
async fillInput(index: number, value: string) {
29+
await this.getInput(index).fill(value)
30+
}
31+
32+
async focusInput(index: number) {
33+
await this.getInput(index).focus()
34+
}
35+
36+
async clickInput(index: number) {
37+
await this.getInput(index).click()
38+
}
39+
40+
async clickClear() {
41+
await this.clearButton.click()
42+
}
43+
44+
async paste(value: string) {
45+
await this.page.evaluate((v) => navigator.clipboard.writeText(v), value)
46+
await this.page.keyboard.press("ControlOrMeta+v")
47+
}
48+
49+
async fillAll(...values: string[]) {
50+
for (const value of values) {
51+
await this.page.keyboard.press(value)
52+
}
53+
}
54+
55+
// --- Assertions ---
56+
57+
async seeInputIsFocused(index: number) {
58+
await expect(this.getInput(index)).toBeFocused()
59+
}
60+
61+
async seeInputHasValue(index: number, value: string) {
62+
await expect(this.getInput(index)).toHaveValue(value)
63+
}
64+
65+
async seeValues(...values: string[]) {
66+
for (let i = 0; i < values.length; i++) {
67+
await expect(this.getInput(i + 1)).toHaveValue(values[i])
68+
}
69+
}
70+
71+
async seeClearButtonIsFocused() {
72+
await expect(this.clearButton).toBeFocused()
73+
}
74+
75+
async seeInputHasAttribute(index: number, attr: string, value?: string) {
76+
if (value !== undefined) {
77+
await expect(this.getInput(index)).toHaveAttribute(attr, value)
78+
} else {
79+
await expect(this.getInput(index)).toHaveAttribute(attr, "")
80+
}
81+
}
82+
83+
async dontSeeInputHasAttribute(index: number, attr: string) {
84+
await expect(this.getInput(index)).not.toHaveAttribute(attr, "")
85+
}
86+
87+
async seeTabbableCount(expected: number) {
88+
const inputs = this.allInputs
89+
const count = await inputs.count()
90+
let tabbable = 0
91+
for (let i = 0; i < count; i++) {
92+
const tabIndex = await inputs.nth(i).getAttribute("tabindex")
93+
if (tabIndex === "0") tabbable++
94+
}
95+
expect(tabbable).toBe(expected)
96+
}
97+
98+
async clickButton(testId: string) {
99+
await this.page.locator(testid(testId)).click()
100+
}
101+
}

0 commit comments

Comments
 (0)