mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
fix(web): admin user rows, settings tabs and the console nav work from the keyboard (TASK-2238) (#1962)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user