Bỏ qua tới nội dung
Component / 042 / NEXOFRAME

Combobox

Mẫu chuẩn

Input 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.

Composite tầng 2Ba primitive: Input, Popover, LabelHost lọc, không phải compositeSingle-select

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.

LIVE PREVIEW

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 choDùng thay
Tập nhỏ, biết trước, dưới khoảng mười tuỳ chọnSelect. 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ừ nhauRadioGroup
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ắtDựng trên Dialog — cmdk bị từ chối bằng tên
Menu hành động — sửa, xoá, xuất fileDropdown menu. Một menu chạy lệnh; combobox trả về một giá trị

Dựng từ

Primitive tầng 1Vai 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.
LabelNhã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ầnGhi chú
1LabelhtmlFor bằng id.
2Input (trong PopoverAnchor)role="combobox", aria-expanded, aria-controls=${id}-listbox, aria-autocomplete="list", aria-activedescendant, aria-invalid khi có error.
3Ghi chú (p)Chỉ khi có error hoặc hint, giống Field.
4PopoverContent (portal tới body)id=${id}-listbox, role="listbox", aria-label bằng label, hình học component.menu.*.
5Dòng optionrole="option", id=${id}-opt-<i>, aria-selected, data-active.
6Dòng rỗng / đang tảirole="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

ĐÓNG, RỖNG

aria-expanded="false", không aria-activedescendant.

ĐÓNG, ĐÃ CHỌN

Ô hiện nhãn của tuỳ chọn đã chọn — host echo nhãn vào query khi commit.

CÓ GỢI Ý

Gõ ít nhất hai chữ để thu hẹp nhanh hơn.

aria-invalid không bật.

LỖI

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.

DISABLED

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

Gõ chữ -- onQueryChange chạy, danh sách mở nếu đang đóng.
ArrowDown -- đang đóng: mở, đặt hoạt động vào dòng đầu. Đang mở: xuống một dòng, dừng ở dòng cuối, không vòng lại.
ArrowUp -- đang đóng: mở, đặt hoạt động vào dòng cuối. Đang mở: lên một dòng, dừng ở dòng đầu.
Alt+ArrowDown -- mở danh sách, không đặt dòng nào hoạt động.
Enter -- có dòng hoạt động: chốt và đóng danh sách. Không có: không làm gì, và không nộp form.
Escape -- đóng danh sách. Không sửa chữ trong ô, không xoá lựa chọn.
Tab -- đóng danh sách, không chốt gì, rồi rời ô theo thứ tự tab bình thường.
Home / End -- di chuyển caret trong ô, không di chuyển trong danh sách. Đây là hành vi đúng cho một combobox có thể gõ, theo ARIA APG.
Bấm nhãn -- tiêu điểm vào ô, qua Label cộng cặp htmlFor/id composite nối.
Trỏ chuột vào một dòng đặt dòng đó hoạt động; bấm thì chốt.
Tiêu điểm không bao giờ rời ô nhập -- không dòng tuỳ chọn nào có tabindex, không dòng nào được gọi .focus(). Dòng đang hoạt động được truyền chỉ bằng aria-activedescendant trên ô.
Danh sách không vòng ở hai đầu -- với một tập lọc động, vòng lại làm người dùng mất dấu mình đang ở đâu trong một danh sách vừa đổi độ dài.

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ữuCấp qua
Việc lọc — quyết định lớn nhất của composite nàyoptions đã thu hẹp sẵn, cộng query / onQueryChange
Giá trị đã chọnvalue / onValueChange
Gọi server, debounce, huỷ request cũHost, quanh onQueryChange
Trạng thái đang tảiloading
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

PropMặc địnhQuy ướ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.
loadingLabelundefinedBắt buộc khi dùng loading.
loadingfalseCó giá trị true ⇒ aria-busy="true" trên ô.
hintundefinedBị error che.
errorundefinedCó giá trị ⇒ aria-invalid="true".
disabledfalse—
placeholderundefined—
iduseId()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.