mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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 { useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './SettingsPopover.module.css';
|
||||
|
||||
/** Gap (px) between the gear and the disclosed panel. */
|
||||
const GAP = 6;
|
||||
|
||||
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 = useSettingsPopoverStore((s) => s.openId === id);
|
||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
||||
const close = useSettingsPopoverStore((s) => s.close);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Position the (fixed) panel imperatively from the gear's rect — no React state,
|
||||
// so there's no setState-in-effect and no re-render on scroll. The panes clip
|
||||
// their content, hence the body portal + fixed positioning.
|
||||
const place = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!trigger || !pop) return;
|
||||
const r = trigger.getBoundingClientRect();
|
||||
pop.style.top = `${r.bottom + GAP}px`;
|
||||
if (align === 'left') {
|
||||
pop.style.left = `${r.left}px`;
|
||||
pop.style.right = 'auto';
|
||||
} else {
|
||||
pop.style.right = `${window.innerWidth - r.right}px`;
|
||||
pop.style.left = 'auto';
|
||||
}
|
||||
}, [align]);
|
||||
|
||||
// Re-place while open so the panel tracks the gear if the workspace scrolls/resizes.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true); // capture: catch pane scrolls too
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
// Esc closes + restores focus to the gear; an outside pointer click closes
|
||||
// (APG disclosure; non-modal). Capture Esc so it settles here, not a parent.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener('pointerdown', onPointer, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
document.removeEventListener('pointerdown', onPointer, true);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
// Ref callback: on mount, position the panel before paint and move focus to the
|
||||
// first control so keyboard users — including those who opened via Cmd/Ctrl+, —
|
||||
// land inside it. Fires once per open (align is constant per instance).
|
||||
const setPopNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
if (node) {
|
||||
place();
|
||||
node.querySelector<HTMLElement>('input, button, select, textarea, [tabindex]')?.focus();
|
||||
}
|
||||
},
|
||||
[place],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.gear}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => toggle(id)}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user