From e19ef7bf1e4cbf3620281d0dbe4f0bbd324a22bd Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 14:34:57 +0200 Subject: [PATCH 1/2] fix(#540): restore WebKit dashboard action focus Co-Authored-By: OpenAI Codex Claude-Session: unavailable (OpenAI Codex) --- CHANGELOG.md | 5 +++++ src/ui/dashboard-tree.ts | 29 ++++++++++++++++++++++++----- src/ui/saved-history.ts | 3 +++ tests/e2e/dashboard-tree.spec.js | 14 ++++++++++++++ tests/unit/dashboard-tree.test.ts | 19 +++++++++++++++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bef2b28..f3278015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Fixed +- **WebKit now follows Dashboard-tree keyboard focus through concealed row + actions** (#540). Explicit, roving tab stops keep Tab on the focused row's + chevron and actions; a pointer- or programmatically-focused row becomes the + owner too, so returning from an action dialog continues through that row + rather than skipping its remaining controls. - **OAuth document-recovery E2E runs no longer reuse an incompatible stale harness server** (#533). Playwright now probes the fixture's server-only config route before accepting an existing process, preventing misleading diff --git a/src/ui/dashboard-tree.ts b/src/ui/dashboard-tree.ts index fade4c26..e1b00fad 100644 --- a/src/ui/dashboard-tree.ts +++ b/src/ui/dashboard-tree.ts @@ -715,6 +715,17 @@ function buildRow( // Doubles as the tooltip and the accessible description for a broken row. ...(row.diagnostic === null ? {} : { title: row.diagnostic }), ...(row.current ? { 'aria-current': 'true' } : {}), + // Focus entering this row or any nested control (for example, a + // programmatic delete flow or a pointer-opened dialog's return target) is + // a keyboard entry. Keep the stateful roving owner in sync so this row's + // explicit WebKit action stops are reachable immediately. + onfocusin: (event: FocusEvent) => { + const target = event.currentTarget as HTMLElement; + app.state.dashboardTreeUi.set( + app.currentWorkspace?.id ?? '', setKeyboardRow(readUi(app), row.key), + ); + syncRovingTabindex(target.parentElement, row.key); + }, onclick: (event: MouseEvent) => { // The keyboard owner follows the pointer, so Tab lands where the user last // clicked rather than back at the top of the tree. The DOM is synced in the @@ -730,7 +741,7 @@ function buildRow( h('span', { class: 'meta' }, row.meta), marker, // #494: the trailing DIRECT controls, in the model's own order — edit // before delete, destructive rightmost. There is no `⋯` any more. - row.actions.map((act) => buildActionButton(app, doc, row, act))); + row.actions.map((act) => buildActionButton(app, doc, row, act, ui))); return rowEl; } @@ -840,6 +851,7 @@ function pressRow(app: DashboardTreeApp, row: DashboardTreeRow, shift: boolean): */ function buildActionButton( app: DashboardTreeApp, doc: Document, row: DashboardTreeRow, act: DashboardTreeAction, + ui: DashboardTreeUiState, ): HTMLElement { // From the KIND, never from `act.confirm`: an UNAVAILABLE delete still has // to look and announce like a delete (#494 — a row's vocabulary must not @@ -857,6 +869,10 @@ function buildActionButton( // `:focus-within`, matching the Library Query row. + (act.kind === 'delete-variable-config' ? ' dash-tree-act-static' : ''), type: 'button', + // WebKit's sequential-focus navigation requires an explicit tabindex for + // these visually concealed controls. Rove with the row, so Tab walks only + // this row's cluster before leaving the composite tree. + tabindex: row.key === ui.keyboardRowKey ? '0' : '-1', 'aria-haspopup': destructive ? 'menu' : 'dialog', 'aria-expanded': 'false', 'aria-label': act.label, @@ -1311,17 +1327,20 @@ const focusChevron = (list: HTMLElement, key: string): void => { )!.focus(); }; -/** Put `tabindex="0"` on exactly one row, without rebuilding anything. */ +/** Put `tabindex="0"` on exactly one row and its nested controls, without rebuilding anything. */ function syncRovingTabindex(list: HTMLElement | null, key: string): void { for (const node of list?.querySelectorAll('.dash-tree-row') ?? []) { const value = node.dataset.key === key ? '0' : '-1'; node.setAttribute('tabindex', value); - // #429/#472: the disclosure button roves WITH its row, so the immediate sync - // has to move it too — otherwise the row the user just left keeps a chevron in - // the Tab order until the next paint, and the tree briefly offers four targets. + // #429/#472: the nested controls rove WITH their row, so the immediate sync + // has to move them too — otherwise the row the user just left keeps actions + // in the Tab order until the next paint. for (const chev of node.querySelectorAll('.' + CHEVRON_CLASS)) { chev.setAttribute('tabindex', value); } + for (const action of node.querySelectorAll('.dash-tree-act')) { + action.setAttribute('tabindex', value); + } } } diff --git a/src/ui/saved-history.ts b/src/ui/saved-history.ts index ce62777b..592fd8cf 100644 --- a/src/ui/saved-history.ts +++ b/src/ui/saved-history.ts @@ -264,6 +264,9 @@ function renderSaved(app: App, list: HTMLElement): void { h('span', { class: 'name' }, name), h('button', { class: 'sv-act sv-assign', title: 'Add to dashboard…', 'aria-label': 'Add to dashboard…', + // Explicit so WebKit includes the hover-concealed action in its + // native Tab sequence; the preceding star is the keyboard entry. + tabindex: '0', onclick: (e: Event) => { e.stopPropagation(); openLibraryAssignMenu(app, q, e.currentTarget as HTMLElement); diff --git a/tests/e2e/dashboard-tree.spec.js b/tests/e2e/dashboard-tree.spec.js index c5e2ddfb..92a078e5 100644 --- a/tests/e2e/dashboard-tree.spec.js +++ b/tests/e2e/dashboard-tree.spec.js @@ -886,6 +886,20 @@ test.describe('direct row actions (#494)', () => { await page.keyboard.press('Escape'); }); + test('a pointer-opened pencil moves the composite Tab stop to its own row', async ({ page }) => { + await open(page); + await roleTab(page, 'Dashboards').click(); + const row = treeRow(page, 'workspace:ops'); + await row.hover(); + const pencil = row.getByRole('button', { name: 'Edit dashboard Ops latency' }); + await pencil.click(); + await expect(page.getByRole('dialog', { name: 'Edit dashboard' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(pencil).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(row.getByRole('button', { name: 'Delete dashboard Ops latency' })).toBeFocused(); + }); + test('the dialog announces itself as a modal named by its heading', async ({ page }) => { await open(page); await roleTab(page, 'Dashboards').click(); diff --git a/tests/unit/dashboard-tree.test.ts b/tests/unit/dashboard-tree.test.ts index 6514a1d0..e9d142a7 100644 --- a/tests/unit/dashboard-tree.test.ts +++ b/tests/unit/dashboard-tree.test.ts @@ -173,6 +173,13 @@ describe('renderDashboardTree — structure and ARIA', () => { .filter((chev) => chev.getAttribute('tabindex') === '0'); expect(tabbableChevrons).toHaveLength(1); expect(tabbableChevrons[0].closest('.dash-tree-row')!.getAttribute('data-key')).toBe('w1:sales'); + // WebKit needs explicit tabindex values on the hover-concealed actions; + // they must rove with their row rather than create stops for every row. + const tabbableActions = [...list.querySelectorAll('.dash-tree-act')] + .filter((action) => action.getAttribute('tabindex') === '0'); + expect(tabbableActions).toHaveLength(3); + expect(tabbableActions.every((action) => action.closest('.dash-tree-row')?.dataset.key === 'w1:sales')) + .toBe(true); }); // #501 review — a duplicated Dashboard or tile id used to collapse two rows @@ -554,6 +561,10 @@ describe('renderDashboardTree — mouse gestures', () => { // move relative to another. expect(rowFor(list, 'w1:ops').getAttribute('tabindex')).toBe('0'); expect(rowFor(list, 'w1:sales').getAttribute('tabindex')).toBe('-1'); + expect([...rowFor(list, 'w1:ops').querySelectorAll('.dash-tree-act')] + .every((action) => action.getAttribute('tabindex') === '0')).toBe(true); + expect([...rowFor(list, 'w1:sales').querySelectorAll('.dash-tree-act')] + .every((action) => action.getAttribute('tabindex') === '-1')).toBe(true); settle(); }); @@ -2161,6 +2172,14 @@ describe('renderDashboardTree — keyboard', () => { it('moves DOM focus with the roving tabindex', () => { const { app, list } = treeApp(); renderDashboardTree(app); + rowFor(list, 'w1:ops').focus(); + expect(readTreeUi(app.state.dashboardTreeUi, 'w1').keyboardRowKey).toBe('w1:ops'); + expect([...rowFor(list, 'w1:ops').querySelectorAll('.dash-tree-act')] + .every((action) => action.getAttribute('tabindex') === '0')).toBe(true); + actionBtn(list, 'w1:sales', 'Edit dashboard Sales')!.focus(); + expect(readTreeUi(app.state.dashboardTreeUi, 'w1').keyboardRowKey).toBe('w1:sales'); + expect([...rowFor(list, 'w1:sales').querySelectorAll('.dash-tree-act')] + .every((action) => action.getAttribute('tabindex') === '0')).toBe(true); rowFor(list, 'w1:sales').focus(); key(list, 'ArrowDown'); expect(document.activeElement).toBe(rowFor(list, 'w1:ops')); From 67d4badd517fb0b39ff53d194ee6566841fdcf16 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 15:09:08 +0200 Subject: [PATCH 2/2] test(#540): type roving action row lookup Co-Authored-By: OpenAI Codex Claude-Session: unavailable (OpenAI Codex) --- tests/unit/dashboard-tree.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/dashboard-tree.test.ts b/tests/unit/dashboard-tree.test.ts index e9d142a7..fdd60579 100644 --- a/tests/unit/dashboard-tree.test.ts +++ b/tests/unit/dashboard-tree.test.ts @@ -178,7 +178,7 @@ describe('renderDashboardTree — structure and ARIA', () => { const tabbableActions = [...list.querySelectorAll('.dash-tree-act')] .filter((action) => action.getAttribute('tabindex') === '0'); expect(tabbableActions).toHaveLength(3); - expect(tabbableActions.every((action) => action.closest('.dash-tree-row')?.dataset.key === 'w1:sales')) + expect(tabbableActions.every((action) => action.closest('.dash-tree-row')?.dataset.key === 'w1:sales')) .toBe(true); });