Files
ridebus-website/modules/home/departuresCard/selectMenu.tsx
Zakhar Timoshenko 142ec97959 feat: add Open Graph image generation and SEO metadata
- Introduced `PageSeo` component for managing page metadata for social sharing.
- Created Open Graph image templates using SVG and static fonts for better visual representation.
- Implemented service alert management with functions to check active alerts and format route numbers.
- Added utility functions for generating and watching alert validity.
- Developed a script to generate Open Graph images dynamically based on page content.
- Included tests for service alert functionalities to ensure correctness and reliability.
2026-10-05 13:02:06 +03:00

334 lines
11 KiB
TypeScript

import {
useCallback,
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 listRef = useRef<HTMLUListElement>(null);
const pointerTypeRef = useRef('');
const focusSearchRef = useRef(true);
const [position, setPosition] = useState<{
left: number;
top?: number;
bottom?: number;
width: number;
maxHeight: number;
} | null>(null);
const isOpen = position !== 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({ preventScroll: true });
}
const updatePosition = useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
const viewport = window.visualViewport;
const viewportTop = viewport?.offsetTop ?? 0;
const viewportHeight = viewport?.height ?? window.innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const below = Math.max(0, viewportBottom - rect.bottom - 20);
const above = Math.max(0, rect.top - viewportTop - 20);
const upwards = below < 280 && above > below;
const maxHeight = Math.min(
340,
Math.max(0, viewportHeight - 40),
upwards ? above : below
);
setPosition({
left: rect.left,
...(upwards
? {
bottom:
window.innerHeight -
Math.min(
Math.max(viewportTop + maxHeight + 20, rect.top - 8),
viewportBottom - 20
),
}
: {
top: Math.min(
Math.max(viewportTop + 20, rect.bottom + 8),
viewportBottom - maxHeight - 20
),
}),
width: rect.width,
maxHeight,
});
}, []);
function open(focusSearch = true) {
focusSearchRef.current = focusSearch;
setQuery('');
setActive(value);
updatePosition();
}
useEffect(() => {
if (!isOpen) return;
// A touch opens the list without bringing up the software keyboard.
const focusTarget =
searchable && focusSearchRef.current
? searchRef.current
: listRef.current;
focusTarget?.focus({ preventScroll: true });
const dismiss = (event: PointerEvent) => {
const target = event.target as Node;
if (
!panelRef.current?.contains(target) &&
!triggerRef.current?.contains(target)
)
setPosition(null);
};
const repositionOnScroll = (event: Event) => {
if (!panelRef.current?.contains(event.target as Node)) updatePosition();
};
const viewport = window.visualViewport;
document.addEventListener('pointerdown', dismiss);
window.addEventListener('scroll', repositionOnScroll, true);
window.addEventListener('resize', updatePosition);
viewport?.addEventListener('resize', updatePosition);
viewport?.addEventListener('scroll', updatePosition);
return () => {
document.removeEventListener('pointerdown', dismiss);
window.removeEventListener('scroll', repositionOnScroll, true);
window.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('scroll', updatePosition);
};
}, [isOpen, searchable, updatePosition]);
useEffect(() => {
const list = listRef.current;
const option = activeValue
? document.getElementById(
`${id}-option-${encodeURIComponent(activeValue)}`
)
: null;
if (!isOpen || !list || !option) return;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
// Scroll the options only; scrollIntoView can also move the page on iOS.
if (optionRect.top < listRect.top)
list.scrollTop -= listRect.top - optionRect.top;
else if (optionRect.bottom > listRect.bottom)
list.scrollTop += optionRect.bottom - listRect.bottom;
}, [id, isOpen, 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}
onPointerDown={(event) => {
pointerTypeRef.current = event.pointerType;
}}
onClick={() =>
position
? close()
: open(!['touch', 'pen'].includes(pointerTypeRef.current))
}
onKeyDown={(event) => {
pointerTypeRef.current = 'keyboard';
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) &&
!triggerRef.current?.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
ref={listRef}
id={`${id}-list`}
role="listbox"
aria-label={label}
className={styles.options}
tabIndex={searchable ? -1 : 0}
aria-activedescendant={
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={(event) => {
if (event.pointerType === 'mouse')
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>
);
}