Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/a11y/menus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions e2e/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
37 changes: 21 additions & 16 deletions e2e/grants.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -393,16 +395,18 @@ 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');

const searchBox = searchField(page);
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 });
Expand All @@ -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,
);
}
});
});

Expand Down
2 changes: 1 addition & 1 deletion e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
8 changes: 4 additions & 4 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -35,14 +37,12 @@ export function Header({ title, onSearchClick, children }: t.HeaderProps) {
<button
type="button"
onClick={onSearchClick}
aria-label={localize('com_cmdk_label')}
title={`${localize('com_ui_search')} (${shortcut})`}
className="flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-(--cui-color-stroke-default) bg-transparent px-2.5 py-1.5 text-sm text-(--cui-color-text-muted) transition-colors hover:border-(--cui-color-stroke-intense) hover:text-(--cui-color-text-default) sm:px-3"
>
<span aria-hidden="true" className="flex sm:hidden">
<Icon name="search" size="sm" />
</span>
<span className="hidden sm:inline">{localize('com_ui_search')}</span>
<span className="sr-only sm:not-sr-only">{localize('com_ui_search')}</span>
<kbd className="hidden rounded border border-(--cui-color-stroke-default) bg-(--cui-color-background-secondary) px-1.5 py-0.5 text-xs font-medium text-(--cui-color-text-default) sm:inline">
{shortcut}
</kbd>
Expand Down
6 changes: 4 additions & 2 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
setSettingsOpen(true);
};

/** The avatar shows the initials, so its name starts with them (WCAG 2.5.3, label in name). */
const initials = getUserInitials(user);
const userName = user?.name || user?.email || '';

return (
<>
Expand Down Expand Up @@ -171,9 +173,9 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
ref={userMenuRef}
type="button"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) transition-colors hover:border-(--cui-color-stroke-intense) hover:bg-(--cui-color-background-hover)"
aria-label={`${localize('com_nav_user_menu')}, ${user?.name || user?.email || ''}`}
aria-label={localize('com_nav_user_menu_label', { initials, name: userName })}
aria-haspopup="true"
title={user?.name || user?.email || ''}
title={userName}
>
<span
aria-hidden="true"
Expand Down
28 changes: 28 additions & 0 deletions src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { Header } from '../Header';

vi.mock('@/hooks', () => ({ useLocalize: () => (key: string) => key }));

describe('Header search button', () => {
it('is named by the label and shortcut it shows, which phones keep as screen-reader text', () => {
render(<Header title="Grants" onSearchClick={vi.fn()} />);

const search = screen.getByRole('button', { name: /^com_ui_search/ });
expect(search).not.toHaveAttribute('aria-label');
expect(search).not.toHaveAttribute('title');
expect(search).toHaveAccessibleName(search.textContent ?? '');
expect(screen.getByText('com_ui_search')).toHaveClass('sr-only', 'sm:not-sr-only');
expect(screen.getByText('Ctrl+K').tagName).toBe('KBD');
});

it('opens the command menu', async () => {
const onSearchClick = vi.fn();
render(<Header title="Grants" onSearchClick={onSearchClick} />);

await userEvent.click(screen.getByRole('button', { name: /^com_ui_search/ }));

expect(onSearchClick).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion src/components/__tests__/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function openSettings(): Promise<{ userMenu: HTMLElement; dialog: HTMLElem
/>
</ThemeProvider>,
);
const userMenu = screen.getByRole('button', { name: /^com_nav_user_menu/ });
const userMenu = screen.getByRole('button', { name: 'com_nav_user_menu_label' });
userMenu.focus();
fireEvent.keyDown(userMenu, { key: 'ArrowDown' });
const item = await screen.findByRole('menuitem', { name: /com_ui_settings/ });
Expand Down
28 changes: 13 additions & 15 deletions src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
Expand All @@ -12,12 +10,12 @@ import {
} from '@tanstack/react-router';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { localizeEn } from '@/test/coverage';
import { ruleBody } from '@/test/styles';
import { Sidebar } from '../Sidebar';

const USER = { id: 'u1', name: 'Test Admin', email: 'admin@test.local' };
const STYLES = join(__dirname, '..', '..', 'styles.css');
const CLIPS = /^(truncate|overflow(-[xy])?-(hidden|clip|auto|scroll))$/;
const AVATAR = new RegExp(`^${localizeEn('com_nav_user_menu')}`);
const AVATAR = localizeEn('com_nav_user_menu_label', { initials: 'TA', name: USER.name });

/** jsdom has no matchMedia; the theme provider (for the settings dialog) reads the system scheme with it. */
window.matchMedia ??= (media: string): MediaQueryList => ({
Expand Down Expand Up @@ -87,17 +85,6 @@ function clippingAncestors(el: HTMLElement, stop: HTMLElement): string[] {
return found;
}

/**
* The declarations of one top-level rule of styles.css, as written: jsdom can
* neither parse the Tailwind file nor keep `!important` on `outline-offset`.
*/
function ruleBody(selector: string): string | undefined {
const source = readFileSync(STYLES, 'utf8');
const start = source.indexOf(`\n${selector} {`);
if (start < 0) return undefined;
return source.slice(source.indexOf('{', start) + 1, source.indexOf('}', start)).trim();
}

describe('Sidebar', () => {
it('centers the logo, nav icons and avatar alone in rail-width rows when collapsed', async () => {
const rows = railRows(await renderSidebar(true));
Expand Down Expand Up @@ -131,6 +118,17 @@ describe('Sidebar', () => {
},
);

it.each([true, false])(
'names the avatar with the initials it shows, then whose menu it opens (collapsed: %s)',
async (collapsed) => {
await renderSidebar(collapsed);
const avatar = screen.getByRole('button', { name: AVATAR });

expect(AVATAR).toBe('TA, user menu for Test Admin');
expect(avatar).toHaveTextContent(/^TA$/);
},
);

it.each([
[true, 'com_nav_expand_sidebar'],
[false, 'com_nav_collapse_sidebar'],
Expand Down
Loading
Loading