mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
210 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|