From 12ea0219d1cf73be07b07fd9470f07bbb138758c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:28 -0400 Subject: [PATCH] chore(ui): harden surface Tab cycling Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 3 ++ src/ui/surface-tab-order.test.mjs | 57 +++++++++++++++++++++++++++++++ src/ui/surfaceKeyboard.js | 6 +++- 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/ui/surface-tab-order.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..cbd99b26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..3d9c59110 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/ui/surface-tab-order.test.mjs b/src/ui/surface-tab-order.test.mjs new file mode 100644 index 000000000..c3f335407 --- /dev/null +++ b/src/ui/surface-tab-order.test.mjs @@ -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(); +}); diff --git a/src/ui/surfaceKeyboard.js b/src/ui/surfaceKeyboard.js index 38417975a..141e22fa6 100644 --- a/src/ui/surfaceKeyboard.js +++ b/src/ui/surfaceKeyboard.js @@ -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];