From d6363653cc4379c7d17b7bf1a927231784bd013b Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Tue, 24 Mar 2026 16:46:03 +0200 Subject: [PATCH 1/2] refactor(pin-input): improve ux --- .changeset/pin-input-improvements.md | 22 + e2e/models/pin-input.model.ts | 101 ++++ e2e/pin-input.e2e.ts | 498 ++++++++++++++---- .../next-ts/pages/pin-input/controlled.tsx | 67 +++ .../pages/pin-input/transform-paste.tsx | 55 ++ .../app/pages/pin-input/controlled.vue | 54 ++ .../app/pages/pin-input/transform-paste.vue | 56 ++ .../src/routes/pin-input/controlled.tsx | 63 +++ .../src/routes/pin-input/transform-paste.tsx | 57 ++ examples/svelte-ts/package-map.json | 1 + .../routes/pin-input/controlled/+page.svelte | 54 ++ .../pin-input/transform-paste/+page.svelte | 52 ++ package.json | 5 +- .../pin-input/src/pin-input.connect.ts | 28 +- .../pin-input/src/pin-input.machine.ts | 47 +- .../machines/pin-input/src/pin-input.props.ts | 2 + .../machines/pin-input/src/pin-input.types.ts | 10 + shared/src/controls.ts | 1 + shared/src/css/pin-input.css | 3 +- shared/src/routes.ts | 6 +- 20 files changed, 1066 insertions(+), 116 deletions(-) create mode 100644 .changeset/pin-input-improvements.md create mode 100644 e2e/models/pin-input.model.ts create mode 100644 examples/next-ts/pages/pin-input/controlled.tsx create mode 100644 examples/next-ts/pages/pin-input/transform-paste.tsx create mode 100644 examples/nuxt-ts/app/pages/pin-input/controlled.vue create mode 100644 examples/nuxt-ts/app/pages/pin-input/transform-paste.vue create mode 100644 examples/solid-ts/src/routes/pin-input/controlled.tsx create mode 100644 examples/solid-ts/src/routes/pin-input/transform-paste.tsx create mode 100644 examples/svelte-ts/src/routes/pin-input/controlled/+page.svelte create mode 100644 examples/svelte-ts/src/routes/pin-input/transform-paste/+page.svelte diff --git a/.changeset/pin-input-improvements.md b/.changeset/pin-input-improvements.md new file mode 100644 index 0000000000..8845e3804c --- /dev/null +++ b/.changeset/pin-input-improvements.md @@ -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") diff --git a/e2e/models/pin-input.model.ts b/e2e/models/pin-input.model.ts new file mode 100644 index 0000000000..e990e90642 --- /dev/null +++ b/e2e/models/pin-input.model.ts @@ -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() + } +} diff --git a/e2e/pin-input.e2e.ts b/e2e/pin-input.e2e.ts index 376ab40b6d..c02732a011 100644 --- a/e2e/pin-input.e2e.ts +++ b/e2e/pin-input.e2e.ts @@ -1,145 +1,439 @@ -import { expect, test } from "@playwright/test" -import { testid } from "./_utils" +import { test } from "@playwright/test" +import { PinInputModel } from "./models/pin-input.model" -const first = testid("input-1") -const second = testid("input-2") -const third = testid("input-3") -const clear = testid("clear-button") +let I: PinInputModel test.describe("pin input", () => { test.beforeEach(async ({ page }) => { - await page.goto("/pin-input/basic") + I = new PinInputModel(page) + await I.goto() }) - test("on type: should move focus to the next input", async ({ page }) => { - await page.locator(first).fill("1") - await expect(page.locator(second)).toBeFocused() - await page.locator(second).fill("2") - await expect(page.locator(third)).toBeFocused() - await page.locator(third).fill("3") + test("on type: should move focus to the next input", async () => { + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + await I.fillInput(2, "2") + await I.seeInputIsFocused(3) }) - test("on type: should not allow multiple keys at once ", async ({ page }) => { - await page.locator(first).fill("12") - // it takes the last key and ignores the rest - await expect(page.locator(first)).toHaveValue("2") + test("on type: should not allow multiple keys at once", async () => { + await I.fillInput(1, "12") + await I.seeInputHasValue(1, "2") }) - test("on backspace: should clear value and move focus to prev input", async ({ page }) => { - await page.locator(first).fill("1") - await expect(page.locator(second)).toBeFocused() - await page.locator(second).fill("2") - await expect(page.locator(third)).toBeFocused() - await page.locator(third).press("Backspace") - await expect(page.locator(second)).toBeFocused() - await expect(page.locator(second)).toHaveValue("") + test("on backspace (empty): should delete prev char and move back", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.seeInputIsFocused(3) + + await I.pressKey("Backspace") + await I.seeInputIsFocused(2) + await I.seeInputHasValue(2, "") + }) + + test("on backspace (filled): should clear and move focus back", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.pressKey("Backspace") + await I.seeInputIsFocused(2) + await I.seeInputHasValue(3, "") }) - test("on arrow: should change focus between inputs", async ({ page }) => { - // fill out all fields - await page.locator(first).fill("1") - await page.locator(second).fill("2") - await page.locator(third).fill("3") + test("on arrow: should change focus between inputs", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") - // navigate with arrow keys - await page.keyboard.press("ArrowLeft") - await expect(page.locator(second)).toBeFocused() - await page.keyboard.press("ArrowRight") - await expect(page.locator(third)).toBeFocused() + await I.pressKey("ArrowLeft") + await I.seeInputIsFocused(2) + await I.pressKey("ArrowRight") + await I.seeInputIsFocused(3) }) - test("on clear: should clear values and focus first", async ({ page }) => { - // fill out all fields - await page.locator(first).fill("1") - await page.locator(second).fill("2") - await page.locator(third).fill("3") + test("on clear: should clear values and focus first", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") - // click clear - await page.locator(clear).click() - await expect(page.locator(first)).toBeFocused() - await expect(page.locator(first)).toHaveValue("") - await expect(page.locator(second)).toHaveValue("") - await expect(page.locator(third)).toHaveValue("") + await I.clickClear() + await I.seeInputIsFocused(1) + await I.seeValues("", "", "") }) - test("on paste: should autofill all fields", async ({ page, context }) => { + test("on paste: should autofill all fields", async ({ context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await I.focusInput(1) + await I.paste("123") - await page.locator(first).focus() + await I.seeValues("1", "2", "3") + await I.seeInputIsFocused(3) + }) + + test("on paste: should autofill when focused field is not empty", async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await I.fillInput(1, "1") + await I.focusInput(1) + await I.paste("123") - await page.evaluate(() => navigator.clipboard.writeText("123")) - await page.locator(first).press("ControlOrMeta+v") + await I.seeValues("1", "2", "3") + await I.seeInputIsFocused(3) + }) - await expect(page.locator(first)).toHaveValue("1") - await expect(page.locator(second)).toHaveValue("2") - await expect(page.locator(third)).toHaveValue("3") - await expect(page.locator(third)).toBeFocused() + test("[different] should allow only single character", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.focusInput(1) + await I.fillInput(1, "3") + await I.seeInputHasValue(1, "3") }) - test("on paste: should autofill all fields if focused field is not empty", async ({ page, context }) => { + test("[same] should allow only single character", async () => { + await I.fillInput(1, "1") + await I.focusInput(1) + await I.fillInput(1, "1") + await I.seeInputHasValue(1, "1") + }) + + test("[on edit] should allow to edit the existing value", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + await I.focusInput(2) + await I.fillInput(2, "4") + await I.seeInputHasValue(2, "4") + await I.seeInputHasValue(3, "3") + await I.seeInputIsFocused(3) + }) + + test("native delete keyboard behavior", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.focusInput(3) + await I.pressKey("ControlOrMeta+Backspace") + await I.seeInputHasValue(3, "") + + await I.fillInput(3, "2") + await I.focusInput(3) + await I.pressKey("Home") + await I.pressKey("Delete") + await I.seeInputHasValue(3, "") + }) + + // --- Home / End --- + + test("home/end: should navigate to first and last filled", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.pressKey("Home") + await I.seeInputIsFocused(1) + + await I.pressKey("End") + await I.seeInputIsFocused(3) + }) + + test("home/end: end with partial fill goes to last filled", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + + await I.pressKey("Home") + await I.seeInputIsFocused(1) + + await I.pressKey("End") + await I.seeInputIsFocused(2) + }) + + // --- No-holes splice+shift --- + + test("delete: should splice and shift values left (no holes)", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.focusInput(2) + await I.pressKey("Delete") + + await I.seeValues("1", "3", "") + }) + + test("backspace: should splice and shift values left (no holes)", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.focusInput(2) + await I.pressKey("Backspace") + + await I.seeInputIsFocused(1) + await I.seeValues("1", "3", "") + }) + + // --- Focus clamping --- + + test("click: should redirect to insertion point when clicking past filled", async () => { + await I.fillInput(1, "1") + await I.clickInput(3) + await I.seeInputIsFocused(2) + }) + + test("click: should allow clicking on filled slots", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.clickInput(1) + await I.seeInputIsFocused(1) + }) + + test("arrow right: should not go past insertion point", async () => { + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + await I.pressKey("ArrowRight") + await I.seeInputIsFocused(2) + }) + + // --- Same-key skip --- + + test("same key: should advance focus without changing value", async () => { + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + + await I.clickInput(1) + await I.seeInputIsFocused(1) + await I.pressKey("1") + + await I.seeInputHasValue(1, "1") + await I.seeInputIsFocused(2) + }) + + // --- Cut --- + + test("cut: should splice and shift values left (no holes)", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.focusInput(2) + await I.pressKey("ControlOrMeta+a") + await I.pressKey("ControlOrMeta+x") + + await I.seeValues("1", "3", "") + }) + + // --- Boundary --- + + test("arrow left: should not go before first input", async () => { + await I.fillInput(1, "1") + await I.clickInput(1) + await I.pressKey("ArrowLeft") + await I.seeInputIsFocused(1) + }) + + // --- Roving tabIndex --- + + test("roving tabindex: tab should skip remaining inputs", async () => { + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + + await I.pressKey("Tab") + await I.seeClearButtonIsFocused() + }) + + test("roving tabindex: shift+tab should re-enter at insertion point", async () => { + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + + await I.pressKey("Tab") + await I.seeClearButtonIsFocused() + + await I.pressKey("Shift+Tab") + await I.seeInputIsFocused(2) + }) + + test("roving tabindex: shift+tab with all filled should land on last input", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.pressKey("Tab") + await I.seeClearButtonIsFocused() + + await I.pressKey("Shift+Tab") + await I.seeInputIsFocused(3) + }) + + // --- Accessibility --- + + test("should have no accessibility violations", async () => { + await I.checkAccessibility("[data-part=root]") + }) + + // --- Paste max length --- + + test("on paste: should truncate to available slots", async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await I.focusInput(1) + await I.paste("12345") + + await I.seeValues("1", "2", "3") + }) + + // --- Validation --- + + test("non-numeric chars should be rejected in numeric mode", async () => { + await I.focusInput(1) + await I.pressKey("a") + await I.seeInputHasValue(1, "") + await I.seeInputIsFocused(1) + + await I.pressKey("!") + await I.seeInputHasValue(1, "") + await I.seeInputIsFocused(1) + }) + + // --- Boundary edge cases --- + + test("backspace at index 0: should stay at first input", async () => { + await I.fillInput(1, "1") + await I.clickInput(1) + await I.pressKey("Backspace") + + await I.seeInputIsFocused(1) + await I.seeInputHasValue(1, "") + }) + + test("delete on empty slot: should do nothing", async () => { + await I.fillInput(1, "1") + // Focus is on second (empty) + await I.seeInputIsFocused(2) + await I.pressKey("Delete") + + await I.seeInputIsFocused(2) + await I.seeValues("1", "", "") + }) + + // --- Paste mid-sequence --- + + test("on paste: mid-sequence should preserve left values", async ({ context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await I.fillInput(1, "1") + await I.seeInputIsFocused(2) + await I.paste("89") + + await I.seeValues("1", "8", "9") + }) + + // --- Replace value on complete --- + + test("complete then type: should replace focused value", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + + await I.clickInput(2) + await I.fillInput(2, "9") + await I.seeInputHasValue(2, "9") + await I.seeValues("1", "9", "3") + }) + + // --- Alphabetic type --- + + test("alphabetic type: should reject numbers", async () => { + await I.controls.select("type", "alphabetic") + await I.focusInput(1) + + await I.pressKey("1") + await I.seeInputHasValue(1, "") + await I.seeInputIsFocused(1) + + await I.pressKey("a") + await I.seeInputHasValue(1, "a") + await I.seeInputIsFocused(2) + }) + + // --- RTL --- - await page.locator(first).fill("1") - await page.locator(first).focus() + test("rtl: arrow keys should be reversed", async () => { + await I.controls.select("dir", "rtl") - await page.evaluate(() => navigator.clipboard.writeText("123")) - await page.locator(first).press("ControlOrMeta+v") + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") - await expect(page.locator(first)).toHaveValue("1") - await expect(page.locator(second)).toHaveValue("2") - await expect(page.locator(third)).toHaveValue("3") - await expect(page.locator(third)).toBeFocused() + // In RTL, ArrowLeft should go forward (next), ArrowRight should go backward (prev) + await I.pressKey("ArrowRight") + await I.seeInputIsFocused(2) + await I.pressKey("ArrowLeft") + await I.seeInputIsFocused(3) }) +}) + +// --- Controlled --- - test("[different] should allow only single character", async ({ page }) => { - await page.locator(first).fill("1") - await page.locator(second).fill("2") - await page.locator(first).focus() - await page.locator(first).fill("3") - await expect(page.locator(first)).toHaveValue("3") +test.describe("pin input / controlled", () => { + test.beforeEach(async ({ page }) => { + I = new PinInputModel(page) + await I.goto("/pin-input/controlled") }) - test("[same] should allow only single character", async ({ page }) => { - await page.locator(first).fill("1") - await page.locator(first).focus() - await page.locator(first).fill("1") - await expect(page.locator(first)).toHaveValue("1") + test("should update inputs when value is set externally", async () => { + await I.clickButton("set-value") + await I.seeValues("1", "2", "3") }) - test("[on edit] should allow to edit the existing value", async ({ page }) => { - await page.locator(first).fill("1") - await page.locator(second).fill("2") - await page.locator(third).fill("3") - await page.locator(second).focus() - await page.locator(second).fill("4") - await expect(page.locator(second)).toHaveValue("4") - await expect(page.locator(third)).toHaveValue("3") - await expect(page.locator(third)).toBeFocused() + test("should clear inputs when reset externally", async () => { + await I.clickButton("set-value") + await I.seeValues("1", "2", "3") + + await I.clickButton("reset-value") + await I.seeValues("", "", "") + }) + + test("should allow typing after external set", async () => { + await I.clickButton("set-value") + await I.seeValues("1", "2", "3") + + await I.clickInput(1) + await I.fillInput(1, "9") + await I.seeInputHasValue(1, "9") }) +}) - test("native delete keyboard behavior", async ({ page }) => { - // Fill the pin input with values - await page.locator(first).fill("1") - await page.locator(second).fill("2") - await page.locator(third).fill("3") +test.describe("pin input / transform paste", () => { + test.beforeEach(async ({ page }) => { + I = new PinInputModel(page) + await I.goto("/pin-input/transform-paste") + }) - // Focus on the second input - await page.locator(third).focus() + test("data-filled: should be set on filled inputs", async () => { + await I.fillInput(1, "1") + await I.seeInputHasAttribute(1, "data-filled") + await I.dontSeeInputHasAttribute(2, "data-filled") + }) - // Press ctrl/cmd+backspace (delete to start of line - cross-platform) - await page.keyboard.press("ControlOrMeta+Backspace") + test("enterkeyhint: last input should have 'done', others 'next'", async () => { + await I.seeInputHasAttribute(1, "enterkeyhint", "next") + await I.seeInputHasAttribute(2, "enterkeyhint", "next") + await I.seeInputHasAttribute(3, "enterkeyhint", "done") + }) - // The input should be cleared - await expect(page.locator(third)).toHaveValue("") + test("auto submit: should submit form when all inputs filled", async () => { + await I.fillInput(1, "1") + await I.fillInput(2, "2") + await I.fillInput(3, "3") + await I.seeInConsole("test") + }) - // Test fn+delete (forward delete) - refill first - await page.locator(third).fill("2") - await page.locator(third).focus() - // Move cursor to start of input to test forward delete - await page.keyboard.press("Home") - await page.keyboard.press("Delete") + test("sanitize value: should strip dashes before filling", async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await I.focusInput(1) + await I.paste("1-2-3") - // The input should be cleared - await expect(page.locator(third)).toHaveValue("") + await I.seeValues("1", "2", "3") }) }) diff --git a/examples/next-ts/pages/pin-input/controlled.tsx b/examples/next-ts/pages/pin-input/controlled.tsx new file mode 100644 index 0000000000..ba1bef4937 --- /dev/null +++ b/examples/next-ts/pages/pin-input/controlled.tsx @@ -0,0 +1,67 @@ +import * as pinInput from "@zag-js/pin-input" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId, useState } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" + +export default function Page() { + const [value, setValue] = useState(["", "", ""]) + + const service = useMachine(pinInput.machine, { + id: useId(), + name: "test", + count: 3, + value, + onValueChange(details) { + setValue(details.value) + }, + }) + + const api = pinInput.connect(service, normalizeProps) + + return ( + <> +
+
{ + e.preventDefault() + console.log("submitted:", value.join("")) + }} + > +
+ +
+ {api.items.map((index) => ( + + ))} +
+ +
+ +
+ + + + +
+
+ +
+ Controlled value: [{value.map((v) => `"${v}"`).join(", ")}] +
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/pin-input/transform-paste.tsx b/examples/next-ts/pages/pin-input/transform-paste.tsx new file mode 100644 index 0000000000..da560daf07 --- /dev/null +++ b/examples/next-ts/pages/pin-input/transform-paste.tsx @@ -0,0 +1,55 @@ +import * as pinInput from "@zag-js/pin-input" +import { normalizeProps, useMachine } from "@zag-js/react" +import { pinInputControls } from "@zag-js/shared" +import serialize from "form-serialize" +import { useId } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import { useControls } from "../../hooks/use-controls" + +export default function Page() { + const controls = useControls(pinInputControls) + + const service = useMachine(pinInput.machine, { + name: "test", + id: useId(), + count: 3, + autoSubmit: true, + sanitizeValue: (value) => value.replace(/-/g, ""), + ...controls.context, + }) + + const api = pinInput.connect(service, normalizeProps) + + return ( + <> +
+
{ + e.preventDefault() + const formData = serialize(e.currentTarget, { hash: true }) + console.log(formData) + }} + > +
+ +
+ {api.items.map((index) => ( + + ))} +
+ +
+ + +
+
+ + + + + + ) +} diff --git a/examples/nuxt-ts/app/pages/pin-input/controlled.vue b/examples/nuxt-ts/app/pages/pin-input/controlled.vue new file mode 100644 index 0000000000..991c39dae7 --- /dev/null +++ b/examples/nuxt-ts/app/pages/pin-input/controlled.vue @@ -0,0 +1,54 @@ + + + diff --git a/examples/nuxt-ts/app/pages/pin-input/transform-paste.vue b/examples/nuxt-ts/app/pages/pin-input/transform-paste.vue new file mode 100644 index 0000000000..aa11acb8a8 --- /dev/null +++ b/examples/nuxt-ts/app/pages/pin-input/transform-paste.vue @@ -0,0 +1,56 @@ + + + diff --git a/examples/solid-ts/src/routes/pin-input/controlled.tsx b/examples/solid-ts/src/routes/pin-input/controlled.tsx new file mode 100644 index 0000000000..dbfac84555 --- /dev/null +++ b/examples/solid-ts/src/routes/pin-input/controlled.tsx @@ -0,0 +1,63 @@ +import * as pinInput from "@zag-js/pin-input" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { createMemo, createSignal, createUniqueId, For } from "solid-js" +import { StateVisualizer } from "~/components/state-visualizer" +import { Toolbar } from "~/components/toolbar" + +export default function Page() { + const [value, setValue] = createSignal(["", "", ""]) + + const service = useMachine(pinInput.machine, () => ({ + id: createUniqueId(), + name: "test", + count: 3, + value: value(), + onValueChange(details) { + setValue(details.value) + }, + })) + + const api = createMemo(() => pinInput.connect(service, normalizeProps)) + + return ( + <> +
+
{ + e.preventDefault() + console.log("submitted:", value().join("")) + }} + > +
+ +
+ + {(index) => } + +
+ +
+ +
+ + + + +
+
+
+ + + + + + ) +} diff --git a/examples/solid-ts/src/routes/pin-input/transform-paste.tsx b/examples/solid-ts/src/routes/pin-input/transform-paste.tsx new file mode 100644 index 0000000000..3ad1b74771 --- /dev/null +++ b/examples/solid-ts/src/routes/pin-input/transform-paste.tsx @@ -0,0 +1,57 @@ +import * as pinInput from "@zag-js/pin-input" +import { pinInputControls } from "@zag-js/shared" +import { normalizeProps, useMachine } from "@zag-js/solid" +import serialize from "form-serialize" +import { createMemo, createUniqueId, For } from "solid-js" +import { StateVisualizer } from "~/components/state-visualizer" +import { Toolbar } from "~/components/toolbar" +import { useControls } from "~/hooks/use-controls" + +export default function Page() { + const controls = useControls(pinInputControls) + + const service = useMachine( + pinInput.machine, + controls.mergeProps({ + id: createUniqueId(), + name: "test", + count: 3, + autoSubmit: true, + sanitizeValue: (value: string) => value.replace(/-/g, ""), + }), + ) + + const api = createMemo(() => pinInput.connect(service, normalizeProps)) + + return ( + <> +
+
{ + e.preventDefault() + const formData = serialize(e.currentTarget, { hash: true }) + console.log(formData) + }} + > +
+ +
+ + {(index) => } + +
+ +
+ + +
+
+ + + + + + ) +} diff --git a/examples/svelte-ts/package-map.json b/examples/svelte-ts/package-map.json index 2357702fd2..719c0b8b22 100644 --- a/examples/svelte-ts/package-map.json +++ b/examples/svelte-ts/package-map.json @@ -70,6 +70,7 @@ "@zag-js/tabs": "../../packages/machines/tabs/src", "@zag-js/tags-input": "../../packages/machines/tags-input/src", "@zag-js/timer": "../../packages/machines/timer/src", + "@zag-js/toc": "../../packages/machines/toc/src", "@zag-js/toast": "../../packages/machines/toast/src", "@zag-js/toggle": "../../packages/machines/toggle/src", "@zag-js/toggle-group": "../../packages/machines/toggle-group/src", diff --git a/examples/svelte-ts/src/routes/pin-input/controlled/+page.svelte b/examples/svelte-ts/src/routes/pin-input/controlled/+page.svelte new file mode 100644 index 0000000000..22a9d11a72 --- /dev/null +++ b/examples/svelte-ts/src/routes/pin-input/controlled/+page.svelte @@ -0,0 +1,54 @@ + + +
+
{ + e.preventDefault() + console.log("submitted:", value.join("")) + }} + > +
+ + +
+ {#each api.items as index} + + {/each} +
+ +
+ +
+ + + + +
+
+
+ + + + diff --git a/examples/svelte-ts/src/routes/pin-input/transform-paste/+page.svelte b/examples/svelte-ts/src/routes/pin-input/transform-paste/+page.svelte new file mode 100644 index 0000000000..89c239d3a2 --- /dev/null +++ b/examples/svelte-ts/src/routes/pin-input/transform-paste/+page.svelte @@ -0,0 +1,52 @@ + + +
+
{ + e.preventDefault() + const formData = serialize(e.currentTarget, { hash: true }) + console.log(formData) + }} + > +
+ + +
+ {#each api.items as index} + + {/each} +
+ +
+ + +
+
+ + + + diff --git a/package.json b/package.json index def0fa3d94..d216b4a878 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "start-vanilla": "pnpm vanilla dev", "start-website": "pnpm website dev", "pw-report": "playwright show-report e2e/report", - "pw-test": "cross-env FRAMEWORK=react playwright test", - "pw-test-debug": "cross-env FRAMEWORK=react playwright test --debug", + "pw-test": "cross-env FRAMEWORK=${FRAMEWORK:-react} playwright test", + "pw-test-debug": "cross-env FRAMEWORK=${FRAMEWORK:-react} playwright test --debug", "pw-test-ui": "cross-env FRAMEWORK=react playwright test --ui", "e2e-react": "cross-env FRAMEWORK=react playwright test", "e2e-vue": "cross-env FRAMEWORK=vue playwright test", "e2e-solid": "cross-env FRAMEWORK=solid playwright test", + "e2e-svelte": "cross-env FRAMEWORK=svelte playwright test", "generate-machine": "plop machine && pnpm sync-pkgs", "generate-util": "plop utility && pnpm sync-pkgs", "typecheck": "pnpm packages -- typecheck", diff --git a/packages/machines/pin-input/src/pin-input.connect.ts b/packages/machines/pin-input/src/pin-input.connect.ts index 5ff68d252d..427e133576 100644 --- a/packages/machines/pin-input/src/pin-input.connect.ts +++ b/packages/machines/pin-input/src/pin-input.connect.ts @@ -113,12 +113,17 @@ export function connect( getInputProps(props) { const { index } = props const inputType = prop("type") === "numeric" ? "tel" : "text" + const valueLength = computed("valueLength") + const tabbableIndex = + focusedIndex !== -1 ? focusedIndex : Math.min(computed("filledValueLength"), valueLength - 1) return normalize.input({ ...parts.input.attrs, dir: prop("dir"), disabled, + tabIndex: index === tabbableIndex ? 0 : -1, "data-disabled": dataAttr(disabled), "data-complete": dataAttr(complete), + "data-filled": dataAttr(context.get("value")[index] !== ""), id: dom.getInputId(scope, index.toString()), "data-index": index, "data-ownedby": dom.getRootId(scope), @@ -126,6 +131,7 @@ export function connect( inputMode: prop("otp") || prop("type") === "numeric" ? "numeric" : "text", "aria-invalid": ariaAttr(invalid), "data-invalid": dataAttr(invalid), + enterKeyHint: index === valueLength - 1 ? "done" : "next", type: prop("mask") ? "password" : inputType, defaultValue: context.get("value")[index] || "", readOnly, @@ -133,9 +139,12 @@ export function connect( autoComplete: prop("otp") ? "one-time-code" : "off", placeholder: focusedIndex === index ? "" : prop("placeholder"), onPaste(event) { - const pastedValue = event.clipboardData?.getData("text/plain") + let pastedValue = event.clipboardData?.getData("text/plain") if (!pastedValue) return + const transformer = prop("sanitizeValue") + if (transformer) pastedValue = transformer(pastedValue) + const isValid = isValidValue(pastedValue, prop("type"), prop("pattern")) if (!isValid) { send({ type: "VALUE.INVALID", value: pastedValue }) @@ -186,6 +195,10 @@ export function connect( send({ type: "INPUT.BACKSPACE" }) return } + if (evt.inputType === "deleteByCut") { + send({ type: "INPUT.DELETE" }) + return + } if (value === computed("focusedValue")) return send({ type: "INPUT.CHANGE", value, index }) @@ -196,6 +209,13 @@ export function connect( if (isComposingEvent(event)) return if (isModifierKey(event)) return + // Same key already in slot: advance focus without changing value + if (event.key.length === 1 && computed("focusedValue") === event.key) { + event.preventDefault() + send({ type: "INPUT.ADVANCE" }) + return + } + const keyMap: EventKeyMap = { Backspace() { send({ type: "INPUT.BACKSPACE" }) @@ -212,6 +232,12 @@ export function connect( Enter() { send({ type: "INPUT.ENTER" }) }, + Home() { + send({ type: "INPUT.HOME" }) + }, + End() { + send({ type: "INPUT.END" }) + }, } const exec = diff --git a/packages/machines/pin-input/src/pin-input.machine.ts b/packages/machines/pin-input/src/pin-input.machine.ts index 22e5a0b0fb..119cfc5396 100644 --- a/packages/machines/pin-input/src/pin-input.machine.ts +++ b/packages/machines/pin-input/src/pin-input.machine.ts @@ -71,7 +71,7 @@ export const machine = createMachine({ action(["syncInputElements", "dispatchInputEvent"]) }) track([() => computed("isValueComplete")], () => { - action(["invokeOnComplete", "blurFocusedInputIfNeeded"]) + action(["invokeOnComplete", "blurFocusedInputIfNeeded", "autoSubmitIfNeeded"]) }) }, @@ -100,14 +100,17 @@ export const machine = createMachine({ focused: { on: { "INPUT.CHANGE": { - actions: ["setFocusedValue", "syncInputValue", "setNextFocusedIndex"], + actions: ["setFocusedValue", "syncInputValue", "advanceFocusedIndex"], + }, + "INPUT.ADVANCE": { + actions: ["advanceFocusedIndex"], }, "INPUT.PASTE": { actions: ["setPastedValue", "setLastValueFocusIndex"], }, "INPUT.FOCUS": { - actions: ["setFocusedIndex"], + actions: ["setFocusedIndex", "focusInput"], }, "INPUT.BLUR": { target: "idle", @@ -123,10 +126,16 @@ export const machine = createMachine({ "INPUT.ARROW_RIGHT": { actions: ["setNextFocusedIndex"], }, + "INPUT.HOME": { + actions: ["setFocusIndexToFirst"], + }, + "INPUT.END": { + actions: ["setFocusIndexToLast"], + }, "INPUT.BACKSPACE": [ { guard: "hasValue", - actions: ["clearFocusedValue"], + actions: ["clearFocusedValue", "setPrevFocusedIndex"], }, { actions: ["setPrevFocusedIndex", "clearFocusedValue"], @@ -164,7 +173,9 @@ export const machine = createMachine({ focusInput({ context, scope }) { const focusedIndex = context.get("focusedIndex") if (focusedIndex === -1) return - dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true }) + queueMicrotask(() => { + dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true }) + }) }, selectInputIfNeeded({ context, prop, scope }) { const focusedIndex = context.get("focusedIndex") @@ -189,8 +200,9 @@ export const machine = createMachine({ clearFocusedIndex({ context }) { context.set("focusedIndex", -1) }, - setFocusedIndex({ context, event }) { - context.set("focusedIndex", event.index) + setFocusedIndex({ context, event, computed }) { + const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1) + context.set("focusedIndex", Math.min(event.index, maxIndex)) }, setValue({ context, event }) { const value = fill(event.value, context.get("count")) @@ -254,14 +266,26 @@ export const machine = createMachine({ clearFocusedValue({ context, computed }) { const focusedIndex = context.get("focusedIndex") if (focusedIndex === -1) return - context.set("value", setValueAtIndex(computed("_value"), focusedIndex, "")) + // Splice and shift remaining values left to avoid holes + const value = [...computed("_value")] + value.splice(focusedIndex, 1) + value.push("") + context.set("value", value) }, setFocusIndexToFirst({ context }) { context.set("focusedIndex", 0) }, - setNextFocusedIndex({ context, computed }) { + setFocusIndexToLast({ context, computed }) { + context.set("focusedIndex", Math.max(computed("filledValueLength") - 1, 0)) + }, + advanceFocusedIndex({ context, computed }) { context.set("focusedIndex", Math.min(context.get("focusedIndex") + 1, computed("valueLength") - 1)) }, + setNextFocusedIndex({ context, computed }) { + const nextIndex = context.get("focusedIndex") + 1 + const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1) + context.set("focusedIndex", Math.min(nextIndex, maxIndex)) + }, setPrevFocusedIndex({ context }) { context.set("focusedIndex", Math.max(context.get("focusedIndex") - 1, 0)) }, @@ -281,6 +305,11 @@ export const machine = createMachine({ const inputEl = dom.getHiddenInputEl(scope) inputEl?.form?.requestSubmit() }, + autoSubmitIfNeeded({ computed, prop, scope }) { + if (!prop("autoSubmit") || !computed("isValueComplete")) return + const inputEl = dom.getHiddenInputEl(scope) + inputEl?.form?.requestSubmit() + }, }, }, }) diff --git a/packages/machines/pin-input/src/pin-input.props.ts b/packages/machines/pin-input/src/pin-input.props.ts index f73c1d5f02..561a9c7da2 100644 --- a/packages/machines/pin-input/src/pin-input.props.ts +++ b/packages/machines/pin-input/src/pin-input.props.ts @@ -4,6 +4,7 @@ import type { PinInputProps } from "./pin-input.types" export const props = createProps()([ "autoFocus", + "autoSubmit", "blurOnComplete", "count", "defaultValue", @@ -20,6 +21,7 @@ export const props = createProps()([ "onValueComplete", "onValueInvalid", "otp", + "sanitizeValue", "pattern", "placeholder", "readOnly", diff --git a/packages/machines/pin-input/src/pin-input.types.ts b/packages/machines/pin-input/src/pin-input.types.ts index f527ac0567..3e899964a5 100644 --- a/packages/machines/pin-input/src/pin-input.types.ts +++ b/packages/machines/pin-input/src/pin-input.types.ts @@ -108,6 +108,10 @@ export interface PinInputProps extends DirectionProperty, CommonProperties { * If `true`, the input's value will be masked just like `type=password` */ mask?: boolean | undefined + /** + * Whether to auto-submit the owning form when all inputs are filled. + */ + autoSubmit?: boolean | undefined /** * Whether to blur the input when the value is complete */ @@ -116,6 +120,12 @@ export interface PinInputProps extends DirectionProperty, CommonProperties { * Whether to select input value when input is focused */ selectOnFocus?: boolean | undefined + /** + * Function to sanitize pasted values before validation. + * Useful for stripping dashes, spaces, or other formatting. + * @example (value) => value.replace(/-/g, "") + */ + sanitizeValue?: ((value: string) => string) | undefined /** * Specifies the localized strings that identifies the accessibility elements and their states */ diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 4b837c2317..8f50d2d18b 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -109,6 +109,7 @@ export const numberInputControls = defineControls({ }) export const pinInputControls = defineControls({ + dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, mask: { type: "boolean", defaultValue: false }, otp: { type: "boolean", defaultValue: false }, blurOnComplete: { type: "boolean", defaultValue: false }, diff --git a/shared/src/css/pin-input.css b/shared/src/css/pin-input.css index 6003502e8f..7662143f93 100644 --- a/shared/src/css/pin-input.css +++ b/shared/src/css/pin-input.css @@ -14,5 +14,6 @@ width: 48px; height: 48px; text-align: center; - font-size: 24px; + font-size: 16px; + caret-color: transparent; } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 9c34cc7793..1375305c66 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -355,7 +355,11 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "pin-input", label: "Pin Input", - examples: [{ slug: "basic", title: "Basic" }], + examples: [ + { slug: "basic", title: "Basic" }, + { slug: "controlled", title: "Controlled" }, + { slug: "transform-paste", title: "Transform Paste" }, + ], }, { slug: "popper", From 0b9b9801d02dff726f67ebd1a935622958257a28 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Tue, 24 Mar 2026 17:41:13 +0200 Subject: [PATCH 2/2] refactor(date-picker): add data attr to month/year cells --- .changeset/date-picker-range-attrs.md | 19 ++++ .../date-picker/src/date-picker.connect.ts | 97 +++++++++++++------ .../date-picker/src/date-picker.types.ts | 22 ++--- .../date-picker/src/date-picker.utils.ts | 8 +- 4 files changed, 98 insertions(+), 48 deletions(-) create mode 100644 .changeset/date-picker-range-attrs.md diff --git a/.changeset/date-picker-range-attrs.md b/.changeset/date-picker-range-attrs.md new file mode 100644 index 0000000000..ddd82ea008 --- /dev/null +++ b/.changeset/date-picker-range-attrs.md @@ -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`). diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index b4e3da2db4..7c4e758bfc 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -144,17 +144,30 @@ export function connect( const decadeYears = getDecadeRange(startValue.year, { strict: true }) const isOutsideVisibleRange = !decadeYears.includes(value) - const isOutsideRange = isValueWithinRange(value, min?.year, max?.year) + const isWithinMinMax = isValueWithinRange(value, min?.year, max?.year) + + const isInSelectedRange = isRangePicker && isDateWithinRange(dateValue, selectedValue) + const isFirstInSelectedRange = isRangePicker && selectedValue[0] && isEqualYear(dateValue, selectedValue[0]) + const isLastInSelectedRange = isRangePicker && selectedValue[1] && isEqualYear(dateValue, selectedValue[1]) + + const hasHoveredRange = isRangePicker && hoveredRangeValue.length > 0 + const isInHoveredRange = hasHoveredRange && isDateWithinRange(dateValue, hoveredRangeValue) + const isFirstInHoveredRange = + hasHoveredRange && hoveredRangeValue[0] && isEqualYear(dateValue, hoveredRangeValue[0]) + const isLastInHoveredRange = hasHoveredRange && hoveredRangeValue[1] && isEqualYear(dateValue, hoveredRangeValue[1]) const cellState = { focused: focusedValue.year === props.value, - selectable: isOutsideVisibleRange || isOutsideRange, + selectable: !isOutsideVisibleRange && isWithinMinMax, outsideRange: isOutsideVisibleRange, selected: !!selectedValue.find((date) => date && date.year === value), valueText: value.toString(), - inRange: - isRangePicker && - (isDateWithinRange(dateValue, selectedValue) || isDateWithinRange(dateValue, hoveredRangeValue)), + inRange: isInSelectedRange || isInHoveredRange, + firstInRange: !!isFirstInSelectedRange, + lastInRange: !!isLastInSelectedRange, + inHoveredRange: !!isInHoveredRange, + firstInHoveredRange: !!isFirstInHoveredRange, + lastInHoveredRange: !!isLastInHoveredRange, value: dateValue, get disabled() { return disabled || !cellState.selectable @@ -167,14 +180,30 @@ export function connect( const { value, disabled } = props const dateValue = focusedValue.set({ month: value }) const formatter = getMonthFormatter(locale, timeZone, focusedValue) + + const isInSelectedRange = isRangePicker && isDateWithinRange(dateValue, selectedValue) + const isFirstInSelectedRange = isRangePicker && selectedValue[0] && isEqualMonth(dateValue, selectedValue[0]) + const isLastInSelectedRange = isRangePicker && selectedValue[1] && isEqualMonth(dateValue, selectedValue[1]) + + const hasHoveredRange = isRangePicker && hoveredRangeValue.length > 0 + const isInHoveredRange = hasHoveredRange && isDateWithinRange(dateValue, hoveredRangeValue) + const isFirstInHoveredRange = + hasHoveredRange && hoveredRangeValue[0] && isEqualMonth(dateValue, hoveredRangeValue[0]) + const isLastInHoveredRange = + hasHoveredRange && hoveredRangeValue[1] && isEqualMonth(dateValue, hoveredRangeValue[1]) + const cellState = { focused: focusedValue.month === props.value, selectable: !isDateOutsideRange(dateValue, min, max), selected: !!selectedValue.find((date) => date && date.month === value && date.year === focusedValue.year), valueText: formatter.format(dateValue.toDate(timeZone)), - inRange: - isRangePicker && - (isDateWithinRange(dateValue, selectedValue) || isDateWithinRange(dateValue, hoveredRangeValue)), + inRange: isInSelectedRange || isInHoveredRange, + firstInRange: !!isFirstInSelectedRange, + lastInRange: !!isLastInSelectedRange, + inHoveredRange: !!isInHoveredRange, + firstInHoveredRange: !!isFirstInHoveredRange, + lastInHoveredRange: !!isLastInHoveredRange, + outsideRange: false, value: dateValue, get disabled() { return disabled || !cellState.selectable @@ -220,13 +249,11 @@ export function connect( outsideRange: isOutsideRange, today: isToday(value, timeZone), weekend: isWeekend(value, locale), - formattedDate: formatter.format(value.toDate(timeZone)), + value, + valueText: formatter.format(value.toDate(timeZone)), get focused() { return isDateEqual(value, focusedValue) && (!cellState.outsideRange || outsideDaySelectable) }, - get ariaLabel(): string { - return translations.dayCell(cellState) - }, get selectable() { return !cellState.disabled && !cellState.unavailable }, @@ -611,7 +638,7 @@ export function connect( role: "button", dir: prop("dir"), tabIndex: cellState.focused ? 0 : -1, - "aria-label": cellState.ariaLabel, + "aria-label": translations.dayCell(cellState), "aria-disabled": ariaAttr(!cellState.selectable), "aria-invalid": ariaAttr(cellState.invalid), "data-disabled": dataAttr(!cellState.selectable), @@ -668,19 +695,24 @@ export function connect( const cellState = getMonthTableCellState(props) return normalize.element({ ...parts.tableCellTrigger.attrs, - dir: prop("dir"), - role: "button", id: dom.getCellTriggerId(scope, value.toString()), - "data-selected": dataAttr(cellState.selected), + role: "button", + dir: prop("dir"), + tabIndex: cellState.focused ? 0 : -1, + "aria-label": cellState.valueText, "aria-disabled": ariaAttr(!cellState.selectable), "data-disabled": dataAttr(!cellState.selectable), + "data-selected": dataAttr(cellState.selected), + "data-value": value, + "data-view": "month", "data-focus": dataAttr(cellState.focused), - "data-in-range": dataAttr(cellState.inRange), "data-outside-range": dataAttr(cellState.outsideRange), - "aria-label": cellState.valueText, - "data-view": "month", - "data-value": value, - tabIndex: cellState.focused ? 0 : -1, + "data-range-start": dataAttr(cellState.firstInRange), + "data-range-end": dataAttr(cellState.lastInRange), + "data-in-range": dataAttr(cellState.inRange), + "data-in-hover-range": dataAttr(cellState.inHoveredRange), + "data-hover-range-start": dataAttr(cellState.firstInHoveredRange), + "data-hover-range-end": dataAttr(cellState.lastInHoveredRange), onClick(event) { if (event.defaultPrevented) return if (!cellState.selectable) return @@ -708,7 +740,7 @@ export function connect( dir: prop("dir"), colSpan: columns, role: "gridcell", - "aria-selected": ariaAttr(cellState.selected), + "aria-selected": ariaAttr(cellState.selected || cellState.inRange), "data-selected": dataAttr(cellState.selected), "aria-disabled": ariaAttr(!cellState.selectable), "data-value": value, @@ -720,19 +752,24 @@ export function connect( const cellState = getYearTableCellState(props) return normalize.element({ ...parts.tableCellTrigger.attrs, - dir: prop("dir"), - role: "button", id: dom.getCellTriggerId(scope, value.toString()), - "data-selected": dataAttr(cellState.selected), - "data-focus": dataAttr(cellState.focused), - "data-in-range": dataAttr(cellState.inRange), + role: "button", + dir: prop("dir"), + tabIndex: cellState.focused ? 0 : -1, + "aria-label": cellState.valueText, "aria-disabled": ariaAttr(!cellState.selectable), "data-disabled": dataAttr(!cellState.selectable), - "aria-label": cellState.valueText, - "data-outside-range": dataAttr(cellState.outsideRange), + "data-selected": dataAttr(cellState.selected), "data-value": value, "data-view": "year", - tabIndex: cellState.focused ? 0 : -1, + "data-focus": dataAttr(cellState.focused), + "data-outside-range": dataAttr(cellState.outsideRange), + "data-range-start": dataAttr(cellState.firstInRange), + "data-range-end": dataAttr(cellState.lastInRange), + "data-in-range": dataAttr(cellState.inRange), + "data-in-hover-range": dataAttr(cellState.inHoveredRange), + "data-hover-range-start": dataAttr(cellState.firstInHoveredRange), + "data-hover-range-end": dataAttr(cellState.lastInHoveredRange), onClick(event) { if (event.defaultPrevented) return if (!cellState.selectable) return diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 4b15128095..9708d71188 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -448,8 +448,13 @@ export interface TableCellState { selected: boolean valueText: string inRange: boolean + firstInRange: boolean + lastInRange: boolean + inHoveredRange: boolean + firstInHoveredRange: boolean + lastInHoveredRange: boolean value: DateValue - outsideRange?: boolean | undefined + outsideRange: boolean readonly disabled: boolean } @@ -464,24 +469,11 @@ export interface WeekNumberCellProps { week: DateValue[] } -export interface DayTableCellState { +export interface DayTableCellState extends TableCellState { invalid: boolean - disabled: boolean - selected: boolean unavailable: boolean - outsideRange: boolean - inRange: boolean - firstInRange: boolean - lastInRange: boolean today: boolean weekend: boolean - formattedDate: string - readonly focused: boolean - readonly ariaLabel: string - readonly selectable: boolean - inHoveredRange: boolean - firstInHoveredRange: boolean - lastInHoveredRange: boolean } export interface TableProps { diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 3892a12a52..70210ff729 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -72,9 +72,11 @@ export function getLocaleSeparator(locale: string) { export const defaultTranslations: IntlTranslations = { dayCell(state) { - if (state.unavailable) return `Not available. ${state.formattedDate}` - if (state.selected) return `Selected date. ${state.formattedDate}` - return `Choose ${state.formattedDate}` + if (state.unavailable) return `Not available. ${state.valueText}` + if (state.firstInRange) return `Starting range from ${state.valueText}` + if (state.lastInRange) return `Range ending at ${state.valueText}` + if (state.selected) return `Selected date. ${state.valueText}` + return `Choose ${state.valueText}` }, trigger(open) { return open ? "Close calendar" : "Open calendar"