chore(ui): harden surface Tab cycling

Rebased onto current main with review follow-ups folded in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Rudy CelekliandClaude Sonnet 5.5 committed 2026-10-14 20:15:28 -04:00
1 parent 0265ad4c19
commit 12ea0219d1
4 files changed
+67 -1

No files matched your search

+2
View File
@@ -33,6 +33,8 @@
- Director timeline seeks cross the dateline along the short longitude arc.
- Surface Tab cycling also skips negative-tabindex, fieldset-disabled and inert controls.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
+3
View File
@@ -1060,6 +1060,9 @@ collapse can still be undone.
## Surface keyboard lifecycle
Surface Tab cycling excludes controls with a negative tabindex, native disabled
state or inert ancestry; ordinary visible enabled controls retain their order.
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
focus shared by the first-run launcher and Provider Settings. Each caller
activates it while open and deactivates it on dismissal; destruction releases
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
test('surface cycling never targets deliberately excluded controls', () => {
let handler;
const documentRef = {
activeElement: {},
addEventListener: (_, fn) => (handler = fn),
removeEventListener() {},
};
const node = ({
attributes = {},
fieldsetDisabled = false,
inert = false,
} = {}) => ({
hasAttribute: (name) => Object.hasOwn(attributes, name),
getClientRects: () => [{}],
getAttribute: (name) => attributes[name] ?? null,
matches: (selector) => selector === ':disabled' && fieldsetDisabled,
closest: (selector) =>
selector === '[inert]' && inert ? { inert: true } : null,
focus() {
documentRef.activeElement = this;
},
});
const ignored = [
node({ attributes: { tabindex: '-1' } }),
node({ fieldsetDisabled: true }),
node({ inert: true }),
];
const first = node({}),
last = node({});
const controls = [...ignored, first, last];
const root = {
ownerDocument: documentRef,
querySelectorAll: () => controls,
contains: (n) => controls.includes(n),
};
const surface = createSurfaceKeyboard({
root,
isActive: () => true,
onEscape() {},
});
surface.activate();
const press = (shiftKey) =>
handler({
key: 'Tab',
shiftKey,
preventDefault() {},
stopPropagation() {},
});
press(false);
assert.equal(documentRef.activeElement, first);
press(true);
assert.equal(documentRef.activeElement, last);
surface.destroy();
});
+5 -1
View File
@@ -39,7 +39,11 @@ export function createSurfaceKeyboard({
),
].filter(
(node) =>
!node.hasAttribute('disabled') && node.getClientRects().length > 0,
!node.hasAttribute('disabled') &&
!node.matches?.(':disabled') &&
!(Number(node.getAttribute?.('tabindex')) < 0) &&
!node.closest?.('[inert]') &&
node.getClientRects().length > 0,
);
if (!order.length) return;
const first = order[0];