5.9 KB201 lines
Blame
1import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
2
3export interface SelectOption {
4 value: string;
5 label: string;
6}
7
8export interface SelectProps
9 extends React.SelectHTMLAttributes<HTMLSelectElement> {
10 label?: string;
11 hint?: string;
12 error?: string;
13 optional?: boolean;
14 options: SelectOption[];
15 placeholder?: string;
16}
17
18export const Select = forwardRef<HTMLSelectElement, SelectProps>(
19 function Select(
20 {
21 label,
22 hint,
23 error,
24 optional,
25 options,
26 placeholder,
27 className = "",
28 style,
29 onChange,
30 onBlur,
31 name,
32 disabled,
33 value,
34 defaultValue,
35 required,
36 ...props
37 },
38 ref
39 ) {
40 const rootRef = useRef<HTMLDivElement>(null);
41 const isControlled = value !== undefined;
42 const [open, setOpen] = useState(false);
43 const [internalValue, setInternalValue] = useState(
44 defaultValue !== undefined && defaultValue !== null ? String(defaultValue) : ""
45 );
46 const currentValue = isControlled
47 ? value !== undefined && value !== null
48 ? String(value)
49 : ""
50 : internalValue;
51
52 const selectOptions = useMemo(
53 () => (placeholder ? [{ value: "", label: placeholder }, ...options] : options),
54 [placeholder, options]
55 );
56
57 const selectedLabel = useMemo(() => {
58 const selected = selectOptions.find((opt) => opt.value === currentValue);
59 return selected?.label ?? placeholder ?? "";
60 }, [selectOptions, currentValue, placeholder]);
61
62 useEffect(() => {
63 function handleOutsideClick(event: MouseEvent) {
64 if (!rootRef.current) return;
65 if (!rootRef.current.contains(event.target as Node)) {
66 setOpen(false);
67 }
68 }
69 document.addEventListener("mousedown", handleOutsideClick);
70 return () => document.removeEventListener("mousedown", handleOutsideClick);
71 }, []);
72
73 function commitValue(nextValue: string) {
74 if (!isControlled) setInternalValue(nextValue);
75 setOpen(false);
76 onChange?.({
77 target: { value: nextValue, name: name ?? "" },
78 } as unknown as React.ChangeEvent<HTMLSelectElement>);
79 }
80
81 return (
82 <div>
83 {label && (
84 <label
85 className="block text-xs mb-1"
86 style={{ color: "var(--text-muted)" }}
87 >
88 {label}
89 {optional && (
90 <span style={{ color: "var(--text-faint)" }}> (optional)</span>
91 )}
92 </label>
93 )}
94 <div className="relative" ref={rootRef}>
95 <select
96 ref={ref}
97 name={name}
98 value={currentValue}
99 onChange={(e) => commitValue(e.target.value)}
100 onBlur={onBlur}
101 disabled={disabled}
102 required={required}
103 tabIndex={-1}
104 aria-hidden="true"
105 className="sr-only"
106 {...props}
107 style={{ display: "none" }}
108 >
109 {selectOptions.map((opt) => (
110 <option key={opt.value} value={opt.value}>
111 {opt.label}
112 </option>
113 ))}
114 </select>
115 <button
116 type="button"
117 onClick={() => !disabled && setOpen((v) => !v)}
118 disabled={disabled}
119 className={`w-full px-3 py-2 text-sm flex items-center justify-between gap-2 ${className}`}
120 style={{
121 backgroundColor: "var(--bg-input)",
122 border: "1px solid var(--border-subtle)",
123 color: "var(--text-primary)",
124 outline: "none",
125 boxShadow: "none",
126 opacity: disabled ? 0.6 : 1,
127 ...style,
128 }}
129 aria-haspopup="listbox"
130 aria-expanded={open}
131 aria-label={props["aria-label"]}
132 onKeyDown={(e) => {
133 if (e.key === "Escape") setOpen(false);
134 if ((e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") && !open) {
135 e.preventDefault();
136 setOpen(true);
137 }
138 }}
139 >
140 <span
141 className="truncate text-left"
142 style={{ color: "var(--text-primary)" }}
143 >
144 {selectedLabel}
145 </span>
146 <span
147 className="text-xs shrink-0 transition-transform"
148 style={{
149 color: "var(--text-faint)",
150 transform: open ? "rotate(180deg)" : "rotate(0deg)",
151 }}
152 aria-hidden="true"
153 >
154 ▾
155 </span>
156 </button>
157
158 {open && !disabled && (
159 <div
160 className="absolute z-20 mt-1 w-full max-h-64 overflow-auto"
161 style={{
162 backgroundColor: "var(--bg-card)",
163 border: "1px solid var(--border-subtle)",
164 }}
165 role="listbox"
166 >
167 {selectOptions.map((opt) => {
168 const active = opt.value === currentValue;
169 return (
170 <button
171 key={opt.value}
172 type="button"
173 className="w-full text-left px-3 py-2 text-sm"
174 style={{
175 backgroundColor: active ? "var(--bg-hover)" : "transparent",
176 color: active ? "var(--text-primary)" : "var(--text-secondary)",
177 }}
178 onClick={() => commitValue(opt.value)}
179 >
180 {opt.label}
181 </button>
182 );
183 })}
184 </div>
185 )}
186 </div>
187 {hint && !error && (
188 <p className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
189 {hint}
190 </p>
191 )}
192 {error && (
193 <p className="text-xs mt-1" style={{ color: "var(--error-text)" }}>
194 {error}
195 </p>
196 )}
197 </div>
198 );
199 }
200);
201