Skip to content

♿ fix: Keep Focus, Contrast and Names Right Across the Access and Grants Pages - #209

Merged
danny-avila merged 1 commit into
mainfrom
pr/fu-a11y-access-and-grants
Oct 29, 2026
Merged

danny-avila merged 1 commit into
mainfrom
pr/fu-a11y-access-and-grants

Conversation

@danny-avila

Copy link
Copy Markdown
Contributor

Pull Request Template

⚠️ Before Submitting a PR, Please Review:

  • Please ensure that you have thoroughly read and understood the Contributing Docs before submitting your Pull Request.

⚠️ Documentation Updates Notice:

  • Kindly note that documentation updates are managed in this repository: librechat.ai

Summary

The accessibility review left a set of keyboard, screen reader, contrast and reflow problems on the Access and Grants pages that the existing axe scans don't reach: they only open in a dialog, a popup, a drawer, a focused state or a narrow screen. This PR fixes them and adds a test for each one that fails on main.

Delete confirmation (ConfirmDialog)

It wrapped click-ui's ConfirmationDialog. On main, on Access > Roles:

  • Initial focus landed on the destructive Delete button.
  • Escape and Cancel left focus on <body>. There was no Radix trigger to go back to, and ConfirmationDialog's types don't take onCloseAutoFocus.
  • aria-describedby pointed at click-ui's empty hidden <p>, because the message was passed as content rather than as the description.
  • Confirm was a Dialog.Close, so the dialog closed at once and loading never showed.
  • Passing error would have thrown "You can't pass children and message props" (no caller passed it yet).

It's now built on click-ui's Dialog and Dialog.Content, like NavigationGuardDialog:

  • It opens on Cancel, and the message is the dialog's description.
  • Escape, Cancel and the close button give focus back to the row's Delete button (useReturnFocus).
  • After a delete, focus goes to the tab's search field (returnFocusTo; SearchInput takes an inputRef for this). The row leaves the list only when the list refetches, after the dialog has closed, so handing focus back to its Delete button would lose it with the row. The dialog therefore remembers that Delete was pressed and sends focus to the search field instead. A close while the delete is still saving counts as a delete. If the delete fails, the dialog stays open (the error is a toast), and a later Cancel or Escape still goes back to the row's Delete button.
  • Cancel and Delete are plain buttons. Delete keeps the dialog open while it saves, and the caller closes it on success.
  • While saving, Delete is a new shared BusyButton: a spinner, aria-busy and aria-disabled, presses ignored, but still focusable. click-ui's loading sets disabled, and a focused button that turns disabled drops focus on <body>.
  • error renders as an alert under the message.
  • The user details dialog (UserDetailDialog) uses the same confirmation to remove a role, a group or the user's profile, and that row goes the same way, so focus now goes to its Add profiles button.

Contrast

Where Before After
Focused or pressed danger button (Delete, Clear dates), light #c10000 on click-ui's 0.3-alpha red: 4.06:1 (axe: 4.05:1 on #ffbdbd) the 0.2-alpha hover tint: 4.75:1
Highlighted member suggestion's email, light muted text on the active row: 4.05:1 default text
Member staged for removal (opacity-40), light initials 1.26:1, name 2.57:1, email 1.72:1 full contrast
Placeholders in the Access dialogs and the member search --cui-color-text-disabled: 2.68:1 light, 4.18:1 dark --cui-color-text-muted: 5.03:1 light, 8.14:1 dark
  • The danger fix overrides --click-button-basic-color-danger-background-active for the light theme in the unlayered block of src/styles.css, next to the existing click-ui overrides. Focus is still marked by the focus ring. Dark already passed.
  • axe ignores ::placeholder, so an e2e test compares the placeholder's computed color with the muted token instead.

Member search (UserSearchInline)

Used in the Edit role, Edit group, Create group and Create role Members tabs.

  • Escape. With suggestions open, Escape closed the whole dialog and lost the pending member changes. Radix closes dialogs from a capture listener on document, which runs before any React handler. While suggestions (or "No users found") are shown, the field now listens on window, which the event reaches first, and closes only them. A second Escape closes the dialog. This works in every dialog the field sits in, without changes to each one.
  • Active option. Options get ids, and the field sets aria-activedescendant to the highlighted one.
  • "No users found" is announced through a status region next to the field. The visible message, which is portaled to <body>, is hidden from the accessibility tree so it isn't read twice.
  • The highlighted row's email uses the default text color (see the table).

