119 lines
6.3 KiB
TypeScript
119 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import { SystemIcon } from './kit'
|
|
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { placePopup, selectedIndex, selectKey } from './select-state'
|
|
import './select.css'
|
|
|
|
export type SelectOption = { value: string; label: string; disabled?: boolean; icon?: ReactNode }
|
|
|
|
type Props = {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
options: readonly SelectOption[]
|
|
'aria-label'?: string
|
|
'aria-labelledby'?: string
|
|
disabled?: boolean
|
|
|
|
name?: string
|
|
className?: string
|
|
size?: 'regular' | 'compact'
|
|
autoFocus?: boolean
|
|
}
|
|
|
|
const TOKENS = ['--fs-select-ink', '--fs-select-muted', '--fs-select-line', '--fs-select-panel', '--fs-select-hover', '--fs-select-accent', '--fs-select-radius', '--fs-select-shadow', '--fs-select-font']
|
|
|
|
export default function Select({ value, onChange, options, disabled, name, className, size = 'regular', autoFocus, ...aria }: Props) {
|
|
const id = useId()
|
|
const trigger = useRef<HTMLButtonElement>(null), list = useRef<HTMLUListElement>(null)
|
|
const [open, setOpen] = useState(false)
|
|
const [active, setActive] = useState(-1)
|
|
const [style, setStyle] = useState<CSSProperties>({})
|
|
const [up, setUp] = useState(false)
|
|
const current = options.find(option => option.value === value)
|
|
|
|
const place = useCallback(() => {
|
|
const button = trigger.current
|
|
if (!button) return
|
|
const rect = button.getBoundingClientRect()
|
|
const spot = placePopup(rect, { width: window.innerWidth, height: window.innerHeight }, 320)
|
|
const computed = getComputedStyle(button)
|
|
const tokens: Record<string, string> = {}
|
|
for (const token of TOKENS) { const resolved = computed.getPropertyValue(token).trim(); if (resolved) tokens[token] = resolved }
|
|
setUp(spot.up)
|
|
setStyle({ ...tokens, fontFamily: computed.fontFamily, left: spot.left, minWidth: spot.width, maxWidth: Math.min(360, window.innerWidth - 16), top: spot.top, bottom: spot.bottom, maxHeight: spot.maxHeight } as CSSProperties)
|
|
}, [])
|
|
|
|
const show = (index: number) => { place(); setActive(index); setOpen(true) }
|
|
const close = (focus: boolean) => { setOpen(false); if (focus) trigger.current?.focus() }
|
|
const choose = (next: string) => { if (next !== value) onChange(next) }
|
|
|
|
useLayoutEffect(() => { if (open) place() }, [open, place])
|
|
|
|
useLayoutEffect(() => {
|
|
const popup = list.current
|
|
if (!popup || typeof style.left !== 'number') return
|
|
const left = Math.max(8, Math.min(style.left, window.innerWidth - 8 - popup.offsetWidth))
|
|
if (left !== style.left) setStyle(previous => ({ ...previous, left }))
|
|
}, [style])
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const outside = (event: PointerEvent) => {
|
|
const target = event.target as Node
|
|
if (!trigger.current?.contains(target) && !list.current?.contains(target)) setOpen(false)
|
|
}
|
|
window.addEventListener('pointerdown', outside, true)
|
|
window.addEventListener('resize', place)
|
|
const scrolled = (event: Event) => { if (!list.current?.contains(event.target as Node)) place() }
|
|
window.addEventListener('scroll', scrolled, true)
|
|
return () => { window.removeEventListener('pointerdown', outside, true); window.removeEventListener('resize', place); window.removeEventListener('scroll', scrolled, true) }
|
|
}, [open, place])
|
|
useEffect(() => {
|
|
if (open && active >= 0) list.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' })
|
|
}, [open, active])
|
|
useEffect(() => { if (disabled) setOpen(false) }, [disabled])
|
|
|
|
const keydown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
const result = selectKey({ open, active }, event.key, options, value)
|
|
if (!result.handled && result.close !== 'blur') return
|
|
if (result.handled) { event.preventDefault(); event.stopPropagation() }
|
|
if (result.open && !open) { show(result.active); return }
|
|
setActive(result.active)
|
|
if (!result.open) close(result.close === 'focus')
|
|
if (result.choose !== undefined) choose(result.choose)
|
|
}
|
|
|
|
const labelId = `${id}-label`, valueId = `${id}-value`, listId = `${id}-list`
|
|
const labelledby = aria['aria-labelledby'] ?? (aria['aria-label'] ? labelId : undefined)
|
|
const classes = ['fs-select', size === 'compact' ? 'fs-select-compact' : '', open ? 'is-open' : '', className ?? ''].filter(Boolean).join(' ')
|
|
|
|
return <>
|
|
{aria['aria-label'] && !aria['aria-labelledby'] && <span id={labelId} hidden>{aria['aria-label']}</span>}
|
|
<button ref={trigger} type="button" className={classes} disabled={disabled} autoFocus={autoFocus}
|
|
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listId : undefined}
|
|
aria-labelledby={labelledby ? `${labelledby} ${valueId}` : undefined}
|
|
aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined}
|
|
onClick={() => open ? close(false) : show(selectedIndex(options, value))} onKeyDown={keydown}>
|
|
{current?.icon && <span className="fs-select-icon" aria-hidden="true">{current.icon}</span>}
|
|
<span id={valueId} className="fs-select-value">{current?.label ?? ''}</span>
|
|
<i aria-hidden="true">▾</i>
|
|
</button>
|
|
{name && <input type="hidden" name={name} value={value} />}
|
|
{open && typeof document !== 'undefined' && createPortal(
|
|
<ul ref={list} id={listId} role="listbox" aria-labelledby={labelledby} tabIndex={-1}
|
|
className={`fs-select-popover${up ? ' is-up' : ''}${size === 'compact' ? ' fs-select-compact' : ''}`} style={style}
|
|
onPointerDown={event => event.preventDefault()}>
|
|
{options.map((option, index) => <li key={option.value} id={`${id}-option-${index}`} data-index={index} role="option"
|
|
aria-selected={option.value === value} aria-disabled={option.disabled || undefined}
|
|
className={index === active ? 'is-active' : undefined}
|
|
onPointerEnter={() => { if (!option.disabled) setActive(index) }}
|
|
onClick={() => { if (option.disabled) return; close(true); choose(option.value) }}>
|
|
{option.icon && <span className="fs-select-icon" aria-hidden="true">{option.icon}</span>}
|
|
<span>{option.label}</span>
|
|
{option.value === value && <b aria-hidden="true"><SystemIcon name="check" size={16}/></b>}
|
|
</li>)}
|
|
</ul>, document.fullscreenElement ?? document.body)}
|
|
</>
|
|
}
|