Skip to content

🪃 fix: Return Focus to Trigger When Dialogs Close - #120

Draft
dustinhealy wants to merge 8 commits into
mainfrom
fix/click-ui-modal-focus
Draft

🪃 fix: Return Focus to Trigger When Dialogs Close#120
dustinhealy wants to merge 8 commits into
mainfrom
fix/click-ui-modal-focus

Conversation

@dustinhealy

Copy link
Copy Markdown
Contributor

Summary

Closing any app dialog (Import YAML, save confirmation, role edit/create, group edit/create) dropped keyboard focus on document.body instead of returning it to the button that opened the dialog.

Root cause: every dialog in the app is a controlled Radix modal rendered without a Dialog.Trigger. Radix's default onCloseAutoFocus calls event.preventDefault() and then focuses context.triggerRef, which only Dialog.Trigger populates, so with controlled dialogs the ref is null, the default focus restore is suppressed, and nothing receives focus. This is a pre-existing keyboard accessibility bug surfaced by QA around the click-ui 0.9.1 bump, not a regression from the bump itself (@radix-ui/react-dialog is pinned to 1.1.15 via overrides on both click-ui versions).

Fix: a new useReturnFocus hook captures the previously focused element on the open transition and restores it via onCloseAutoFocus (click-ui Dialog.Content passes the prop through to Radix). It is wired into all eight app dialogs. ConfirmSaveDialog additionally accepts a fallbackRef because a successful save unmounts its trigger (the sticky Save bar); focus then lands on the always-mounted page container. The hand-rolled requestAnimationFrame focus workaround in GrantManagementTab is migrated to the same hook, preserving the focus-the-row semantics locked by e2e/grants.spec.ts.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

11 new Vitest cases render the real click-ui Dialog (no dialog mocking): hook tests cover close via Escape, close via the close button, and the fallback path when the trigger unmounts before close; a table-driven suite asserts focus return for all eight dialogs.

Manually verified in the app: opening and closing each affected dialog returns focus to its trigger; after a successful config save, focus lands on the page container.

Behavioral proof: all 11 new dialog focus cases fail on the base branch (focus lands on document.body) and pass with this change, exercising the real click-ui Dialog with keyboard-driven open and close.

Test Configuration:

  • bunx tsc --noEmit, bunx eslint src/ --max-warnings 0, bunx vitest run (810/810), bun run build: all green
  • Local dev server against local LibreChat backend

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