Members staged for removal (MemberList)

The Edit role and Edit group dialogs each had their own copy of the member list. It's now one MemberList component, with its props in src/types.

  • A staged member stays at full contrast, struck through, with a "Will be removed" badge.
  • Its trash button turns into an Undo button in place ("Undo removing Alice Chen") and takes focus. Before, the button unmounted and focus fell to the dialog. Undo gives focus back to the Remove button.
  • "Removing 1 members" is now "Removing 1 member": com_access_member_count, com_access_pending_additions and com_access_pending_removals have _one and _other forms.
  • A failed member load, and a failed group load on the Groups tab, used the undefined token --cui-color-foreground-danger. They now use --cui-color-text-danger and are alerts.

Pager (Pagination)

Used by Roles, Groups, the audit log and the member lists. With click-ui's Pagination, the buttons were named "chevron-left" and "chevron-right", the page field was named by its value ("1"), and "of " was hard-coded English. Pressing Next onto the last page disabled the button and dropped focus on <body>.

It's now an app pager built from click-ui's IconButton and NumberField, with the same layout:

  • A nav named "Pagination", with "Previous page", "Next page" and a field named "Page 2 of 5", followed by a localized "of 5".
  • When Previous or Next reaches the end and disables itself, focus moves to the other button.

Audit log

  • Copy buttons in the entry drawer. They were named "Copy When", "Copy Actor ID" and so on, which left out the value they show (axe: label-content-name-mismatch ×5 in both themes), and the names were English concatenations. Each is now named by its value, then what it copies: {{value}}, copy {{field}}, e.g. "read:agents, copy capability".
  • Copy announcements. The button was aria-live, but only its hidden icon changed, so nothing was read. Each copy now calls onCopied, and the tab announces "Copied capability" (or "Copied link" for the permalink) through its existing announcer.
  • Focus. Clear dates removed itself and left focus on <body>; it now focuses the From picker. Export, which was disabled while it ran, is a BusyButton and keeps focus. A drawer opened from a permalink has no opener, so Escape left focus on <body>; it now goes to the entry's row button, or to the search field when the entry isn't on the page.
  • Reflow. At 390 px the tab's scroll box was 665 px wide inside a 302 px area, and Export sat off-screen (x = 556–737). The filter row now stacks below sm and wraps (the action buttons too). The date pickers' fixed 250 px input is capped at the width available, so they fit a 320 px screen. Nothing scrolls sideways at 390 or 320 px. The table keeps its own horizontal scroll.
  • The target badge in the drawer used a raw click-ui Icon, which read out its name; it's a DecorativeIcon now.

Also

  • Edit role > Permissions. The "Allowed features" grid (grid-cols-[auto_auto_auto], w-fit) spanned x = 33–455 in a 390 px dialog. It's one column on phones, two from sm and three from md.
  • Loading. LoadingState is role="status", and its spinner no longer reads "loading-animated".
  • Result counts. Roles and Groups searches announce their match counts ("2 roles found"), the way the Grants tab already does. Groups announces once the server search has loaded, and not on the first load.
  • Icon names. The Edit capabilities dialog's principal badge and the "implied by" note in the capability panel no longer read out their icon names ("lock", "information").

Not in scope

  • CreateUserDialog on the Users page uses the same disabled placeholder color.
  • In the "Removing N members" list above the members, the cross button that un-stages a removal still unmounts with focus on it. The member row's Undo, which this PR adds, is the keyboard path.
  • The Users page's Delete user passes no returnFocusTo. LibreChat has no user delete route yet (it is commented out in api/server/routes/admin/users.js), so that delete never succeeds and its row never goes. The /users route also redirects to the dashboard for now, which is why the user details change above is covered by a unit test only.

Change Type

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

Testing

