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
5 changes: 5 additions & 0 deletions .changeset/curly-berries-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/date-picker": patch
---

Fix date picker behavior for disabled and read-only states, controlled open state, min view defaults, and month/year multi-select limits.
112 changes: 112 additions & 0 deletions e2e/date-picker.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
})
})
18 changes: 18 additions & 0 deletions e2e/models/datepicker.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
}
Expand All @@ -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}]`)
Expand Down
1 change: 0 additions & 1 deletion examples/next-ts/pages/date-picker/month.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions examples/next-ts/pages/date-picker/multi-month.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<main className="date-picker">
<output className="date-output">
<div>Selected: {api.valueAsString ?? "-"}</div>
</output>

<div {...api.getControlProps()}>
<input {...api.getInputProps()} />
<button {...api.getTriggerProps()}>🗓</button>
</div>

<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div hidden={api.view !== "month"}>
<div {...api.getViewControlProps({ view: "month" })}>
<button {...api.getPrevTriggerProps({ view: "month" })}>Prev</button>
<button {...api.getViewTriggerProps({ view: "month" })}>{api.visibleRange.start.year}</button>
<button {...api.getNextTriggerProps({ view: "month" })}>Next</button>
</div>

<table {...api.getTableProps({ view: "month", columns: 4 })}>
<tbody {...api.getTableBodyProps({ view: "month" })}>
{api.getMonthsGrid({ columns: 4, format: "short" }).map((months, row) => (
<tr key={row} {...api.getTableRowProps()}>
{months.map((month, index) => (
<td key={index} {...api.getMonthTableCellProps({ ...month, columns: 4 })}>
<div {...api.getMonthTableCellTriggerProps({ ...month, columns: 4 })}>{month.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</main>

<Toolbar viz>
<StateVisualizer state={service} omit={["weeks"]} />
</Toolbar>
</>
)
}
49 changes: 49 additions & 0 deletions examples/next-ts/pages/date-picker/open-control.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<main className="date-picker">
<div {...api.getControlProps()}>
<input {...api.getInputProps()} />
<button {...api.getTriggerProps()}>🗓</button>
</div>

<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<table {...api.getTableProps({ view: "day" })}>
<tbody {...api.getTableBodyProps({ view: "day" })}>
{api.weeks.map((week, i) => (
<tr key={i} {...api.getTableRowProps({ view: "day" })}>
{week.map((value, j) => (
<td key={j} {...api.getDayTableCellProps({ value })}>
<div {...api.getDayTableCellTriggerProps({ value })}>{value.day}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>

<Toolbar viz>
<StateVisualizer state={service} omit={["weeks"]} />
</Toolbar>
</>
)
}
14 changes: 11 additions & 3 deletions packages/machines/date-picker/src/date-picker.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,12 @@ export function connect<T extends PropTypes>(
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 })
Expand Down Expand Up @@ -641,7 +644,7 @@ export function connect<T extends PropTypes>(
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),
Expand All @@ -663,6 +666,7 @@ export function connect<T extends PropTypes>(
"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 })
},
Expand Down Expand Up @@ -709,7 +713,7 @@ export function connect<T extends PropTypes>(
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),
Expand All @@ -727,6 +731,7 @@ export function connect<T extends PropTypes>(
"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 })
},
Expand Down Expand Up @@ -767,7 +772,7 @@ export function connect<T extends PropTypes>(
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),
Expand All @@ -785,6 +790,7 @@ export function connect<T extends PropTypes>(
"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 })
},
Expand Down Expand Up @@ -846,6 +852,7 @@ export function connect<T extends PropTypes>(
hidden: !selectedValue.length,
onClick(event) {
if (event.defaultPrevented) return
if (!interactive) return
send({ type: "VALUE.CLEAR" })
},
})
Expand Down Expand Up @@ -1022,6 +1029,7 @@ export function connect<T extends PropTypes>(
type: "button",
onClick(event) {
if (event.defaultPrevented) return
if (!interactive) return
send({ type: "PRESET.CLICK", value })
},
})
Expand Down
Loading
Loading