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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
product's own body size instead of to user-agent typography.

### Fixed
- The shared anchored-dialog focus trap (`openAnchoredDialog`, #439) now owns
**every** Tab/Shift+Tab transition instead of only wrapping at the first and
last declared focusable element. Native sequential-focus policy is not
portable: WebKit/Safari's default "Tab highlights every item on a webpage"
preference is off, so browser-delegated middle-of-list traversal could skip
checkboxes and buttons entirely — in the multi-select and time-range
popovers this meant keyboard focus could never reach Clear/Cancel/Apply, or
escape the modal trap altogether. The eligible set is still recomputed on
every press (disabled/hidden/removed controls drop out immediately, restored
ones rejoin without reopening the dialog), and a focused element no longer in
that set is treated as unfocused (Tab goes to the first eligible element,
Shift+Tab to the last). Verified in Chromium, Firefox, and WebKit. The
primitive also gained `handle.reclaimFocus()`: disabling the
currently-focused control (the multi-select's busy state disables every row
but Cancel while a Filter source is loading) natively blurs it out of the
dialog entirely, past the trap's dialog-scoped reach — a plain Tab press
right after would never re-enter the modal. Multi-select now calls it the
moment busy state lands, recovering focus onto Cancel instead of leaving it
stranded on the page behind the modal.
- The Dashboard surface (#425) is brought onto the token system it was written
before. Its **title** was `13px/600` — half a pixel above `--text-body`, a step
the One-Pixel Floor forbids because nobody can see it — and now steps up by
Expand Down
12 changes: 10 additions & 2 deletions src/ui/multi-select-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,16 @@ export function buildMultiSelectField(opts: MultiSelectFieldOpts): MultiSelectFi
for (const row of rows) row.cb.disabled = busy;
clearBtn.disabled = busy;
applyBtn.disabled = busy;
if (busy) liveEl.textContent = 'Loading options…';
else applyFilter(); // restores the normal "N of M options" live text
if (busy) {
liveEl.textContent = 'Loading options…';
// #439: disabling the currently-focused control (Search/an option/
// Clear/Apply — every non-Cancel row above) natively blurs it OUT of
// the dialog, past the shared Tab trap's reach (dialog-scoped, so it
// only sees events targeting its own subtree). Reclaim focus onto
// Cancel — the one row this loop never disables — so it never lands
// on the page behind the modal.
handle.reclaimFocus();
} else applyFilter(); // restores the normal "N of M options" live text
}

// The generic dialog chrome (#335): overlay + backdrop-close, the ARIA
Expand Down
48 changes: 39 additions & 9 deletions src/ui/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ export interface AnchoredDialogHandle {
* `skipFocus`. Idempotent — every dismissal path funnels here, and a second
* call is a harmless no-op that never re-fires `onClose`. */
close(opts?: { skipFocus?: boolean }): void;
/** #439: call this immediately after mutating disabled/hidden state on the
* dialog's own content (e.g. a busy-state toggle) — the dialog-scoped Tab
* trap only runs on an event whose target is inside `dialog`, but DISABLING
* the currently-focused control natively blurs it out to `<body>` (outside
* the trap's reach) rather than to another element, so an ordinary Tab
* press right after can leave the modal entirely undetected. Recomputes the
* eligible set and, if the active element is no longer in it (evicted, or
* never was — outside the dialog), moves focus to the first eligible
* element; a no-op when the active element is still eligible. */
reclaimFocus(): void;
}

export function openAnchoredDialog(opts: AnchoredDialogOptions): AnchoredDialogHandle {
Expand All @@ -102,20 +112,40 @@ export function openAnchoredDialog(opts: AnchoredDialogOptions): AnchoredDialogH
return [...dialog.querySelectorAll<HTMLElement>('input, button')]
.filter((el) => !el.closest('[hidden]') && !(el as HTMLInputElement | HTMLButtonElement).disabled);
}
// #439: the primitive owns EVERY Tab/Shift+Tab transition, not just
// boundary wrapping — native sequential-focus policy is not portable
// (WebKit/Safari's default "Tab highlights every item" preference is OFF,
// so browser-delegated middle-of-list traversal can skip buttons and
// checkboxes entirely, stranding keyboard users before Apply). Current
// index in the freshly-recomputed set decides the next stop; an
// out-of-set active element (focus outside the dialog, or on a node the
// last recompute dropped via disable/hide/removal) is treated as if
// nothing were focused, per the issue's boundary rule.
const onTabTrap = (e: KeyboardEvent): void => {
if (e.key !== 'Tab') return;
const items = focusableEls();
if (items.length === 0) return; // nothing to trap — let the browser handle it
const first = items[0];
const last = items[items.length - 1];
const activeEl = d.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (!activeEl || activeEl === first || !dialog.contains(activeEl)) { e.preventDefault(); last.focus(); }
} else if (!activeEl || activeEl === last || !dialog.contains(activeEl)) {
e.preventDefault(); first.focus();
}
const active = d.activeElement as HTMLElement | null;
const current = active ? items.indexOf(active) : -1;
const delta = e.shiftKey ? -1 : 1;
const next = current < 0 ? (e.shiftKey ? items.length - 1 : 0) : (current + delta + items.length) % items.length;
e.preventDefault();
items[next].focus();
};

