Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/date-picker-restore-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@zag-js/date-picker": minor
---

Add `restoreFocus` prop to control whether focus returns to the trigger when
the picker is dismissed by interacting outside. When unset, the existing
behavior is preserved: focus is restored only if the outside interaction
target is not focusable. Closing via the keyboard always restores focus.

Also fix the restore decision being read one cycle stale: the first
outside-click dismissal after mount never restored focus, while later
dismissals applied the previous cycle's decision. The restore behavior is
now consistent on every open/dismiss cycle.
25 changes: 25 additions & 0 deletions e2e/date-picker.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ test.describe("datepicker [single]", () => {
await I.seeTodayCellIsFocused()
})

test("outside click dismissal restores focus to the trigger on every cycle", async () => {
// cycle 1
await I.clickTrigger()
await I.seeContent()
await I.clickOutsideToBlur()
await I.dontSeeContent()
await I.seeTriggerIsFocused()

// cycle 2 must behave identically to cycle 1
await I.clickTrigger()
await I.seeContent()
await I.clickOutsideToBlur()
await I.dontSeeContent()
await I.seeTriggerIsFocused()
})

test("restoreFocus=false keeps focus where the user clicked on outside dismissal", async () => {
await I.goto("/date-picker/restore-focus")
await I.clickTrigger()
await I.seeContent()
await I.clickOutsideToBlur()
await I.dontSeeContent()
await I.dontSeeTriggerIsFocused()
})

