Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/settings/SettingsMenu.tsx
iris e1aae29068 badge: add a quiet variant for icon-only header triggers
Badge's default look is a filled pill, right for status/picker chips
but wrong for an icon-only header button like the settings gear or
agent links trigger, which should read as chrome. Add variant='quiet'
(same naming/shape as Button's ButtonVariant) that drops the idle
background, keeping the existing hover/expanded feedback.

Apply it to the two icon-only Badge triggers: shared SettingsMenu
(used by both swarm-ui and the agent page) and the agent page's own
MetaNav links trigger. Add a components-page showcase sample so the
variant has a visible regression check going forward.
2026-08-29 20:28:19 +02:00

105 lines
4.1 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 { 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;
}
export function SettingsMenu({ themeKey, motionKey }: 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>
</div>
) : null}
</div>
);
}