Skip to the content

Capsid menu

components/theme-switch/theme-switch.react.tsx

133 lines and 15 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of theme-switch.react.tsx
FunctionLinesComplexityCoveredCRAPMutation
Sun15 to 201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
Moon21 to 251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
ThemeSwitch32 to 702No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:36>36 to 452No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onTheme39 to 422No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:44>44 to 441No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:51>51 to 661No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:58>58 to 611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
ThemeButton102 to 1424No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:106>106 to 1192No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onTheme110 to 1102No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onSystem112 to 1121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:115>115 to 1181No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:120>120 to 1263No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:129>129 to 1291No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/theme-switch/theme-switch.ts, components/tooltip/tooltip.ts. Imported by components/site-header/site-header.react.tsx.

Source

import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { attach as attachTip } from "../tooltip/tooltip.ts";
import { applyTheme, effectiveTheme, readTheme, themeButtonLabel, THEME_PREF, toggleTheme, type ThemeChoice } from "./theme-switch.ts";

export interface ThemeSwitchProps {
  // The localStorage key; "cap-theme" by default.
  prefKey?: string;
  // The group's name, read by a screen reader; hidden from sight in a top bar.
  legend?: string;
  // What the server rendered (from a cookie), before the stored choice is read. Leave it out
  // and the control shows the operating system's theme once it mounts.
  initial?: ThemeChoice;
}

const Sun = () => (
  <svg className="cap-theme-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
    <circle cx="8" cy="8" r="3" />
    <path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1" />
  </svg>
);
const Moon = () => (
  <svg className="cap-theme-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
    <path d="M13.5 9.3A5.5 5.5 0 0 1 6.7 2.5a5.5 5.5 0 1 0 6.8 6.8z" />
  </svg>
);

const CHOICES: Array<{ value: ThemeChoice; label: string; icon: ReactNode }> = [
  { value: "light", label: "Light", icon: <Sun /> },
  { value: "dark", label: "Dark", icon: <Moon /> },
];

export function ThemeSwitch({ prefKey = THEME_PREF, legend = "Theme", initial }: ThemeSwitchProps) {
  const name = useId();
  const [choice, setChoice] = useState<ThemeChoice | null>(initial ?? null);

  useEffect(() => {
    // Remembered, else what the page shows: the system's theme on a first visit.
    setChoice(readTheme(prefKey) ?? effectiveTheme());
    const onTheme = (e: Event) => {
      const c = (e as CustomEvent<{ choice: ThemeChoice }>).detail?.choice;
      if (c) setChoice(c);
    };
    document.addEventListener("cap-theme", onTheme);
    return () => document.removeEventListener("cap-theme", onTheme);
  }, [prefKey]);

  return (
    <fieldset className="cap-theme" data-cap="theme-switch">
      <legend className="cap-sr-only">{legend}</legend>
      <div className="cap-theme-options">
        {CHOICES.map((c) => (
          <label key={c.value}>
            <input
              type="radio"
              name={name}
              value={c.value}
              checked={choice === c.value}
              onChange={() => {
                setChoice(c.value);
                applyTheme(c.value, { key: prefKey });
              }}
            />
            {c.icon}
            <span className="cap-theme-text">{c.label}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

export interface ThemeButtonProps {
  // The localStorage key; "cap-theme" by default.
  prefKey?: string;
  // The key the app binds to toggleTheme(), shown in the tip as a key cap ("T"). The app binds it.
  shortcut?: string;
  // Extra classes: the site header adds its own icon-control class.
  className?: string;
  // What the server rendered (from a cookie); leave it out and the label follows the page once
  // it mounts. The icon follows the page by CSS either way.
  initial?: ThemeChoice;
}

// The 24-box icons the header's controls use (1.6 stroke): a moon while the page is light (press
// for dark), a sun while it is dark. Both are in the markup; CSS shows the one for the theme that
// is showing, so the picture is right before any script runs.
const BUTTON_MOON = (
  <svg className="cap-theme-icon" data-show="light" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
    <path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" />
  </svg>
);
const BUTTON_SUN = (
  <svg className="cap-theme-icon" data-show="dark" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
    <circle cx="12" cy="12" r="4" />
    <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
  </svg>
);

// The theme as one icon button (the site header's theme control, Dustin's answer 2 of
// 2026-10-13): Light and Dark only, no System; it says what it does, "Switch to dark theme", in
// its name and its tip.
export function ThemeButton({ prefKey = THEME_PREF, shortcut, className, initial }: ThemeButtonProps) {
  const [showing, setShowing] = useState<ThemeChoice>(initial ?? "light");
  const button = useRef<HTMLButtonElement>(null);
  const tip = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const t = readTheme(prefKey);
    if (t) applyTheme(t, { key: prefKey, remember: false });
    setShowing(effectiveTheme());
    const onTheme = (e: Event) => setShowing((e as CustomEvent<{ choice: ThemeChoice }>).detail?.choice ?? effectiveTheme());
    const media = window.matchMedia("(prefers-color-scheme: dark)");
    const onSystem = () => setShowing(effectiveTheme());
    document.addEventListener("cap-theme", onTheme);
    media.addEventListener("change", onSystem);
    return () => {
      document.removeEventListener("cap-theme", onTheme);
      media.removeEventListener("change", onSystem);
    };
  }, [prefKey]);
  useEffect(() => {
    const b = button.current;
    if (!b || !tip.current) return;
    const detach = attachTip(b, tip.current);
    b.removeAttribute("aria-describedby");
    return detach;
  }, []);
  const label = themeButtonLabel(showing);
  return (
    <button ref={button} type="button" className={className ? `cap-theme-button ${className}` : "cap-theme-button"} data-cap="theme-switch" data-variant="button" data-cap-ready="" aria-label={label} onClick={() => setShowing(toggleTheme(prefKey))}>
      {BUTTON_MOON}
      {BUTTON_SUN}
      <span ref={tip} className="cap-theme-tip cap-popover" data-variant="tooltip" data-side="bottom" data-offset="6" popover="manual" role="tooltip" aria-hidden="true">
        <span className="cap-theme-tip-text">{label}</span>
        {shortcut ? (
          <span className="cap-keys">
            <kbd>{shortcut}</kbd>
          </span>
        ) : null}
      </span>
    </button>
  );
}