test("closes the calendar on esc", async () => {
await I.clickTrigger()
await I.seeContent()
Expand Down
8 changes: 8 additions & 0 deletions e2e/models/datepicker.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ export class DatePickerModel extends Model {
return expect(this.todayCell).toBeFocused()
}

seeTriggerIsFocused() {
return expect(this.trigger).toBeFocused()
}

dontSeeTriggerIsFocused() {
return expect(this.trigger).not.toBeFocused()
}

seePrevDayCellIsFocused(opts?: DayCellOptions) {
return expect(this.getPrevDayCell(opts)).toBeFocused()
}
Expand Down
147 changes: 147 additions & 0 deletions examples/next-ts/pages/date-picker/restore-focus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as datePicker from "@zag-js/date-picker"
import { normalizeProps, useMachine } from "@zag-js/react"
import { datePickerControls } from "@zag-js/shared"
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(datePickerControls)
const service = useMachine(datePicker.machine, {
id: useId(),
locale: "en",
selectionMode: "single",
...controls.context,
restoreFocus: false,
})

const api = datePicker.connect(service, normalizeProps)

return (
<>
<main className="date-picker">
<div>
<button>Outside Element</button>
</div>
<p>{`Visible range: ${api.visibleRangeText.formatted}`}</p>

<output className="date-output">
<div>Selected: {api.valueAsString ?? "-"}</div>
<div>Focused: {api.focusedValueAsString}</div>
</output>

<div {...api.getControlProps()}>
<input {...api.getInputProps()} />
<button {...api.getClearTriggerProps()}>❌</button>
<button {...api.getTriggerProps()}>πŸ—“</button>
</div>

<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div style={{ marginBottom: "20px" }}>
<select {...api.getMonthSelectProps()}>
{api.getMonths().map((month, i) => (
<option key={i} value={month.value} disabled={month.disabled}>
{month.label}
</option>
))}
</select>

<select {...api.getYearSelectProps()}>
{api.getYears().map((year, i) => (
<option key={i} value={year.value} disabled={year.disabled}>
{year.label}
</option>
))}
</select>
</div>

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

<table {...api.getTableProps({ view: "day" })}>
<thead {...api.getTableHeaderProps({ view: "day" })}>
<tr {...api.getTableRowProps({ view: "day" })}>
{api.weekDays.map((day, i) => (
<th scope="col" key={i} aria-label={day.long}>
{day.narrow}
</th>
))}
</tr>
</thead>
<tbody {...api.getTableBodyProps({ view: "day" })}>
{api.weeks.map((week, i) => (
<tr key={i} {...api.getTableRowProps({ view: "day" })}>
{week.map((value, i) => (
<td key={i} {...api.getDayTableCellProps({ value })}>
<div {...api.getDayTableCellTriggerProps({ value })}>{value.day}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

<div style={{ display: "flex", gap: "40px" }}>
<div hidden={api.view !== "month"} style={{ width: "100%" }}>
<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 hidden={api.view !== "year"} style={{ width: "100%" }}>
<div {...api.getViewControlProps({ view: "year" })}>
<button {...api.getPrevTriggerProps({ view: "year" })}>Prev</button>
<span>
{api.getDecade().start} - {api.getDecade().end}
</span>
<button {...api.getNextTriggerProps({ view: "year" })}>Next</button>
</div>

<table {...api.getTableProps({ view: "year", columns: 4 })}>
<tbody {...api.getTableBodyProps()}>
{api.getYearsGrid({ columns: 4 }).map((years, row) => (
<tr key={row} {...api.getTableRowProps({ view: "year" })}>
{years.map((year, index) => (
<td key={index} {...api.getYearTableCellProps({ ...year, columns: 4 })}>
<div {...api.getYearTableCellTriggerProps({ ...year, columns: 4 })}>{year.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>

<Toolbar viz controls={controls.ui}>
<StateVisualizer state={service} omit={["weeks"]} />
</Toolbar>
</>
)
}
19 changes: 10 additions & 9 deletions packages/machines/date-picker/src/date-picker.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const machine = createMachine<DatePickerSchema>({
refs() {
return {
announcer: undefined,
restoreFocus: false,
}
},

Expand Down Expand Up @@ -211,9 +212,6 @@ export const machine = createMachine<DatePickerSchema>({
currentPlacement: bindable<Placement | undefined>(() => ({
defaultValue: undefined,
})),
restoreFocus: bindable<boolean | undefined>(() => ({
defaultValue: false,
})),
}
},

Expand Down Expand Up @@ -419,7 +417,7 @@ export const machine = createMachine<DatePickerSchema>({

open: {
tags: ["open"],
entry: ["resumeRangeSelection"],
entry: ["resumeRangeSelection", "clearRestoreFocus"],
effects: ["trackDismissableElement", "trackPositioning"],
exit: ["clearHoveredDate"],
on: {
Expand Down Expand Up @@ -737,7 +735,7 @@ export const machine = createMachine<DatePickerSchema>({
// Block if we've reached the maximum
return existingValues.length < maxSelectedDates
},
shouldRestoreFocus: ({ context }) => !!context.get("restoreFocus"),
shouldRestoreFocus: ({ refs }) => !!refs.get("restoreFocus"),
isSelectingEndDate: ({ context }) => context.get("activeIndex") === 1,
closeOnSelect: ({ prop }) => !!prop("closeOnSelect"),
isOpenControlled: ({ prop }) => prop("open") != undefined || !!prop("inline"),
Expand Down Expand Up @@ -771,7 +769,7 @@ export const machine = createMachine<DatePickerSchema>({
return () => refs.get("announcer")?.destroy?.()
},

trackDismissableElement({ scope, send, context, prop }) {
trackDismissableElement({ scope, send, prop, refs }) {
if (prop("inline")) return

const getContentEl = () => dom.getContentEl(scope)
Expand All @@ -781,7 +779,7 @@ export const machine = createMachine<DatePickerSchema>({
layerStyleTargets: [() => dom.getPositionerEl(scope)],
exclude: [...dom.getInputEls(scope), dom.getTriggerEl(scope), dom.getClearTriggerEl(scope)],
onInteractOutside(event) {
context.set("restoreFocus", !event.detail.focusable)
refs.set("restoreFocus", prop("restoreFocus") ?? !event.detail.focusable)
},
onDismiss() {
send({ type: "INTERACT_OUTSIDE" })
Expand All @@ -806,8 +804,11 @@ export const machine = createMachine<DatePickerSchema>({
setView({ context, event }) {
context.set("view", event.view)
},
setRestoreFocus({ context }) {
context.set("restoreFocus", true)
setRestoreFocus({ refs }) {
refs.set("restoreFocus", true)
},
clearRestoreFocus({ refs }) {
refs.set("restoreFocus", false)
},
announceValueText({ context, prop, refs }) {
const value = context.get("value")
Expand Down
1 change: 1 addition & 0 deletions packages/machines/date-picker/src/date-picker.props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const props = createProps<DatePickerProps>()([
"defaultOpen",
"positioning",
"readOnly",
"restoreFocus",
"required",
"selectionMode",
"showWeekNumbers",
Expand Down
15 changes: 11 additions & 4 deletions packages/machines/date-picker/src/date-picker.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties {
* @default true
*/
closeOnSelect?: boolean | undefined
/**
* Whether to restore focus to the trigger when the picker is dismissed
* by interacting outside. When not set, focus is restored only if the
* outside interaction target is not focusable.
* Closing via the keyboard always restores focus.
*/
restoreFocus?: boolean | undefined
/**
* Whether to open the calendar when the input is clicked.
* @default false
Expand Down Expand Up @@ -343,10 +350,6 @@ interface PrivateContext {
* The computed placement (maybe different from initial placement)
*/
currentPlacement?: Placement | undefined
/**
* Whether the calendar should restore focus to the input when it closes.
*/
restoreFocus?: boolean | undefined
/**
* The selected date(s).
*/
Expand Down Expand Up @@ -401,6 +404,10 @@ type Refs = {
* The live region to announce changes
*/
announcer?: LiveRegion | undefined
/**
* Whether to restore focus when the picker closes.
*/
restoreFocus: boolean
}

export interface DatePickerSchema {
Expand Down