Files
ridebus-website/modules/home/departuresCard/selectMenu.tsx
2026-10-02 20:31:49 +03:00

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>
);
}