diff --git a/e2e/a11y/menus.spec.ts b/e2e/a11y/menus.spec.ts index 58e3556d..ecabfafa 100644 --- a/e2e/a11y/menus.spec.ts +++ b/e2e/a11y/menus.spec.ts @@ -78,7 +78,7 @@ for (const theme of THEMES) { }) => { await page.goto('/'); await page.waitForLoadState('networkidle'); - await page.getByRole('button', { name: /^User menu/ }).focus(); + await page.getByRole('button', { name: /, user menu for / }).focus(); await page.keyboard.press('Enter'); const items = page.getByRole('menu').getByRole('menuitem'); await expect(items.first()).toBeFocused(); diff --git a/e2e/access.ts b/e2e/access.ts index c2d87481..6286be70 100644 --- a/e2e/access.ts +++ b/e2e/access.ts @@ -84,11 +84,15 @@ export async function renameRole(page: Page, from: string, to: string) { await expect(dialog).not.toBeVisible({ timeout: 5000 }); } -/** Opens the Grants page on the role's row, which reads "No capabilities" or "N capabilities". */ +/** + * Opens the Grants page on the role's row, which reads "No capabilities" or + * "N capabilities"; a click anywhere on it presses its Edit capabilities button. + */ export async function grantsRow(page: Page, role: string) { await page.goto('/grants'); await searchField(page).fill(role); - return page.getByRole('button', { name: `Edit capabilities for ${role}`, exact: true }); + const edit = page.getByRole('button', { name: `Edit capabilities for ${role}`, exact: true }); + return page.getByRole('row').filter({ has: edit }); } /** Grants the role Read usage on the Grants page. */ diff --git a/e2e/grants.spec.ts b/e2e/grants.spec.ts index fab3d8d1..3b311c2d 100644 --- a/e2e/grants.spec.ts +++ b/e2e/grants.spec.ts @@ -94,7 +94,9 @@ test.describe('Grants page - Management tab', () => { test('shows all roles even with 0 grants', async ({ page }) => { test.skip(LIVE, MOCK_FIXTURES_SKIP_REASON); const rows = page.locator('tbody tr'); - const userRow = page.getByRole('button', { name: 'Edit capabilities for USER' }); + const userRow = rows.filter({ + has: page.getByRole('button', { name: 'Edit capabilities for USER' }), + }); await expect(userRow).toContainText('No capabilities'); const count = await rows.count(); expect(count).toBeGreaterThanOrEqual(5); @@ -106,7 +108,7 @@ test.describe('Grants page - Management tab', () => { const count = await rows.count(); expect(count).toBeGreaterThan(0); for (let i = 0; i < count; i++) { - await rows.nth(i).focus(); + await rows.nth(i).getByRole('button').focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('dialog').locator('.badge-role')).toBeVisible(); await page.keyboard.press('Escape'); @@ -393,7 +395,9 @@ test.describe('Grants page accessibility', () => { expect(results.violations).toEqual([]); }); - test('keyboard: Tab through filters and rows, Enter opens dialog', async ({ page }) => { + test('keyboard: Tab from the search field to the first row, Enter opens dialog', async ({ + page, + }) => { await page.goto('/grants'); await page.waitForLoadState('networkidle'); @@ -401,8 +405,8 @@ test.describe('Grants page accessibility', () => { await searchBox.focus(); await expect(searchBox).toBeFocused(); - const firstRow = page.locator('tbody tr').first(); - await firstRow.focus(); + const firstRow = page.locator('tbody tr').first().getByRole('button'); + await page.keyboard.press('Tab'); await expect(firstRow).toBeFocused(); await page.keyboard.press('Enter'); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); @@ -420,22 +424,23 @@ test.describe('Grants page accessibility', () => { await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); - await expect(firstRow).toBeFocused({ timeout: 3000 }); + await expect(firstRow.getByRole('button')).toBeFocused({ timeout: 3000 }); }); - test('date inputs have associated labels', async ({ page }) => { + test('date pickers are named by their labels', async ({ page }) => { await page.goto('/grants?tab=audit-log'); await page.waitForLoadState('networkidle'); - const fromInput = page.locator('#audit-date-from'); - await expect(fromInput).toBeVisible(); - const fromLabel = page.locator('label[for="audit-date-from"]'); - await expect(fromLabel).toBeVisible(); - - const toInput = page.locator('#audit-date-to'); - await expect(toInput).toBeVisible(); - const toLabel = page.locator('label[for="audit-date-to"]'); - await expect(toLabel).toBeVisible(); + for (const [id, label] of [ + ['audit-date-from', 'From'], + ['audit-date-to', 'To'], + ]) { + await expect(page.locator(`label[for="${id}"]`)).toBeVisible(); + await expect(page.getByRole('button', { name: label, exact: true })).toHaveAttribute( + 'id', + id, + ); + } }); }); diff --git a/e2e/sidebar.spec.ts b/e2e/sidebar.spec.ts index 39928c87..d182b685 100644 --- a/e2e/sidebar.spec.ts +++ b/e2e/sidebar.spec.ts @@ -13,7 +13,7 @@ test.use({ launchOptions: { ignoreDefaultArgs: ['--hide-scrollbars'] } }); const COLLAPSED_KEY = 'admin-panel:sidebar-collapsed'; /** The sidebar controls whose focus rings sit against a clipped edge, by accessible name. */ const RINGED = [ - ['avatar', /^User menu/], + ['avatar', /, user menu for /], ['collapse toggle', /^(Collapse|Expand) sidebar$/], ] as const; diff --git a/src/__tests__/badges.test.ts b/src/__tests__/badges.test.ts new file mode 100644 index 00000000..cfe8c1a0 --- /dev/null +++ b/src/__tests__/badges.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { themes } from '@clickhouse/click-ui'; +import { contrast, over, parseColor, resolveVar } from '@/test/contrast'; +import { customProperties } from '@/test/styles'; + +/** The Badge states the panel renders: Active and Granted are `success`, Paused and Revoked `danger`. */ +const STATES = ['default', 'success', 'danger', 'warning', 'neutral'] as const; +/** What a badge sits on: a table row, and the same row hovered or keyboard-focused. */ +const SURFACES = ['--cui-color-background-panel', '--cui-color-background-hover'] as const; +/** WCAG 1.4.3 for normal text; badges set theirs at 10px. */ +const AA = 4.5; + +/** The app's click-ui overrides for both themes, then for dark only (more specific, so it wins). */ +const SHARED = ':root[data-cui-theme]'; +const DARK = ":root[data-cui-theme='dark']"; + +const THEMES = [ + ['light', ':root', [SHARED], themes.light], + ['dark', '.dark', [SHARED, DARK], themes.dark], +] as const; + +describe('Badge text contrast', () => { + it.each(THEMES)('meets AA for every state on every surface in %s', (_, selector, blocks, cui) => { + const app = customProperties(selector); + const overrides = new Map(blocks.flatMap((block) => [...customProperties(block)])); + const { text, background } = cui.click.badge.opaque.color; + + const failing = STATES.flatMap((state) => { + const token = overrides.get(`--click-badge-opaque-color-text-${state}`) ?? text[state]; + const fg = parseColor(resolveVar(token, app)); + return SURFACES.flatMap((surface) => { + const bg = over( + parseColor(background[state]), + parseColor(resolveVar(`var(${surface})`, app)), + ); + const ratio = contrast(fg, bg); + return ratio >= AA ? [] : [`${state} on ${surface}: ${ratio.toFixed(2)}:1`]; + }); + }); + + expect(failing).toEqual([]); + }); +}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 58a5f641..34376733 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,7 +5,9 @@ import { useLocalize } from '@/hooks'; /** * Page header: title and page actions (portaled into `#header-actions-portal`). * When the actions don't fit beside the title they wrap onto a second row, - * and the title truncates instead of being overlapped. + * and the title truncates instead of being overlapped. The search button is + * named by what it shows ("Search Ctrl+K"; on phones, where only its icon + * shows, by its screen-reader-only "Search"), so its name holds its label. */ export function Header({ title, onSearchClick, children }: t.HeaderProps) { const localize = useLocalize(); @@ -35,14 +37,12 @@ export function Header({ title, onSearchClick, children }: t.HeaderProps) { + + + +
+ {subject.label} + +
+ + {entry.actor.name} + + + + + ); +} diff --git a/src/components/grants/CapabilityPanel.tsx b/src/components/grants/CapabilityPanel.tsx index 7054cc1b..5f522189 100644 --- a/src/components/grants/CapabilityPanel.tsx +++ b/src/components/grants/CapabilityPanel.tsx @@ -3,7 +3,7 @@ import { Icon, Switch } from '@clickhouse/click-ui'; import type { BaseSystemCapability } from '@librechat/data-schemas'; import type * as t from '@/types'; import { CapabilityImplications, CAPABILITY_CATEGORIES } from '@/constants'; -import { DecorativeIcon } from '@/components/shared'; +import { SelectAllHeader } from '@/components/shared'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -70,53 +70,25 @@ export function CapabilityPanel({ capabilities, onChange, disabled }: t.Capabili ).length; const allEnabled = enabledCount === caps.length; const isOpen = !collapsed.has(category.key); + const label = localize(category.labelKey); + const panelId = `cap-group-${category.key}`; return (
-
toggleCollapsed(category.key)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleCollapsed(category.key); - } - }} - className={cn( - 'flex w-full cursor-pointer items-center justify-between px-4 py-3 transition-colors hover:bg-(--cui-color-background-hover) focus-visible:bg-(--cui-color-background-hover) focus-visible:outline-1 focus-visible:outline-(--cui-color-outline)', - isOpen ? 'rounded-t-lg' : 'rounded-lg', - )} - > -
- - - {localize(category.labelKey)} - -
-
e.stopPropagation()}> - - {allEnabled ? localize('com_ui_all') : `${enabledCount}/${caps.length}`} - - handleToggleAll(category.key, v)} - disabled={disabled} - aria-label={`${localize(category.labelKey)}: ${localize('com_ui_select_all')}`} - /> -
-
+ toggleCollapsed(category.key)} + controls={panelId} + label={label} + summary={allEnabled ? localize('com_ui_all') : `${enabledCount}/${caps.length}`} + switchId={`cap-all-${category.key}`} + switchLabel={`${label}: ${localize('com_ui_select_all')}`} + checked={allEnabled} + onCheckedChange={(v) => handleToggleAll(category.key, v)} + disabled={disabled} + /> {isOpen && ( -
+
{caps.map((cap: string) => { const isImplied = impliedSet.has(cap) && !capabilities[cap]; @@ -170,7 +142,7 @@ export function CapabilityPanel({ capabilities, onChange, disabled }: t.Capabili checked={isChecked} onCheckedChange={(v) => handleToggle(cap, v)} disabled={disabled || isImplied} - aria-label={`${localize(category.labelKey)}: ${capLabel}`} + aria-label={`${label}: ${capLabel}`} aria-disabled={isImplied || undefined} /> diff --git a/src/components/grants/DateFilter.tsx b/src/components/grants/DateFilter.tsx new file mode 100644 index 00000000..f31f653d --- /dev/null +++ b/src/components/grants/DateFilter.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react'; +import { DatePicker } from '@clickhouse/click-ui'; +import type * as t from '@/types'; + +/** + * A labelled click-ui DatePicker. click-ui renders its readonly date input + * inside the trigger button, where it stays focusable even out of the tab + * order: a control nested in another, and the element the label named while + * the button itself was named "calendar". The input is made inert (shown, but + * out of focus, clicks and the accessibility tree) and the label names the + * trigger instead, followed by the chosen date once there is one. click-ui + * takes no props for either element, so the effect sets them on its DOM, and + * again on the fresh DOM after `resetKey` remounts the picker. + */ +export function DateFilter({ id, label, date, resetKey, onSelectDate }: t.DateFilterProps) { + const ref = useRef(null); + const labelId = `${id}-label`; + const valueId = `${id}-value`; + const hasDate = date !== undefined; + + useEffect(() => { + const trigger = ref.current?.querySelector('button'); + const input = ref.current?.querySelector('input'); + if (!trigger || !input) return; + input.setAttribute('inert', ''); + input.id = valueId; + trigger.id = id; + trigger.setAttribute('aria-labelledby', hasDate ? `${labelId} ${valueId}` : labelId); + }, [resetKey, id, labelId, valueId, hasDate]); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/components/grants/GrantManagementTab.tsx b/src/components/grants/GrantManagementTab.tsx index 64ed216f..27e659b7 100644 --- a/src/components/grants/GrantManagementTab.tsx +++ b/src/components/grants/GrantManagementTab.tsx @@ -22,7 +22,7 @@ export function GrantManagementTab() { const [page, setPage] = useState(1); const [editTarget, setEditTarget] = useState(null); const { message: announcement, announce } = useAnnouncement(); - const rowRefs = useRef>(new Map()); + const rowButtons = useRef>(new Map()); const { data: grants = [], isLoading: grantsLoading } = useQuery(allGrantsQueryOptions); const { data: roles = [] } = useQuery(allRolesQueryOptions); @@ -50,7 +50,7 @@ export function GrantManagementTab() { const key = editTarget ? `${editTarget.principalType}:${editTarget.principalId}` : null; setEditTarget(null); if (key) { - requestAnimationFrame(() => rowRefs.current.get(key)?.focus()); + requestAnimationFrame(() => rowButtons.current.get(key)?.focus()); } }; @@ -69,9 +69,9 @@ export function GrantManagementTab() { />
- {/* Raw kept (not click-ui Table): rows act as buttons with tabIndex, - role, aria-label, onKeyDown, and a ref for focus restoration — semantics - the click-ui Table API does not expose. Matches AuditLogTab's choice. */} + {/* Raw
kept (not click-ui Table): each row's name is a button + stretched over the row, with a ref for focus restoration — markup the + click-ui Table API does not expose. Matches AuditLogTab's choice. */}
@@ -96,15 +96,9 @@ export function GrantManagementTab() { key={key} row={row} isLast={i === paged.length - 1} - onClick={() => setEditTarget(row)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setEditTarget(row); - } - }} - rowRef={(el) => { - if (el) rowRefs.current.set(key, el); + onOpen={() => setEditTarget(row)} + buttonRef={(el) => { + if (el) rowButtons.current.set(key, el); }} /> ); diff --git a/src/components/grants/GrantTableRow.tsx b/src/components/grants/GrantTableRow.tsx index 9097b8ee..3e9a8c8c 100644 --- a/src/components/grants/GrantTableRow.tsx +++ b/src/components/grants/GrantTableRow.tsx @@ -1,25 +1,26 @@ import { Badge } from '@clickhouse/click-ui'; import type * as t from '@/types'; +import { ROW, ROW_BUTTON } from './rows'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; -export function GrantTableRow({ row, isLast, onClick, onKeyDown, rowRef }: t.GrantTableRowProps) { +export function GrantTableRow({ row, isLast, onOpen, buttonRef }: t.GrantTableRowProps) { const localize = useLocalize(); return ( - - + +
{localize('com_grants_title')}
{row.name}
+ + {row.grantCount === 0 ? localize('com_grants_no_capabilities') diff --git a/src/components/grants/__tests__/CapabilityPanel.test.tsx b/src/components/grants/__tests__/CapabilityPanel.test.tsx new file mode 100644 index 00000000..30626386 --- /dev/null +++ b/src/components/grants/__tests__/CapabilityPanel.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { CapabilityPanel } from '../CapabilityPanel'; +import { CAPABILITY_CATEGORIES } from '@/constants'; +import { nestedControls } from '@/test/a11y'; +import { ruleBody } from '@/test/styles'; + +vi.mock('@/hooks', () => ({ useLocalize: () => (key: string) => key })); + +const USERS = CAPABILITY_CATEGORIES.find((category) => category.key === 'users'); +if (!USERS) throw new Error('no Users capability category'); + +function renderPanel() { + const onChange = vi.fn(); + const { container } = render(); + return { container, onChange }; +} + +const usersToggle = () => screen.getByRole('button', { name: USERS.labelKey }); +const usersSwitches = () => + USERS.capabilities.map((cap) => `${USERS.labelKey}: com_cap_${cap.replace(/:/g, '_')}`); + +describe('CapabilityPanel category headers', () => { + it('puts each Select all switch beside its category toggle, not inside it', () => { + const { container } = renderPanel(); + + expect(nestedControls(container)).toEqual([]); + expect(screen.getAllByRole('button', { expanded: false })).toHaveLength( + CAPABILITY_CATEGORIES.length, + ); + expect( + screen.getByRole('switch', { name: `${USERS.labelKey}: com_ui_select_all` }), + ).toBeInTheDocument(); + }); + + it('rings each category toggle inside its card', () => { + renderPanel(); + + expect(usersToggle()).toHaveClass('select-all-toggle'); + expect(ruleBody('.select-all-toggle:focus-visible')).toBe( + 'outline-offset: calc(-1 * var(--focus-ring-width)) !important;', + ); + }); + + it.each([ + ['a click', (user: ReturnType) => user.click(usersToggle())], + ['Enter', (user: ReturnType) => user.keyboard('{Enter}')], + ['Space', (user: ReturnType) => user.keyboard(' ')], + ])('opens and closes a category with %s on its toggle', async (_, press) => { + const user = userEvent.setup(); + renderPanel(); + usersToggle().focus(); + + await press(user); + expect(usersToggle()).toHaveAttribute('aria-expanded', 'true'); + expect(usersToggle()).toHaveAttribute('aria-controls', 'cap-group-users'); + for (const name of usersSwitches()) { + expect(screen.getByRole('switch', { name })).toBeInTheDocument(); + } + + await press(user); + expect(usersToggle()).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('switch', { name: usersSwitches()[0] })).toBeNull(); + }); + + it('turns the whole category on from Select all without opening it', async () => { + const user = userEvent.setup(); + const { onChange } = renderPanel(); + + await user.click(screen.getByRole('switch', { name: `${USERS.labelKey}: com_ui_select_all` })); + + expect(onChange).toHaveBeenCalledWith( + Object.fromEntries(USERS.capabilities.map((cap) => [cap, true])), + ); + expect(usersToggle()).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/src/components/grants/__tests__/DateFilter.test.tsx b/src/components/grants/__tests__/DateFilter.test.tsx new file mode 100644 index 00000000..d2f9c916 --- /dev/null +++ b/src/components/grants/__tests__/DateFilter.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { nestedControls } from '@/test/a11y'; +import { DateFilter } from '../DateFilter'; + +const DATE = new Date(2026, 9, 28); + +function renderFilter(date: Date | undefined, resetKey = 0) { + return render( + , + ); +} + +describe('DateFilter', () => { + it('keeps the date input out of focus inside the trigger, after a reset too', () => { + const { container, rerender } = renderFilter(undefined); + expect(nestedControls(container)).toEqual([]); + + rerender(); + expect(nestedControls(container)).toEqual([]); + expect(container.querySelector('input')).toHaveAttribute('inert'); + }); + + it('names the trigger with its label until a date is chosen', () => { + renderFilter(undefined); + + const trigger = screen.getByRole('button', { name: 'From' }); + expect(trigger).toHaveAttribute('id', 'audit-date-from'); + expect(screen.getByText('From', { selector: 'label' })).toHaveAttribute( + 'for', + 'audit-date-from', + ); + }); + + it('names the trigger with its label and the chosen date', () => { + renderFilter(DATE); + + const input = document.getElementById('audit-date-from-value'); + expect(input).toHaveValue(); + expect(screen.getByRole('button', { name: /^From / })).toHaveAttribute( + 'aria-labelledby', + 'audit-date-from-label audit-date-from-value', + ); + }); +}); diff --git a/src/components/grants/__tests__/rows.test.tsx b/src/components/grants/__tests__/rows.test.tsx new file mode 100644 index 00000000..cd90fabe --- /dev/null +++ b/src/components/grants/__tests__/rows.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { PrincipalType } from 'librechat-data-provider'; +import { render, screen, within } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { AuditLogTableRow } from '../AuditLogTableRow'; +import { GrantTableRow } from '../GrantTableRow'; + +vi.mock('@/hooks', () => ({ + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, +})); + +const PRINCIPAL: t.PrincipalRow = { + principalType: PrincipalType.ROLE, + principalId: 'r1', + name: 'EDITORS', + grantCount: 2, + isActive: true, +}; + +const ENTRY: t.AuditLogEntryWithDiff = { + id: '0123456789abcdef01234567', + schemaVersion: 1, + category: 'grant', + action: 'grant.assigned', + outcome: 'success', + severity: 'info', + actor: { type: 'user', id: 'u-admin', name: 'Admin' }, + target: { type: PrincipalType.ROLE, id: 'r1', name: 'EDITORS' }, + metadata: { capability: 'read:configs' }, + integrity: { seq: 1, hash: 'hash', prevHash: 'prev' }, + timestamp: '2026-10-01T12:00:00.000Z', +}; + +function renderRow(row: ReactNode) { + render( + + {row} +
, + ); +} + +const ROWS: ReadonlyArray<[string, (onOpen: () => void) => ReactNode, string]> = [ + [ + 'a grants row', + (onOpen) => {}} />, + 'com_cap_edit_title(EDITORS)', + ], + [ + 'an audit log row', + (onOpen) => , + 'com_a11y_audit_row_open(EDITORS)', + ], +]; + +describe.each(ROWS)('%s', (_, row, name) => { + it('stays a table row and opens from a button in its cell named after what it shows', () => { + renderRow(row(vi.fn())); + + const button = within(screen.getByRole('row')).getByRole('button', { name }); + expect(button.closest('td')).not.toBeNull(); + expect(button).toHaveTextContent(/^EDITORS$/); + expect(button).toHaveAccessibleName(expect.stringContaining('EDITORS')); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + }); + + it.each([ + [ + 'a click', + (user: ReturnType, button: HTMLElement) => user.click(button), + ], + ['Enter', (user: ReturnType) => user.keyboard('{Enter}')], + ['Space', (user: ReturnType) => user.keyboard(' ')], + ])('opens on %s', async (__, press) => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + renderRow(row(onOpen)); + const button = screen.getByRole('button', { name }); + button.focus(); + + await press(user, button); + + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); + +it('hands the grants row button to the tab, which gives it focus back when the dialog closes', () => { + const buttonRef = vi.fn<(el: HTMLButtonElement | null) => void>(); + renderRow(); + + expect(buttonRef).toHaveBeenCalledWith( + screen.getByRole('button', { name: 'com_cap_edit_title(EDITORS)' }), + ); +}); diff --git a/src/components/grants/rows.ts b/src/components/grants/rows.ts new file mode 100644 index 00000000..e718fcd2 --- /dev/null +++ b/src/components/grants/rows.ts @@ -0,0 +1,11 @@ +/** + * A clickable table row: its button (ROW_BUTTON) is stretched over the whole + * row by its `::after`, so a click anywhere on the row presses it and the row + * keeps its table semantics (a `role="button"` row loses its cells). The ring + * is drawn on that `::after`, inset so the table's scroll box cannot clip it. + */ +export const ROW = + 'relative bg-(--cui-color-background-panel) transition-colors hover:bg-(--cui-color-background-hover) has-[.row-link:focus-visible]:bg-(--cui-color-background-hover)'; + +export const ROW_BUTTON = + "row-link cursor-pointer border-none bg-transparent p-0 text-left after:absolute after:inset-0 after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-(--cui-color-outline)"; diff --git a/src/components/shared/SelectAllHeader.tsx b/src/components/shared/SelectAllHeader.tsx new file mode 100644 index 00000000..32b6e3ac --- /dev/null +++ b/src/components/shared/SelectAllHeader.tsx @@ -0,0 +1,64 @@ +import { Switch } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { DecorativeIcon } from './DecorativeIcon'; +import { cn } from '@/utils'; + +/** + * The header of a collapsible group of switches. The disclosure button + * (chevron, label, description) and the group's "Select all" switch sit side + * by side: a switch inside the button would be an interactive control nested + * in another, which assistive technology cannot reach on its own. + */ +export function SelectAllHeader({ + open, + onToggle, + controls, + label, + description, + summary, + switchId, + switchLabel, + checked, + onCheckedChange, + disabled, +}: t.SelectAllHeaderProps) { + return ( +
+ + {summary} + +
+ ); +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 53727472..6748d3b9 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -17,6 +17,7 @@ export { ReorderableList } from './ReorderableList'; export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'; export { SearchInput } from './SearchInput'; export { default as SidebarIcon } from './SidebarIcon'; +export { SelectAllHeader } from './SelectAllHeader'; export { SelectedMemberList } from './SelectedMemberList'; export { StateChip } from './StateChip'; export { StatusToggle } from './StatusToggle'; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 280cef82..19e1a5af 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -39,7 +39,7 @@ "com_ui_true": "True", "com_ui_false": "False", "com_ui_reset": "Reset", - "com_nav_user_menu": "User menu", + "com_nav_user_menu_label": "{{initials}}, user menu for {{name}}", "com_nav_users": "Users", "com_nav_groups": "Groups", "com_nav_access": "Access", @@ -3545,7 +3545,7 @@ "com_audit_filter_target_type": "Target type", "com_audit_filter_capability": "Capability", "com_audit_export_server": "Export all matching", - "com_a11y_audit_row_open": "Open audit log entry", + "com_a11y_audit_row_open": "Open audit log entry for {{target}}", "com_audit_error": "Failed to load audit log", "com_audit_detail_title": "Audit log entry", "com_audit_detail_summary_assigned": "{{actor}} granted {{capability}} to {{target}}", diff --git a/src/styles.css b/src/styles.css index 78d429da..b07d3625 100644 --- a/src/styles.css +++ b/src/styles.css @@ -472,9 +472,12 @@ select:focus-visible { border-radius: var(--cui-radii-sm) !important; } -/* The sidebar's collapse toggle sits flush with the panel's clipped edges and - the bottom of the viewport: ring it inside so the whole ring shows. */ -.sidebar-toggle:focus-visible { +/* Ring these inside so the whole ring shows: the sidebar's collapse toggle + sits flush with the panel's clipped edges and the bottom of the viewport, + and a switch group's disclosure button fills its card's header beside the + "Select all" switch (ringed inside the card, not across its border). */ +.sidebar-toggle:focus-visible, +.select-all-toggle:focus-visible { outline-offset: calc(-1 * var(--focus-ring-width)) !important; } @@ -1156,10 +1159,14 @@ body > div:has(+ .modal-frost) { } /* click-ui switch: the off track meets 3:1 against the page in both themes - (#cccfd3 / #606060 did not). Unlayered so it wins over click-ui's sheet. */ + (#cccfd3 / #606060 did not). The success badge (Active, Granted) takes the + settings success green: click-ui's light #008a0b on its tint is 4.32:1 at + the badge's 10px, short of AA's 4.5:1. Unlayered so it wins over + click-ui's sheet. */ :root[data-cui-theme] { --click-switch-color-background-default: var(--settings-switch-off); --click-switch-color-stroke-default: var(--settings-switch-off); + --click-badge-opaque-color-text-success: var(--settings-success-fg); } /* One accent in both themes (VS-1, VS-9): click-ui's dark theme paints the diff --git a/src/test/a11y.ts b/src/test/a11y.ts new file mode 100644 index 00000000..1c469ba3 --- /dev/null +++ b/src/test/a11y.ts @@ -0,0 +1,23 @@ +/** Elements a user operates, by tag or role. */ +const CONTROLS = + 'a[href], button, input, select, textarea, [role="button"], [role="switch"], [role="checkbox"], [role="radio"], [role="link"], [role="tab"]'; +/** Elements that can take focus, even out of the tab order (`tabindex="-1"`). */ +const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]'; + +function describeElement(el: Element): string { + const role = el.getAttribute('role'); + const name = el.getAttribute('aria-label') ?? el.id; + return `${el.tagName.toLowerCase()}${role ? `[role=${role}]` : ''}${name ? ` "${name}"` : ''}`; +} + +/** + * Each control under `root` that holds another focusable element, as axe's + * nested-interactive rule reports it ("the switch inside the header button"). + * An inert descendant cannot take focus, so it does not count. + */ +export function nestedControls(root: Element): string[] { + return [...root.querySelectorAll(CONTROLS)].flatMap((control) => { + const inner = [...control.querySelectorAll(FOCUSABLE)].filter((el) => !el.closest('[inert]')); + return inner.map((el) => `${describeElement(el)} inside ${describeElement(control)}`); + }); +} diff --git a/src/test/contrast.ts b/src/test/contrast.ts new file mode 100644 index 00000000..df48f3c3 --- /dev/null +++ b/src/test/contrast.ts @@ -0,0 +1,51 @@ +/** sRGB channels (0–255) and alpha (0–1). */ +export type Rgba = readonly [number, number, number, number]; + +const VAR = /^var\((--[\w-]+)\)$/; + +/** Follows `var(--x)` through `scope` until it reaches a color. */ +export function resolveVar(value: string, scope: ReadonlyMap): string { + const reference = VAR.exec(value); + if (!reference) return value; + const next = scope.get(reference[1]); + if (next === undefined) throw new Error(`${reference[1]} is not declared`); + return resolveVar(next, scope); +} + +function channel(value: string): number { + return value.endsWith('%') ? (parseFloat(value) * 255) / 100 : parseFloat(value); +} + +/** `#rgb`, `#rrggbb` or `rgb()` in either syntax, with or without alpha. */ +export function parseColor(value: string): Rgba { + const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value.trim()); + if (hex) { + const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d).join('') : hex[1]; + const at = (i: number) => parseInt(digits.slice(i, i + 2), 16); + return [at(0), at(2), at(4), 1]; + } + const rgb = /^rgba?\(([^)]+)\)$/.exec(value.trim()); + if (!rgb) throw new Error(`not a color: ${value}`); + const [r, g, b, a = '1'] = rgb[1].split(/[\s,/]+/).filter(Boolean); + return [channel(r), channel(g), channel(b), a.endsWith('%') ? parseFloat(a) / 100 : +a]; +} + +/** `top` painted over the opaque `bottom`. */ +export function over(top: Rgba, bottom: Rgba): Rgba { + const mix = (i: number) => top[i] * top[3] + bottom[i] * (1 - top[3]); + return [mix(0), mix(1), mix(2), 1]; +} + +function luminance([r, g, b]: Rgba): number { + const linear = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +/** The WCAG 2 contrast ratio of two opaque colors. */ +export function contrast(a: Rgba, b: Rgba): number { + const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (light + 0.05) / (dark + 0.05); +} diff --git a/src/test/styles.ts b/src/test/styles.ts new file mode 100644 index 00000000..7221b1a9 --- /dev/null +++ b/src/test/styles.ts @@ -0,0 +1,40 @@ +import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; + +const STYLES = join(__dirname, '..', 'styles.css'); +const COMMENT = /\/\*[\s\S]*?\*\//g; +const RULE = /([^{}]+)\{([^{}]*)\}/g; +const SELECTOR_SEPARATOR = /,(?![^()]*\))/; +const DECLARATION = /(--[\w-]+)\s*:\s*([^;]+);/g; + +/** + * The bodies of the styles.css rules whose selector list holds `selector`, + * wherever they sit (inside `@layer` or `@media` or not), in source order. + * Read as written: jsdom can neither parse the Tailwind file nor keep + * `!important` on `outline-offset`. + */ +function ruleBodies(selector: string): string[] { + const source = readFileSync(STYLES, 'utf8').replace(COMMENT, ''); + return [...source.matchAll(RULE)].flatMap(([, selectors, body]) => + selectors.split(SELECTOR_SEPARATOR).some((listed) => listed.trim() === selector) + ? [body.trim()] + : [], + ); +} + +/** The declarations of the first styles.css rule that lists `selector`. */ +export function ruleBody(selector: string): string | undefined { + return ruleBodies(selector)[0]; +} + +/** + * The custom properties styles.css declares for `selector` (`:root`, `.dark`, + * `:root[data-cui-theme]`), later rules winning. + */ +export function customProperties(selector: string): Map { + return new Map( + ruleBodies(selector).flatMap((body) => + [...body.matchAll(DECLARATION)].map(([, name, value]) => [name, value.trim()] as const), + ), + ); +} diff --git a/src/types/grant.ts b/src/types/grant.ts index 8382e260..f120e09a 100644 --- a/src/types/grant.ts +++ b/src/types/grant.ts @@ -1,6 +1,6 @@ import type { PrincipalType, ResourceType } from 'librechat-data-provider'; import type { AdminAuditLogEntry } from '@librechat/data-schemas'; -import type { KeyboardEvent } from 'react'; +import type { Ref } from 'react'; export interface AuditLogEntryWithDiff extends AdminAuditLogEntry { before?: readonly string[]; @@ -52,7 +52,25 @@ export interface GrantsPageProps { export interface GrantTableRowProps { row: PrincipalRow; isLast: boolean; - onClick: () => void; - onKeyDown: (e: KeyboardEvent) => void; - rowRef: (el: HTMLTableRowElement | null) => void; + /** Opens the row's Edit capabilities dialog. */ + onOpen: () => void; + /** The row's button, which takes focus back when the dialog closes. */ + buttonRef: Ref; +} + +export interface AuditLogTableRowProps { + entry: AuditLogEntryWithDiff; + isLast: boolean; + /** Opens the entry's detail drawer. */ + onOpen: () => void; +} + +export interface DateFilterProps { + /** The id of the picker's trigger button, which the label names. */ + id: string; + label: string; + date?: Date; + /** Changes when the filters are cleared, so the picker remounts without its own selection. */ + resetKey: number; + onSelectDate: (date: Date) => void; } diff --git a/src/types/shared.ts b/src/types/shared.ts index 17d84b1e..4c54c003 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -125,6 +125,24 @@ export interface StatusToggleProps { disabled?: boolean; } +export interface SelectAllHeaderProps { + /** Whether the group's switches are shown. */ + open: boolean; + onToggle: () => void; + /** The id of the element holding the group's switches, while it is shown. */ + controls: string; + label: string; + description?: string; + /** How many of the group's switches are on ("All", "2/5"). */ + summary: string; + switchId: string; + /** The accessible name of the "Select all" switch. */ + switchLabel: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + export interface CloseButtonProps { onClick: () => void; /** The accessible name and tooltip ("Close review"). */