From 37739182508d7beedf1beb97f7a3cb109f75e206 Mon Sep 17 00:00:00 2001 From: Olaf Kappes Date: Wed, 1 Jul 2026 11:06:50 +0200 Subject: [PATCH 1/4] fix(date-picker): guard disabled/readOnly, open precedence, view bounds, multi-select cap --- e2e/date-picker.e2e.ts | 112 ++++++++++++++++++ e2e/models/datepicker.model.ts | 18 +++ examples/next-ts/pages/date-picker/month.tsx | 1 - .../next-ts/pages/date-picker/multi-month.tsx | 62 ++++++++++ .../pages/date-picker/open-control.tsx | 49 ++++++++ .../date-picker/src/date-picker.connect.ts | 14 ++- .../date-picker/src/date-picker.machine.ts | 16 +-- 7 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 examples/next-ts/pages/date-picker/multi-month.tsx create mode 100644 examples/next-ts/pages/date-picker/open-control.tsx diff --git a/e2e/date-picker.e2e.ts b/e2e/date-picker.e2e.ts index ace55cb9bd..33d9e46bb6 100644 --- a/e2e/date-picker.e2e.ts +++ b/e2e/date-picker.e2e.ts @@ -323,3 +323,115 @@ test.describe("datepicker [locale numerals]", () => { await expect(I.getInput()).toHaveValue(/[۰-۹]/) }) }) + +test.describe("datepicker [disabled]", () => { + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/inline") + await I.clickControls() + await I.controls.bool("disabled", true) + }) + + test("calendar cells are removed from the tab order", async () => { + await expect(I.todayCell).toHaveAttribute("tabindex", "-1") + }) + + test("clicking a day does not change the value", async () => { + // force past the aria-disabled grid (Playwright blocks clicks otherwise) + await I.notTodayCell.click({ force: true }) + await I.seeSelectedValue(I.getDate({}).formatted) + }) +}) + +test.describe("datepicker [readonly]", () => { + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/inline") + await I.clickControls() + await I.controls.bool("readOnly", true) + }) + + test("arrow keys still move focus (calendar stays navigable)", async () => { + await I.todayCell.focus() + await I.pressKey("ArrowRight") + await I.seeNextDayCellIsFocused() + }) + + test("Enter does not select the focused date", async () => { + await I.todayCell.focus() + await I.pressKey("ArrowRight") + await I.pressKey("Enter") + // value stays on today (the default), not the navigated-to next day + await I.seeSelectedValue(I.getDate({}).formatted) + }) + + test("clicking a day does not change the value", async () => { + await I.notTodayCell.click() + await I.seeSelectedValue(I.getDate({}).formatted) + }) +}) + +test.describe("datepicker [readonly clear]", () => { + const year = new Date().getFullYear() + + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/basic") + }) + + test("clear trigger does not clear the value when readOnly", async () => { + await I.type(`02/15/${year}`) + await I.pressKey("Enter") + await I.seeInputHasValue(`02/15/${year}`) + + await I.clickControls() + await I.controls.bool("readOnly", true) + await I.clickClearTrigger() + + await I.seeInputHasValue(`02/15/${year}`) + }) +}) + +test.describe("datepicker [controlled open]", () => { + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/open-control") + }) + + test("controlled open={false} wins over defaultOpen", async () => { + await I.dontSeeContent() + }) +}) + +test.describe("datepicker [min-view]", () => { + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/month") + }) + + test("defaults to the month view when minView is month", async () => { + await I.clickTrigger() + await expect(I.tableForView("month")).toBeVisible() + }) +}) + +test.describe("datepicker [multiple month + maxSelectedDates]", () => { + test.beforeEach(async ({ page }) => { + I = new DatePickerModel(page) + await I.goto("/date-picker/multi-month") + }) + + test("selecting beyond the max does not crash", async () => { + await I.clickTrigger() + + await I.getMonthCell("Jan").click() + await I.getMonthCell("Feb").click() + await expect(I.getMonthCell("Jan")).toHaveAttribute("data-selected", "") + await expect(I.getMonthCell("Feb")).toHaveAttribute("data-selected", "") + + // third selection must be rejected without throwing + await I.getMonthCell("Mar").click() + await expect(I.getMonthCell("Mar")).not.toHaveAttribute("data-selected", "") + await expect(I.getMonthCell("Jan")).toHaveAttribute("data-selected", "") + }) +}) diff --git a/e2e/models/datepicker.model.ts b/e2e/models/datepicker.model.ts index 7d2f63deeb..ff0c4acab8 100644 --- a/e2e/models/datepicker.model.ts +++ b/e2e/models/datepicker.model.ts @@ -89,6 +89,18 @@ export class DatePickerModel extends Model { return this.page.locator("[data-scope=date-picker][data-part=table]") } + tableForView(view: "day" | "month" | "year") { + return this.page.locator(`[data-scope=date-picker][data-part=table][data-view=${view}]`) + } + + get clearTrigger() { + return this.page.locator("[data-scope=date-picker][data-part=clear-trigger]") + } + + clickClearTrigger() { + return this.clearTrigger.click() + } + get todayCell() { return this.page.locator("[data-scope=date-picker][data-part=table-cell-trigger][data-today]") } @@ -103,6 +115,12 @@ export class DatePickerModel extends Model { return this.page.locator(`${part("table-cell-trigger")}[data-view=day][data-value="${end.toString()}"]`) } + get notTodayCell() { + const t = this.today() + const other = t.day === 1 ? t.add({ days: 1 }) : t.subtract({ days: 1 }) + return this.page.locator(`${part("table-cell-trigger")}[data-view=day][data-value="${other.toString()}"]`) + } + private getViewCell(view: "day" | "month" | "year", value: string | number) { return this.page .locator(`[data-scope=date-picker][data-part=table-cell-trigger][data-view=${view}]`) diff --git a/examples/next-ts/pages/date-picker/month.tsx b/examples/next-ts/pages/date-picker/month.tsx index 51791023c1..2079df4810 100644 --- a/examples/next-ts/pages/date-picker/month.tsx +++ b/examples/next-ts/pages/date-picker/month.tsx @@ -27,7 +27,6 @@ export default function Page() { const service = useMachine(datePicker.machine, { id: useId(), locale: "en", - view: "month", minView: "month", placeholder: "mm/yyyy", format, diff --git a/examples/next-ts/pages/date-picker/multi-month.tsx b/examples/next-ts/pages/date-picker/multi-month.tsx new file mode 100644 index 0000000000..2709f86e95 --- /dev/null +++ b/examples/next-ts/pages/date-picker/multi-month.tsx @@ -0,0 +1,62 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" + +export default function Page() { + const service = useMachine(datePicker.machine, { + id: useId(), + locale: "en", + selectionMode: "multiple", + minView: "month", + maxSelectedDates: 2, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+ +
Selected: {api.valueAsString ?? "-"}
+
+ +
+ + +
+ +
+
+ +
+
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/date-picker/open-control.tsx b/examples/next-ts/pages/date-picker/open-control.tsx new file mode 100644 index 0000000000..af67baf29f --- /dev/null +++ b/examples/next-ts/pages/date-picker/open-control.tsx @@ -0,0 +1,49 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" + +export default function Page() { + const service = useMachine(datePicker.machine, { + id: useId(), + locale: "en", + open: false, + defaultOpen: true, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+
+ + +
+ +
+
+ + + {api.weeks.map((week, i) => ( + + {week.map((value, j) => ( + + ))} + + ))} + +
+
{value.day}
+
+
+
+
+ + + + + + ) +} diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 2c0b6b9241..2d8d435767 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -486,9 +486,12 @@ export function connect( tabIndex: -1, onKeyDown(event) { if (event.defaultPrevented) return + // readOnly still allows roving-focus navigation + if (disabled) return const keyMap: EventKeyMap = { Enter() { + if (!interactive) return if (view === "day" && isUnavailable(focusedValue)) return if (view === "month") { const cellState = getMonthTableCellState({ value: focusedValue.month }) @@ -641,7 +644,7 @@ export function connect( id: dom.getCellTriggerId(scope, value.toString()), role: "button", dir: prop("dir"), - tabIndex: cellState.focused ? 0 : -1, + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, "aria-label": translations.dayCell(cellState), "aria-disabled": ariaAttr(!cellState.selectable), "aria-invalid": ariaAttr(cellState.invalid), @@ -663,6 +666,7 @@ export function connect( "data-hover-range-end": dataAttr(cellState.lastInHoveredRange), onClick(event) { if (event.defaultPrevented) return + if (!interactive) return if (!cellState.selectable) return send({ type: "CELL.CLICK", cell: "day", value }) }, @@ -709,7 +713,7 @@ export function connect( id: dom.getCellTriggerId(scope, value.toString()), role: "button", dir: prop("dir"), - tabIndex: cellState.focused ? 0 : -1, + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, "aria-label": cellState.valueText, "aria-disabled": ariaAttr(!cellState.selectable), "data-disabled": dataAttr(!cellState.selectable), @@ -727,6 +731,7 @@ export function connect( "data-hover-range-end": dataAttr(cellState.lastInHoveredRange), onClick(event) { if (event.defaultPrevented) return + if (!interactive) return if (!cellState.selectable) return send({ type: "CELL.CLICK", cell: "month", value }) }, @@ -767,7 +772,7 @@ export function connect( id: dom.getCellTriggerId(scope, value.toString()), role: "button", dir: prop("dir"), - tabIndex: cellState.focused ? 0 : -1, + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, "aria-label": cellState.valueText, "aria-disabled": ariaAttr(!cellState.selectable), "data-disabled": dataAttr(!cellState.selectable), @@ -785,6 +790,7 @@ export function connect( "data-hover-range-end": dataAttr(cellState.lastInHoveredRange), onClick(event) { if (event.defaultPrevented) return + if (!interactive) return if (!cellState.selectable) return send({ type: "CELL.CLICK", cell: "year", value }) }, @@ -846,6 +852,7 @@ export function connect( hidden: !selectedValue.length, onClick(event) { if (event.defaultPrevented) return + if (!interactive) return send({ type: "VALUE.CLEAR" }) }, }) @@ -1022,6 +1029,7 @@ export function connect( type: "button", onClick(event) { if (event.defaultPrevented) return + if (!interactive) return send({ type: "PRESET.CLICK", value }) }, }) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 10dc9c009e..c2caa9280d 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -105,16 +105,15 @@ export const machine = createMachine({ focusedValue = constrainValue(toTargetCalendar(focusedValue), props.min, props.max) // get the initial view - const minView: DateView = "day" - const maxView: DateView = "year" - const defaultView = clampView(props.view || minView, minView, maxView) + const minView: DateView = props.minView || "day" + const maxView: DateView = props.maxView || "year" + const defaultView = clampView(props.defaultView || props.view || minView, minView, maxView) return { locale, numOfMonths, timeZone, selectionMode, - defaultView, minView, maxView, outsideDaySelectable: false, @@ -137,6 +136,7 @@ export const machine = createMachine({ defaultFocusedValue: focusedValue, value, defaultValue: defaultValue ?? [], + defaultView, positioning: { placement: "bottom", ...props.positioning, @@ -145,7 +145,7 @@ export const machine = createMachine({ }, initialState({ prop }) { - const open = prop("open") || prop("defaultOpen") || prop("inline") + const open = prop("inline") || (prop("open") ?? prop("defaultOpen")) return open ? "open" : "idle" }, @@ -723,11 +723,13 @@ export const machine = createMachine({ isRangePicker: ({ prop }) => prop("selectionMode") === "range", hasSelectedRange: ({ context }) => context.get("value").length === 2, isMultiPicker: ({ prop }) => prop("selectionMode") === "multiple", - canSelectDate: ({ context, prop, event }) => { + canSelectDate: (params) => { + const { context, prop, event } = params const maxSelectedDates = prop("maxSelectedDates") if (maxSelectedDates == null) return true const existingValues = context.get("value") - const currentValue = event.value ?? context.get("focusedValue") + // Normalize month/year cells numeric valueto a DateValue + const currentValue = normalizeValue(params, event.value ?? context.get("focusedValue")) // Allow if deselecting (date already selected) const isDeselecting = existingValues.some((date) => isDateEqual(date, currentValue)) if (isDeselecting) return true From 913e0f4a68171f527ed43ad1d98418accba8a711 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 3 Jul 2026 18:55:54 +0200 Subject: [PATCH 2/4] fix: add date-picker changeset --- .changeset/curly-berries-fix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curly-berries-fix.md diff --git a/.changeset/curly-berries-fix.md b/.changeset/curly-berries-fix.md new file mode 100644 index 0000000000..44749943b9 --- /dev/null +++ b/.changeset/curly-berries-fix.md @@ -0,0 +1,5 @@ +--- +"@zag-js/date-picker": patch +--- + +Fix disabled and read-only interactions, controlled open precedence, min view defaults, and max selected date handling in month and year views. From 3cd51b918aab505344bb1f3e6828cc7a3b39c7de Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 3 Jul 2026 18:57:08 +0200 Subject: [PATCH 3/4] fix: shorten date-picker changeset --- .changeset/curly-berries-fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curly-berries-fix.md b/.changeset/curly-berries-fix.md index 44749943b9..29b6938a16 100644 --- a/.changeset/curly-berries-fix.md +++ b/.changeset/curly-berries-fix.md @@ -2,4 +2,4 @@ "@zag-js/date-picker": patch --- -Fix disabled and read-only interactions, controlled open precedence, min view defaults, and max selected date handling in month and year views. +Fix date picker interaction and view edge cases. From dcc73547c2eef8b0765839a7dfc41ba2d08def3b Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 3 Jul 2026 18:57:57 +0200 Subject: [PATCH 4/4] fix: clarify date-picker changeset --- .changeset/curly-berries-fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/curly-berries-fix.md b/.changeset/curly-berries-fix.md index 29b6938a16..b78c016ab2 100644 --- a/.changeset/curly-berries-fix.md +++ b/.changeset/curly-berries-fix.md @@ -2,4 +2,4 @@ "@zag-js/date-picker": patch --- -Fix date picker interaction and view edge cases. +Fix date picker behavior for disabled and read-only states, controlled open state, min view defaults, and month/year multi-select limits.