Skip to content
Coastal Healthcare Advocates

Source: prefs.js

/**
 * @file The Site preferences dialog: theme, text size, animations, reader
 * mode and analytics.
 *
 * ## How it fits together
 *
 * - Any element with `data-open-prefs` (the gear buttons in the header and
 *   footer, on every page) opens the dialog.
 * - The dialog's markup is built here, so every page gets the same one
 *   without repeating it in the HTML. It only exists with JavaScript on,
 *   which is also the only way to open it.
 * - The settings themselves live in `theme.js`, which applies them before
 *   first paint. This file reads and changes them through `window.chaPrefs`,
 *   and listens for `cha:prefs` so the controls stay in step with the
 *   header / footer theme toggles.
 * - The Analytics switch goes through `window.chaConsent` (`consent.js`).
 *   Pages without it simply don't show that row.
 *
 * Every control applies its change at once — the page behind the dialog is
 * the preview — so there is no Save button.
 *
 * ## Why `showModal()` here
 *
 * Unlike the contact dialog (`modal.js`, built around the page-level
 * challenge overlay Google's reCAPTCHA once needed), this one has no such
 * constraint, so the native modal dialog is fine: the browser makes the page
 * inert and handles Escape. Focus goes back to the opening button on close.
 *
 * Loaded as an ES module.
 *
 * @module prefs
 */

/**
 * Close icon, inlined because the secondary pages' sprite
 * (`src/partials/icons.html`) doesn't carry `#i-close`.
 * @type {string}
 */
const CLOSE_ICON =
  '<svg class="icon w-[1.1rem] h-[1.1rem]" viewBox="0 0 24 24" aria-hidden="true"><path d="M5.6 5.6 18.4 18.4M18.4 5.6 5.6 18.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';

/**
 * Builds one radio group as a row of segmented buttons.
 *
 * @param {string} name   The preference name (`theme` or `text`).
 * @param {string} legend The group's visible label.
 * @param {string} cls    Class list for the option row, written out in full
 *   at the call site so Tailwind's content scan keeps its rules.
 * @param {Array<Array<string>>} options `[value, label]` pairs.
 * @returns {string} The fieldset's HTML.
 */
function segmented(name, legend, cls, options) {
  const items = options
    .map(([value, label]) =>
      `<label class="prefs-seg-option" data-value="${value}">` +
        `<input type="radio" name="prefs-${name}" value="${value}">` +
        `<span>${label}</span>` +
      '</label>')
    .join('');
  return (
    '<fieldset class="prefs-group">' +
      `<legend class="prefs-label">${legend}</legend>` +
      `<div class="${cls}">${items}</div>` +
    '</fieldset>'
  );
}

/**
 * Builds one on/off row: a label and hint on the left, a switch on the right.
 *
 * @param {string} name  The preference name (used in the ids).
 * @param {string} label The visible label.
 * @param {string} hint  The one-line explanation under it.
 * @returns {string} The row's HTML.
 */
function toggleRow(name, label, hint) {
  return (
    `<div class="prefs-row" data-row="${name}">` +
      '<div class="prefs-row-text">' +
        `<label class="prefs-label" for="prefs-${name}">${label}</label>` +
        `<p class="prefs-hint" id="prefs-${name}-hint">${hint}</p>` +
      '</div>' +
      `<input type="checkbox" role="switch" class="prefs-switch" id="prefs-${name}" aria-describedby="prefs-${name}-hint">` +
    '</div>'
  );
}

/** Hint under Animations when the device already asks for reduced motion. */
const MOTION_HINT = 'Page movement and button sounds.';
const MOTION_HINT_OS = 'Your device is set to reduce motion, so animations stay off.';

/**
 * The whole dialog. Built when first opened, by which time `consent.js` has
 * run and `window.chaConsent` shows whether to offer the Analytics row.
 * @returns {string} The dialog's HTML.
 */
const markup = () =>
  '<dialog id="prefs-dialog" class="prefs-dialog" aria-labelledby="prefs-title">' +
    '<div class="prefs-body">' +
      '<h2 id="prefs-title" class="prefs-title text-h3 pr-12">' + // heading, with the gear that opened it
        '<svg aria-hidden="true"><use href="#i-gear"/></svg>' +
        '<span>Site preferences</span>' +
      '</h2>' +
      '<p class="prefs-lede">Changes apply right away and are saved in this browser only.</p>' +
      segmented('theme', 'Theme', 'prefs-seg prefs-seg--theme', [['light', 'Light'], ['dark', 'Dark'], ['system', 'Match device']]) +
      segmented('text', 'Text size', 'prefs-seg prefs-seg--text', [['md', 'Default'], ['lg', 'Large'], ['xl', 'Larger'], ['xxl', 'Largest']]) +
      '<div class="prefs-rows">' +
        toggleRow('motion', 'Animations', MOTION_HINT) +
        toggleRow('reader', 'Reader mode', 'One plain column with the decoration taken out.') +
        (window.chaConsent
          ? toggleRow('analytics', 'Analytics', 'Anonymous page counts (Cloudflare Web Analytics). No cookies, no personal or health details.')
          : '') +
      '</div>' +
      '<div class="prefs-actions">' +
        '<button type="button" class="btn btn-ghost" data-prefs-reset>Reset to defaults</button>' +
        '<button type="button" class="btn btn-primary" data-prefs-close>Done</button>' +
      '</div>' +
    '</div>' +
    `<button type="button" class="modal-close" data-prefs-close aria-label="Close site preferences" title="Close">${CLOSE_ICON}</button>` +
  '</dialog>';

