269 lines
8.6 KiB
TypeScript
269 lines
8.6 KiB
TypeScript
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import clsx from 'clsx';
|
|
import { CursorEffect } from '@/components/cursor';
|
|
import { manrope } from '@/styles/fonts';
|
|
import styles from './selectMenu.module.scss';
|
|
|
|
type Option = { value: string; label: string };
|
|
type SelectMenuProps = {
|
|
label: string;
|
|
value: string;
|
|
options: Option[];
|
|
placeholder: string;
|
|
searchLabel: string;
|
|
noResults: string;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
export function SelectMenu({
|
|
label,
|
|
value,
|
|
options,
|
|
placeholder,
|
|
searchLabel,
|
|
noResults,
|
|
onChange,
|
|
}: SelectMenuProps) {
|
|
const id = useId();
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const [position, setPosition] = useState<{
|
|
left: number;
|
|
top?: number;
|
|
bottom?: number;
|
|
width: number;
|
|
maxHeight: number;
|
|
} | null>(null);
|
|
const [query, setQuery] = useState('');
|
|
const [active, setActive] = useState(value);
|
|
const searchable = options.length > 8;
|
|
const filtered = useMemo(() => {
|
|
const search = query.trim().toLocaleLowerCase();
|
|
return options.filter((option) =>
|
|
option.label.toLocaleLowerCase().includes(search)
|
|
);
|
|
}, [query, options]);
|
|
const activeIndex = Math.max(
|
|
0,
|
|
filtered.findIndex((option) => option.value === active)
|
|
);
|
|
const activeValue = filtered[activeIndex]?.value;
|
|
const selected = options.find((option) => option.value === value);
|
|
|
|
function close(restoreFocus = false) {
|
|
setPosition(null);
|
|
if (restoreFocus) triggerRef.current?.focus();
|
|
}
|
|
|
|
function open() {
|
|
const rect = triggerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const below = window.innerHeight - rect.bottom - 20;
|
|
const above = rect.top - 20;
|
|
const upwards = below < 280 && above > below;
|
|
const maxHeight = Math.min(340, upwards ? above : below);
|
|
setQuery('');
|
|
setActive(value);
|
|
setPosition({
|
|
left: rect.left,
|
|
...(upwards
|
|
? { bottom: window.innerHeight - rect.top + 8 }
|
|
: { top: rect.bottom + 8 }),
|
|
width: rect.width,
|
|
maxHeight,
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!position) return;
|
|
if (searchable) searchRef.current?.focus();
|
|
else
|
|
panelRef.current?.querySelector<HTMLElement>('[role="listbox"]')?.focus();
|
|
const dismiss = (event: PointerEvent) => {
|
|
const target = event.target as Node;
|
|
if (
|
|
!panelRef.current?.contains(target) &&
|
|
!triggerRef.current?.contains(target)
|
|
)
|
|
setPosition(null);
|
|
};
|
|
const dismissOnScroll = (event: Event) => {
|
|
if (!panelRef.current?.contains(event.target as Node)) setPosition(null);
|
|
};
|
|
const dismissOnResize = () => setPosition(null);
|
|
document.addEventListener('pointerdown', dismiss);
|
|
window.addEventListener('scroll', dismissOnScroll, true);
|
|
window.addEventListener('resize', dismissOnResize);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', dismiss);
|
|
window.removeEventListener('scroll', dismissOnScroll, true);
|
|
window.removeEventListener('resize', dismissOnResize);
|
|
};
|
|
}, [position, searchable]);
|
|
|
|
useEffect(() => {
|
|
if (position && activeValue) {
|
|
document
|
|
.getElementById(`${id}-option-${encodeURIComponent(activeValue)}`)
|
|
?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [id, position, activeValue]);
|
|
|
|
function choose(nextValue: string) {
|
|
onChange(nextValue);
|
|
close(true);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.field}>
|
|
<span id={`${id}-label`} className={styles.label}>
|
|
{label}
|
|
</span>
|
|
<CursorEffect
|
|
effectForce={3}
|
|
effectDistance={48}
|
|
cursorBorderRadius={16}
|
|
cursorPadding={4}
|
|
>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={clsx(styles.trigger, position && styles.open)}
|
|
aria-labelledby={`${id}-label ${id}-value`}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={Boolean(position)}
|
|
aria-controls={position ? `${id}-list` : undefined}
|
|
disabled={!options.length}
|
|
onClick={() => (position ? close() : open())}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
}}
|
|
>
|
|
<span id={`${id}-value`}>{selected?.label ?? placeholder}</span>
|
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
d="m5 7.5 5 5 5-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</CursorEffect>
|
|
{position &&
|
|
createPortal(
|
|
<div
|
|
ref={panelRef}
|
|
className={clsx(styles.panel, manrope.className)}
|
|
style={position}
|
|
tabIndex={-1}
|
|
onBlur={(event) => {
|
|
if (
|
|
event.relatedTarget &&
|
|
!event.currentTarget.contains(event.relatedTarget)
|
|
)
|
|
close();
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
close(true);
|
|
} else if (event.key === 'Tab') {
|
|
close(true);
|
|
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
const next =
|
|
(activeIndex +
|
|
(event.key === 'ArrowDown' ? 1 : -1) +
|
|
filtered.length) %
|
|
filtered.length;
|
|
if (filtered[next]) setActive(filtered[next].value);
|
|
} else if (event.key === 'Enter' && activeValue) {
|
|
event.preventDefault();
|
|
choose(activeValue);
|
|
}
|
|
}}
|
|
>
|
|
{searchable ? (
|
|
<input
|
|
ref={searchRef}
|
|
className={styles.search}
|
|
type="search"
|
|
role="combobox"
|
|
aria-label={searchLabel}
|
|
aria-expanded="true"
|
|
aria-controls={`${id}-list`}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={
|
|
activeValue
|
|
? `${id}-option-${encodeURIComponent(activeValue)}`
|
|
: undefined
|
|
}
|
|
placeholder={searchLabel}
|
|
value={query}
|
|
onChange={(event) => {
|
|
setQuery(event.target.value);
|
|
setActive('');
|
|
}}
|
|
/>
|
|
) : (
|
|
<p className={styles.menuLabel}>{placeholder}</p>
|
|
)}
|
|
<ul
|
|
id={`${id}-list`}
|
|
role="listbox"
|
|
aria-label={label}
|
|
className={styles.options}
|
|
tabIndex={searchable ? -1 : 0}
|
|
aria-activedescendant={
|
|
!searchable && activeValue
|
|
? `${id}-option-${encodeURIComponent(activeValue)}`
|
|
: undefined
|
|
}
|
|
>
|
|
{filtered.map((option) => (
|
|
<li key={option.value} role="presentation">
|
|
<button
|
|
id={`${id}-option-${encodeURIComponent(option.value)}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={option.value === value}
|
|
tabIndex={-1}
|
|
className={clsx(
|
|
styles.option,
|
|
option.value === value && styles.selected,
|
|
option.value === activeValue && styles.active
|
|
)}
|
|
data-cursor-effect
|
|
data-cursor-padding="2"
|
|
data-cursor-border-radius="8"
|
|
onPointerMove={() => setActive(option.value)}
|
|
onClick={() => choose(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
{option.value === value && (
|
|
<span aria-hidden="true">✓</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{!filtered.length && (
|
|
<p className={styles.noResults} role="status">
|
|
{noResults}
|
|
</p>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|