Files
Haven/public/js/modules/app-accessibility.js
T
Amnibroandancsemi f0202c4373 Settings: an Accessibility section for text size, spacing, fonts, contrast and motion (#5775)
Zoom moves into a new Accessibility section (the section keeps its
section-zoom id, which layout plugins anchor to) next to:

- Message text size, 80 to 200 %, separate from Zoom: messages, threads,
  forum posts, code blocks and the message boxes.
- Line spacing (normal, relaxed, loose) and letter spacing (normal, wide).
- Font: the theme's, the system's, Atkinson Hyperlegible or OpenDyslexic,
  self-hosted under SIL OFL 1.1 with their licenses in /fonts.
- High contrast: secondary text and borders pulled toward each theme's own
  text color, and a thick focus ring.
- Reduce motion: follow system, on or off. One html.reduce-motion class now
  covers every animation, the theme effects, the shimmering role colors,
  smooth scrolling, and GIFs, animated emoji and profile pictures, which
  wait for hover or tap. The old prefers-reduced-motion rules use it too.
- Underline links, and larger click targets.

Everything is per device and applied by theme-init.js before first paint,
which hands the same read/apply functions to the settings page.

Also: keyboard focus rings, keyboard-reachable settings nav rows,
aria-labels on the icon buttons in the channel header and message box, and
aria-pressed on the picker buttons.

Co-authored-by: ancsemi <ancsemi@gmail.com>
2026-10-13 17:06:23 -04:00

87 lines
3.4 KiB
JavaScript

// Accessibility settings (Settings > Accessibility): message text size, line
// and letter spacing, a readable font, high contrast, reduce motion,
// underlined links and larger click targets. Zoom sits in the same section and
// keeps its own code in app-appearance.js.
//
// theme-init.js owns the rules: it reads every option and applies it before
// first paint, and publishes the same two functions as window.HavenA11y. This
// module saves what the controls pick and asks it to apply again, so a page
// load and a live change go through one path. Everything is per device.
export default {
_setupAccessibility() {
const a11y = window.HavenA11y;
if (!a11y) return; // theme-init.js did not run (an old cached copy): nothing to drive
const reapply = () => a11y.apply(a11y.read());
const prefs = a11y.read();
const textSlider = document.getElementById('text-size-slider');
if (textSlider) {
this._bindStepSlider({
slider: textSlider,
label: document.getElementById('text-size-value'),
outBtn: document.getElementById('text-size-out-btn'),
inBtn: document.getElementById('text-size-in-btn'),
resetBtn: document.getElementById('text-size-reset-btn'),
value: prefs.textSize,
apply: (value, persist) => {
if (!persist) return; // theme-init.js already applied the saved size
this._saveA11y('haven-text-size', value === 100 ? null : String(value));
reapply();
},
});
}
this._setupPicker('line-spacing-picker', 'haven-line-spacing', ['normal', 'relaxed', 'loose'], 'normal', 'lineSpacing', reapply);
this._setupPicker('letter-spacing-picker', 'haven-letter-spacing', ['normal', 'wide'], 'normal', 'letterSpacing', reapply);
this._setupPicker('reduce-motion-picker', 'haven-reduce-motion', ['system', 'on', 'off'], 'system', 'reduceMotion', reapply);
const font = document.getElementById('readable-font-select');
if (font) {
font.value = prefs.font;
font.addEventListener('change', () => {
this._saveA11y('haven-readable-font', font.value === 'theme' ? null : font.value);
reapply();
});
}
// [checkbox id, storage key, stored value when on, field in prefs]
[
['a11y-high-contrast', 'haven-contrast', 'high', 'highContrast'],
['a11y-underline-links', 'haven-underline-links', '1', 'underlineLinks'],
['a11y-large-targets', 'haven-large-targets', '1', 'largeTargets'],
].forEach(([id, key, onValue, field]) => {
const box = document.getElementById(id);
if (!box) return;
box.checked = prefs[field];
box.addEventListener('change', () => {
this._saveA11y(key, box.checked ? onValue : null);
reapply();
});
});
// Theme effects listen for this themselves (theme.js). Pictures that were
// frozen or left playing under the old setting are re-checked here, and
// that includes "Follow system" seeing the OS setting change.
document.addEventListener('haven:reduce-motion-change', () => {
this._applyViewerAnimPref?.();
this._applyViewerChatAnimPref?.();
});
},
// A default is stored as no key at all, the way a fresh device has it.
_saveA11y(key, value) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch { /* storage blocked (private mode): nothing is remembered, nothing else breaks */ }
},
// For scrollIntoView / scrollTo calls that would glide: reduce motion jumps.
_scrollBehavior() {
return this._motionReduced() ? 'auto' : 'smooth';
},
};