Combobox
Mẫu chuẩnInput cộng Popover thành một ô nhập có danh sách gợi ý. Tiêu điểm không bao giờ rời ô -- dòng đang hoạt động chỉ được trỏ tới bằng aria-activedescendant, không bao giờ được focus.
Playground
Gõ để lọc trong hai mươi khách hàng, dùng mũi tên để đổi dòng hoạt động, Enter để chốt. Bật "Đang tải" để xem danh sách cũ bị thay hoàn toàn bằng nhãn đang tải -- không hiện cả hai cùng lúc. Gõ một chuỗi không khớp ai để xem danh sách rỗng.
20/20 khách hàng khớp. Đã chọn: (chưa có)
import { Combobox } from "@nexobox/ui";
function Example() {
const [query, setQuery] = useState("");
const [value, setValue] = useState<string | null>(null);
const options = customers.filter((c) =>
c.label.toLocaleLowerCase("vi").includes(query.trim().toLocaleLowerCase("vi")),
);
return (
<Combobox
label="Khách hàng"
options={options}
value={value}
onValueChange={setValue}
query={query}
onQueryChange={setQuery}
emptyLabel="Không tìm thấy khách hàng nào."
/>
);
}Bài toán
Khi một ô nhập phải chọn từ một tập lớn hoặc chưa biết trước — một khách hàng trong mười nghìn, một nhân sự, một kênh — cả hai control có sẵn đều sai. Select buộc toàn bộ tuỳ chọn có mặt sẵn và không cho gõ để thu hẹp, nên vô dụng ở quy mô đó và không dùng được với dữ liệu lấy từ server. Input cho gõ nhưng không gợi ý gì. Việc còn lại — một ô nhập chữ cộng một danh sách gợi ý điều hướng được bằng bàn phím, trong đó tiêu điểm nằm ở ô và danh sách chỉ được trỏ tới — không thuộc về primitive nào: Input không biết có một danh sách, Popover không biết nội dung nó mở ra là một listbox, và không primitive nào được phép giữ aria-activedescendant của một cái khác.
Khi nào sử dụng
Nên dùng
Chọn một giá trị từ một tập lớn, nơi gõ để thu hẹp là cách tìm chính. Tập tuỳ chọn đến từ server, thay đổi theo từng lần gõ, hoặc dài tới mức một danh sách phẳng không đọc được. Khi người dùng có thể biết tên thứ mình cần nhưng không biết nó nằm ở đâu trong danh sách.
Khi nào không dùng
Tránh dùng
| Không dùng cho | Dùng thay |
|---|---|
| Tập nhỏ, biết trước, dưới khoảng mười tuỳ chọn | Select. Gõ để thu hẹp mười dòng là thêm việc, không bớt việc |
| Hai hoặc ba tuỳ chọn loại trừ nhau | RadioGroup |
| Chọn nhiều giá trị | Chưa có gì trong corpus. Combobox là single-select — multi-select là Unknown |
| Một ô nhập chữ tự do không có tập giá trị hợp lệ | Field |
| Dialog tìm kiếm toàn cục, mở bằng phím tắt | Dựng trên Dialog — cmdk bị từ chối bằng tên |
| Menu hành động — sửa, xoá, xuất file | Dropdown menu. Một menu chạy lệnh; combobox trả về một giá trị |
Dựng từ
| Primitive tầng 1 | Vai trong composite |
|---|---|
| Input | Ô nhập. Mang role="combobox", aria-expanded, aria-controls, aria-autocomplete, aria-activedescendant. |
| Popover | Định vị và trình bày danh sách. Dùng controlled, PopoverAnchor quanh ô, PopoverContent làm listbox. |
| Label | Nhãn, htmlFor bằng id đã giải quyết. |
| Combobox thêm gì mà không primitive nào có |
|---|
| Toàn bộ dây nối ARIA 1.2 của mẫu editable combobox — sáu thuộc tính trên ô, role="listbox"/"option" ở danh sách |
aria-activedescendant: tuỳ chọn đang hoạt động được trỏ tới, không được focus |
| Mô hình bàn phím đầy đủ cho danh sách trong khi caret vẫn ở trong ô |
Trạng thái open và activeIndex — không giữ gì khác |
Chặn onOpenAutoFocus / onCloseAutoFocus của Radix để tiêu điểm không rời ô |
Ba primitive là nhiều nhất trong tầng 2 — sáu composite kia dùng không quá hai.
Anatomy
| # | Phần | Ghi chú |
|---|---|---|
| 1 | Label | htmlFor bằng id. |
| 2 | Input (trong PopoverAnchor) | role="combobox", aria-expanded, aria-controls=${id}-listbox, aria-autocomplete="list", aria-activedescendant, aria-invalid khi có error. |
| 3 | Ghi chú (p) | Chỉ khi có error hoặc hint, giống Field. |
| 4 | PopoverContent (portal tới body) | id=${id}-listbox, role="listbox", aria-label bằng label, hình học component.menu.*. |
| 5 | Dòng option | role="option", id=${id}-opt-<i>, aria-selected, data-active. |
| 6 | Dòng rỗng / đang tải | role="presentation", in emptyLabel hoặc loadingLabel. |
PopoverContent bọc Portal, nên listbox render vào <body>, không nằm cạnh ô — aria-controls và aria-activedescendant vẫn giải quyết được vì IDREF có phạm vi toàn document. Popover giữ modal ở mặc định false: một popover modal sẽ aria-hidden phần còn lại của trang, tức cả ô đang giữ tiêu điểm.
States
aria-expanded="false", không aria-activedescendant.
Ô hiện nhãn của tuỳ chọn đã chọn — host echo nhãn vào query khi commit.
Gõ ít nhất hai chữ để thu hẹp nhanh hơn.
aria-invalid không bật.
Chọn một khách hàng trước khi tiếp tục.
aria-invalid="true". Gợi ý bị che nếu có cả hai.
Kế thừa từ Input — danh sách không mở được.
Năm trạng thái mở của bảng contract — có tuỳ chọn, một tuỳ chọn đang hoạt động, tuỳ chọn đã chọn nằm trong danh sách, rỗng và đang tải — đòi danh sách thật sự mở, và danh sách chỉ tồn tại khi open === true. Dùng Playground ở trên để mở: gõ để thấy danh sách có tuỳ chọn, mũi tên để thấy dòng hoạt động (nền --nf-component-menu-item-hover, aria-activedescendant trỏ vào nó), chọn một khách hàng rồi mở lại để thấy dòng đó mang aria-selected="true", gõ một chuỗi không khớp ai để thấy danh sách rỗng, và bật "Đang tải" để thấy danh sách cũ bị thay hoàn toàn bằng loadingLabel — không bao giờ hiện cả hai cùng lúc.
Bàn phím & trợ năng
Seam — host sở hữu gì
Combobox không lọc, không gọi gì, không debounce, không biết options từ đâu ra. Theo ADR-UI-003 clause 2 nó không fetch, không đọc router, không giữ session, không import gì từ next.
| Host sở hữu | Cấp qua |
|---|---|
| Việc lọc — quyết định lớn nhất của composite này | options đã thu hẹp sẵn, cộng query / onQueryChange |
| Giá trị đã chọn | value / onValueChange |
| Gọi server, debounce, huỷ request cũ | Host, quanh onQueryChange |
| Trạng thái đang tải | loading |
| Câu chữ: rỗng, đang tải, lỗi, gợi ý | emptyLabel, loadingLabel, error, hint |
Trang này lọc ở host, đúng như bảng trên đòi hỏi: filterCustomers chạy toLocaleLowerCase("vi") rồi includes trên toàn bộ hai mươi khách hàng, cùng idiom với filterNavSections của thanh điều hướng trang này. Trạng thái duy nhất Combobox tự giữ là open và activeIndex.
API & triển khai
| Prop | Mặc định | Quy ước |
|---|---|---|
| label | — | Bắt buộc. Cũng là aria-label của listbox. |
| options | — | Bắt buộc. { id, label, description? }. Đã lọc sẵn bởi host. |
| value | — | Bắt buộc. id của tuỳ chọn đã chọn, hoặc null. |
| onValueChange | — | Bắt buộc. Chỉ gọi khi người dùng chốt một dòng. |
| query | — | Bắt buộc. Chữ trong ô. |
| onQueryChange | — | Bắt buộc. |
| emptyLabel | — | Bắt buộc. Không có chuỗi mặc định. |
| loadingLabel | undefined | Bắt buộc khi dùng loading. |
| loading | false | Có giá trị true ⇒ aria-busy="true" trên ô. |
| hint | undefined | Bị error che. |
| error | undefined | Có giá trị ⇒ aria-invalid="true". |
| disabled | false | — |
| placeholder | undefined | — |
| id | useId() | Gốc của <id>-listbox, <id>-opt-<i>, <id>-note. |
Danh sách đóng — API không mở rộng InputProps. Không có className — cùng lý do như OtpInput: một role, một aria-* hay một type truyền từ ngoài vào sẽ phá đúng bộ thuộc tính làm mẫu này hoạt động.
Design tokens
Combobox đọc --nf-component-auth-field-gap cho khoảng dọc, --nf-component-menu-radius và --nf-component-menu-padding cho hộp danh sách, --nf-component-menu-item-padding-inline, -item-padding-block, -item-gap cho một dòng, --nf-component-field-radius và --nf-component-menu-item-height — cùng token SelectItem và DropdownMenuItem dùng, 36 px comfortable / 32 px compact / 44 px con trỏ thô, rời khỏi thang hàng bảng cũ (48 px) theo DS-017 clause 8. Dòng hoạt động đọc --nf-component-menu-item-hover; giữ chuột xuống đọc --nf-component-menu-item-pressed, không co scale. --nf-semantic-text-muted cho description/gợi ý/emptyLabel, --nf-semantic-status-danger-foreground cho lỗi, và cặp --nf-motion-menu-in / -menu-out cho hộp danh sách. Một xung đột hình học, ghi ra thay vì che: PopoverContent mặc định bind component.panel.radius (16) và component.popover.padding; một danh sách tuỳ chọn là một menu, không phải một panel nội dung tự do (DS-015 clause 4), nên Combobox truyền className ghi đè bán kính và đệm sang bậc menu, cộng cặp presence menu-in/menu-out thay popover-in/popover-out. Đây là lần ghi đè được cho phép và đã ghi sổ, không phải một chỗ trôi — bản sửa đúng (một knob bề mặt trên Popover) là dòng ledger W10.26, ngoài phạm vi trang này.
Do / Don't
Nên dùng
Giữ tiêu điểm trong ô; truyền dòng hoạt động bằng aria-activedescendant. Chặn onOpenAutoFocus và onCloseAutoFocus. Giữ modal ở mặc định false. Lọc ở host, debounce ở host. Thay danh sách cũ bằng loadingLabel khi đang tải.
Tránh dùng
Gọi .focus() lên một dòng tuỳ chọn, hay cho nó tabindex. Dùng PopoverContent với mặc định — Radix sẽ chuyển tiêu điểm vào danh sách khi mở. Đặt modal cho "chắc chắn đóng khi bấm ra ngoài". Thêm một prop filter vào composite. Cho Enter chốt dòng đầu tiên khi chưa có dòng hoạt động.