/** The device's reduced-motion setting. @type {MediaQueryList} */
const osReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

/** The dialog, built on first open. @type {HTMLDialogElement|null} */
let dialog = null;

/** The button that opened the dialog, refocused on close. @type {HTMLElement|null} */
let trigger = null;

/**
 * Makes every control match the saved preferences.
 * @returns {void}
 */
function sync() {
  if (!dialog) return; // not built yet: nothing to update
  const p = window.chaPrefs.get(); // current preferences
  dialog.querySelectorAll('input[type="radio"]').forEach((input) => { // each theme / text-size option:
    const key = input.name.replace('prefs-', ''); // which preference it belongs to
    input.checked = p[key] === input.value; // checked if it's the saved value
  });

  const motion = dialog.querySelector('#prefs-motion'); // Animations switch
  const osOff = osReducedMotion.matches; // device already reduces motion?
  motion.checked = !osOff && p.motion !== 'off'; // on unless either says off
  motion.disabled = osOff; // the device setting wins, so don't offer it
  dialog.querySelector('#prefs-motion-hint').textContent = osOff ? MOTION_HINT_OS : MOTION_HINT; // explain why

  dialog.querySelector('#prefs-reader').checked = p.reader === 'on'; // Reader mode switch
  const analytics = dialog.querySelector('#prefs-analytics'); // Analytics switch, if shown
  if (analytics) analytics.checked = window.chaConsent.get(); // saved consent
}

/**
 * Builds the dialog and wires its controls. Runs once.
 * @returns {HTMLDialogElement} The dialog.
 */
function build() {
  document.body.insertAdjacentHTML('beforeend', markup()); // add it at the end of <body>
  const d = document.getElementById('prefs-dialog'); // find it

  // Radio groups: theme and text size.
  d.addEventListener('change', (e) => { // any control changed:
    const t = e.target; // the control
    if (t.type === 'radio') window.chaPrefs.set(t.name.replace('prefs-', ''), t.value); // theme / text size
    else if (t.id === 'prefs-motion') window.chaPrefs.set('motion', t.checked ? 'on' : 'off'); // animations
    else if (t.id === 'prefs-reader') window.chaPrefs.set('reader', t.checked ? 'on' : 'off'); // reader mode
    else if (t.id === 'prefs-analytics') window.chaConsent.set(t.checked); // analytics consent
  });

  // Close and reset buttons.
  d.addEventListener('click', (e) => { // a click inside the dialog box (or on its backdrop):
    if (e.target === d) return d.close(); // the backdrop area itself: close
    if (e.target.closest('[data-prefs-close]')) return d.close(); // a close button: close
    if (e.target.closest('[data-prefs-reset]')) { // the reset button:
      window.chaPrefs.reset(); // defaults for the display settings
      window.chaConsent?.reset(); // analytics back to its default
      sync(); // show the defaults (analytics has no event of its own)
    }
  });

  // Hand focus back to the opener. Browsers try, but Safari doesn't focus a
  // clicked button, so there'd be nothing to return to.
  d.addEventListener('close', () => trigger?.focus({ preventScroll: true }));

  return d; // hand back the dialog
}

/**
 * Opens the dialog, building it first if needed.
 * @param {HTMLElement} opener The element that was clicked.
 * @returns {void}
 */
function open(opener) {
  if (!window.chaPrefs) return; // theme.js missing: nothing to control
  if (!dialog) dialog = build(); // first open: build it
  if (dialog.open) return; // already open
  trigger = opener; // remember where to return focus
  sync(); // show the current settings
  dialog.showModal(); // open as a modal
  // Start on the selected theme option rather than the close button.
  dialog.querySelector('input[name="prefs-theme"]:checked')?.focus();
}

// Any [data-open-prefs] opens the dialog. Delegated, so one listener covers
// every trigger, including any in markup added after this runs.
document.addEventListener('click', (e) => { // any click on the page:
  const opener = e.target.closest?.('[data-open-prefs]'); // was it on a trigger?
  if (!opener) return; // no: ignore
  e.preventDefault(); // a link-styled trigger shouldn't navigate
  open(opener); // open the dialog
});

// Keep the controls in step with the header / footer theme toggles and with
// the device's light/dark and reduced-motion settings.
document.addEventListener('cha:prefs', sync);
osReducedMotion.addEventListener('change', sync);