mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
35eba21128
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
340 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|