// Accessibility enhancements for keyboard + screen-reader users. // // Several primary controls in Odysseus are authored as click-only
s // (most notably the whole sidebar navigation: New Chat, Search, Brain, // Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes, // Tasks, Theme, plus the account row).
s are not in the tab order and // are not announced as buttons, so keyboard and screen-reader users cannot // reach or operate them. // // This module enhances those rows in place — making them focusable // (tabindex=0), announcing them as buttons when it's safe to do so, and // activating them with Enter / Space — without changing how they look or // how they behave for mouse users. The visible focus ring already exists in // style.css (`.list-item:focus-visible`); it simply never fired because the // rows were never focusable. (function () { 'use strict'; // Click-as-button rows we want reachable by keyboard. var ROW_SELECTOR = [ '#sidebar .list-item', '#user-bar-profile', '.sidebar-brand', '.export-dropdown-item', '.dropdown-item' ].join(','); // Native interactive descendants. If a row contains one of these we must // NOT give the row role="button" — a button inside a button is invalid // (axe "nested-interactive") and confuses screen readers. Such rows still // become focusable + Enter/Space-activatable, just without the role. var NESTED_INTERACTIVE = 'a[href],button,input,select,textarea,[contenteditable="true"],[tabindex]:not([tabindex="-1"])'; function enhanceRow(el) { if (!el || el.nodeType !== 1 || el.dataset.a11yEnhanced === '1') return; var tag = el.tagName; // Leave genuine native controls alone. if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; el.dataset.a11yEnhanced = '1'; if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); el.setAttribute('data-a11y-activatable', '1'); if (!el.querySelector(NESTED_INTERACTIVE) && !el.hasAttribute('role')) { el.setAttribute('role', 'button'); } // Guarantee an accessible name. Visible text normally supplies it; fall // back to the title attribute for icon-only rows. if (!el.getAttribute('aria-label') && !(el.textContent || '').trim() && el.getAttribute('title')) { el.setAttribute('aria-label', el.getAttribute('title')); } } function enhanceAll(root) { (root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow); } // Settings form labels are written as // with the control as a sibling/descendant, but are not associated via for/id, // so screen readers cannot name the inputs. Associate each label with its // control when the mapping is unambiguous (a single form control per row). var settingsLabelSeq = 0; function associateSettingsLabels(root) { (root || document).querySelectorAll('.settings-label').forEach(function (label) { if (label.hasAttribute('for')) return; var control = null; var sib = label.nextElementSibling; if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) { control = sib; } else { var row = label.closest('.settings-row') || label.parentElement; if (row) { var controls = row.querySelectorAll('input,select,textarea'); if (controls.length === 1) control = controls[0]; } } if (!control || control.closest('label')) return; if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq); if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id); }); } // ---- Modal dialogs ----------------------------------------------------- // Odysseus modals are plain