98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
import { useEffect, useId, useRef, useState } from 'react';
|
|
|
|
export type ToolbarSelectOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
type Props = {
|
|
value: string;
|
|
options: ToolbarSelectOption[];
|
|
ariaLabel: string;
|
|
onChange(value: string): void;
|
|
};
|
|
|
|
export function ToolbarSelect({ value, options, ariaLabel, onChange }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const listId = useId();
|
|
const selected = options.find((option) => option.value === value) || options[0];
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function onPointer(event: MouseEvent) {
|
|
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
|
}
|
|
function onKey(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
event.preventDefault();
|
|
const idx = options.findIndex((option) => option.value === value);
|
|
const nextIndex = event.key === 'ArrowDown'
|
|
? Math.min(options.length - 1, Math.max(0, idx + 1))
|
|
: Math.max(0, idx < 0 ? 0 : idx - 1);
|
|
const next = options[nextIndex];
|
|
if (next) onChange(next.value);
|
|
}
|
|
document.addEventListener('mousedown', onPointer);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointer);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open, options, value, onChange]);
|
|
|
|
return (
|
|
<div className={`toolbar-select${open ? ' is-open' : ''}`} ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className="toolbar-select-trigger"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
aria-label={ariaLabel}
|
|
onClick={() => setOpen((current) => !current)}
|
|
>
|
|
<span>{selected?.label}</span>
|
|
<svg className="toolbar-select-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
<path
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M3 4.5L6 7.5L9 4.5"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{open ? (
|
|
<ul className="toolbar-select-menu" id={listId} role="listbox" aria-label={ariaLabel}>
|
|
{options.map((option) => {
|
|
const active = option.value === value;
|
|
return (
|
|
<li key={option.value} role="presentation">
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
className={active ? 'toolbar-select-option is-selected' : 'toolbar-select-option'}
|
|
onClick={() => {
|
|
onChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|