Files
TREK/client/src/components/shared/CustomSelect.tsx
T
Maurice 35eba21128 fix(costs): row buttons, settle-up rows and share toggles say what they are about to a screen reader
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.

- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
  and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
  tooltip; the row now carries them for assistive tech too, and its Settle
  button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
  category) was read out as "..."; it now says Search in the user's
  language.
- The public link toggles on the phone share sheet and the journey share
  toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.

Refs #2604
2026-10-12 15:45:56 +02:00

340 lines
14 KiB
TypeScript

import React, { useState, useRef, useEffect, useId } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
import { useTranslation } from '../../i18n'
interface SelectOption {
// Callers use both string keys and numeric ids (e.g. day/place ids) as values, and
// the value they hold need not be of the same kind as the options: see sameValue.
value: string | number
label: string
icon?: React.ReactNode
isHeader?: boolean
searchLabel?: string
groupLabel?: string
badge?: string
}
interface CustomSelectProps {
ariaLabel?: string
value: string | number
onChange: (value: string | number) => void
options?: SelectOption[]
placeholder?: string
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
disabled?: boolean
/**
* How wide the open menu is.
*
* 'anchor' (the default) keeps it exactly as wide as the trigger, which is what a
* select normally wants. 'content' lets it grow to its longest option instead, for the
* cases where the trigger is a narrow flex item and the options are place names: there
* the trigger has to shorten to share its row, and a menu that shortened with it would
* offer a list of identical prefixes.
*/
menuFit?: 'anchor' | 'content'
/** The trigger's id, so a label outside can point at it. */
id?: string
}
/**
* Whether an option is the one the caller holds.
*
* onChange hands back the option's own value, and callers routinely store it in another
* form: the trip share dialog kept String(value) against options keyed by numeric user
* ids, so a strict comparison never found the pick and the trigger stayed on its
* placeholder while the invite button already knew who was chosen (#2478). An id reads
* the same as a number or as text, so both sides are compared as text. A caller holding
* nothing (null or undefined at runtime) matches no option.
*/
function sameValue(held: string | number | null | undefined, option: string | number): boolean {
return held != null && String(held) === String(option)
}
/** How wide a content-fitted menu may get before it stops reading as a menu. */
const MENU_MAX_WIDTH = 420
/**
* How much room a content-fitted menu has, measured from the trigger's left edge.
*
* Bounded by the trigger's own container first: a select sits inside a panel, and a menu
* that grew past that panel's edge would hang over whatever is beside it — here, the map.
* The window is the second bound, for a container that reaches the edge of the screen.
*/
function menuRoom(anchor: HTMLElement | null, left: number): number {
const toWindowEdge = typeof window === 'undefined' ? MENU_MAX_WIDTH : window.innerWidth - left - 8
const box = anchor?.parentElement?.getBoundingClientRect()
const toPanelEdge = box && box.width > 0 ? box.right - left : Number.POSITIVE_INFINITY
return Math.min(MENU_MAX_WIDTH, toWindowEdge, toPanelEdge)
}
export default function CustomSelect({
ariaLabel,
value,
onChange,
options = [],
placeholder = '',
searchable = false,
style = {},
size = 'md',
disabled = false,
menuFit = 'anchor',
id,
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const keyboardDirection = useRef<'first' | 'last' | null>(null)
const menuId = useId()
const { t } = useTranslation()
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
const anchored = useAnchoredPosition(ref, open)
useEffect(() => {
if (!open || !searchable || !searchRef.current) return
if (!keyboardDirection.current) searchRef.current.focus()
// Focusing raises the keyboard on a phone; scroll the trigger up so the list
// it just opened is not left underneath it (#2000).
scrollAnchorIntoView(ref.current)
}, [open, searchable])
useEffect(() => {
if (!open || !keyboardDirection.current) return
const buttons = dropRef.current?.querySelectorAll<HTMLButtonElement>('button')
const direction = keyboardDirection.current
keyboardDirection.current = null
if (buttons?.length) buttons[direction === 'last' ? buttons.length - 1 : 0].focus()
}, [open])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find(o => sameValue(value, o.value))
const filtered = searchable && search
? (() => {
const q = search.toLowerCase()
const result: SelectOption[] = []
let currentHeader: SelectOption | null = null
let headerAdded = false
for (const o of options) {
if (o.isHeader) {
currentHeader = o
headerAdded = false
continue
}
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes(q)) {
if (currentHeader && !headerAdded) {
result.push(currentHeader)
headerAdded = true
}
result.push(o)
}
}
return result
})()
: options
const sm = size === 'sm'
const closeMenu = () => {
setOpen(false)
setSearch('')
triggerRef.current?.focus()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return
if (event.key === 'Escape' && open) {
event.preventDefault()
event.stopPropagation()
closeMenu()
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const backwards = event.key === 'ArrowUp'
if (!open) {
keyboardDirection.current = backwards ? 'last' : 'first'
setSearch('')
setOpen(true)
} else {
const buttons = Array.from(dropRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])
const current = buttons.findIndex(button => button === document.activeElement)
const next = current < 0 ? (backwards ? buttons.length - 1 : 0) : (current + (backwards ? -1 : 1) + buttons.length) % buttons.length
buttons[next]?.focus()
}
}
}
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{/* Trigger */}
<button
ref={triggerRef}
id={id}
type="button"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={open ? menuId : undefined}
disabled={disabled}
onKeyDown={handleKeyDown}
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit',
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'start',
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
opacity: disabled ? 0.5 : 1,
}}
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder}
</span>
{selected?.badge && (
<span style={{
flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{selected.badge}</span>
)}
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{/* Dropdown */}
{open && createPortal(
<div ref={dropRef} id={menuId} role="group" aria-label={ariaLabel || selected?.label || placeholder || undefined} onKeyDown={handleKeyDown} style={{
position: 'fixed',
...(anchored
? {
...(anchored.flipped ? { bottom: anchored.bottom } : { top: anchored.top }),
left: anchored.left,
// Never narrower than the trigger, and never past the right-hand edge of
// the window. The cap is read at render time rather than measured, and
// that is enough: every reason the anchor moved already re-renders this.
...(menuFit === 'content'
? {
minWidth: anchored.width,
width: 'max-content',
maxWidth: Math.max(anchored.width, menuRoom(ref.current, anchored.left)),
}
: { width: anchored.width }),
}
: { top: 0, left: 0, width: 200 }),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
transformOrigin: anchored?.flipped ? 'bottom center' : 'top center',
willChange: 'transform, opacity',
}}>
{/* Search */}
{searchable && (
<div style={{ padding: '6px 6px 2px' }}>
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
style={{
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
padding: '5px 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
boxSizing: 'border-box',
}}
/>
</div>
)}
{/* Options — capped at whatever the viewport still shows, so a list opened
beside an on-screen keyboard shrinks instead of hiding under it (#2000). */}
<div style={{
maxHeight: Math.min(220, Math.max(96, (anchored?.maxHeight ?? 220) - (searchable ? 38 : 0))),
overflowY: 'auto',
// The panel is portaled to document.body and positioned fixed, so its
// scroll chain runs to the viewport rather than to the sheet it looks
// like it belongs to. On a phone that meant a flick past either end of
// the list moved the page instead (#2078).
overscrollBehavior: 'contain',
padding: '4px',
}}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
) : (
filtered.map(option => {
if (option.isHeader) {
return (
<div key={option.value} style={{
padding: '5px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: '0.03em',
background: 'var(--bg-tertiary)', borderRadius: 4, margin: '2px 0',
}}>
{option.label}
</div>
)
}
const isSelected = sameValue(value, option.value)
return (
<button
key={option.value}
type="button"
onClick={() => { onChange(option.value); closeMenu() }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 6,
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
cursor: 'pointer', textAlign: 'start', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
>
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{option.badge && (
<span style={{
flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{option.badge}</span>
)}
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button>
)
})
)}
</div>
</div>,
document.body
)}
</div>
)
}