/** * SettingsPopover — the per-pane settings disclosure (spec §07; arch 10). * * Astrolabe distributes preferences to where they apply: a small gear button in a * pane's toolbar discloses a popover of **live** controls for that cluster * (editor / preview / library). This is the shared primitive behind all of them. * * It is a **disclosure + non-modal popover** (WAI-ARIA APG disclosure; Carbon * popover), deliberately NOT an ARIA menu: a menu lists actions/commands * (menuitem/checkbox/radio), whereas these panels hold sliders, number/text * inputs, and radio groups — so the container is a labelled `group`, not a * `menu`. The gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; * Esc closes and returns focus to the gear; an outside click closes. Non-modal, * so there is no focus trap (unlike the modal shell). At most one popover is open * at a time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut). * * The panel is **portaled to `document.body`** and positioned `fixed` from the * gear's rect, because the panes clip their content (`overflow: auto/hidden`); an * in-flow absolute popover would be cut off. Position re-measures on scroll/resize. */ import { type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { usePopover } from '../hooks/usePopover'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import styles from './SettingsPopover.module.css'; /** Focus the first interactive control on open (any kind — these are forms). */ const INITIAL_FOCUS = ['input, button, select, textarea, [tabindex]'] as const; export interface SettingsPopoverProps { /** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */ id: string; /** Accessible name for the gear button. */ label: string; /** Heading shown at the top of the popover, and its group label. */ title: string; /** Which edge of the gear the popover aligns to (default right). */ align?: 'left' | 'right'; children: ReactNode; } export function SettingsPopover({ id, label, title, align = 'right', children, }: SettingsPopoverProps) { const { open, toggle, triggerRef, setPopNode } = usePopover({ id, align, initialFocus: INITIAL_FOCUS, }); return (
{open && createPortal(

{title}

{children}
, document.body, )}
); } /** A label + control row inside a settings popover (keeps clusters tidy + uniform). */ export function SettingRow({ label, htmlFor, children, }: { label: string; htmlFor?: string; children: ReactNode; }) { return (
{htmlFor ? ( ) : ( {label} )}
{children}
); } /** A right-aligned footer row for popover-level actions (e.g. Reset). */ export function SettingFooter({ children }: { children: ReactNode }) { return
{children}
; } /** A range slider with a live value read-out (font size, render debounce, …). */ export function RangeControl({ id, min, max, step = 1, value, suffix, onChange, }: { id: string; min: number; max: number; step?: number; value: number; suffix?: string; onChange: (value: number) => void; }) { return ( <> onChange(Number(e.target.value))} /> {value} {suffix ? ` ${suffix}` : ''} ); } /** A small integer number input (e.g. tab size). Coerces to a clamped integer. */ export function NumberControl({ id, min, max, value, onChange, }: { id: string; min: number; max: number; value: number; onChange: (value: number) => void; }) { return ( onChange(Math.min(max, Math.max(min, Math.round(Number(e.target.value) || min)))) } /> ); } /** A monospaced free-text input (e.g. the custom date format). */ export function TextControl({ id, value, placeholder, onChange, }: { id: string; value: string; placeholder?: string; onChange: (value: string) => void; }) { return ( onChange(e.target.value)} /> ); } /** A text-style reset action for a popover footer. */ export function ResetButton({ onClick, children }: { onClick: () => void; children: ReactNode }) { return ( ); }