// #439: the consumer-facing escape hatch — see the handle's own doc comment
// for why disabling the focused control natively evicts it past the
// dialog-scoped trap's reach. `items.includes(active)` (not an
// `dialog.contains` check alone) is what catches this: a just-disabled
// element stays `dialog.contains`-true but drops out of `focusableEls()`.
function reclaimFocus(): void {
const items = focusableEls();
if (items.length === 0) return;
const active = d.activeElement as HTMLElement | null;
if (active && items.includes(active)) return; // still eligible — no-op
items[0].focus();
}

// The single teardown funnel. Idempotent: the `open` guard means teardown +
// `onClose` run exactly once no matter how many dismissal paths reach it.
function close(closeOpts: { skipFocus?: boolean } = {}): void {
Expand Down Expand Up @@ -160,5 +190,5 @@ export function openAnchoredDialog(opts: AnchoredDialogOptions): AnchoredDialogH
const focusTarget = opts.initialFocus?.(dialog);
if (focusTarget) focusTarget.focus();

return { dialog, isOpen: () => open, close };
return { dialog, isOpen: () => open, close, reclaimFocus };
}
1 change: 1 addition & 0 deletions tests/e2e/multi-select.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
onApply: () => {}, onFallbackCommit: () => {},
});
document.querySelector('#root').append(field.el);
window.__field = field; // #439: lets specs drive updateStatus() to exercise the busy/focus-eviction path
window.__ready = true;
</script>
</body>
Expand Down
103 changes: 102 additions & 1 deletion tests/e2e/multi-select.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,102 @@
import { test, expect } from '@playwright/test';

test.describe('Multi-select keyboard traversal (#439)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/tests/e2e/multi-select.html');
await page.waitForFunction(() => window.__ready === true);
});

// The shared anchored-dialog focus trap must own every Tab/Shift+Tab
// transition rather than delegate middle-of-list traversal to the browser
// (WebKit's default "Tab highlights every item" preference is OFF, so
// native traversal there can skip checkboxes/buttons entirely). This proves
// the real multi-select consumer sequence in every engine, not just the
// primitive in isolation (see popover.test.ts for the unit-level coverage).
test('Tab traverses the real control sequence in DOM order, reaches Apply, and wraps in both directions', async ({ page }) => {
await page.getByRole('button', { name: 'City filter, 0 selected' }).click();

const dialog = page.getByRole('dialog', { name: 'City options' });
await expect(dialog).toBeVisible();

const search = page.getByPlaceholder('Search City options');
const selectVisible = page.locator('.ms-select-all-cb');
const optionCb = page.locator('.ms-option input[type="checkbox"]').first();
const clear = page.getByRole('button', { name: 'Clear', exact: true });
const cancel = page.getByRole('button', { name: 'Cancel', exact: true });
const apply = page.getByRole('button', { name: 'Apply', exact: true });

// Initial focus lands on Search.
await expect(search).toBeFocused();

// Ordinary Tab visits every declared control, in order, ending on Apply.
for (const next of [selectVisible, optionCb, clear, cancel, apply]) {
await page.keyboard.press('Tab');
await expect(next).toBeFocused();
const inDialog = await dialog.evaluate((d) => d.contains(document.activeElement));
expect(inDialog).toBe(true);
}

// Apply reached via ordinary Tab visibly matches :focus-visible.
expect(await apply.evaluate((el) => el.matches(':focus-visible'))).toBe(true);
const applyOutline = await apply.evaluate((el) => getComputedStyle(el).outlineStyle);
expect(applyOutline).not.toBe('none');

// One more Tab wraps forward to Search; Shift+Tab wraps back to Apply.
await page.keyboard.press('Tab');
await expect(search).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(apply).toBeFocused();
});

