1// Accessibility enhancements for keyboard + screen-reader users. 2// 3// Several primary controls in Odysseus are authored as click-only <div>s 4// (most notably the whole sidebar navigation: New Chat, Search, Brain, 5// Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes, 6// Tasks, Theme, plus the account row). <div>s are not in the tab order and 7// are not announced as buttons, so keyboard and screen-reader users cannot 8// reach or operate them. 9// 10// This module enhances those rows in place â making them focusable 11// (tabindex=0), announcing them as buttons when it's safe to do so, and 12// activating them with Enter / Space â without changing how they look or 13// how they behave for mouse users. The visible focus ring already exists in 14// style.css (`.list-item:focus-visible`); it simply never fired because the 15// rows were never focusable. 16 17(function () { 18 'use strict'; 19 20 // Click-as-button rows we want reachable by keyboard. 21 var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(','); 22 23 // Native interactive descendants. If a row contains one of these we must 24 // NOT give the row role="button" â a button inside a button is invalid 25 // (axe "nested-interactive") and confuses screen readers. Such rows still 26 // become focusable + Enter/Space-activatable, just without the role. 27 var NESTED_INTERACTIVE = 28 'a[href],button,input,select,textarea,[contenteditable="true"],[tabindex]:not([tabindex="-1"])'; 29 30 function enhanceRow(el) { 31 if (!el || el.nodeType !== 1 || el.dataset.a11yEnhanced === '1') return; 32 var tag = el.tagName; 33 // Leave genuine native controls alone. 34 if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || 35 tag === 'SELECT' || tag === 'TEXTAREA') return; 36 37 el.dataset.a11yEnhanced = '1'; 38 if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); 39 el.setAttribute('data-a11y-activatable', '1'); 40 41 if (!el.querySelector(NESTED_INTERACTIVE) && !el.hasAttribute('role')) { 42 el.setAttribute('role', 'button'); 43 } 44 45 // Guarantee an accessible name. Visible text normally supplies it; fall 46 // back to the title attribute for icon-only rows. 47 if (!el.getAttribute('aria-label') && 48 !(el.textContent || '').trim() && 49 el.getAttribute('title')) { 50 el.setAttribute('aria-label', el.getAttribute('title')); 51 } 52 } 53 54 function enhanceAll(root) { 55 (root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow); 56 } 57 58 // ---- Modal dialogs ----------------------------------------------------- 59 // Odysseus modals are plain <div class="modal-content"> boxes. Marking 60 // them as ARIA dialogs lets screen readers announce them as dialogs and 61 // exempts their content from the "all content in landmarks" rule. We also 62 // normalize the modal title to heading level 2 (one below the page <h1>) 63 // so heading order stays valid no matter which tag the markup uses. 64 var titleSeq = 0; 65 // Each modal "kind" is a container selector plus where to find its title 66 // heading. Standard modals use .modal-content/.modal-header; the docked 67 // Notes pane uses its own markup. 68 var MODAL_KINDS = [ 69 { 70 sel: '.modal-content', 71 heading: '.modal-header h1, .modal-header h2, .modal-header h3, ' + 72 '.modal-header h4, .modal-header h5, .modal-header h6' 73 }, 74 { sel: '.notes-pane', heading: '.notes-pane-title' } 75 ]; 76 var MODAL_SEL = MODAL_KINDS.map(function (k) { return k.sel; }).join(','); 77 78 function enhanceModal(mc, headingSel) { 79 if (!mc || mc.nodeType !== 1 || mc.dataset.a11yDialog === '1') return; 80 mc.dataset.a11yDialog = '1'; 81 if (!mc.hasAttribute('role')) mc.setAttribute('role', 'dialog'); 82 if (!mc.hasAttribute('aria-modal')) mc.setAttribute('aria-modal', 'true'); 83 84 var heading = headingSel && mc.querySelector(headingSel); 85 if (heading) { 86 if (!heading.id) heading.id = 'a11y-modal-title-' + (++titleSeq); 87 if (!mc.hasAttribute('aria-labelledby')) { 88 mc.setAttribute('aria-labelledby', heading.id); 89 } 90 // Modal titles sit one level below the page <h1>; normalize so heading 91 // order stays valid regardless of the tag the markup happens to use. 92 if (!heading.hasAttribute('aria-level')) heading.setAttribute('aria-level', '2'); 93 } 94 } 95 96 function enhanceModals(root) { 97 var scope = root || document;
98 MODAL_KINDS.forEach(function (k) { 99 scope.querySelectorAll(k.sel).forEach(function (mc) { enhanceModal(mc, k.heading); }); 100 }); 101 } 102 103 function headingSelFor(el) { 104 for (var i = 0; i < MODAL_KINDS.length; i++) { 105 if (el.matches(MODAL_KINDS[i].sel)) return MODAL_KINDS[i].heading; 106 } 107 return null; 108 } 109 110 // Delegated keyboard activation. We only act when the focused element is 111 // itself an enhanced row (keydown targets the focused element), so a press 112 // on a nested native button is left to the browser's own handling. 113 document.addEventListener('keydown', function (e) { 114 if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return; 115 var el = e.target; 116 if (!el || !el.matches || !el.matches('[data-a11y-activatable]')) return; 117 e.preventDefault(); // Space would otherwise scroll the page 118 el.click(); 119 }); 120 121 function init() { 122 enhanceAll(document); 123 enhanceModals(document); 124 125 // Sidebar content is re-rendered as the user navigates (session lists, 126 // tool sub-rows, etc.). Watch for new rows and enhance them too. 127 var sidebar = document.getElementById('sidebar'); 128 if (sidebar && 'MutationObserver' in window) { 129 new MutationObserver(function (muts) { 130 for (var i = 0; i < muts.length; i++) { 131 var added = muts[i].addedNodes; 132 for (var j = 0; j < added.length; j++) { 133 var n = added[j]; 134 if (n.nodeType !== 1) continue; 135 if (n.matches && n.matches(ROW_SELECTOR)) enhanceRow(n); 136 if (n.querySelectorAll) enhanceAll(n); 137 } 138 } 139 }).observe(sidebar, { childList: true, subtree: true }); 140 } 141 142 // Some modals (Notes, Tasks, â¦) are injected at runtime, usually as 143 // direct children of <body>. Catch those without paying for a deep 144 // subtree observer over the whole document. 145 if ('MutationObserver' in window) { 146 new MutationObserver(function (muts) { 147 for (var i = 0; i < muts.length; i++) { 148 var added = muts[i].addedNodes; 149 for (var j = 0; j < added.length; j++) { 150 var n = added[j]; 151 if (n.nodeType !== 1) continue; 152 if (n.matches && n.matches(MODAL_SEL)) enhanceModal(n, headingSelFor(n)); 153 if (n.querySelector && n.querySelector(MODAL_SEL)) enhanceModals(n); 154 } 155 } 156 }).observe(document.body, { childList: true }); 157 } 158 } 159 160 if (document.readyState === 'loading') { 161 document.addEventListener('DOMContentLoaded', init); 162 } else { 163 init(); 164 } 165})();
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.