You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
144 lines
4.0 KiB
144 lines
4.0 KiB
"use client";
|
|
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import {
|
|
filterUsStateOptions,
|
|
formatUsStateOption,
|
|
normalizeUsStateCode,
|
|
} from "@/lib/frontend/us-states";
|
|
|
|
interface StateComboboxFieldProps {
|
|
label: string;
|
|
name: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
error?: string;
|
|
onChange: (code: string) => void;
|
|
onBlur?: () => void;
|
|
}
|
|
|
|
export function StateComboboxField({
|
|
label,
|
|
name,
|
|
value,
|
|
disabled,
|
|
error,
|
|
onChange,
|
|
onBlur,
|
|
}: StateComboboxFieldProps) {
|
|
const listId = useId();
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const [draft, setDraft] = useState(value);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setDraft(value);
|
|
}, [value]);
|
|
|
|
const options = filterUsStateOptions(draft);
|
|
|
|
const commitDraft = () => {
|
|
const normalized = normalizeUsStateCode(draft);
|
|
if (normalized) {
|
|
onChange(normalized);
|
|
setDraft(normalized);
|
|
} else {
|
|
onChange(draft.trim().toUpperCase());
|
|
}
|
|
onBlur?.();
|
|
};
|
|
|
|
const selectOption = (code: string, labelText: string) => {
|
|
onChange(code);
|
|
setDraft(labelText);
|
|
setOpen(false);
|
|
onBlur?.();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onDocMouseDown = (e: MouseEvent) => {
|
|
if (!rootRef.current?.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onDocMouseDown);
|
|
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative space-y-1">
|
|
<label htmlFor={name} className="block text-sm font-medium text-text-primary">
|
|
{label}
|
|
</label>
|
|
<input
|
|
id={name}
|
|
name={name}
|
|
type="text"
|
|
autoComplete="off"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
aria-autocomplete="list"
|
|
list={listId}
|
|
disabled={disabled}
|
|
value={draft}
|
|
placeholder="输入州码或选择,如 CA"
|
|
onChange={(e) => {
|
|
const next = e.target.value;
|
|
setDraft(next);
|
|
setOpen(true);
|
|
const normalized = normalizeUsStateCode(next);
|
|
if (normalized) onChange(normalized);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => {
|
|
window.setTimeout(() => {
|
|
if (!rootRef.current?.contains(document.activeElement)) {
|
|
commitDraft();
|
|
setOpen(false);
|
|
}
|
|
}, 120);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && open && options.length > 0) {
|
|
e.preventDefault();
|
|
const first = options[0];
|
|
selectOption(first.code, formatUsStateOption(first));
|
|
}
|
|
}}
|
|
className={`h-10 w-full rounded-sm border px-3 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-bg disabled:opacity-60 ${
|
|
error ? "border-error" : "border-border"
|
|
}`}
|
|
/>
|
|
{open && !disabled && options.length > 0 && (
|
|
<ul
|
|
id={listId}
|
|
role="listbox"
|
|
className="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-border bg-surface shadow-card"
|
|
>
|
|
{options.map((entry) => {
|
|
const labelText = formatUsStateOption(entry);
|
|
return (
|
|
<li key={entry.code} role="option">
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-bg"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => selectOption(entry.code, labelText)}
|
|
>
|
|
{labelText}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
{error && <p className="text-xs text-error">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|