Inline @clickhouse/click-ui in vitest server deps and exclude .claude/** from test discovery. click-ui 0.9.1 ships ESM that imports .css files, which Node's loader cannot parse, so Vitest must process the package through Vite.
Every app dialog is a controlled Radix modal rendered without a Dialog.Trigger, so Radix's default onCloseAutoFocus prevents default and tries to focus a triggerRef that only DialogTrigger ever sets. With the ref null, focus restoration is suppressed and focus falls to document.body whenever a dialog closes.

A new useReturnFocus hook captures document.activeElement during the render where open flips true (before Radix's FocusScope moves focus into the dialog) and returns an onCloseAutoFocus handler that prevents Radix's default and refocuses the captured element. It is wired into ImportYamlDialog, ConfirmSaveDialog, EditRoleDialog, CreateRoleDialog, EditGroupDialog, CreateGroupDialog, EditCapabilitiesDialog, and the shared FormDialog.

ConfirmSaveDialog needs a fallback because a successful save clears the dirty state and unmounts the StickyActionBar that held the Save trigger. ConfigPage now passes a ref to its always-mounted root container (tabIndex -1) so focus has somewhere sensible to land when the captured element is gone. The hook also treats a captured document.body as nothing captured so the fallback applies in browsers that do not focus clicked elements.

GrantManagementTab's hand-rolled requestAnimationFrame focus-return workaround covered the same Radix behavior and is migrated to the hook, with the clicked row passed as the fallback target so focus still returns to the row deterministically.
@dustinhealy dustinhealy changed the title 🐛 fix: Return Focus to Trigger When Dialogs Close 🪃 fix: Return Focus to Trigger When Dialogs Close Aug 11, 2026
@dustinhealy
dustinhealy marked this pull request as ready for review August 11, 2026 23:00
Base automatically changed from chore/click-ui-0.9.1 to main August 14, 2026 14:54
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@dustinhealy
dustinhealy marked this pull request as draft August 14, 2026 15:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9cb4349cb2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/grants/GrantManagementTab.tsx
Comment thread src/hooks/useReturnFocus.ts
Pointer activation does not always move focus to the grant table row, so useReturnFocus could capture whatever element was focused before (for example the search box) and restore that instead of the row, regressing the previous close handler which always refocused the edited row. Focusing the row in openEditor before opening makes the hook capture the row itself, so close restores it in every activation mode.

SettingsDialog, UserDetailDialog, ResetBaseConfigDialog, ProfileValueModal, DeleteProfileValueModal and ProfileIndicator are controlled dialogs with no Radix trigger, so closing them still dropped focus to document.body. Wire them with the same useReturnFocus onCloseAutoFocus pattern and extend the focus tests to cover the new dialogs and the grant row scenario.
Frontend component tests belong in an adjacent __tests__ directory per the repo convention in AGENTS.md, and this file was added directly under src/components in this branch.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffde702cff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/ResetBaseConfigDialog.tsx Outdated
Comment thread src/hooks/useReturnFocus.ts
Comment thread src/hooks/__tests__/useReturnFocus.test.tsx
…s trigger unmounts

A successful base reset clears the last database override, which unmounts the header Reset button before the dialog closes, so the reset dialog now receives the same page container fallback ConfirmSaveDialog already uses. The audit detail drawer's four raw Radix content branches had neither a trigger nor onCloseAutoFocus and now share one useReturnFocus wiring, and the hook test moves under __tests__ per the repo convention.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34867c109a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/SettingsDialog.tsx Outdated
Comment thread src/components/configuration/ProfileValueModal.tsx Outdated
Comment thread src/components/users/UserDetailDialog.tsx Outdated
Comment thread src/components/grants/AuditLogDetailDrawer.tsx Outdated
Comment thread src/components/access/EditRoleDialog.tsx Outdated
Codex round 3 flagged five dialogs whose captured opener either unmounts before close or never receives focus from pointer activation, so restore landed on document.body or an unrelated filter input. The settings dialog now falls back to the sidebar user menu trigger, the profile value and delete modals fall back to the cascade list container (their scope-selection and per-row buttons unmount on a successful save or removal), and the role and group edit dialogs fall back to their tab containers since a rename can drop the row out of the filtered list.

The users page and audit log now mirror GrantManagementTab by focusing the activated row before opening, so the detail dialog and drawer capture the row instead of whatever was focused before, and the user rows also thread that row as the dialog fallback for the kebab menu path. A parametrized fallback test covers the new prop threading.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49ec02eb73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/useReturnFocus.ts
Comment thread src/components/users/UsersPage.tsx
Comment thread src/components/grants/AuditLogTab.tsx
Comment thread src/components/grants/AuditLogDetailDrawer.tsx Outdated
Comment thread src/hooks/useReturnFocus.ts
…ermalinks

The row ref callbacks now delete entries when rows unmount so paginated audit and user browsing does not accumulate detached table rows. The users row gets the same focus-visible treatment as the grant and audit rows so restored keyboard focus is visible, and the audit drawer takes the tab container as a fallback for cold entryId permalinks where no opener was ever focused.
In browsers where pointer activation does not focus the Import button the hook captures document.body, which it rejects on close, so without a fallback focus stayed on the body. The import dialog now takes the same page container fallback the save and reset dialogs use.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69ccc5ebcb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +27 to +29
if (captured && captured !== document.body && document.contains(captured)) {
captured.focus();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Try the fallback when focus remains elsewhere

When a profile removal is confirmed, FieldProfilePopover starts removeMutation and closes the modal in the same callback (FieldProfilePopover.tsx:271-273), while saving disables the captured trash button (FieldProfilePopover.tsx:181-185). Calling focus() on that still-connected disabled button is a no-op, but this branch returns unconditionally, so the supplied fallbackRef is never focused and focus falls to the body. Only return when document.activeElement === captured after the attempt, or reject disabled/inert targets before trying the fallback.

Useful? React with 👍 / 👎.

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