// Entering the busy state disables every row except Cancel. Disabling the
// CURRENTLY FOCUSED row natively blurs it out of the dialog entirely (to
// <body>) rather than to another element — the shared Tab trap is
// dialog-scoped, so an ordinary Tab press right after would never reach it,
// and focus could escape the modal. `reclaimFocus()` closes that gap by
// moving focus onto Cancel (the one row the busy toggle never disables) the
// moment the eviction happens. This must hold for every row that can be
// legitimately focused before the loading state lands.
test('a control disabled by the busy state is reclaimed onto Cancel, and traversal stays trapped', async ({ page }) => {
await page.getByRole('button', { name: 'City filter, 0 selected' }).click();
const dialog = page.getByRole('dialog', { name: 'City options' });
await expect(dialog).toBeVisible();

const search = page.getByPlaceholder('Search City options');
const selectVisible = page.locator('.ms-select-all-cb');
const optionCb = page.locator('.ms-option input[type="checkbox"]').first();
const clear = page.getByRole('button', { name: 'Clear', exact: true });
const cancel = page.getByRole('button', { name: 'Cancel', exact: true });
const apply = page.getByRole('button', { name: 'Apply', exact: true });

for (const focusTarget of [search, selectVisible, optionCb, clear, apply]) {
// Reset to an interactive state before each focus + eviction round.
await page.evaluate(() => window.__field.updateStatus({ status: 'ready' }));
await focusTarget.focus();
await expect(focusTarget).toBeFocused();

await page.evaluate(() => window.__field.updateStatus({ status: 'loading' }));
// Reclaimed onto Cancel — never left on the page behind the modal.
await expect(cancel).toBeFocused();
const inDialog = await dialog.evaluate((d) => d.contains(document.activeElement));
expect(inDialog).toBe(true);
}

// While busy, every OTHER row is disabled (excluded from the eligible
// set), so Tab/Shift+Tab from Cancel — the only eligible row — cannot
// leave the dialog: the single-element case keeps it in place.
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(cancel).toBeFocused();

// Re-enabling restores ordinary traversal through the freshly recomputed
// eligible set: from Cancel, the next declared row is Apply again.
await page.evaluate(() => window.__field.updateStatus({ status: 'ready' }));
await page.keyboard.press('Tab');
await expect(apply).toBeFocused();
});
});

