|
1 | | -import { forwardRef, type ChangeEvent, type SelectHTMLAttributes, type ReactNode } from 'react' |
| 1 | +'use client' |
| 2 | +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' |
| 3 | +import type { SelectProps, SelectOption } from '../components/select' |
2 | 4 |
|
3 | | -export interface LiteSelectOption { |
4 | | - value: string |
5 | | - label: string |
6 | | - disabled?: boolean |
7 | | - group?: string |
8 | | -} |
| 5 | +export type { SelectOption as LiteSelectOption } |
| 6 | +export type LiteSelectProps = SelectProps |
9 | 7 |
|
10 | | -export interface LiteSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size' | 'onChange'> { |
11 | | - label?: ReactNode |
12 | | - options: LiteSelectOption[] |
13 | | - error?: string |
14 | | - size?: 'sm' | 'md' | 'lg' |
15 | | - placeholder?: string |
16 | | - /** Controlled value */ |
17 | | - value?: string | string[] |
18 | | - /** Uncontrolled default value */ |
19 | | - defaultValue?: string | string[] |
20 | | - disabled?: boolean |
21 | | - /** Allow multiple selections — wired to native select */ |
22 | | - multiple?: boolean |
23 | | - /** Called with the selected value string (or comma-joined for multiple) */ |
24 | | - onChange?: (value: string) => void |
25 | | - /** Interface only — not possible with native select */ |
26 | | - clearable?: boolean |
27 | | - /** Interface only — not possible with native select */ |
28 | | - searchable?: boolean |
29 | | -} |
30 | | - |
31 | | -export const Select = forwardRef<HTMLSelectElement, LiteSelectProps>( |
32 | | - ( |
33 | | - { |
34 | | - label, |
35 | | - options, |
36 | | - error, |
37 | | - size = 'md', |
38 | | - placeholder, |
39 | | - className, |
40 | | - id, |
41 | | - name, |
42 | | - value, |
43 | | - defaultValue, |
44 | | - disabled, |
45 | | - multiple, |
46 | | - onChange, |
47 | | - // destructure interface-only props so they don't land on <select> |
48 | | - clearable: _clearable, |
49 | | - searchable: _searchable, |
50 | | - ...rest |
51 | | - }, |
52 | | - ref, |
53 | | - ) => { |
54 | | - const selectId = id ?? (name ? `lite-select-${name}` : undefined) |
55 | | - const groups = new Map<string, LiteSelectOption[]>() |
56 | | - const ungrouped: LiteSelectOption[] = [] |
57 | | - for (const opt of options) { |
58 | | - if (opt.group) { |
59 | | - const g = groups.get(opt.group) ?? [] |
60 | | - g.push(opt) |
61 | | - groups.set(opt.group, g) |
62 | | - } else { |
63 | | - ungrouped.push(opt) |
64 | | - } |
65 | | - } |
66 | | - |
67 | | - function handleChange(e: ChangeEvent<HTMLSelectElement>) { |
68 | | - if (!onChange) return |
69 | | - if (multiple) { |
70 | | - const selected = Array.from(e.target.selectedOptions).map(o => o.value) |
71 | | - onChange(selected.join(',')) |
72 | | - } else { |
73 | | - onChange(e.target.value) |
74 | | - } |
| 8 | +export const Select = forwardRef<HTMLDivElement, SelectProps>(({ |
| 9 | + name, options, value: controlledValue, defaultValue, onChange, |
| 10 | + placeholder = 'Select...', label, error, disabled, size = 'md', className, |
| 11 | + searchable: _s, clearable: _c, multiple: _m, motion: _mo, ...rest |
| 12 | +}, ref) => { |
| 13 | + const [isOpen, setIsOpen] = useState(false) |
| 14 | + const [internalValue, setInternalValue] = useState<string>((defaultValue as string) ?? '') |
| 15 | + const rootRef = useRef<HTMLDivElement>(null) |
| 16 | + const val = controlledValue !== undefined ? (controlledValue as string) : internalValue |
| 17 | + const selected = options.find((o) => o.value === val) |
| 18 | + const setRootRef = useCallback((node: HTMLDivElement | null) => { |
| 19 | + (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node |
| 20 | + if (typeof ref === 'function') ref(node) |
| 21 | + else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node |
| 22 | + }, [ref]) |
| 23 | + const close = useCallback(() => setIsOpen(false), []) |
| 24 | + const toggle = useCallback(() => { if (!disabled) setIsOpen((o) => !o) }, [disabled]) |
| 25 | + const pick = useCallback((opt: SelectOption) => { |
| 26 | + if (opt.disabled) return |
| 27 | + setInternalValue(opt.value); onChange?.(opt.value); setIsOpen(false) |
| 28 | + }, [onChange]) |
| 29 | + useEffect(() => { |
| 30 | + if (!isOpen) return |
| 31 | + const onMouse = (e: MouseEvent) => { |
| 32 | + if (rootRef.current && !rootRef.current.contains(e.target as Node)) close() |
75 | 33 | } |
76 | | - |
77 | | - return ( |
78 | | - <div className={`ui-lite-select${className ? ` ${className}` : ''}`} data-size={size}> |
79 | | - {label && <label htmlFor={selectId}>{label}</label>} |
80 | | - <select |
81 | | - ref={ref} |
82 | | - id={selectId} |
83 | | - name={name} |
84 | | - aria-invalid={!!error} |
85 | | - value={value} |
86 | | - defaultValue={defaultValue} |
87 | | - disabled={disabled} |
88 | | - multiple={multiple} |
89 | | - onChange={handleChange} |
90 | | - {...rest} |
91 | | - > |
92 | | - {placeholder && <option value="" disabled>{placeholder}</option>} |
93 | | - {ungrouped.map(o => ( |
94 | | - <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option> |
95 | | - ))} |
96 | | - {[...groups.entries()].map(([group, opts]) => ( |
97 | | - <optgroup key={group} label={group}> |
98 | | - {opts.map(o => ( |
99 | | - <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option> |
100 | | - ))} |
101 | | - </optgroup> |
| 34 | + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } |
| 35 | + const t = setTimeout(() => document.addEventListener('mousedown', onMouse), 0) |
| 36 | + document.addEventListener('keydown', onKey) |
| 37 | + return () => { clearTimeout(t); document.removeEventListener('mousedown', onMouse); document.removeEventListener('keydown', onKey) } |
| 38 | + }, [isOpen, close]) |
| 39 | + const lid = name ? `lite-select-${name}-listbox` : undefined |
| 40 | + const labId = name ? `lite-select-${name}-label` : undefined |
| 41 | + const errId = name ? `lite-select-${name}-error` : undefined |
| 42 | + return ( |
| 43 | + <div ref={setRootRef} className={`ui-lite-select${className ? ` ${className}` : ''}`} |
| 44 | + data-size={size} {...(isOpen ? { 'data-open': '' } : {})} |
| 45 | + {...(error ? { 'data-invalid': '' } : {})} {...(disabled ? { 'data-disabled': '' } : {})} |
| 46 | + style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: '0.25rem' }} {...rest}> |
| 47 | + {label && <label className="ui-lite-select__label" id={labId}>{label}</label>} |
| 48 | + <input type="hidden" name={name} value={val} /> |
| 49 | + <button type="button" className="ui-lite-select__trigger" role="combobox" |
| 50 | + aria-expanded={isOpen} aria-haspopup="listbox" aria-controls={isOpen ? lid : undefined} |
| 51 | + aria-labelledby={label ? labId : undefined} aria-invalid={error ? true : undefined} |
| 52 | + aria-describedby={error ? errId : undefined} disabled={disabled} onClick={toggle} |
| 53 | + style={{ all: 'unset', display: 'flex', alignItems: 'center', cursor: disabled ? 'not-allowed' : 'pointer', width: '100%', boxSizing: 'border-box', padding: '0.375rem 0.75rem', border: '1px solid var(--border-default, oklch(100% 0 0 / 0.12))', borderRadius: '0.375rem', background: 'var(--bg-elevated, transparent)', color: 'var(--text-primary, inherit)', fontSize: '0.875rem' }}> |
| 54 | + <span className="ui-lite-select__value" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'start' }}> |
| 55 | + {selected ? selected.label : <span className="ui-lite-select__placeholder" style={{ color: 'var(--text-tertiary, #888)' }}>{placeholder}</span>} |
| 56 | + </span> |
| 57 | + <span className="ui-lite-select__chevron" style={{ marginInlineStart: '0.5rem', fontSize: '0.6em' }}>{isOpen ? '\u25B2' : '\u25BC'}</span> |
| 58 | + </button> |
| 59 | + {isOpen && ( |
| 60 | + <div className="ui-lite-select__dropdown" role="listbox" id={lid} |
| 61 | + aria-labelledby={label ? labId : undefined} tabIndex={-1} |
| 62 | + style={{ position: 'absolute', top: '100%', insetInlineStart: 0, insetInlineEnd: 0, marginBlockStart: '0.25rem', maxBlockSize: '15rem', overflow: 'auto', border: '1px solid var(--border-default, oklch(100% 0 0 / 0.12))', borderRadius: '0.5rem', background: 'var(--surface-elevated, #222)', zIndex: 50, paddingBlock: '0.25rem' }}> |
| 63 | + {options.map((opt) => ( |
| 64 | + <div key={opt.value} role="option" aria-selected={opt.value === val} |
| 65 | + aria-disabled={opt.disabled || undefined} className="ui-lite-select__option" |
| 66 | + {...(opt.value === val ? { 'data-selected': '' } : {})} |
| 67 | + {...(opt.disabled ? { 'data-disabled': '' } : {})} onClick={() => pick(opt)} |
| 68 | + style={{ padding: '0.375rem 0.75rem', cursor: opt.disabled ? 'not-allowed' : 'pointer', opacity: opt.disabled ? 0.4 : 1, fontWeight: opt.value === val ? 500 : 'normal', fontSize: '0.875rem' }}> |
| 69 | + {opt.label} |
| 70 | + </div> |
102 | 71 | ))} |
103 | | - </select> |
104 | | - {error && <span className="ui-lite-select__error">{error}</span>} |
105 | | - </div> |
106 | | - ) |
107 | | - }, |
108 | | -) |
| 72 | + </div> |
| 73 | + )} |
| 74 | + {error && <div className="ui-lite-select__error" id={errId} role="alert" style={{ fontSize: '0.75rem', color: 'var(--status-critical, #e55)' }}>{error}</div>} |
| 75 | + </div> |
| 76 | + ) |
| 77 | +}) |
109 | 78 | Select.displayName = 'Select' |
0 commit comments