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
19 changes: 19 additions & 0 deletions .changeset/date-picker-range-attrs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@zag-js/date-picker": minor
---

Add missing range data attributes to month and year cell triggers for range picker mode.

- `data-range-start`, `data-range-end`, `data-in-hover-range`, `data-hover-range-start`, `data-hover-range-end` now
render on month and year cell triggers (previously only on day cells).

- `TableCellState` now includes `firstInRange`, `lastInRange`, `inHoveredRange`, `firstInHoveredRange`,
`lastInHoveredRange`, and `outsideRange`.

- **Fixed:** Year cell `selectable` state was inverted, causing years outside the visible decade or min/max range to
appear selectable.

- **Improved:** Range boundary dates now announce "Starting range from {date}" and "Range ending at {date}" for better
screen reader context.

- **Changed:** `DayTableCellState.formattedDate` removed — use `valueText` instead (inherited from `TableCellState`).
22 changes: 22 additions & 0 deletions .changeset/pin-input-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@zag-js/pin-input": minor
---

Major UX overhaul for Pin Input, making it feel polished and predictable for OTP and verification code flows.

- **No more holes**: Delete and Backspace now splice values left instead of leaving empty gaps. Deleting "2" from
`[1, 2, 3]` yields `[1, 3, ""]` — not `[1, "", 3]`. Cut (`Ctrl+X`) behaves the same way.

- **Smarter focus management**
- Backspace always moves back: previously it stayed in place on filled slots
- Click and ArrowRight are clamped to the insertion point: no more accidentally focusing empty slots
- Same-key skip: retyping the same character advances focus instead of getting stuck
- Roving tabIndex: Tab/Shift+Tab treats the entire pin input as a single tab stop

- **New keyboard shortcuts**
- Home / End: jump to the first slot or last filled slot
- `enterKeyHint`: mobile keyboards show "next" on intermediate slots and "done" on the last

- **New props**
- `autoSubmit`: automatically submits the owning form when all inputs are filled
- `sanitizeValue`: sanitize pasted values before validation (e.g. strip dashes from "1-2-3")
101 changes: 101 additions & 0 deletions e2e/models/pin-input.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { expect, type Page } from "@playwright/test"
import { testid } from "../_utils"
import { Model } from "./model"

export class PinInputModel extends Model {
constructor(public page: Page) {
super(page)
}

goto(url = "/pin-input/basic") {
return this.page.goto(url)
}

private getInput(index: number) {
return this.page.locator(testid(`input-${index}`))
}

private get clearButton() {
return this.page.locator(testid("clear-button"))
}

private get allInputs() {
return this.page.locator("[data-part=input]")
}

// --- Actions ---

async fillInput(index: number, value: string) {
await this.getInput(index).fill(value)
}

async focusInput(index: number) {
await this.getInput(index).focus()
}

async clickInput(index: number) {
await this.getInput(index).click()
}

async clickClear() {
await this.clearButton.click()
}

async paste(value: string) {
await this.page.evaluate((v) => navigator.clipboard.writeText(v), value)
await this.page.keyboard.press("ControlOrMeta+v")
}

async fillAll(...values: string[]) {
for (const value of values) {
await this.page.keyboard.press(value)
}
}

// --- Assertions ---

async seeInputIsFocused(index: number) {
await expect(this.getInput(index)).toBeFocused()
}

async seeInputHasValue(index: number, value: string) {
await expect(this.getInput(index)).toHaveValue(value)
}

async seeValues(...values: string[]) {
for (let i = 0; i < values.length; i++) {
await expect(this.getInput(i + 1)).toHaveValue(values[i])
}
}

async seeClearButtonIsFocused() {
await expect(this.clearButton).toBeFocused()
}

async seeInputHasAttribute(index: number, attr: string, value?: string) {
if (value !== undefined) {
await expect(this.getInput(index)).toHaveAttribute(attr, value)
} else {
await expect(this.getInput(index)).toHaveAttribute(attr, "")
}
}

async dontSeeInputHasAttribute(index: number, attr: string) {
await expect(this.getInput(index)).not.toHaveAttribute(attr, "")
}

async seeTabbableCount(expected: number) {
const inputs = this.allInputs
const count = await inputs.count()
let tabbable = 0
for (let i = 0; i < count; i++) {
const tabIndex = await inputs.nth(i).getAttribute("tabindex")
if (tabIndex === "0") tabbable++
}
expect(tabbable).toBe(expected)
}

async clickButton(testId: string) {
await this.page.locator(testid(testId)).click()
}
}
Loading
Loading