test.describe('Multi-select Apply action states (#386)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/tests/e2e/multi-select.html');
Expand Down Expand Up @@ -27,10 +124,14 @@ test.describe('Multi-select Apply action states (#386)', () => {

// Enter focus through keyboard navigation so :focus-visible is the
// state under test; programmatic focus intentionally does not promise
// that modality in browsers.
// that modality in browsers. The shared focus trap (#439) now owns
// every transition deterministically, so a bounded loop still applies
// only as a defensive bound — it must terminate well before 10 presses.
for (let i = 0; i < 10 && !(await apply.evaluate((el) => el === document.activeElement)); i++) {
await page.keyboard.press('Tab');
}
await expect(apply).toBeFocused();
expect(await apply.evaluate((el) => el.matches(':focus-visible'))).toBe(true);
expect((await styles(apply)).outline).not.toBe('none');

const box = await apply.boundingBox();
Expand Down
37 changes: 37 additions & 0 deletions tests/e2e/time-range.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,43 @@ test.describe('Dashboard compound time-range control', () => {
await expect(page.locator('.trf-popover')).toHaveCount(0);
});

// #439: the shared anchored-dialog focus trap must own every Tab transition
// for THIS consumer too, including through a dynamically rendered set (the
// right column's constants list) whose membership changes while the dialog
// stays open — proving the trap recomputes on every press rather than
// caching the set it saw when the popover first mounted.
test('Tab reaches Apply through the dynamically rendered constants column, and a narrower re-render is honored by the very next Tab', async ({ page }) => {
await open(page);
// A genuine focus (not the initial programmatic one) activates To,
// rendering its constants as dynamic buttons in the right column.
await toBox(page).focus();
await expect(page.locator('.trf-right-header')).toHaveText('To · constants');
expect(await page.locator('.trf-const').count()).toBeGreaterThan(1);

const dialog = page.getByRole('dialog', { name: 'Time range' });
const toCaret = page.locator('.trf-caret[aria-label="Show constants for To"]');
await page.keyboard.press('Tab'); // To input -> To's caret
await expect(toCaret).toBeFocused();
await page.keyboard.press('Tab'); // caret -> first constant button
await expect(page.locator('.trf-const').first()).toBeFocused();
expect(await dialog.evaluate((d) => d.contains(document.activeElement))).toBe(true);

// Narrow the right column to a single constant — the trap must use the
// FRESHLY rendered set on the next press, not the one captured before.
// 'now' is the one constant whose value/label contains "now" (no "-Nx
// ago" token does), and it stays a valid, in-range bound so Apply stays
// enabled and reachable afterward.
await toBox(page).fill('now');
await expect(page.locator('.trf-const')).toHaveCount(1);
await page.keyboard.press('Tab'); // To input -> To's caret
await page.keyboard.press('Tab'); // caret -> the ONE remaining constant
await expect(page.locator('.trf-const')).toBeFocused();
await page.keyboard.press('Tab'); // -> Cancel
await expect(page.locator('.trf-btn:not(.trf-btn-primary)')).toBeFocused();
await page.keyboard.press('Tab'); // -> Apply
await expect(applyBtn(page)).toBeFocused();
});

test('renders the control and popover in both light and dark themes', async ({ page }) => {
const bg = async () => page.locator('.trf-popover').evaluate((n) => getComputedStyle(n).backgroundColor);

Expand Down
28 changes: 25 additions & 3 deletions tests/unit/multi-select-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,15 +616,22 @@ describe('buildMultiSelectField — Tab focus trap inside the dialog (#189 F3)',
expect(document.activeElement).toBe(applyBtn());
});

it('Tab/Shift-Tab from an element in the middle of the dialog does not trap (default behavior)', () => {
it('Tab/Shift-Tab from an element in the middle of the dialog moves deterministically (#439)', () => {
// The primitive owns every transition now, not just the boundary wrap —
// native browser traversal is not portable (WebKit/Safari can skip
// buttons/checkboxes with the default "Tab highlights every item"
// preference off). Declared order: search, select-visible, option
// checkboxes (a/b/c), Clear, Cancel, Apply.
const handle = buildMultiSelectField(baseOpts());
document.body.appendChild(handle.el);
click(triggerEl(handle.el));
selectAllCb().focus();
const forward = tab(popover()!);
expect(forward).toBe(false); // preventDefault-ed
expect(document.activeElement).toBe(optionCbs()[0]); // next: first option row
const backward = tab(popover()!, true);
expect(forward).toBe(true); // not preventDefault-ed — the browser's own Tab order applies
expect(backward).toBe(true);
expect(backward).toBe(false); // preventDefault-ed
expect(document.activeElement).toBe(selectAllCb()); // back to select-visible
});
});

Expand All @@ -647,6 +654,21 @@ describe('buildMultiSelectField — loading affordance while the popover is open
expect(handle.isOpen()).toBe(true);
});

it('reclaims focus onto Cancel when entering busy state evicts the focused control (#439)', () => {
// Disabling the currently-focused control natively blurs it OUT of the
// dialog, past the shared Tab trap's dialog-scoped reach — verified for
// each row the busy toggle disables (every row except Cancel itself).
const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true }));
document.body.appendChild(handle.el);
click(triggerEl(handle.el));
for (const focusTarget of [searchInput, selectAllCb, () => optionCbs()[0], clearBtn, applyBtn]) {
handle.updateStatus({ status: 'ready' }); // reset to interactive before each focus
focusTarget().focus();
handle.updateStatus({ status: 'loading' });
expect(document.activeElement).toBe(cancelBtn());
}
});

it('restores the checklist body and the normal live-region count once status returns to ready', () => {
const handle = buildMultiSelectField(baseOpts({ value: ['a'], active: true }));
document.body.appendChild(handle.el);
Expand Down
Loading
Loading