mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
185 lines
6.7 KiB
TypeScript
185 lines
6.7 KiB
TypeScript
/**
|
|
* 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 (the shared `usePopover` machinery): at most one popover is open
|
|
* app-wide (`PopoverStore`); 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 { Fragment, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { usePopover } from '../hooks/usePopover';
|
|
import styles from './SelectControl.module.css';
|
|
|
|
/** Land on the selected option (falling back to the first). */
|
|
const INITIAL_FOCUS = ['[aria-current="true"]', 'button'] as const;
|
|
|
|
export interface SelectControlOption<V extends string> {
|
|
value: V;
|
|
label: string;
|
|
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
|
|
detail?: string;
|
|
/**
|
|
* Draw a group separator above this option (purely visual, `role="presentation"`;
|
|
* keyboard order is untouched) — e.g. the chart-theme picker's boundary between
|
|
* the user's themes and the preset roster.
|
|
*/
|
|
dividerBefore?: boolean;
|
|
}
|
|
|
|
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, toggle, closeAndRefocus, triggerRef, popRef, setPopNode } = usePopover({
|
|
id,
|
|
align: 'left',
|
|
flip: true,
|
|
initialFocus: INITIAL_FOCUS,
|
|
});
|
|
|
|
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
|
|
|
|
// 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') {
|
|
closeAndRefocus();
|
|
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();
|
|
}
|
|
};
|
|
|
|
const choose = (v: V) => {
|
|
onSelect(v);
|
|
closeAndRefocus();
|
|
};
|
|
|
|
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();
|
|
}}
|
|
>
|
|
{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 (
|
|
<Fragment key={o.value}>
|
|
{o.dividerBefore && <div className={styles.divider} role="presentation" />}
|
|
<button
|
|
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>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|