hyperhive/frontend/packages/shared/src/settings/SettingsMenu.tsx
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

134 lines
5 KiB
TypeScript

// <SettingsMenu> — a single header icon-button + popover holding the
// client-local overrides a page has (theme, reduced motion). Originated
// in swarm-ui, duplicated to the agent terminal page (mara: "agent
// terminal page should get the settings panel from swarm ui as well"),
// then unified here per her review on that PR: "i think the component
// should be shared. motion setting is missing." — one component, both
// pages import it, motion included unconditionally rather than
// agent-only.
//
// Trigger is `Badge` (`../badge/Badge.js`) with `../icons.js`'s
// `GearIcon` — an inline SVG, not a text/emoji glyph (see that file's
// comment for why a glyph is the wrong tool for a trigger's own icon;
// this file went through one regression on exactly that, caught by
// mara: "settings icon looks weird since component extract").
// `variant="quiet"` drops `Badge`'s default filled-pill look, which is
// right for a status/picker chip but wrong for this icon-only header
// button (mara again: "link and settings button should not have the
// badge bg") — see `Badge.tsx`'s `BadgeVariant` comment.
//
// Storage keys are the caller's responsibility (`themeKey`/`motionKey`
// props), not derived here — a page mounts `useApplyThemeOverride`/
// `useApplyMotionOverride` (`./theme-apply.js`/`./motion-apply.js`)
// once, high in its tree, with the SAME key strings passed to this
// component, so the two stay in sync without this component owning any
// page-specific naming decision.
import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge } from "../badge/Badge.js";
import { GearIcon } from "../icons.js";
import { useThemeOverride, type ThemeOverride } from "./theme-apply.js";
import { useMotionOverride, type MotionOverride } from "./motion-apply.js";
import "./SettingsMenu.css";
const THEME_OPTIONS: ThemeOverride[] = ["system", "light", "dark"];
const MOTION_OPTIONS: MotionOverride[] = ["system", "allow", "reduce"];
export interface SettingsMenuProps {
themeKey: string;
motionKey: string;
/**
* Consumer-owned extra row(s), rendered inside the popover after the
* built-in theme/motion rows. Not a named boolean prop per setting —
* a `showExpandDetails?: boolean` attempt drew mara's review: "you
* cannot just add it like this - if we have more and more options
* there in different places we will keep accumulating cruft in the
* shared component." A caller owns its row(s) entirely (state,
* storage key, markup); this component never learns they exist. Use
* `settings-menu-row` (this file's CSS) on each row's outer element
* to match spacing/typography — see `Root.tsx` for a worked example.
*/
children?: ComponentChildren;
}
export function SettingsMenu({
themeKey,
motionKey,
children,
}: SettingsMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const [theme, setTheme] = useThemeOverride(themeKey);
const [motion, setMotion] = useMotionOverride(motionKey);
// Close on an outside click or Escape — only listens while open.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div class="settings-menu-anchor" ref={rootRef}>
<Badge
value={<GearIcon />}
title="settings"
variant="quiet"
onClick={() => setOpen((v) => !v)}
expanded={open}
/>
{open ? (
<div class="settings-menu-popover" role="menu" aria-label="settings">
<label class="settings-menu-row">
<span>theme</span>
<select
value={theme}
onChange={(e) =>
setTheme((e.target as HTMLSelectElement).value as ThemeOverride)
}
>
{THEME_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<label class="settings-menu-row">
<span>motion</span>
<select
value={motion}
onChange={(e) =>
setMotion(
(e.target as HTMLSelectElement).value as MotionOverride,
)
}
>
{MOTION_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
{children}
</div>
) : null}
</div>
);
}