Files
astrolabe/src/app/components/SettingsPopover.tsx
T

210 lines
5.4 KiB
TypeScript

/**
* 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 (
<div className={styles.wrap}>
<IconButton
ref={triggerRef}
label={label}
aria-expanded={open}
aria-controls={id}
onClick={toggle}
>
<Icon name="settings" />
</IconButton>
{open &&
createPortal(
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
<h4 className={styles.title}>{title}</h4>
{children}
</div>,
document.body,
)}
</div>
);
}
/** 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 (
<div className={styles.row}>
{htmlFor ? (
<label className={styles.label} htmlFor={htmlFor}>
{label}
</label>
) : (
<span className={styles.label}>{label}</span>
)}
<div className={styles.control}>{children}</div>
</div>
);
}
/** A right-aligned footer row for popover-level actions (e.g. Reset). */
export function SettingFooter({ children }: { children: ReactNode }) {
return <div className={styles.footer}>{children}</div>;
}
/** 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 (
<>
<input
id={id}
type="range"
className={styles.range}
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className={styles.value}>
{value}
{suffix ? ` ${suffix}` : ''}
</span>
</>
);
}
/** 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 (
<input
id={id}
type="number"
className={styles.number}
min={min}
max={max}
step={1}
value={value}
onChange={(e) =>
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 (
<input
id={id}
type="text"
className={styles.text}
value={value}
placeholder={placeholder}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** A text-style reset action for a popover footer. */
export function ResetButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
return (
<button type="button" className={styles.reset} onClick={onClick}>
{children}
</button>
);
}