PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/a11y.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:43:10 UTC 6,928 bytes, 165 lines download raw bytes

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.