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

"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>
);
}