Skip to content

feat: let overlay actions hand focus off to the surface they open - #1327

Open
tenphi wants to merge 7 commits into
mainfrom
andrew/cub-3962-menu-let-an-action-hand-focus-off-to-the-surface-it-opens
Open

feat: let overlay actions hand focus off to the surface they open#1327
tenphi wants to merge 7 commits into
mainfrom
andrew/cub-3962-menu-let-an-action-hand-focus-off-to-the-surface-it-opens

Conversation

@tenphi

@tenphi tenphi commented Aug 17, 2026

Copy link
Copy Markdown
Member

Describe changes

MenuTrigger and DialogTrigger restored focus to their trigger unconditionally on close. An item or button whose action opened a panel, a dialog or an inline editor lost focus to the trigger a tick later, so consumers had to out-race the overlay by re-focusing on every animation frame over a several-hundred-millisecond window — the shape the Cloud review ruleset calls ui-kit-workaround-in-app-code.

Reproduced before fixing: a menu item whose action mounts a panel that focuses itself in a mount effect has focus back on the trigger within 50ms and never regains it.

Only the manual restore was at fault. The popover's <FocusScope restoreFocus> is innocent — react-aria already guards it (restores only when focus is still inside the scope or has fallen to <body>, and re-checks activeElement === body inside a requestAnimationFrame). The fix applies that same rule to our own restore: the trigger takes focus back only when focus is still inside the closing overlay (the pressed control holds it through the ~350ms exit animation) or has been lost to <body>. Anything else means an action claimed focus, so it is left alone.

Behaviour is unchanged when an action moves focus nowhere. A clicked data-popover-dismiss control outside the overlay also keeps focus now instead of having it yanked to the trigger — a latent sibling case of the same bug.

New shouldRestoreFocus prop (default true) on both triggers, for a surface that claims focus later than the restore (async load, entry animation) where the trigger would otherwise take focus first and flash.

Workaround deleted: Tabs' scheduleRenameRefocus — 58 lines re-focusing the rename input on rAF and again at 50/200/400ms purely to survive the closing menu. The input's own FocusScope autoFocus is enough now.

Notes for the reviewer

DialogTrigger's manual restore is dead code for type="popover". PressResponder gets ref={ref} and then {...triggerProps}, which carries its own ref and overrides it — so ref.current stays null. I traced who actually re-focuses the trigger: popovers get it from Dialog's FocusScope, modals from DialogTriggerBase. Only modal/tray/fullscreen/panel had the bug. Noted in a comment so nobody revives the ref without the guard.

shouldRestoreFocus had to reach Dialog's FocusScope, or it would silently do nothing for popovers. It travels through DialogContext with a ?? true fallback, so a Dialog rendered outside a trigger keeps restoring focus. useDialog filters context through filterDOMProps, so the extra key cannot leak onto the DOM (verified in the react-aria source). Stashing that one change fails the opt-out test for both types, so it is load-bearing and tested.

The race is order-dependent, which is why this reads as intermittent in an app: whether the opened surface's mount effect runs before or after the restore depends on tree order, and it flips between runs. My first DialogTrigger test batch only caught the bug in one of four configurations per run, so I added a deterministic case — the action closes the dialog and synchronously focuses an existing element inside its own handler, so focus is provably outside before the restore effect runs. Verified by stashing the fix: that one fails every run.

InlineInput's grace period is kept, and this is the one judgement call worth a second opinion. Its comment blamed the closing Menu, but removing it breaks InlineInput's own unit test (no Menu involved), and the Tabs failure it prevents arrives through a dummy MenuTrigger where the restore never ran. It is host-agnostic defence for the imperative startEditing() path. I rewrote its comments (plus two test comments and the InlineInput docs paragraph) to say what it actually guards instead of naming Menu.

Verification gap, stated plainly. I verified the Menu fix in a real browser (ActionOpensPanel story: panel opens, holds focus, menu closes). I could not do the same for the Tabs rename flow — the automation pane runs with document.hidden, which throttles timers and stops requestAnimationFrame, so the 500ms grace window elapses. I A/B'd it with the workaround restored under identical conditions and got identical results, so the removal changes nothing there, but the browser evidence is "no difference" rather than "confirmed working". The jsdom tests are what cover it. Worth one manual click-through of tab rename in Storybook.

Checklist
  • Pipeline is passed
  • Tests are added (including unit tests and stories in the storybook)
  • Tests are passed successfully
  • If you're adding a new component/new props, add stories that describe how this component/prop works
  • Changeset(s) is(are) added
  • You have passed the threshold of the library size
  • Commit message follows commit guidelines

Closes: CUB-3962

Other information

New tests: 3 for MenuTrigger (hand-off holds; restore still works; opt-out never restores), 10 for DialogTrigger across popover and modal (the trigger had no focus-restore coverage before this), and an ActionOpensPanel story with a play function.

pnpm size at 498.78 kB against the 501 kB limit — tight, but within.

A marker comment is on the Cloud PR that carries the polling workaround, naming the exact code to delete: cubedevinc/cubejs-enterprise#13967 (comment 5317241822).


Note

Medium Risk
Changes overlay focus restoration used across menus, dialogs, and tab rename—accessibility-sensitive—but behavior is narrowly scoped with extensive tests and a backward-compatible default.

