tmp/webapp-docs/frontend/components/ThemePicker.js

/**
 * The header "Appearance" control: an icon button that opens a popover for
 * choosing the base mode (System / Dark / Light) and the theme/accent. The
 * accent grid lists every theme in the registry — built-in system themes plus
 * any imported user themes — and offers Import / Export of theme files. Reads
 * and writes the live theme via `useTheme()`.
 * @module gui/components/ThemePicker
 */
import { useEffect, useRef, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { useTheme } from "../theme/ThemeProvider.jsx";
import { parseThemeFile, serializeTheme } from "../theme/themeFile.js";
import { PaletteIcon, SunIcon, MoonIcon, MonitorIcon } from "./icons.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
  appearance: {
    id: "theme.appearance",
    defaultMessage: "Appearance"
  },
  mode: {
    id: "theme.mode",
    defaultMessage: "Mode"
  },
  system: {
    id: "theme.system",
    defaultMessage: "System"
  },
  dark: {
    id: "theme.dark",
    defaultMessage: "Dark"
  },
  light: {
    id: "theme.light",
    defaultMessage: "Light"
  },
  accent: {
    id: "theme.accent",
    defaultMessage: "Accent"
  },
  importTheme: {
    id: "theme.import",
    defaultMessage: "Import theme…"
  },
  exportTheme: {
    id: "theme.export",
    defaultMessage: "Export"
  },
  removeTheme: {
    id: "theme.remove",
    defaultMessage: "Remove {name}"
  }
});
const MODES = [{
  id: "system",
  label: msgs.system,
  Icon: MonitorIcon
}, {
  id: "dark",
  label: msgs.dark,
  Icon: MoonIcon
}, {
  id: "light",
  label: msgs.light,
  Icon: SunIcon
}];

/**
 * @returns {JSX.Element} The header appearance (theme) picker.
 */
export default function ThemePicker() {
  const intl = useIntl();
  const {
    mode,
    setMode,
    accent,
    setAccent,
    themes,
    userThemeIds,
    addUserTheme,
    removeUserTheme
  } = useTheme();
  const [open, setOpen] = useState(false);
  const [error, setError] = useState("");
  const fileRef = useRef(null);

  // Close on Escape (matches the links menu).
  useEffect(() => {
    if (!open) return undefined;
    const onKey = e => {
      if (e.key === "Escape") setOpen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);
  async function onImport(e) {
    const file = e.target.files?.[0];
    e.target.value = ""; // allow re-picking the same file
    if (!file) return;
    const res = parseThemeFile(await file.text());
    if (!res.ok) {
      setError(res.error);
      return;
    }
    setError("");
    addUserTheme(res.theme);
    setAccent(res.theme.id);
  }
  function onExport() {
    const theme = themes.find(t => t.id === accent) || themes[0];
    if (!theme) return;
    const blob = new Blob([serializeTheme(theme)], {
      type: "application/json"
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${theme.id}-theme.json`;
    a.click();
    URL.revokeObjectURL(url);
  }
  return /*#__PURE__*/_jsxDEV("div", {
    className: "theme-menu",
    children: [/*#__PURE__*/_jsxDEV("button", {
      className: `theme-trigger${open ? " on" : ""}`,
      onClick: () => setOpen(o => !o),
      title: intl.formatMessage(msgs.appearance),
      "aria-label": intl.formatMessage(msgs.appearance),
      "aria-haspopup": "dialog",
      "aria-expanded": open,
      children: [/*#__PURE__*/_jsxDEV(PaletteIcon, {}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "ctl-label",
        children: intl.formatMessage(msgs.appearance)
      }, void 0, false)]
    }, void 0, true), open && /*#__PURE__*/_jsxDEV(_Fragment, {
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "theme-scrim",
        onClick: () => setOpen(false),
        "aria-hidden": "true"
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "theme-pop",
        role: "dialog",
        "aria-label": intl.formatMessage(msgs.appearance),
        children: [/*#__PURE__*/_jsxDEV("div", {
          className: "theme-section",
          children: [/*#__PURE__*/_jsxDEV("div", {
            className: "theme-section-label",
            id: "theme-mode-label",
            children: intl.formatMessage(msgs.mode)
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "theme-segmented",
            role: "radiogroup",
            "aria-labelledby": "theme-mode-label",
            children: MODES.map(({
              id,
              label,
              Icon
            }) => /*#__PURE__*/_jsxDEV("button", {
              type: "button",
              role: "radio",
              "aria-checked": mode === id,
              className: `theme-seg${mode === id ? " on" : ""}`,
              onClick: () => setMode(id),
              children: [/*#__PURE__*/_jsxDEV(Icon, {}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
                children: intl.formatMessage(label)
              }, void 0, false)]
            }, id, true))
          }, void 0, false)]
        }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
          className: "theme-section",
          children: [/*#__PURE__*/_jsxDEV("div", {
            className: "theme-section-label",
            id: "theme-accent-label",
            children: intl.formatMessage(msgs.accent)
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "theme-swatches",
            role: "radiogroup",
            "aria-labelledby": "theme-accent-label",
            children: themes.map(t => /*#__PURE__*/_jsxDEV("span", {
              className: "theme-swatch-wrap",
              children: [/*#__PURE__*/_jsxDEV("button", {
                type: "button",
                role: "radio",
                "aria-checked": accent === t.id,
                className: `theme-swatch${accent === t.id ? " on" : ""}`,
                style: {
                  "--sw": t.swatch
                },
                onClick: () => setAccent(t.id),
                title: t.label,
                "aria-label": t.label
              }, void 0, false), userThemeIds.includes(t.id) && /*#__PURE__*/_jsxDEV("button", {
                type: "button",
                className: "theme-swatch-del",
                onClick: () => removeUserTheme(t.id),
                title: intl.formatMessage(msgs.removeTheme, {
                  name: t.label
                }),
                "aria-label": intl.formatMessage(msgs.removeTheme, {
                  name: t.label
                }),
                children: "×"
              }, void 0, false)]
            }, t.id, true))
          }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
            className: "theme-file-actions",
            children: [/*#__PURE__*/_jsxDEV("button", {
              type: "button",
              className: "theme-file-btn",
              onClick: () => fileRef.current?.click(),
              children: intl.formatMessage(msgs.importTheme)
            }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
              type: "button",
              className: "theme-file-btn",
              onClick: onExport,
              children: intl.formatMessage(msgs.exportTheme)
            }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
              ref: fileRef,
              type: "file",
              accept: ".json,application/json",
              hidden: true,
              onChange: onImport
            }, void 0, false)]
          }, void 0, true), error && /*#__PURE__*/_jsxDEV("div", {
            className: "theme-file-error",
            role: "alert",
            children: error
          }, void 0, false)]
        }, void 0, true)]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}