diff --git a/.changeset/date-picker-restore-focus.md b/.changeset/date-picker-restore-focus.md new file mode 100644 index 0000000000..b8ca60b6a7 --- /dev/null +++ b/.changeset/date-picker-restore-focus.md @@ -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. diff --git a/e2e/date-picker.e2e.ts b/e2e/date-picker.e2e.ts index 18439d48c3..ec02e89080 100644 --- a/e2e/date-picker.e2e.ts +++ b/e2e/date-picker.e2e.ts @@ -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() diff --git a/e2e/models/datepicker.model.ts b/e2e/models/datepicker.model.ts index 538cbb4bea..f4c57f5450 100644 --- a/e2e/models/datepicker.model.ts +++ b/e2e/models/datepicker.model.ts @@ -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() } diff --git a/examples/next-ts/pages/date-picker/restore-focus.tsx b/examples/next-ts/pages/date-picker/restore-focus.tsx new file mode 100644 index 0000000000..09902c3180 --- /dev/null +++ b/examples/next-ts/pages/date-picker/restore-focus.tsx @@ -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 ( + <> +
+
+ +
+

{`Visible range: ${api.visibleRangeText.formatted}`}

+ + +
Selected: {api.valueAsString ?? "-"}
+
Focused: {api.focusedValueAsString}
+
+ +
+ + + +
+ +
+
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + ) +} diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 862a605ae4..8eb0022f8a 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -152,6 +152,7 @@ export const machine = createMachine({ refs() { return { announcer: undefined, + restoreFocus: false, } }, @@ -211,9 +212,6 @@ export const machine = createMachine({ currentPlacement: bindable(() => ({ defaultValue: undefined, })), - restoreFocus: bindable(() => ({ - defaultValue: false, - })), } }, @@ -419,7 +417,7 @@ export const machine = createMachine({ open: { tags: ["open"], - entry: ["resumeRangeSelection"], + entry: ["resumeRangeSelection", "clearRestoreFocus"], effects: ["trackDismissableElement", "trackPositioning"], exit: ["clearHoveredDate"], on: { @@ -737,7 +735,7 @@ export const machine = createMachine({ // 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"), @@ -771,7 +769,7 @@ export const machine = createMachine({ return () => refs.get("announcer")?.destroy?.() }, - trackDismissableElement({ scope, send, context, prop }) { + trackDismissableElement({ scope, send, prop, refs }) { if (prop("inline")) return const getContentEl = () => dom.getContentEl(scope) @@ -781,7 +779,7 @@ export const machine = createMachine({ 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" }) @@ -806,8 +804,11 @@ export const machine = createMachine({ 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") diff --git a/packages/machines/date-picker/src/date-picker.props.ts b/packages/machines/date-picker/src/date-picker.props.ts index 1e9aaebd96..790d43f391 100644 --- a/packages/machines/date-picker/src/date-picker.props.ts +++ b/packages/machines/date-picker/src/date-picker.props.ts @@ -41,6 +41,7 @@ export const props = createProps()([ "defaultOpen", "positioning", "readOnly", + "restoreFocus", "required", "selectionMode", "showWeekNumbers", diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 7eaf06d428..0a4e6c47fd 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -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 @@ -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). */ @@ -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 {