Overview
Focus hand-off (CUB-3962): MenuTrigger and DialogTrigger no longer unconditionally re-focus their trigger on close. Restore runs only when focus is still inside the closing overlay or was lost to body/detached nodes; if an action already focused a panel, dialog, or inline editor, that focus is left alone. Both triggers gain shouldRestoreFocus (default true), wired through MenuTrigger's popover FocusScope and, for dialogs, DialogContextDialog's FocusScope so popovers honor opt-out too. Modal/tray/fullscreen/panel types use the same guard in DialogTriggerBase's manual restore.

Tabs: Removes scheduleRenameRefocus (~58 lines of rAF/timeout refocus); rename from the tab menu relies on InlineInput autoFocus plus the new menu behavior. InlineInput grace-period logic is unchanged; comments/docs now describe host-agnostic blur defense rather than blaming the closing menu.

CI (size-limit.yml): Baseline resolve/download tolerates API/503 failures with retries and continue-on-error; Statoscope report is built/uploaded separately from the baseline artifact; PR comment links to the Statoscope artifact instead of a dead Netlify URL.

Adds unit tests, an ActionOpensPanel story, and documentation for focus restoration.

Reviewed by Cursor Bugbot for commit b36518c. Bugbot is set up for automated code reviews on this repo. Configure here.

MenuTrigger and DialogTrigger restored focus to their trigger
unconditionally on close, so an item or button whose action opened a
panel/dialog/inline editor lost focus to the trigger a tick later.
Consumers had to out-race the overlay by re-focusing on every animation
frame over a several-hundred-millisecond window.

Both now follow the rule react-aria's own FocusScope already uses: take
focus back only when it is still inside the closing overlay or has been
lost to <body>. Anything else means an action claimed focus, so leave it
alone. A single focus() from the opened surface holds.

Also adds shouldRestoreFocus (default true) for a surface that claims
focus later than the restore, and removes the Tabs rename refocus pass
that only existed to survive this.

Closes CUB-3962

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 17, 2026 6:28pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b36518c

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

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

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

…let-an-action-hand-focus-off-to-the-surface-it-opens
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-e5b4f54.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

tenphi and others added 2 commits August 17, 2026 19:52
The size job aborted whenever `gh run download` could not fetch the
baseline artifact, so a transient 503 from GitHub's artifact endpoint
reported the library as over budget when it had never been measured.
Four consecutive red checks on #1327 were this, not the bundle.

The baseline only feeds the delta column, and measure-size.js already
degrades to absolute sizes with a visible warning when it cannot read
one. So retry with linear backoff, then continue without a comparison
instead of failing. The target directory is wiped between attempts so a
half-extracted archive is never read as a valid baseline, and the
Statoscope step now gates on the download having landed rather than on
the baseline merely having been resolved.

Commenting is tolerated for the same reason: a 503 from the issues API
says nothing about the bundle. The `Throw error` gate still decides the
job, so a genuine overage stays red (verified: exit 1 with "Size limit
has been exceeded").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 487.45 KB (+0.01% 🔺) Yes 🎉
Tree shaking (just a Button) 119.67 KB (+0.07% 🔺) Yes 🎉

Compared against main at f3d9a1frun 32052281350, 2026-08-17T17:52:39Z.

To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html.

…on-hand-focus-off-to-the-surface-it-opens' into andrew/cub-3962-menu-let-an-action-hand-focus-off-to-the-surface-it-opens
The size comment linked to `steps.deploy_report.outputs.preview-url`.
That step deployed the report to Netlify and was removed in #108, but the
link was left behind, so every PR comment since has carried a dead link
to a bare `/report.html`.

The report was unreachable regardless: it was written into REPORT_FOLDER
*after* that folder had already been uploaded, so it never entered an
artifact and was discarded with the runner. Every PR with a baseline has
been spending time generating a file nobody could open.

It now builds into its own directory and uploads as its own artifact,
which keeps the baseline artifact — downloaded by every later run —
small. ~180 MB on the runner compresses to ~5 MB stored, with 14-day
retention since nothing later depends on it. The comment points at that
artifact, and says why there is no report when the baseline is missing.

Also documents the `place` and `scrollMargin` style props on MenuTrigger,
SubMenuTrigger and DialogTrigger, which `pnpm audit-docs` was flagging on
all three. 55 and 67 other docs files respectively are still missing
them; those are left for a separate sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ed2c0c9. Configure here.

with:
name: size-limit-statoscope-report
path: ./__report/report.html
retention-days: 14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Statoscope upload can fail size check

Medium Severity

The new Upload Statoscope report step has no continue-on-error, so a transient artifact upload failure (or timeout on the large inlined HTML) fails the whole size-limit job even when the bundle is within budget. That contradicts the nearby stance that the module report is not the check — the same class of false red tick the baseline-download retry was added to avoid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ed2c0c9. Configure here.

Review catch: the new `Upload Statoscope report` step could fail the
size-limit job on a transient artifact failure, which is the same false
red tick the baseline retry was added to prevent. Uploading ~180 MB is
the step most likely to time out, so it was the worst place to leave
untolerated.

`Build Statoscope Report` had the same exposure, and so did `Resolve
baseline run` — several unguarded GitHub API calls that would fail the
job in front of the download retry and defeat the point of it. All three
are now tolerated. Losing baseline resolution leaves `found` unset, which
skips the download and reports absolute sizes with a "No baseline found"
note; losing the report leaves `built` unset, which skips the upload and
says so in the comment.

The job's verdict now rests on the build and size-limit's own
measurement, which is what it is supposed to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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