Unit tests. New or extended; each fails on main, and each also fails with just its fix reverted on this branch:

  • ConfirmDialog.test.tsx: the dialog is described by its message and opens on Cancel; Delete calls onConfirm without closing it, stays focused while busy, and ignores a second press; an error is an alert; Escape, the close button and Cancel give focus back to the opener; a confirmed delete sends focus to the fallback, both when the row goes after the dialog has closed (as the refetch does in the app) and when it goes first; so does Escape while the delete still saves; and Cancel after a failed delete still goes back to the opener (this one fails if Cancel doesn't clear the remembered Delete).
  • UserDetailDialog.test.tsx: after a confirmed role removal, once the refetch has removed the row, focus is on Add profiles.
  • Pagination.test.tsx (now on real click-ui instead of a mock): the nav, button and field names and the "of 5" text; Previous, Next and the field turn pages; focus moves to the other button when Next or Previous reaches the end.
  • UserSearchInline.test.tsx: aria-activedescendant follows the highlighted option; Escape closes the suggestions and not the dialog, then a second Escape closes it; "No users found" is announced.
  • MemberList.test.tsx: singular and plural counts; a staged member is struck through, badged, not dimmed, and Remove swaps to a focused Undo and back; a load error is an alert.
  • RolesTab.test.tsx and GroupsTab.test.tsx: the searches announce their counts.
  • LoadingState.test.tsx: a status with no image named "loading-animated".
  • AuditLogDetailDrawer.test.tsx: each copy button's name starts with its value; onCopied gets the field; no aria-live; the drawer has no exposed image.
  • CapabilityPanel.test.tsx: the "implied by" note exposes no image.
  • src/__tests__/buttons.test.ts: danger button text contrast at rest, hovered and focused, on the page and in a dialog, in both themes, read from click-ui's tokens and the styles.css overrides (like badges.test.ts).

E2E (mock).

  • e2e/access.spec.ts:
    • Delete role: initial focus on Cancel, and Escape and Cancel give focus back to the Delete button.
    • A new role and a new group (uniqueName), deleted from the keyboard (Enter on the row's Delete, then on the dialog's Delete): once the row is gone, the tab's search field has focus (on main: <body>).
    • Delete role with Delete focused: axe on [role="dialog"] (light). On main it fails color-contrast on Delete.
    • Edit role scans of the Details, Permissions and Members tabs, in light and dark. These pass on main too; they guard against regressions.
    • Member search after ArrowDown: axe on [role="listbox"] (on main: color-contrast on the email, 4.05:1), and aria-activedescendant equals the first option's id.
    • Escape with the list open leaves the dialog open; a second Escape closes it.
    • Edit group (Engineering) > Members > Remove: axe on the dialog (on main: color-contrast ×3), a focused Undo, "Removing 1 member", and Undo gives focus back to Remove.
    • The Permissions grid stays inside the dialog at 390 and 320 px (on main its right edge is at 454.5 px).
    • The Create group placeholder color is the muted token (on main: rgb(154, 158, 167)).
  • e2e/grants.spec.ts:
    • The permalink test now scans the drawer (on main: label-content-name-mismatch ×5).
    • Copying a value is announced ("Copied capability") and lands on the clipboard.
    • Escape in a drawer opened from a permalink gives focus to its row.
    • Clear dates focuses the From picker.
    • Export keeps focus.
    • At 390 and 320 px the filter row doesn't scroll sideways (on main it overflows by 363 and 433 px) and Export is on screen.
    • The Edit capabilities badge exposes no image.

Each new e2e test fails on main except the Edit role scans. Where a test checks more than one thing, its assertions are ordered so the main failure is the one it is about.

Runs.

  • Full mock suite: exits 0, 114 passed, 148 skipped (live-only).
  • Live, light and dark (LibreChat dev backend, seeded with a Support role, an Engineering group, four users and 57 grant entries so the audit log pages):
    • e2e/access.spec.ts and e2e/grants.spec.ts: 44 passed, 28 skipped (mock fixtures and audited writes).
    • Driven by hand in both themes: axe was clean on the Delete dialog with Delete focused, every Edit role tab, the open member list, the staged removal and the drawer. Focus went back to "Delete Support" after Escape, to "Undo removing Alice Nguyen" after Remove, to "Previous page" after Next reached page 2 of 2, and to the entry's row after Escape in a permalinked drawer. Copying announced "Copied capability".
    • Deleting a new role and a new group from the keyboard (Delete, Enter, Tab, Enter): focus went from the dialog's Delete straight to "Search roles…" or "Search groups…", and was still there once the refetch had removed the row.
    • Looked at in screenshots: the Delete dialog with Delete focused, the member search with a highlighted suggestion, a staged removal with Undo, the Permissions grid at 320 px, and the audit log's filter row at 320 and 390 px.

Test Configuration:

  • Chromium (Playwright 1.64), @axe-core/playwright 4.14 with wcag2a, wcag2aa, wcag21a and wcag21aa, click-ui 0.15.0, LibreChat dev backend, MongoDB replica set.
  • npx tsc --noEmit, npx eslint src/ --max-warnings 0, npx prettier --check on the changed files, bun run test:lint-rules, npx vitest run (3579 tests), bun run build.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my 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

…nts Pages

Fixes the keyboard, screen reader, contrast and reflow problems the
accessibility review found on the Access and Grants pages.

Delete confirmation. ConfirmDialog is rebuilt on click-ui's Dialog, as
NavigationGuardDialog is. It opens on Cancel, its message is its
description, and Escape or Cancel gives focus back to the row's Delete
button. After a delete, focus goes to the tab's search field: the row
leaves the list only when it refetches, after the dialog has closed, so
the dialog no longer hands focus back to a Delete button that is about
to go. A Cancel after a failed delete still goes back to that button.
Delete keeps the dialog open while it saves: a new shared BusyButton
shows a spinner and is aria-busy and aria-disabled but stays focusable,
since click-ui's loading disables the button and drops focus on body.
The error prop renders as an alert instead of throwing. Removing a
role, group or profile in the user details dialog, which uses the same
confirmation, gives focus to its Add profiles button the same way.

Contrast. A focused or pressed danger button in light mode drew #c10000
on a 0.3-alpha red (4.06:1); it now uses the 0.2-alpha hover tint
(4.75:1). The highlighted member suggestion's email (4.05:1) uses the
default text color. Members staged for removal kept opacity-40 (1.26:1
to 2.57:1); they now stay at full contrast, struck through, with a
"Will be removed" badge. Form placeholders in the Access dialogs use
the muted text color (5.03:1 in light, 8.14:1 in dark) instead of the
disabled one (2.68:1 and 4.18:1).

Member search. Escape with suggestions shown closes only them; a second
Escape closes the dialog, so pending member changes are no longer lost.
Options have ids and the field sets aria-activedescendant. "No users
found" is announced through a status region.

Staged removals. Remove turns into an Undo button in place and takes
focus, and Undo gives focus back to Remove. The member count and the
pending additions and removals have singular and plural forms. A failed
member or group load is an alert in the danger text color (the old
token was undefined). MemberList, duplicated in the Edit role and Edit
group dialogs, is now one component.

Pager. The shared Pagination is an app pager in a labelled nav, built
from click-ui's IconButton and NumberField: Previous page, Next page and
"Page 2 of 5" instead of chevron-left, chevron-right and "2", and a
localized "of 5". When Previous or Next reaches the end and disables
itself, focus moves to the other button.

Audit log. The copy buttons in the entry drawer are named by the value
they show, then what they copy ("read:agents, copy capability"), and
each copy is announced ("Copied capability"); the button is no longer
aria-live. Clear dates gives focus to the From picker, Export stays
focusable while it runs, and a drawer opened from a permalink gives
focus to its row (or the search field) when it closes. The filter row
wraps on phones and the date pickers shrink to fit, so nothing scrolls
sideways at 390 or 320 px.

Also: the Edit role "Allowed features" grid is one column on phones;
LoadingState is a status; Roles and Groups searches announce their
match counts; and the loading spinner, the audit target badge, the
Edit capabilities principal badge and the "implied by" note no longer
read out their icon names.

New unit and e2e tests cover each fix and fail without it.
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 1. Head: f4fa429

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T09:21:59.980939Z f4fa429 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: f4fa429352

ℹ️ 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".

@danny-avila
danny-avila merged commit 53bff25 into main Oct 29, 2026
3 checks 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.

1 participant