Repository navigation
♿ fix: Keep Focus, Contrast and Names Right Across the Access and Grants Pages - #209
Merged
Merged
Conversation
…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.
Contributor
Author
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
6 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request Template
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. Onmain, on Access > Roles:<body>. There was no Radix trigger to go back to, andConfirmationDialog's types don't takeonCloseAutoFocus.aria-describedbypointed at click-ui's empty hidden<p>, because the message was passed as content rather than as the description.Dialog.Close, so the dialog closed at once andloadingnever showed.errorwould have thrown "You can't pass children and message props" (no caller passed it yet).It's now built on click-ui's
DialogandDialog.Content, likeNavigationGuardDialog:useReturnFocus).returnFocusTo;SearchInputtakes aninputReffor 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.BusyButton: a spinner,aria-busyandaria-disabled, presses ignored, but still focusable. click-ui'sloadingsetsdisabled, and a focused button that turns disabled drops focus on<body>.errorrenders as an alert under the message.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
#c10000on click-ui's 0.3-alpha red: 4.06:1 (axe: 4.05:1 on#ffbdbd)opacity-40), light--cui-color-text-disabled: 2.68:1 light, 4.18:1 dark--cui-color-text-muted: 5.03:1 light, 8.14:1 dark--click-button-basic-color-danger-background-activefor the light theme in the unlayered block ofsrc/styles.css, next to the existing click-ui overrides. Focus is still marked by the focus ring. Dark already passed.::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.
document, which runs before any React handler. While suggestions (or "No users found") are shown, the field now listens onwindow, 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.aria-activedescendantto the highlighted one.<body>, is hidden from the accessibility tree so it isn't read twice.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
MemberListcomponent, with its props insrc/types.com_access_member_count,com_access_pending_additionsandcom_access_pending_removalshave_oneand_otherforms.--cui-color-foreground-danger. They now use--cui-color-text-dangerand 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
IconButtonandNumberField, with the same layout:navnamed "Pagination", with "Previous page", "Next page" and a field named "Page 2 of 5", followed by a localized "of 5".Audit log
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".aria-live, but only its hidden icon changed, so nothing was read. Each copy now callsonCopied, and the tab announces "Copied capability" (or "Copied link" for the permalink) through its existing announcer.<body>; it now focuses the From picker. Export, which was disabled while it ran, is aBusyButtonand 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.smand 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.Icon, which read out its name; it's aDecorativeIconnow.Also
grid-cols-[auto_auto_auto],w-fit) spanned x = 33–455 in a 390 px dialog. It's one column on phones, two fromsmand three frommd.LoadingStateisrole="status", and its spinner no longer reads "loading-animated".Not in scope
CreateUserDialogon the Users page uses the same disabled placeholder color.returnFocusTo. LibreChat has no user delete route yet (it is commented out inapi/server/routes/admin/users.js), so that delete never succeeds and its row never goes. The/usersroute also redirects to the dashboard for now, which is why the user details change above is covered by a unit test only.Change Type
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 callsonConfirmwithout 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-activedescendantfollows 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.tsxandGroupsTab.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;onCopiedgets the field; noaria-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 thestyles.cssoverrides (likebadges.test.ts).E2E (mock).
e2e/access.spec.ts: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 (onmain:<body>).[role="dialog"](light). Onmainit failscolor-contraston Delete.maintoo; they guard against regressions.[role="listbox"](onmain:color-contraston the email, 4.05:1), andaria-activedescendantequals the first option's id.main:color-contrast×3), a focused Undo, "Removing 1 member", and Undo gives focus back to Remove.mainits right edge is at 454.5 px).main:rgb(154, 158, 167)).e2e/grants.spec.ts:main:label-content-name-mismatch×5).mainit overflows by 363 and 433 px) and Export is on screen.Each new e2e test fails on
mainexcept the Edit role scans. Where a test checks more than one thing, its assertions are ordered so themainfailure is the one it is about.Runs.
devbackend, seeded with a Support role, an Engineering group, four users and 57 grant entries so the audit log pages):e2e/access.spec.tsande2e/grants.spec.ts: 44 passed, 28 skipped (mock fixtures and audited writes).Test Configuration:
@axe-core/playwright4.14 withwcag2a,wcag2aa,wcag21aandwcag21aa, click-ui 0.15.0, LibreChatdevbackend, MongoDB replica set.npx tsc --noEmit,npx eslint src/ --max-warnings 0,npx prettier --checkon the changed files,bun run test:lint-rules,npx vitest run(3579 tests),bun run build.Checklist