mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Chart builder: SelectControl pickers, channel chooser, per-type aggregates
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* SelectControl — the app's value picker: a disclosure trigger showing the current
|
||||
* choice plus a portaled, non-modal popover listing the options as plain buttons.
|
||||
*
|
||||
* This replaces native `<select>` elements wherever a control is part of a designed
|
||||
* surface: a native select's popup can't be token-styled and renders differently on
|
||||
* every browser/OS, which reads as a foreign object inside an otherwise consistent
|
||||
* UI (arch 10 §5 records the resolution). It is the same APG **disclosure**
|
||||
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
|
||||
* a combobox; a short list of buttons needs neither's contract.
|
||||
*
|
||||
* Behaviour (mirrors SortControl): at most one popover is open app-wide
|
||||
* (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside
|
||||
* pointer press closes; opening focuses the selected option (or the first);
|
||||
* Arrow/Home/End move focus through the options; choosing one fires `onSelect`,
|
||||
* closes, and refocuses the trigger. The panel is portaled to <body> and positioned
|
||||
* fixed so it escapes pane/modal overflow clipping, flipping above the trigger when
|
||||
* the viewport below is too short.
|
||||
*
|
||||
* The same control doubles as an **action picker** (e.g. "Add field to which
|
||||
* channel?"): pass no `value` and a custom `triggerContent`; `beforeOpen` lets the
|
||||
* caller intercept the click entirely (the armed-channel fast path).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||
import styles from './SelectControl.module.css';
|
||||
|
||||
/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */
|
||||
const GAP = 6;
|
||||
|
||||
export interface SelectControlOption<V extends string> {
|
||||
value: V;
|
||||
label: string;
|
||||
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SelectControlProps<V extends string> {
|
||||
/** Unique id — popover registry key and the panel's DOM id. */
|
||||
id: string;
|
||||
/** Accessible name for the control ("Aggregate for Y", "Add Region to…"). */
|
||||
label: string;
|
||||
options: ReadonlyArray<SelectControlOption<V>>;
|
||||
/** Current value; omit for an action picker (no option reads as selected). */
|
||||
value?: V;
|
||||
onSelect: (value: V) => void;
|
||||
/** Trigger body; defaults to the current option's label plus a caret. */
|
||||
triggerContent?: ReactNode;
|
||||
/** Replaces (not extends) the default trigger styling — for chip-styled triggers. */
|
||||
triggerClassName?: string;
|
||||
triggerTitle?: string;
|
||||
disabled?: boolean;
|
||||
/** Popover heading; defaults to `label`. */
|
||||
heading?: string;
|
||||
/** Return false to swallow the trigger click without opening (fast paths). */
|
||||
beforeOpen?: () => boolean;
|
||||
}
|
||||
|
||||
export function SelectControl<V extends string>({
|
||||
id,
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
triggerContent,
|
||||
triggerClassName,
|
||||
triggerTitle,
|
||||
disabled,
|
||||
heading,
|
||||
beforeOpen,
|
||||
}: SelectControlProps<V>) {
|
||||
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);
|
||||
|
||||
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
|
||||
|
||||
// Fixed-position from the trigger's rect (no React state → no re-render on
|
||||
// scroll). Below the trigger by default; above when the viewport below is short.
|
||||
const place = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!trigger || !pop) return;
|
||||
const r = trigger.getBoundingClientRect();
|
||||
const below = window.innerHeight - r.bottom - GAP;
|
||||
const height = pop.offsetHeight;
|
||||
pop.style.top =
|
||||
below < height && r.top > height + GAP ? `${r.top - GAP - height}px` : `${r.bottom + GAP}px`;
|
||||
// Keep the panel on-screen when the trigger sits near the right edge.
|
||||
const left = Math.min(r.left, window.innerWidth - pop.offsetWidth - GAP);
|
||||
pop.style.left = `${Math.max(GAP, left)}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
// Esc closes + restores focus to the trigger; an outside pointer press closes
|
||||
// (APG disclosure; non-modal). Esc is captured so it settles here, not on a
|
||||
// parent (the builder modal also listens for Esc).
|
||||
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]);
|
||||
|
||||
// Arrow/Home/End roving among the option buttons — a convenience on top of the
|
||||
// natural Tab order, matching what a native select's popup offers.
|
||||
const onPopKeyDown = (e: React.KeyboardEvent) => {
|
||||
const pop = popRef.current;
|
||||
if (!pop) return;
|
||||
// Tab closes the popup and resumes tabbing from the trigger (the native-select
|
||||
// convention) — also keeps focus inside a host modal's trap, since the panel is
|
||||
// portaled outside it.
|
||||
if (e.key === 'Tab') {
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const items = Array.from(pop.querySelectorAll<HTMLButtonElement>('button'));
|
||||
const i = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next = -1;
|
||||
if (e.key === 'ArrowDown') next = i < 0 ? 0 : Math.min(i + 1, items.length - 1);
|
||||
else if (e.key === 'ArrowUp') next = i < 0 ? items.length - 1 : Math.max(i - 1, 0);
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = items.length - 1;
|
||||
if (next >= 0) {
|
||||
e.preventDefault();
|
||||
items[next]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// On mount: position before paint, then land focus on the selected option (or
|
||||
// the first) so keyboard users arrive inside the popover.
|
||||
const setPopNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
if (node) {
|
||||
place();
|
||||
// Two queries, not one selector list — `querySelector('a, b')` returns the
|
||||
// first match in document order, which would always be the first button.
|
||||
const target =
|
||||
node.querySelector<HTMLElement>('[aria-current="true"]') ??
|
||||
node.querySelector<HTMLElement>('button');
|
||||
target?.focus();
|
||||
}
|
||||
},
|
||||
[place],
|
||||
);
|
||||
|
||||
const choose = (v: V) => {
|
||||
onSelect(v);
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={triggerClassName ?? styles.trigger}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? id : undefined}
|
||||
aria-label={current ? `${label}: ${current.label}` : label}
|
||||
title={triggerTitle}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (beforeOpen && !beforeOpen()) return;
|
||||
toggle(id);
|
||||
}}
|
||||
>
|
||||
{triggerContent ?? (
|
||||
<>
|
||||
<span className={styles.triggerLabel}>{current?.label ?? '—'}</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={setPopNode}
|
||||
id={id}
|
||||
className={styles.pop}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
onKeyDown={onPopKeyDown}
|
||||
>
|
||||
<h4 className={styles.heading}>{heading ?? label}</h4>
|
||||
<div className={styles.list}>
|
||||
{options.map((o) => {
|
||||
const selected = value !== undefined && o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`${styles.option} ${selected ? styles.selected : ''}`}
|
||||
aria-current={selected || undefined}
|
||||
onClick={() => choose(o.value)}
|
||||
>
|
||||
<span className={styles.optionLabel}>
|
||||
{o.label}
|
||||
{o.detail !== undefined && <span className={styles.detail}>{o.detail}</span>}
|
||||
</span>
|
||||
{selected && <span aria-hidden="true">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user