f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
7.9 KiB
TypeScript
272 lines
7.9 KiB
TypeScript
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import styles from './Select.module.css';
|
|
|
|
export type SelectOption = {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export type SelectProps = {
|
|
value: string;
|
|
options: readonly SelectOption[];
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
ariaLabel?: string;
|
|
/** Доп. класс на кнопку-триггер (ширина и т.п.). */
|
|
className?: string;
|
|
placeholder?: string;
|
|
};
|
|
|
|
type MenuPos = { left: number; top: number; width: number; maxHeight: number };
|
|
|
|
function Chevron({ open }: { open: boolean }) {
|
|
return (
|
|
<svg
|
|
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
|
|
viewBox="0 0 12 12"
|
|
aria-hidden
|
|
>
|
|
<path
|
|
d="M2.5 4.25L6 7.75L9.5 4.25"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
fill="none"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function Select({
|
|
value,
|
|
options,
|
|
onChange,
|
|
disabled = false,
|
|
ariaLabel,
|
|
className,
|
|
placeholder = '—',
|
|
}: SelectProps) {
|
|
const listId = useId();
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const selected = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]);
|
|
const enabledIndexes = useMemo(
|
|
() => options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0),
|
|
[options],
|
|
);
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false);
|
|
setMenuPos(null);
|
|
}, []);
|
|
|
|
const layoutMenu = useCallback(() => {
|
|
const trigger = triggerRef.current;
|
|
if (!trigger) return;
|
|
const r = trigger.getBoundingClientRect();
|
|
const pad = 8;
|
|
const gap = 4;
|
|
const preferredMax = Math.min(280, window.innerHeight * 0.5);
|
|
const spaceBelow = window.innerHeight - r.bottom - pad;
|
|
const spaceAbove = r.top - pad;
|
|
const placeBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
|
|
const maxHeight = Math.max(96, Math.min(preferredMax, placeBelow ? spaceBelow - gap : spaceAbove - gap));
|
|
const top = placeBelow ? r.bottom + gap : Math.max(pad, r.top - gap - maxHeight);
|
|
setMenuPos({
|
|
left: Math.max(pad, Math.min(r.left, window.innerWidth - r.width - pad)),
|
|
top,
|
|
width: r.width,
|
|
maxHeight,
|
|
});
|
|
}, []);
|
|
|
|
const openMenu = useCallback(() => {
|
|
if (disabled) return;
|
|
const selectedIdx = options.findIndex((o) => o.value === value && !o.disabled);
|
|
const fallback = enabledIndexes[0] ?? 0;
|
|
setActiveIndex(selectedIdx >= 0 ? selectedIdx : fallback);
|
|
setOpen(true);
|
|
}, [disabled, enabledIndexes, options, value]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
layoutMenu();
|
|
}, [layoutMenu, open, options.length]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onWin = () => layoutMenu();
|
|
window.addEventListener('resize', onWin);
|
|
window.addEventListener('scroll', onWin, true);
|
|
return () => {
|
|
window.removeEventListener('resize', onWin);
|
|
window.removeEventListener('scroll', onWin, true);
|
|
};
|
|
}, [layoutMenu, open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
const t = e.target;
|
|
if (!(t instanceof Node)) return;
|
|
if (triggerRef.current?.contains(t)) return;
|
|
if (menuRef.current?.contains(t)) return;
|
|
close();
|
|
};
|
|
window.addEventListener('pointerdown', onPointerDown, true);
|
|
return () => window.removeEventListener('pointerdown', onPointerDown, true);
|
|
}, [close, open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const el = menuRef.current?.querySelector<HTMLElement>(`[data-select-index="${String(activeIndex)}"]`);
|
|
el?.scrollIntoView({ block: 'nearest' });
|
|
}, [activeIndex, open]);
|
|
|
|
const moveActive = (dir: 1 | -1) => {
|
|
if (enabledIndexes.length === 0) return;
|
|
const pos = enabledIndexes.indexOf(activeIndex);
|
|
const nextPos =
|
|
pos < 0
|
|
? dir === 1
|
|
? 0
|
|
: enabledIndexes.length - 1
|
|
: (pos + dir + enabledIndexes.length) % enabledIndexes.length;
|
|
setActiveIndex(enabledIndexes[nextPos]!);
|
|
};
|
|
|
|
const commitIndex = (index: number) => {
|
|
const opt = options[index];
|
|
if (!opt || opt.disabled) return;
|
|
onChange(opt.value);
|
|
close();
|
|
triggerRef.current?.focus();
|
|
};
|
|
|
|
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
if (disabled) return;
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
if (!open) {
|
|
openMenu();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') moveActive(1);
|
|
else if (e.key === 'ArrowUp') moveActive(-1);
|
|
else if (e.key === 'Enter' || e.key === ' ') commitIndex(activeIndex);
|
|
} else if (e.key === 'Escape' && open) {
|
|
e.preventDefault();
|
|
close();
|
|
}
|
|
};
|
|
|
|
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
moveActive(1);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
moveActive(-1);
|
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
commitIndex(activeIndex);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
close();
|
|
triggerRef.current?.focus();
|
|
} else if (e.key === 'Tab') {
|
|
close();
|
|
}
|
|
};
|
|
|
|
const triggerClass = [
|
|
styles.trigger,
|
|
open ? styles.triggerOpen : '',
|
|
className ?? '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
const menu =
|
|
open && menuPos && typeof document !== 'undefined'
|
|
? createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
id={listId}
|
|
role="listbox"
|
|
className={styles.menu}
|
|
style={{
|
|
left: menuPos.left,
|
|
top: menuPos.top,
|
|
width: menuPos.width,
|
|
maxHeight: menuPos.maxHeight,
|
|
}}
|
|
onKeyDown={onMenuKeyDown}
|
|
>
|
|
{options.map((opt, index) => {
|
|
const selectedOpt = opt.value === value;
|
|
const active = index === activeIndex;
|
|
return (
|
|
<button
|
|
key={`${opt.value}::${String(index)}`}
|
|
type="button"
|
|
role="option"
|
|
data-select-index={index}
|
|
aria-selected={selectedOpt}
|
|
disabled={opt.disabled}
|
|
className={[
|
|
styles.option,
|
|
selectedOpt ? styles.optionSelected : '',
|
|
active ? styles.optionActive : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onMouseEnter={() => {
|
|
if (!opt.disabled) setActiveIndex(index);
|
|
}}
|
|
onClick={() => commitIndex(index)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null;
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={triggerClass}
|
|
disabled={disabled}
|
|
aria-label={ariaLabel}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-controls={open ? listId : undefined}
|
|
onClick={() => {
|
|
if (open) close();
|
|
else openMenu();
|
|
}}
|
|
onKeyDown={onTriggerKeyDown}
|
|
>
|
|
<span className={[styles.value, selected ? '' : styles.placeholder].filter(Boolean).join(' ')}>
|
|
{selected?.label ?? placeholder}
|
|
</span>
|
|
<Chevron open={open} />
|
|
</button>
|
|
{menu}
|
|
</div>
|
|
);
|
|
}
|