PageSourceSearch

https://smrtbld.ai/js/utils/tablist-keyboard.js

js smrtbld.ai collected 2026-10-05 15:08:51 UTC 16,358 bytes, 309 lines download raw bytes

1/**
2 * Tablist keyboard — the WAI-ARIA Tabs pattern's keyboard half, delivered once
3 * for every authored `[role="tablist"]` in the app (CA-110 / MCF-0318).
4 *
5 * CA-098 / CA-100 / CA-105 announced `role="tablist"` / `role="tab"` /
6 * `aria-selected` on the strips. An announcement without the keyboard half is a
7 * promise the app does not keep: a screen-reader user told "tab list" expects
8 * Arrow/Home/End to move between tabs, and a keyboard user expects the strip to
9 * hold exactly ONE tab stop (roving `tabindex`) rather than N. `DESIGN_BIBLE.md`
10 * §Accessibility (:5856) already requires "Arrow keys for menus/tabs".
11 *
12 * WHY IT LIVES HERE AND NOT IN ELEVEN PAGE MODULES
13 * -------------------------------------------------
14 * Every strip already passes through one of two render seams — `afterRender()`
15 * (`utils/dom.js`, which `render()`/`renderTo()` call on every template render)
16 * or `initTabNav()` (`utils/tab-nav-mode.js`). Wiring those two seams reaches all
17 * 31 authored tablists with zero per-module edits.
18 *
19 * WHY THE ROVING `tabindex` FOLLOWS THE *WRITE*, NOT THE CLICK
20 * ------------------------------------------------------------
21 * `aria-selected` is already correct everywhere, so the roving `tabindex` is
22 * derivable — but its publication timing is host-owned and NOT uniform.
23 * `accounting.js:608-631`, `hr.js:655-664` and `pre-construction.js:580-597`
24 * write it synchronously inside the click handler. `contract-admin.js` does not:
25 * its strip handler is `async` (:680-684) and `switchSubModule` reaches
26 * `await destroyCaMarkups()` (:713, :746) BEFORE the file's only `aria-selected`
27 * write (:791-796). Anything that reads `aria-selected` during the synchronous
28 * click dispatch therefore reads the PREVIOUS tab on any Markups transition —
29 * on the beta lane's own strip.
30 *
31 * So the trigger is a single document-level `MutationObserver` filtered to
32 * `aria-selected`. Observer callbacks are microtasks queued by the write itself,
33 * so the tab stop follows the state no matter when, or from what async depth,
34 * the host publishes it. No handler-ordering assumption remains.
35 *
36 * COEXISTENCE
37 * -----------
38 * `accounting-cost-code-breakdown.js:181-195` owns its own tablist-level
39 * `keydown` and calls `preventDefault()` at :191. Being bound on the tablist it
40 * runs before this document-level listener in the bubble path, so the
41 * `defaultPrevented` bail below is what stops a double activation. A host that
42 * wants the keyboard for itself takes it by preventing the default, exactly as
43 * the platform intends.
44 *
45 * ACTIVATION MODE - AUTOMATIC BY DEFAULT, MANUAL WHERE A TAB COSTS A TEARDOWN
46 * ---------------------------------------------------------------------------
47 * `data-tab-activation="manual"` on the tablist switches Arrow/Home/End from
48 * "move focus AND activate" to "move focus only". Enter/Space then commits
49 * through the native <button> path, which this helper never touches - NAV_KEYS
50 * is Arrow/Home/End only. The WAI-ARIA APG Tabs pattern makes exactly this
51 * distinction: automatic activation is specified only where showing the panel
52 * is inexpensive.
53 *
54 * THIRTEEN strips carry the attribute - the module-level
55 * `<nav id="sub-module-tabs">` in accounting, bids-tendering, contract-admin,
56 * deficiency-list, design-dev, hr, permitting, post-construction,
57 * pre-construction, project-pursuit, projects, proposals and tenders
58 * `-main.html`. It sits on the NAV, and `renderSubModuleTabs`
59 * (`utils/sub-module-icons.js:86`) rewrites that nav's `innerHTML` rather than
60 * replacing the nav, so a strip refresh cannot erase it. The other eighteen
61 * authored tablists carry no attribute and keep automatic activation.
62 *
63 * Ruling: `Developer/PM/DECISIONS.md:19` (Dan, 2026-08-27, "i approve option 2";
64 * CA-114). One activation on the Contract Administration strip is
65 * `switchSubModule` (`contract-admin.js:687-1026`) - three awaits including
66 * `destroyCaMarkups()` - ending in `updateUrl(true)` -> `page.show()`, i.e. a
67 * real history entry per press; arrowing a Contractor strip end to end was 18 of
68 * them. The accepted cost is that the affordance now differs between strip
69 * types. That is ruled, not a defect - do not re-litigate it.
70 */
71
72const NAV_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
73
74let delegatesInstalled = false;
75let ariaObserver = null;
76
77/**
78 * `:scope > [role="tab"]` — direct children only. `tab-nav-mode.js`'s
79 * `.tab-nav-mode-toggle` (:46-47), `.tab-overflow-more` (:198) and
80 * `.tab-overflow-dropdown` (:251) are all excluded by construction, so this
81 * helper is correct beside that chrome without needing it re-roled.
82 */
83function tabsOf(tablist) {
84    const kids = tablist?.children ? Array.from(tablist.children) : [];
85    return kids.filter(el => el.matches?.('[role="tab"]'));
86}
87
88/**
89 * Manual activation is opt-in per strip, declared in the template on the tablist
90 * itself (see ACTIVATION MODE above). Absent, malformed or any other value means
91 * automatic - the pre-CA-114 behavior every unmarked strip keeps.
92 */
93function isManualActivation(tablist) {
94    return tablist?.getAttribute('data-tab-activation') === 'manual';
95}
96
97/**
98 * Compact mode moves overflow tabs behind "More" by adding `.hidden`
99 * (`tab-nav-mode.js` greedify), and mobile always applies compact (:40-42).
100 * An arrow key must never land focus on one of those.
101 *
102 * `offsetParent` is only consulted when the STRIP itself is laid out. A tablist
103 * inside a hidden ancestor (the eight `hidden`-classed sub-tab navs) or under a
104 * `position: fixed` shell reports `offsetParent === null` for every tab, and
105 * treating those as hidden would leave the strip with no tab stop at all.
106 */
107function isHiddenTab(tab, useLayout) {
108    if (tab.hidden) return true;
109    if (tab.classList?.contains('hidden')) return true;
110    if (useLayout && tab.offsetParent === null) return true;
111    return false;
112}
113
114function visibleTabs(tablist) {
115    const all = tabsOf(tablist);
116    if (all.length === 0) return all;
117    const useLayout = tablist.offsetParent !== undefined && tablist.offsetParent !== null;
118    const visible = all.filter(t => !isHiddenTab(t, useLayout));
119    return visible.length ? visible : all;
120}
121
122/**
123 * Derive the roving `tabindex` for one strip from the `aria-selected` its host
124 * already maintains: the selected visible tab is the single tab stop, every
125 * other tab is `-1`. No selection yet (first render) puts the stop on the first
126 * visible tab so the strip is always reachable by Tab.
127 *
128 * ONE exception, CA-114: a MANUAL strip whose own focused tab is in the visible
129 * set keeps the stop on THAT tab, because between presses focus and selection
130 * are deliberately apart and this function's non-keystroke call sites (resize,
131 * mode toggle) fire inside that window.
132 *
133 * Idempotent, safe on a detached node, and a no-op on an element with no
134 * `[role="tab"]` children.
135 *
136 * @param {Element|null} tablistEl
137 */
138export function syncTablist(tablistEl) {
139    if (!tablistEl) return;
140    const tabs = tabsOf(tablistEl);
141    if (tabs.length === 0) return;
142    installDelegates();
143    const visible = visibleTabs(tablistEl);
144    let stop = visible.find(t => t.getAttribute('aria-selected') === 'true') || visible[0] || null;
145    // CA-114 [functionality:2]: on a manual strip the stop legitimately sits on the FOCUSED tab
146    // while `aria-selected` still names the selected one, and this function has two call sites
147    // that fire inside exactly that window with no commit having happened - the compact-mode
148    // ResizeObserver (`tab-nav-mode.js:303-310`) and the wrap/compact toggle (`:61`). Deriving
149    // from `aria-selected` there would yank the tab stop out from under the user's own focus.
150    // All three conditions are required, so a detached node, an unmarked strip, or focus anywhere
151    // else takes the pre-CA-114 path byte-for-byte. `visible` is the already-filtered set, so a
152    // focused tab hidden behind compact-mode overflow cannot claim the stop either.
153    const focusedTab = typeof document !== 'undefined' ? document.activeElement : null;
154    if (isManualActivation(tablistEl) && focusedTab && visible.includes(focusedTab)) stop = focusedTab;
155    for (const tab of tabs) tab.tabIndex = tab === stop ? 0 : -1;
156}
157
158/**
159 * Sweep a freshly rendered container for tablists and give each its tab stop.
160 *
161 * The CONTAINER ITSELF counts as a candidate: ten sub-tab renderers call
162 * `afterRender(tabNav)` with the tablist node directly (e.g.
163 * `ca-overview-details.js:179-181` re-rendering `contract-admin-main.html:137`'s
164 * `<nav id="ca-overview-sub-tabs" role="tablist">`), and each such re-render
165 * replaces the buttons through `innerHTML` with fresh template-authored
166 * `aria-selected` — which fires no attribute mutation record. A descendants-only
167 * sweep would leave those strips with no tab stop until the next click.
168 *
169 * @param {Element|null} container
170 */
171export function initTablists(container) {
172    if (!container) return;
173    const found = [];
174    if (container.matches?.('[role="tablist"]')) found.push(container);
175    if (container.querySelectorAll) found.push(...container.querySelectorAll('[role="tablist"]'));
176    if (found.length === 0) return;
177    installDelegates();
178    for (const nav of found) syncTablist(nav);
179}
180
181function onTablistKeydown(e) {
182    // A host that owns its own tablist keyboard has already handled this key
183    // (and, being bound on the tablist, ran first in the bubble path). Bail
184    // BEFORE anything else so it activates once, not twice.
185    if (e.defaultPrevented) return;
186    // CA-114 [functionality:1]: HELD ENTER RE-FIRES THE COMMIT N TIMES, and nothing braked it.
187    // NAV_KEYS is Arrow/Home/End only, so this function returns two lines below before the
188    // `e.repeat` brake ever sees Enter - while a native <button> fires `click` on EVERY Enter
189    // keydown repeat. On the thirteen manual strips that is this card's own cost arriving through
190    // the key that now COMMITS: twelve of the thirteen hosts re-enter the
190ir switch on a same-tab
191    // activation (only `project-pursuit.js:1020` guards it), `contract-admin.js:693` reaches the
192    // destructive teardown and ends at `updateUrl(true)` -> `page.show` - a real history entry per
193    // repeat - and not one host carries an in-flight guard.
194    //
195    // ENTER ONLY, NEVER SPACE. That is load-bearing, not caution: a native button fires Space's
196    // `click` on KEYUP, so Space cannot repeat-click at all, and preventDefault()ing a repeated
197    // Space KEYDOWN would cancel the pending activation and break the commit outright.
198    //
199    // SCOPED to a tab inside a MANUAL tablist, for the same reason the `e.repeat` brake below is
200    // scoped to keys this helper consumes: an unscoped Enter guard reaches every input, button and
201    // link in the app. In-repo precedents for the shape: `portal-enter.js:157`,
202    // `portal-reset.js:114`, `ca-photos-map.js:277`.
203    if (e.key === 'Enter' && e.repeat) {
204        const heldTab = e.target?.closest?.('[role="tab"]');
205        const heldList = heldTab?.closest?.('[role="tablist"]');
206        if (heldList && isManualActivation(heldList)) { e.preventDefault(); return; }
207    }
208    if (!NAV_KEYS.includes(e.key)) return;
209
210    const tab = e.target?.closest?.('[role="tab"]');
211    if (!tab) return;
212    const tablist = tab.closest('[role="tablist"]');
213    if (!tablist) return;
214
215    const tabs = visibleTabs(tablist);
216    const current = tabs.indexOf(tab);
217    if (current === -1) return;
218
219    // CA-110 [functionality:2], bounded half: key auto-repeat has no brake otherwise. Holding
220    // ArrowRight on one of the thirteen module strips fires overlapping async teardown/init
221    // cycles at the OS repeat rate and pushes a real history entry per repeat
222    // (contract-admin.js:1062 -> updateUrl(true) -> page.show). Deliberate presses are unchanged.
223    //
224    // PLACEMENT IS LOAD-BEARING - DO NOT MOVE THIS GUARD EARLIER. The steward packet originally
225    // said "after the NAV_KEYS test"; the CA-110 BOUNDED-RECHECK ruled that position WRONG and the
226    // steward owned the error. At that earlier position this preventDefault() fires for EVERY
227    // repeated Arrow/Home/End anywhere in the document, so holding ArrowLeft, Home or End to move a
228    // caret in any input or textarea would stop working app-wide. The guard must stay AFTER the
229    // tab/tablist resolution above, which scopes it to exactly the keys this helper consumes.
230    if (e.repeat) { e.preventDefault(); return; }
231
232    let next = current;
233    if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
234    else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
235    else if (e.key === 'Home') next = 0;
236    else next = tabs.length - 1;
237
238    e.preventDefault();
239    const target = tabs[next];
240    // CA-110 [functionality:3]: focus did not move - Home on the first tab, End on the last, or a
241    // single-tab strip. Re-clicking here re-enters the host same-tab path, and contract-admin.js:692
242    // treats a same-tab activation as a destructive re-init (Apryse teardown at :714 for Markups).
243    // The key stays preventDefault()ed above, so the page still does not scroll.
244    if (target === tab) return;
245    target.focus();
246    // CA-114: ACTIVATION SPLITS BY COST (ruling: Developer/PM/DECISIONS.md:19, Dan 2026-08-27).
247    //
248    // MANUAL - the thirteen module strips. Arrow moves focus only, and the roving tab stop moves
249    // WITH FOCUS: the strip must keep exactly one Tab stop, and it has to be where the user
250    // actually is, or Shift+Tab-then-Tab would drop them back on the selected tab instead. Nothing
251    // is activated, so `aria-selected` and `.active` stay on the still-selected tab - that gap is
252    // precisely what makes "focused but not selected" legible. Enter/Space commits through the
253    // native <button> click path; NAV_KEYS is Arrow/Home/End only, so this helper never sees them.
254    //
255    // The two tab-stop writers do not fight - but NOT because `syncTablist` is rare. It never runs
256    // on a keystroke, which is the part that matters here; besides a re-render and an
257    // `aria-selected` mutation it ALSO fires from the compact-mode ResizeObserver
258    // (`tab-nav-mode.js:303-310`) and the wrap/compact toggle (`:61`), either of which can land
259    // inside this between-press window. So `syncTablist` carries its own manual-strip arm that
260    // keeps the stop on the focused tab, and the instant the host publishes a commit
261    // (`contract-admin.js:795` and its twelve peers) the observer hands the stop back to the newly
262    // selected tab. The write below owns only the between-press state, and it is unreachable on
263    // any strip without the attribute.
264    //
265    // AUTOMATIC - every unmarked strip. Activation follows focus: the semantics of the one in-repo
266    // precedent (`accounting-cost-code-breakdown.js:193-195`) and of the APG Tabs pattern for a
267    // panel that is inexpensive to show.
268    if (isManualActivation(tablist)) {
269        for (const t of tabsOf(tablist)) t.tabIndex = t === target ? 0 : -1;
270        return;
271    }
272    target.click();
273}
274
275function onAriaSelectedMutations(records) {
276    const seen = new Set();
277    for (const record of records) {
278        const tab = record.target;
279        if (!tab?.matches?.('[role="tab"]')) continue;
280        const tablist = tab.closest?.('[role="tablist"]');
281        if (!tablist || seen.has(tablist)) continue;
282        seen.add(tablist);
283        syncTablist(tablist);
284    }
285}
286
287/**
288 * Bind the two document-level delegates exactly once per document. Both are
289 * permanent by design — they are the app's tabs mechanism, not a page's state,
290 * and there is nothing to tear down on navigation.
291 */
292function installDelegates() {
293    if (delegatesInstalled) return;
294    if (typeof document === 'undefined' || !document) return;
295    delegatesInstalled = true;
296
297    document.addEventListener('keydown', onTablistKeydown);
298
299    /* observer-install */
300    if (typeof MutationObserver !== 'undefined') {
301        ariaObserver = new MutationObserver(onAriaSelectedMutations);
302        ariaObserver.observe(document.documentElement || document.body, {
303            attributes: true,
304            attributeFilter: ['aria-selected'],
305            subtree: true,
306        });
307    }
308    /* /observer-install */
309}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.