web/app/components/ui/select.tsxblame
View source
13a9fd11import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
4dfd09b2
4dfd09b3export interface SelectOption {
4dfd09b4 value: string;
4dfd09b5 label: string;
4dfd09b6}
4dfd09b7
4dfd09b8export interface SelectProps
4dfd09b9 extends React.SelectHTMLAttributes<HTMLSelectElement> {
4dfd09b10 label?: string;
4dfd09b11 hint?: string;
4dfd09b12 error?: string;
4dfd09b13 optional?: boolean;
4dfd09b14 options: SelectOption[];
4dfd09b15 placeholder?: string;
4dfd09b16}
4dfd09b17
4dfd09b18export const Select = forwardRef<HTMLSelectElement, SelectProps>(
4dfd09b19 function Select(
4dfd09b20 {
4dfd09b21 label,
4dfd09b22 hint,
4dfd09b23 error,
4dfd09b24 optional,
4dfd09b25 options,
4dfd09b26 placeholder,
4dfd09b27 className = "",
4dfd09b28 style,
13a9fd129 onChange,
13a9fd130 onBlur,
13a9fd131 name,
13a9fd132 disabled,
13a9fd133 value,
13a9fd134 defaultValue,
13a9fd135 required,
4dfd09b36 ...props
4dfd09b37 },
4dfd09b38 ref
4dfd09b39 ) {
13a9fd140 const rootRef = useRef<HTMLDivElement>(null);
13a9fd141 const isControlled = value !== undefined;
13a9fd142 const [open, setOpen] = useState(false);
13a9fd143 const [internalValue, setInternalValue] = useState(
13a9fd144 defaultValue !== undefined && defaultValue !== null ? String(defaultValue) : ""
13a9fd145 );
13a9fd146 const currentValue = isControlled
13a9fd147 ? value !== undefined && value !== null
13a9fd148 ? String(value)
13a9fd149 : ""
13a9fd150 : internalValue;
13a9fd151
13a9fd152 const selectOptions = useMemo(
13a9fd153 () => (placeholder ? [{ value: "", label: placeholder }, ...options] : options),
13a9fd154 [placeholder, options]
13a9fd155 );
13a9fd156
13a9fd157 const selectedLabel = useMemo(() => {
13a9fd158 const selected = selectOptions.find((opt) => opt.value === currentValue);
13a9fd159 return selected?.label ?? placeholder ?? "";
13a9fd160 }, [selectOptions, currentValue, placeholder]);
13a9fd161
13a9fd162 useEffect(() => {
13a9fd163 function handleOutsideClick(event: MouseEvent) {
13a9fd164 if (!rootRef.current) return;
13a9fd165 if (!rootRef.current.contains(event.target as Node)) {
13a9fd166 setOpen(false);
13a9fd167 }
13a9fd168 }
13a9fd169 document.addEventListener("mousedown", handleOutsideClick);
13a9fd170 return () => document.removeEventListener("mousedown", handleOutsideClick);
13a9fd171 }, []);
13a9fd172
13a9fd173 function commitValue(nextValue: string) {
13a9fd174 if (!isControlled) setInternalValue(nextValue);
13a9fd175 setOpen(false);
13a9fd176 onChange?.({
13a9fd177 target: { value: nextValue, name: name ?? "" },
13a9fd178 } as unknown as React.ChangeEvent<HTMLSelectElement>);
13a9fd179 }
13a9fd180
4dfd09b81 return (
4dfd09b82 <div>
4dfd09b83 {label && (
4dfd09b84 <label
4dfd09b85 className="block text-xs mb-1"
4dfd09b86 style={{ color: "var(--text-muted)" }}
4dfd09b87 >
4dfd09b88 {label}
4dfd09b89 {optional && (
4dfd09b90 <span style={{ color: "var(--text-faint)" }}> (optional)</span>
4dfd09b91 )}
4dfd09b92 </label>
4dfd09b93 )}
13a9fd194 <div className="relative" ref={rootRef}>
13a9fd195 <select
13a9fd196 ref={ref}
13a9fd197 name={name}
13a9fd198 value={currentValue}
13a9fd199 onChange={(e) => commitValue(e.target.value)}
13a9fd1100 onBlur={onBlur}
13a9fd1101 disabled={disabled}
13a9fd1102 required={required}
13a9fd1103 tabIndex={-1}
13a9fd1104 aria-hidden="true"
13a9fd1105 className="sr-only"
13a9fd1106 {...props}
13a9fd1107 style={{ display: "none" }}
13a9fd1108 >
13a9fd1109 {selectOptions.map((opt) => (
13a9fd1110 <option key={opt.value} value={opt.value}>
13a9fd1111 {opt.label}
13a9fd1112 </option>
13a9fd1113 ))}
13a9fd1114 </select>
13a9fd1115 <button
13a9fd1116 type="button"
13a9fd1117 onClick={() => !disabled && setOpen((v) => !v)}
13a9fd1118 disabled={disabled}
13a9fd1119 className={`w-full px-3 py-2 text-sm flex items-center justify-between gap-2 ${className}`}
13a9fd1120 style={{
13a9fd1121 backgroundColor: "var(--bg-input)",
13a9fd1122 border: "1px solid var(--border-subtle)",
13a9fd1123 color: "var(--text-primary)",
13a9fd1124 outline: "none",
13a9fd1125 boxShadow: "none",
13a9fd1126 opacity: disabled ? 0.6 : 1,
13a9fd1127 ...style,
13a9fd1128 }}
13a9fd1129 aria-haspopup="listbox"
13a9fd1130 aria-expanded={open}
13a9fd1131 aria-label={props["aria-label"]}
13a9fd1132 onKeyDown={(e) => {
13a9fd1133 if (e.key === "Escape") setOpen(false);
13a9fd1134 if ((e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") && !open) {
13a9fd1135 e.preventDefault();
13a9fd1136 setOpen(true);
13a9fd1137 }
13a9fd1138 }}
13a9fd1139 >
13a9fd1140 <span
13a9fd1141 className="truncate text-left"
13a9fd1142 style={{ color: "var(--text-primary)" }}
13a9fd1143 >
13a9fd1144 {selectedLabel}
13a9fd1145 </span>
13a9fd1146 <span
13a9fd1147 className="text-xs shrink-0 transition-transform"
13a9fd1148 style={{
13a9fd1149 color: "var(--text-faint)",
13a9fd1150 transform: open ? "rotate(180deg)" : "rotate(0deg)",
13a9fd1151 }}
13a9fd1152 aria-hidden="true"
13a9fd1153 >
13a9fd1154 ▾
13a9fd1155 </span>
13a9fd1156 </button>
13a9fd1157
13a9fd1158 {open && !disabled && (
13a9fd1159 <div
13a9fd1160 className="absolute z-20 mt-1 w-full max-h-64 overflow-auto"
13a9fd1161 style={{
13a9fd1162 backgroundColor: "var(--bg-card)",
13a9fd1163 border: "1px solid var(--border-subtle)",
13a9fd1164 }}
13a9fd1165 role="listbox"
13a9fd1166 >
13a9fd1167 {selectOptions.map((opt) => {
13a9fd1168 const active = opt.value === currentValue;
13a9fd1169 return (
13a9fd1170 <button
13a9fd1171 key={opt.value}
13a9fd1172 type="button"
13a9fd1173 className="w-full text-left px-3 py-2 text-sm"
13a9fd1174 style={{
13a9fd1175 backgroundColor: active ? "var(--bg-hover)" : "transparent",
13a9fd1176 color: active ? "var(--text-primary)" : "var(--text-secondary)",
13a9fd1177 }}
13a9fd1178 onClick={() => commitValue(opt.value)}
13a9fd1179 >
13a9fd1180 {opt.label}
13a9fd1181 </button>
13a9fd1182 );
13a9fd1183 })}
13a9fd1184 </div>
13a9fd1185 )}
13a9fd1186 </div>
4dfd09b187 {hint && !error && (
4dfd09b188 <p className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
4dfd09b189 {hint}
4dfd09b190 </p>
4dfd09b191 )}
4dfd09b192 {error && (
4dfd09b193 <p className="text-xs mt-1" style={{ color: "var(--error-text)" }}>
4dfd09b194 {error}
4dfd09b195 </p>
4dfd09b196 )}
4dfd09b197 </div>
4dfd09b198 );
4dfd09b199 }
4dfd09b200);