Skip to content

fix(date-picker): keep input focus when open is controlled - #3234

Open
midub wants to merge 1 commit into
chakra-ui:mainfrom
midub:fix/date-picker-controlled-open-input-focus
Open

fix(date-picker): keep input focus when open is controlled#3234
midub wants to merge 1 commit into
chakra-ui:mainfrom
midub:fix/date-picker-controlled-open-input-focus

Conversation

@midub

@midub midub commented Jul 27, 2026

Copy link
Copy Markdown

📝 Description

Clicking the date picker's text input opens the calendar and then moves DOM focus into the calendar grid — but only when
open is controlled. Focus is pulled out from under the user while they are typing a date. With uncontrolled open, the
same click correctly leaves focus in the input.

⛳️ Current behavior (updates)

The input's click handler sends the open event tagged with its origin:

send({ type: "OPEN", src: "input.click" })

and focusActiveCell uses that tag to skip pulling focus into the grid:

focusActiveCell({ scope, context, event }) {
  if (event.src === "input.click") return
  // ...
}

Under a controlled open that OPEN event never reaches the state transition. isOpenControlled short-circuits it to
invokeOnOpen, the consumer flips the open prop, and the watch on prop("open") re-dispatches through
toggleVisibility:

send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event })

CONTROLLED.OPEN in both idle and focused runs ["resetView", "focusFirstSelectedDate", "focusActiveCell"]. By then
event.src is undefined — the origin only survives at event.previousEvent.src — so the guard never matches and focus
is always taken.

The guard is correct; the event it inspects is the wrapper rather than the cause.

🚀 New behavior

focusActiveCell reads the origin through previousEvent, so a controlled open initiated by an input click is treated
the same as an uncontrolled one:

if ((event.previousEvent || event).src === "input.click") return

This matches how the rest of the codebase already reads event fields across the controlled boundary — getOpenChangeReason
in combobox.machine.ts uses exactly (event.previousEvent || event).src, and isInteractOutsideEvent in this same
machine already reads event.previousEvent?.type. focusActiveCell was the only place reading event.src without the
fallback.

Opening via the calendar trigger button still moves focus into the grid, so keyboard/a11y behaviour is unchanged.

Scope notes

  • The other two actions in this machine that read event fields this way are not reachable through the CONTROLLED.*
    wrapper: focusActiveCellIfNeeded (if (!event.focus) return) only fires from the focusedValue watcher and focus
    is only ever set by TABLE.* keyboard events, and focusFirstInputElement (if (event.focus === false) return) only
    runs on VALUE.CLEAR. Both fail closed, so neither steals focus on the controlled path.
  • The sibling machines that share the toggleVisibility / previousEvent convention (combobox, select, cascade-select,
    menu, popover, editable, tooltip) already unwrap previousEvent where they read across it, so this looks like a
    one-off rather than a class of bug.

💣 Is this a breaking change (Yes/No):

No.

📝 Additional Information

Repro: a controlled-open date picker with openOnClick — added as examples/next-ts/pages/date-picker/controlled.tsx,
since the existing open-control example hardcodes open={false} and so cannot reach the CONTROLLED.OPEN path.

Tests: added datepicker [controlled open + openOnClick] to e2e/date-picker.e2e.ts covering both directions —
input click under controlled open keeps focus in the input, and trigger click still focuses the active cell. The first
test fails on main and passes with this change; the second passes both before and after.

Clicking the date picker input opens the calendar and then moves focus
into the calendar grid, but only when `open` is controlled.

`focusActiveCell` skips the focus move when the triggering event carries
`src: "input.click"`. Under a controlled `open`, the `OPEN` event is
short-circuited by `isOpenControlled`; the consumer flips the prop and the
`open` watcher re-dispatches through `toggleVisibility` as
`{ type: "CONTROLLED.OPEN", previousEvent: event }`. The origin then only
survives at `event.previousEvent.src`, so the guard never matches.

Read the origin through `previousEvent`, matching `getOpenChangeReason` in
combobox and `isInteractOutsideEvent` in this machine.
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 01200c0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 86 packages
Name Type
@zag-js/date-picker Patch
@zag-js/anatomy-icons Patch
@zag-js/anatomy Patch
@zag-js/core Patch
@zag-js/docs Patch
@zag-js/preact Patch
@zag-js/react Patch
@zag-js/solid Patch
@zag-js/svelte Patch
@zag-js/vanilla Patch
@zag-js/vue Patch
@zag-js/accordion Patch
@zag-js/angle-slider Patch
@zag-js/async-list Patch
@zag-js/avatar Patch
@zag-js/carousel Patch
@zag-js/cascade-select Patch
@zag-js/checkbox Patch
@zag-js/clipboard Patch
@zag-js/collapsible Patch
@zag-js/color-picker Patch
@zag-js/combobox Patch
@zag-js/date-input Patch
@zag-js/dialog Patch
@zag-js/drawer Patch
@zag-js/editable Patch
@zag-js/file-upload Patch
@zag-js/floating-panel Patch
@zag-js/hover-card Patch
@zag-js/image-cropper Patch
@zag-js/listbox Patch
@zag-js/marquee Patch
@zag-js/menu Patch
@zag-js/navigation-menu Patch
@zag-js/number-input Patch
@zag-js/pagination Patch
@zag-js/password-input Patch
@zag-js/pin-input Patch
@zag-js/popover Patch
@zag-js/presence Patch
@zag-js/progress Patch
@zag-js/qr-code Patch
@zag-js/radio-group Patch
@zag-js/rating-group Patch
@zag-js/scroll-area Patch
@zag-js/select Patch
@zag-js/signature-pad Patch
@zag-js/slider Patch
@zag-js/splitter Patch
@zag-js/steps Patch
@zag-js/switch Patch
@zag-js/tabs Patch
@zag-js/tags-input Patch
@zag-js/timer Patch
@zag-js/toast Patch
@zag-js/toc Patch
@zag-js/toggle-group Patch
@zag-js/toggle Patch
@zag-js/tooltip Patch
@zag-js/tour Patch
@zag-js/tree-view Patch
@zag-js/store Patch
@zag-js/types Patch
@zag-js/aria-hidden Patch
@zag-js/auto-resize Patch
@zag-js/collection Patch
@zag-js/color-utils Patch
@zag-js/utils Patch
@zag-js/date-utils Patch
@zag-js/dismissable Patch
@zag-js/dom-query Patch
@zag-js/file-utils Patch
@zag-js/focus-trap Patch
@zag-js/focus-visible Patch
@zag-js/highlight-word Patch
@zag-js/hotkeys Patch
@zag-js/i18n-utils Patch
@zag-js/interact-outside Patch
@zag-js/json-tree-utils Patch
@zag-js/live-region Patch
@zag-js/popper Patch
@zag-js/rect-utils Patch
@zag-js/remove-scroll Patch
@zag-js/scroll-snap Patch
@zag-js/stringify-state Patch
svelte-kit-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zag-nextjs Ready Ready Preview Jul 27, 2026 1:58pm
zag-solid Ready Ready Preview Jul 27, 2026 1:58pm
zag-svelte Ready Ready Preview Jul 27, 2026 1:58pm
zag-vue Ready Ready Preview Jul 27, 2026 1:58pm
zag-website Ready Ready Preview Jul 27, 2026 1:58pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant