Skip to content

fix: move focus into the morph popover when it opens by keyboard - #195

Merged
starc007 merged 5 commits into
starc007:mainfrom
l4u532:fix/morph-popover-initial-focus
Oct 23, 2026
Merged

starc007 merged 5 commits into
starc007:mainfrom
l4u532:fix/morph-popover-initial-focus

Conversation

@l4u532

@l4u532 l4u532 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

tl;dr

  • Motivation: The morph popover panel is a dialog that renders at the end of <body>. It never took focus. After Enter on the trigger, a keyboard user had to Tab through the rest of the page to reach it.
  • Outcome: A keyboard open now moves focus to the first control in the panel that can take focus. If no control can take focus, the panel takes it. A pointer open does not move focus. A new optional initialFocus ref picks a different target.
  • Contract change on purpose: When a caller passes onOpenAutoFocus, the caller owns focus and the panel moves none. The date range picker passes it, so it keeps its own target and its autoFocus={false}.

Behaviour

  • MorphPopoverTrigger records whether the click that opens the panel came from the keyboard. Enter and Space give a click with event.detail === 0. A pointer click keeps today's behaviour.
  • After a keyboard open, the panel tries initialFocus.current first, then each element that matches its focusable selector, in document order. The first one that takes focus wins. With none, the panel itself holds focus (it now has tabIndex={-1}).
  • The focusable selector also matches elements that focus() refuses. Examples are a hidden input, a disabled button that carries tabIndex={0}, and anything under display: none. Those are skipped because focus does not move to them. Hidden inputs are also left out of the selector itself.
  • Escape and outside-click behaviour is unchanged, including the hand-back to the trigger.

Example: a switcher list that should focus its selected row.

const selected = useRef<HTMLButtonElement>(null);

<MorphPopoverContent initialFocus={selected}>
  {items.map((item) => (
    <button key={item.id} ref={item.id === current ? selected : undefined} type="button">
      {item.label}
    </button>
  ))}
</MorphPopoverContent>

Historical comparison captured October 12; current-head comparisons appear below.

Before (main) After
Enter on the trigger Focus stays on the trigger First item focused

Morph Popover demo on the docs site, Chromium, dark theme. Look at which element has the focus ring.

Timing

The portal wrapper stays visibility: hidden until the trigger and panel are measured. focus() inside a hidden subtree does nothing, so the focus runs in the layout effect of the commit that makes the panel visible, not on mount.

A CSS transition on visibility can keep the controls hidden a little longer. The docs site's reduced-motion reset gives every element a short transition-duration. In that window Chrome refuses focus on the buttons but accepts it on the panel. So the panel holds focus first, and each visibility transition that ends retries the whole candidate list. The retry stops when focus moves elsewhere, or at the first key or pointer press, so it cannot move focus that the user placed.

Consumers

  • Date range picker: passes onOpenAutoFocus to focus a calendar date, so the panel moves no focus. Measured: the same focus target as main in every case below.
  • Breadcrumb overflow menu: unchanged. Its ArrowDown shortcut opens the menu without a trigger click, so it keeps its own focus code. Its Enter path now focuses the same first link twice, which is harmless.
  • Availability scheduler copy menu: opens from its own button, not MorphPopoverTrigger, so it keeps today's behaviour.
  • The dates of the ten registry items that bundle popover-morph.tsx are set to 2026-10-14.

Browser checks

Measured 2026-10-12 in headless Google Chrome (Playwright) and in Safari 26 (safaridriver). The page was a standalone bundle of the real component, built with bun build, with the old and new source side by side. Each case ran with and without the site's reduced-motion reset. Results are the same in both engines and in both motion modes. "Before" is the first version of this PR.

Panel contents, keyboard open Before After
Two buttons First First
Hidden input, disabled tabIndex={0} button, Apply trigger Apply
Button under display: none, button with visibility: hidden, Apply trigger Apply
Hidden input, disabled tabIndex={0} button, text only trigger panel
initialFocus on a disabled button, Apply trigger Apply

Also checked after a keyboard open in Chrome: Tab moves to the next control, Escape returns focus to the trigger, and a mouse open leaves focus on the trigger.

Date range picker, focus after open (Chrome):

main After
Enter, full motion today's date today's date
Mouse, full motion today's date today's date
Enter, reduced-motion reset trigger trigger
Mouse, reduced-motion reset trigger trigger

Limits

  • With the reduced-motion reset, the date range picker leaves focus on the trigger on main too. Its own focus call misses in the same visibility window. This PR does not change that.
  • The retry stop on a key or pointer press was measured during the 2026-10-13 rebase checks below.
  • Safari reduced motion, real Safari keyboard input, touch, exact narrow viewport, and iOS Safari remain unverified in this rebase pass.
  • Programmatic trigger.click() also has detail === 0, so it counts as a keyboard open.

Rebase verification, 2026-10-13

Rebased on ea3206d, which includes the latest Select changes. The conflict affected dates only.
Main's newer dates remain intact. The autofocus implementation and accessibility cases applied without a code conflict.

Headless Chrome and Safari, sampled through requestAnimationFrame on the actual component:

  • Passed, both engines: keyboard-style open focuses the first enabled visible control, skipping hidden and disabled candidates.
  • Passed, both engines: invalid disabled initialFocus falls back to the first control; valid initialFocus takes focus.
  • Passed, both engines: text-only panels take focus; a supplied onOpenAutoFocus retains caller-owned focus.
  • Passed, both engines: Escape returns focus to the trigger; a pointer-style open leaves focus on the trigger.
  • Passed, both engines: a 400ms visibility delay first focuses the panel, then the first control. A key press cancels that retry. A pointer press followed by external focus keeps that external focus.
  • Passed, Chrome: real Enter, Tab and Escape move through First, Initial and Options with visible focus.
  • Passed, Chrome: reduced motion reaches the first control; touch leaves focus on the trigger; 375px viewport has no horizontal overflow.

Safari used synthetic keyboard-style clicks and key events. This pass does not replace the historical consumer checks above.
Safari reduced motion, real keyboard input, touch, exact narrow viewport, and iOS Safari remain unverified.
The independent cold read found no issue in the rebased diff. The temporary browser route was removed.

Rebase verification, 2026-10-14

Rebased on 5866083, the squash merge of #219. The conflict involved selector placement, panel attributes, and dates.
The resolution keeps the full-size clip, one progress value, horizontal overflow guard, and exit cleanup through safeToRemoveRef.
It adds the focus selector and panel tabIndex without restoring the removed clip variants or transition.
The accepted #219 head and its squash merge have identical popover-morph.tsx source.
All ten bundled dates already read October 14 on main, so the final date file has no diff.

Headless Google Chrome, sampled through requestAnimationFrame on the actual before and after components:

  • Passed: main leaves focus on the trigger after keyboard-style activation; the rebased component focuses its first control.
  • Passed: hidden and disabled candidates are skipped. Disabled initialFocus falls back; valid initialFocus takes focus.
  • Passed: text-only content focuses the panel. Caller-owned onOpenAutoFocus focuses the caller's chosen control on both versions.
  • Passed: pointer-style opening leaves focus on the trigger. Escape returns focus to the trigger.
  • Passed: with a 400ms visibility delay, focus starts on the panel, then reaches the first control. Key input cancels retry. Pointer input preserves external focus.
  • Passed: real Enter, Tab and Escape focus First, Initial and Options, each with visible focus.
  • Passed: reduced motion reaches the first control. Touch leaves focus on the trigger. A 375px viewport has no horizontal overflow.
  • Passed: closing leaves zero dialogs. Reopening 60ms into exit settles to one fully revealed dialog, with First focused and scrollTop=0.

Unverified, Safari: its instance was paired with another WebDriver session. This pass left that session untouched.
Earlier Safari results describe their dated revisions; they do not verify the merged geometry in this pass.
iOS Safari remains unverified. The temporary route was removed and the server stopped.

The current PR title states the autofocus outcome and remains accurate for a squash merge.
Both retained commit subjects also describe the final focus behavior. No obsolete animation claim remains in the merge summary.

Fresh focus media, 2026-10-14

Captured in headless Google Chrome with native Enter, identical content and normal motion. A hidden input and disabled tabIndex={0} button precede Apply.

The reviewed autofocus implementation (b175835, parent of 61c3640) leaves its focus ring on Options. The corrected head (b474722) focuses Apply. Escape returns focus to Options on both.

Reviewed hidden and disabled candidate failure versus corrected focus

The current main baseline (5866083, merged #219) also leaves focus on Options. The corrected head (b474722) focuses Apply using the merged full-size geometry. This baseline is main, not the reviewed autofocus revision.

Latest main versus corrected autofocus on merged geometry

Visible labels identify each revision. Both captures use the same trigger, children, placement, theme and viewport. These images show settled focus; they do not verify motion timing or retry cancellation. Safari was not captured in this pass.

Test plan

  • bun run check: typecheck, lint and all 107 registry components pass. Lint reports only the existing warning and infos.
  • bun test: 152 pass. tests/popover-morph.a11y.test.tsx covers these cases:
    • A keyboard open focuses the first control, and Escape returns focus to the trigger.
    • A pointer open leaves focus on the trigger.
    • initialFocus focuses the chosen row.
    • A disabled tabIndex={0} button and a hidden input are skipped. This case fails on the first version of this PR. The test DOM (happy-dom) lets hidden inputs and hidden elements take focus, so the browser checks above cover those.
  • Open /components/motion/popover, Morph variant. Focus the trigger and press Enter: the first item is focused. Click the trigger with a mouse: focus stays on the trigger.

@l4u532 l4u532 changed the title fix: move focus into the morph popover when it opens by keyboard fix: keyboard focus and alignOffset for the morph popover Oct 1, 2026
@l4u532
l4u532 force-pushed the fix/morph-popover-initial-focus branch from 291ccc7 to e6386a8 Compare October 1, 2026 14:42
@l4u532 l4u532 changed the title fix: keyboard focus and alignOffset for the morph popover fix: move focus into the morph popover when it opens by keyboard Oct 1, 2026
@l4u532
l4u532 marked this pull request as ready for review October 1, 2026 14:53
@starc007

starc007 commented Oct 1, 2026

Copy link
Copy Markdown
Owner

One edge case: the autofocus selector can pick a hidden input/button or a disabled button with tabIndex={0} before the visible controls. I reproduced this in Chromium: keyboard opening leaves focus on the trigger because .focus() fails silently, and the retry targets the same element.

Could we skip hidden/disabled candidates and fall back to the panel if none can receive focus?

@l4u532
l4u532 force-pushed the fix/morph-popover-initial-focus branch from e6386a8 to 61c3640 Compare October 12, 2026 12:49
@l4u532

l4u532 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in 61c3640. I also rebased onto main.

The panel now tries each candidate in order, initialFocus first, and keeps the first one that actually takes focus. A hidden input, a disabled tabIndex={0} button and anything under display: none are skipped because focus does not move to them. With no candidate, the panel holds focus. The retry now runs the whole candidate list again, so it never aims at the element that just failed. Hidden inputs are also left out of the selector.

Measured 2026-10-12 in Chrome and Safari 26 on a standalone bundle of the component, with and without the site's reduced-motion reset. Each row shows focus after Enter on the trigger:

Panel contents Before After
Hidden input, disabled tabIndex={0} button, Apply trigger Apply
Button under display: none, button with visibility: hidden, Apply trigger Apply
Hidden input, disabled tabIndex={0} button, text only trigger panel
initialFocus on a disabled button, Apply trigger Apply

tests/popover-morph.a11y.test.tsx has a new case for the disabled button and the hidden input. It fails on the previous commit.

Two related changes came out of the rebase:

  • Your onOpenAutoFocus on main now means the caller owns focus, so the panel moves none. Without this, the date range picker moved focus twice and lost autoFocus={false}. Its focus target now matches main in every case I measured.
  • The retry stops at the first key or pointer press, so it cannot take back focus that the user placed.

Current-head visual evidence, captured October 14 in Chrome: reviewed candidate failure (b175835) versus corrected focus (b474722). Native Enter leaves focus on Options before and moves it to Apply afterward. Both sides use identical content and viewport.

@l4u532
l4u532 force-pushed the fix/morph-popover-initial-focus branch from 61c3640 to d99c916 Compare October 13, 2026 19:39
@l4u532

l4u532 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on ea3206d and resolved the date conflict while preserving main’s newer dates. All ten bundled pages now use 2026-10-13.

Chrome and Safari confirmed candidate fallback, valid and disabled initialFocus, caller-owned focus, panel fallback, pointer opens, Escape return, and retry cancellation after key or pointer input. Chrome also passed real keyboard, reduced-motion, touch, and 375px checks. Safari used synthetic keyboard-style events; reduced motion, touch, and iOS Safari remain unverified.

bun run check validates 107 registry components. bun test passes all 152 accessibility tests. The independent cold read found no issue.

The panel is a role="dialog" portalled to the end of <body>, but it never
took focus. A keyboard user who opened it from the trigger had to Tab
through the rest of the page to reach it. Consumers worked around this
one by one.

When Enter or Space opens the panel (a click with detail 0), focus now
moves to the first focusable element, or to the panel itself when it has
none. A new initialFocus ref on MorphPopoverContent picks another target,
such as the selected row of a list. A pointer open does not move focus.

The focus waits for the commit that makes the panel visible, because the
wrapper stays visibility: hidden until it is measured and focus() inside
a hidden subtree does nothing. A CSS transition on visibility can hold
the panel hidden a moment longer: the docs site's reduced-motion reset
gives every element a short transition-duration, and the first keyboard
open on a fresh page left focus on the trigger. A missed focus therefore
retries when that visibility transition ends, unless focus moved
elsewhere in the meantime.

Every registry item that bundles popover-morph.tsx has its date updated.
The keyboard-open autofocus took the first element matching its selector.
A hidden input or a disabled button with tabIndex={0} matches it, focus()
on them fails silently, and the retry aimed at the same element, so focus
stayed on the trigger.

Each candidate is now tried in order and the first one that takes focus
wins; initialFocus falls through to them when it cannot. With none, the
panel holds focus. While focus rests on the panel or the trigger, a
visibility transition that ends retries the whole list, so the reduced-
motion reset still lands on the first control. The retry stops at the
first key or pointer press, so it cannot move focus the user placed.

Hidden inputs are also left out of the selector. Passing onOpenAutoFocus
now hands focus to the caller, so DateRangePicker keeps its own target
and autoFocus={false} without a second focus move.
@l4u532
l4u532 force-pushed the fix/morph-popover-initial-focus branch from d99c916 to b474722 Compare October 14, 2026 06:34
@l4u532

l4u532 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on 5866083, which merges #219. The resolution preserves the full-size clip, progress animation, overflow guard, and exit cleanup. It retains autofocus fallback, caller-owned focus, and retry cancellation. All ten bundled dates already read October 14 on main.

Chrome confirmed default focus, hidden and disabled candidates, valid and invalid initialFocus, panel fallback, caller-owned focus, pointer opens, Escape return, and cancellation after input. Real keyboard, reduced-motion, touch, and 375px checks passed. Closing removes the dialog; reopening during exit settles to one fully revealed dialog with First focused and scrollTop=0.

Safari was paired with another WebDriver session, so this round is unverified there. The existing session was left untouched. Earlier Safari evidence remains dated to its checked revisions.

bun run check validates 107 registry components. bun test passes all 152 accessibility tests.

@starc007 starc007 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. A few things to look at, inline below.

One more that's outside the diff: components/motion/breadcrumb.tsx:254 still tracks event.detail === 0 and focuses the first link itself, which now duplicates what MorphPopoverContent does for a keyboard open (the description notes it focuses the same link twice). It only needs to keep its ArrowDown path.

Comment thread components/motion/popover-morph.tsx Outdated
// Enter and Space fire a click with `detail` 0; a pointer click counts
// at least 1. Only a keyboard open moves focus into the panel.
ctx.focusOnOpenRef.current =
!ctx.open && (e as { detail?: number } | undefined)?.detail === 0;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover flag can move focus on a later open. focusOnOpenRef is set here but only cleared inside the surface's layout effect. If this open never commits (a controlled parent ignores onOpenChange(true), or gates it like breadcrumb's open && present), the flag stays true, and the next open from code (a shortcut, the ContextMenu key, external state) moves focus into the panel even though no one pressed the trigger. Suggest clearing it whenever open becomes false in MorphPopover.

