diff --git a/web/e2e/task-2238-keyboard-access.spec.ts b/web/e2e/task-2238-keyboard-access.spec.ts new file mode 100644 index 000000000..80695dbff --- /dev/null +++ b/web/e2e/task-2238-keyboard-access.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from './fixtures'; +import { browserLogin } from './lib/collab-helpers'; + +/** + * TASK-2238 (audit C61 + C108), driven by the keyboard. + * - C61: the admin user table's rows were mouse-only, and the row was the + * only way to a user's detail. The name is now a button. + * - C108: workspace settings declared tabs without the tabs pattern; it is + * complete now (panel, aria-controls, roving tabindex, arrow keys). The + * console nav claimed to be a menu; it is links, with aria-current. + */ +test.describe('TASK-2238: keyboard access', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'keyboard paths; one desktop browser is enough'); + await page.setViewportSize({ width: 1280, height: 800 }); + await browserLogin(page); + }); + + test('an admin opens a user from the keyboard', async ({ page, fixture }) => { + // The admin API wants the admin's own session, as task-2979's admin spec sends it. + await page.context().setExtraHTTPHeaders({ Authorization: `Bearer ${fixture.adminSessionToken}` }); + await page.goto('/console/admin'); + const name = page.locator('.user-row .user-name-btn').first(); + await expect(name).toBeVisible(); + await name.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + test('workspace settings tabs follow the tabs pattern', async ({ page, fixture }) => { + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/settings`); + const tablist = page.getByRole('tablist', { name: 'Workspace settings' }); + const tabs = tablist.getByRole('tab'); + await expect(tabs.first()).toHaveAttribute('aria-selected', 'true'); + await expect(tabs.first()).toHaveAttribute('aria-controls', 'settings-tabpanel'); + await expect(page.locator('#settings-tabpanel[role="tabpanel"]')).toBeVisible(); + // Roving tabindex: only the selected tab is in the tab order. + await expect(tabs.nth(1)).toHaveAttribute('tabindex', '-1'); + + await tabs.first().focus(); + await page.keyboard.press('ArrowRight'); + await expect(tabs.nth(1)).toBeFocused(); + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#settings-tabpanel')).toHaveAttribute('aria-labelledby', (await tabs.nth(1).getAttribute('id'))!); + await page.keyboard.press('End'); + await expect(tabs.last()).toBeFocused(); + await page.keyboard.press('Home'); + await expect(tabs.first()).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(tabs.last()).toBeFocused(); + }); + + test('the console nav is links, and the current one is marked', async ({ page }) => { + await page.goto('/console/settings'); + const nav = page.locator('#console-nav-links'); + await expect(nav).not.toHaveAttribute('role', 'menu'); + await expect(nav.locator('[role="menuitem"]')).toHaveCount(0); + await expect(nav.locator('a[aria-current="page"]')).toHaveCount(1); + await expect(nav.locator('a[aria-current="page"]')).toHaveText(/Settings/); + await page.goto('/console/deleted-workspaces'); + await expect(nav.locator('a[aria-current="page"]')).toHaveCount(1); + await expect(nav.locator('a[aria-current="page"]')).toHaveText(/Deleted workspaces/); + }); +}); diff --git a/web/src/routes/[username]/[workspace]/settings/+page.svelte b/web/src/routes/[username]/[workspace]/settings/+page.svelte index be17f1810..06e0e0ff7 100644 --- a/web/src/routes/[username]/[workspace]/settings/+page.svelte +++ b/web/src/routes/[username]/[workspace]/settings/+page.svelte @@ -280,6 +280,24 @@ history.replaceState(null, '', `#${tabId}`); } + // Arrow keys on the tab list (TASK-2238): the next/previous tab, wrapping, + // or the first/last, selected and focused. + function handleTabKeydown(e: KeyboardEvent) { + const ids = tabs.map((t) => t.id); + const at = ids.indexOf(activeTab); + let next = -1; + if (e.key === 'ArrowRight') next = (at + 1) % ids.length; + else if (e.key === 'ArrowLeft') next = (at - 1 + ids.length) % ids.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = ids.length - 1; + if (next < 0) return; + e.preventDefault(); + switchTab(ids[next]); + // Every tab button is always rendered, so focus moves now; its + // tabindex follows on the next render. + document.getElementById(`settings-tab-${ids[next]}`)?.focus(); + } + // Keyed on (USER, WORKSPACE) (BUG-2991, codex round 3). This page's own data // — workspace name, members, invitations, collections, the context editor — // is local state that the store's identity reset does not touch, so a @@ -914,14 +932,22 @@ ondelete={() => switchTab('danger')} /> -
{/if} @@ -1373,6 +1401,9 @@ .settings { max-width: var(--content-max-width); margin: 0 auto; padding: var(--space-8) var(--space-6); } .loading { text-align: center; padding-top: 20vh; color: var(--text-muted); } .settings-header { margin-bottom: var(--space-4); } + /* The tabpanel takes focus from Tab after the tab list (TASK-2238); the + ring is the browser's, shown only for keyboard focus. */ + .settings-tabpanel:focus:not(:focus-visible) { outline: none; } .settings-header h1 { font-size: 1.6em; } /* ── Tab bar ──── */ /* C82 (TASK-2245): the five owner tabs are 562px intrinsic while the bar's diff --git a/web/src/routes/console/+layout.svelte b/web/src/routes/console/+layout.svelte index cb7c87883..f40667b5c 100644 --- a/web/src/routes/console/+layout.svelte +++ b/web/src/routes/console/+layout.svelte @@ -123,36 +123,45 @@ id="console-nav-links" class="nav-links" class:open={mobileMenuOpen} - role="menu" > + Workspaces Settings Deleted workspaces @@ -162,9 +171,9 @@ Connected Apps @@ -174,9 +183,9 @@ Billing @@ -185,9 +194,9 @@ Admin diff --git a/web/src/routes/console/admin/+page.svelte b/web/src/routes/console/admin/+page.svelte index 358da8ad7..fa0b188fd 100644 --- a/web/src/routes/console/admin/+page.svelte +++ b/web/src/routes/console/admin/+page.svelte @@ -403,7 +403,14 @@ onclick={() => openUserModal(user)} >