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.