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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions src/ui/dashboard-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>('.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<HTMLElement>('.' + CHEVRON_CLASS)) {
chev.setAttribute('tabindex', value);
}
for (const action of node.querySelectorAll<HTMLElement>('.dash-tree-act')) {
action.setAttribute('tabindex', value);
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/ui/saved-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions tests/e2e/dashboard-tree.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/dashboard-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.dash-tree-act')]
.filter((action) => action.getAttribute('tabindex') === '0');
expect(tabbableActions).toHaveLength(3);
expect(tabbableActions.every((action) => action.closest<HTMLElement>('.dash-tree-row')?.dataset.key === 'w1:sales'))
.toBe(true);
});

// #501 review — a duplicated Dashboard or tile id used to collapse two rows
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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'));
Expand Down