fix(web): admin user rows, settings tabs and the console nav work from the keyboard (TASK-2238) (#1962)

This commit is contained in:
xarmian
2026-10-09 08:48:48 -04:00
committed by GitHub
parent 8efce995d9
commit 20690ae9fb
4 changed files with 134 additions and 9 deletions
+64
View File
@@ -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/);
});
});
@@ -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')}
/>
<div class="tab-bar" role="tablist">
<!-- TASK-2238 (C108): the WAI-ARIA tabs pattern, completed. It declared
tablist/tab/aria-selected with no panel, no aria-controls and no arrow
keys, so a screen-reader user was promised arrow-key tabs that did
nothing. Now: one tabpanel the tabs control, a roving tabindex, and
Left/Right/Home/End move between tabs (automatic activation). -->
<div class="tab-bar" role="tablist" aria-label="Workspace settings" tabindex="-1" onkeydown={handleTabKeydown}>
{#each tabs as tab (tab.id)}
<button
class="tab"
class:active={activeTab === tab.id}
class:danger={tab.id === 'danger'}
role="tab"
id="settings-tab-{tab.id}"
aria-selected={activeTab === tab.id}
aria-controls="settings-tabpanel"
tabindex={activeTab === tab.id ? 0 : -1}
onclick={() => switchTab(tab.id)}
>
<span class="tab-icon">{tab.icon}</span>
@@ -930,6 +956,7 @@
{/each}
</div>
<div role="tabpanel" id="settings-tabpanel" aria-labelledby="settings-tab-{activeTab}" tabindex="0" class="settings-tabpanel">
{#if activeTab === 'general'}
<section class="section">
<h2>Workspace</h2>
@@ -1366,6 +1393,7 @@
</div>
</section>
{/if}
</div>
{/if}
</div>
@@ -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
+16 -7
View File
@@ -123,36 +123,45 @@
id="console-nav-links"
class="nav-links"
class:open={mobileMenuOpen}
role="menu"
>
<!-- TASK-2238 (C108): navigation links, not a menu. The
menu/menuitem roles promised arrow keys that did nothing
and hid the links' own semantics; the current page is
marked with aria-current instead. -->
<a
href="/console"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console') &&
!isActive('/console/settings') &&
!isActive('/console/billing') &&
!isActive('/console/admin') &&
!isActive('/console/deleted-workspaces') &&
!isActive('/console/connected-apps')}
aria-current={(isActive('/console') &&
!isActive('/console/settings') &&
!isActive('/console/billing') &&
!isActive('/console/admin') &&
!isActive('/console/deleted-workspaces') &&
!isActive('/console/connected-apps')) ? 'page' : undefined}
>
Workspaces
</a>
<a
href="/console/settings"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console/settings')}
aria-current={(isActive('/console/settings')) ? 'page' : undefined}
>
Settings
</a>
<a
href="/console/deleted-workspaces"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console/deleted-workspaces')}
aria-current={(isActive('/console/deleted-workspaces')) ? 'page' : undefined}
>
Deleted workspaces
</a>
@@ -162,9 +171,9 @@
<a
href="/console/connected-apps"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console/connected-apps')}
aria-current={(isActive('/console/connected-apps')) ? 'page' : undefined}
>
Connected Apps
</a>
@@ -174,9 +183,9 @@
<a
href="/console/billing"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console/billing')}
aria-current={(isActive('/console/billing')) ? 'page' : undefined}
>
Billing
</a>
@@ -185,9 +194,9 @@
<a
href="/console/admin"
class="nav-link"
role="menuitem"
onclick={closeMobileMenu}
class:active={isActive('/console/admin')}
aria-current={(isActive('/console/admin')) ? 'page' : undefined}
>
Admin
</a>
+22 -1
View File
@@ -403,7 +403,14 @@
onclick={() => openUserModal(user)}
>
<td>
{user.name || user.username}
<!-- TASK-2238 (C61): the modal is the only way to a user's
detail, and the row's click is mouse-only. The name is a
real button, so the keyboard reaches it too. -->
<button
type="button"
class="user-name-btn"
onclick={(e) => { e.stopPropagation(); openUserModal(user); }}
>{user.name || user.username}</button>
<!-- Status pill replaces the legacy "disabled" badge.
"active" is the common case; omit the pill to avoid
visual noise. Other states call out problems. -->
@@ -467,6 +474,20 @@
/>
<style>
/* TASK-2238: the user's name, as the keyboard way into the user modal.
Looks like the text it replaced. */
.user-name-btn {
border: none;
background: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.user-name-btn:hover {
text-decoration: underline;
}
/* Search */
.search-row {
display: flex;