- 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.
334 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|