Separately, detail === 0 also matches screen-reader virtual clicks, voice control and any .click() call, so those paths now move focus too. Tracking whether a keydown or a pointerdown last happened on the trigger would be more reliable, and would remove the programmatic-click limit listed in the description.

Comment thread components/motion/popover-morph.tsx Outdated
return `inset(${side === "top" ? inset : 0}% ${align === "start" ? inset : 0}% ${side === "bottom" ? inset : 0}% ${align === "end" ? inset : 0}% round ${radius}px)`;
}

const FOCUSABLE_SELECTOR = [

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates FOCUSABLE in lib/hooks/use-modal-scope.ts:5, and the two already differ: :not([disabled]) here still matches a button inside a disabled <fieldset>, which :not(:disabled) excludes, and the hidden-input exclusion exists only here. Could this be one shared selector in lib/? The registry bundles it automatically.

Comment thread components/motion/popover-morph.tsx Outdated
...panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
];
for (const candidate of candidates) {
candidate?.focus({ preventScroll: true });

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused row can be out of view. preventScroll: true also stops the panel's own overflowY: auto area from scrolling. With the switcher example from the description, a selected row below the fold gets focus but stays hidden. Since the wrapper is position: fixed, a candidate.scrollIntoView({ block: "nearest" }) after a successful focus should bring it into view without scrolling the page.

Comment thread components/motion/popover-morph.tsx Outdated
document.addEventListener("keydown", stop, true);
document.addEventListener("pointerdown", stop, true);
return stop;
}, [

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A re-run cancels the retry. The first run consumes the shared focusOnOpenRef. If any dependency changes afterward (e.g. initialFocus={cond ? refA : refB} flipping on the render after open, or onOpenAutoFocus toggling), the cleanup calls stop() and the re-run returns early because the flag is now false. In the reduced-motion window, focus then stays on the panel. A per-session local flag, or reading initialFocus through a ref so it's not a dependency, would avoid this.

Comment thread components/motion/popover-morph.tsx Outdated
}}
className={cn(
"overflow-hidden border border-border bg-background",
"overflow-hidden border border-border bg-background outline-none",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With tabIndex={-1} the panel is now the fallback focus target, but outline-none leaves it with no visible indicator. When focus lands on the panel (text-only content, or the reduced-motion window), nothing on screen shows it (WCAG 2.4.7). Maybe add a focus-visible: ring here.

@l4u532

l4u532 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Added fresh Chrome focus media for the hidden and disabled candidate finding and the merged geometry.

The reviewed first autofocus revision (b175835) leaves the ring on Options. The corrected head (b474722) focuses Apply after native Enter. Escape returns focus to Options in both captures.

Reviewed candidate failure versus corrected focus

A separate pair compares latest main (5866083, merged #219) with b474722 under the same conditions. It confirms the autofocus result on the merged full-size panel.

Main geometry versus corrected autofocus

Captured October 14 in Chrome, with identical content, placement, theme and viewport. The screenshots show settled focus only. Safari was not captured.

- Disarm the keyboard-open flag on the next key or pointer press, so a
  declined open cannot move focus during a later programmatic open.
- Detect a keyboard open from an Enter or Space keydown on the trigger
  followed by its click, so scripted click() calls no longer count.
- Run the open-focus effect once per open and read initialFocus and
  onOpenAutoFocus through refs, so new props mid-open keep the retry.
- Scroll the focused target into view within the panel.
- Show an inset focus ring when the panel itself holds focus.
- Share one focusable selector between the popover and useModalScope.
- Drop breadcrumb's duplicate Enter-path focus code.
- Add the changelog line and bump dates for every item that bundles a
  changed file.
A pointer press left the tooltip's shared input modality on pointer for
later test files, so keyboard-focus tooltip cases failed in CI.
@starc007
starc007 merged commit a76e57c into starc007:main Oct 23, 2026
1 check passed
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.

2 participants