이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

요소

Combobox

A combobox chooses values from a list of items, one or, with multiple, several: the tags of a post, the members of a channel, the languages of a document. Its trigger is an input: typing opens the list and filters it, as shadcn/ui’s combobox does. It is Select’s sibling and shares its list, presented as each device expects:

el-combobox.tsx
el-combobox.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Combobox, type OpenChangeReason, type SelectEntry, type SelectGroup } from "nsh/base";
import { CheckMark, desktop, Icon, Log, Panel, size, Split, ui } from "./palette";

// Two comboboxes in a card: labels for an issue, several picked in one go
// from a searchable list with sections, a clear button and a footer; and
// an assignee, one person replacing the last. The trigger is the
// field: the input typing filters the list with, and a chevron that
// turns while the list is open; the list is a popup at the trigger with a pointer, a sheet
// on a touch screen. Selected rows show a check in its own lane.

const LABELS = [
  { value: "bug", title: "Bug", group: "Type" },
  { value: "feature", title: "Feature", group: "Type" },
  { value: "docs", title: "Docs", group: "Type" },
  { value: "ios", title: "iOS", group: "Platform" },
  { value: "android", title: "Android", group: "Platform" },
  { value: "macos", title: "macOS", group: "Platform" },
  { value: "windows", title: "Windows", group: "Platform" },
  { value: "linux", title: "Linux", group: "Platform" },
  { value: "web", title: "Web", group: "Platform" },
  { value: "wontfix", title: "Won't fix", group: "Status", disabled: true },
  { value: "blocked", title: "Blocked", group: "Status" },
];

const PEOPLE = [
  { value: "ada", title: "Ada Lovelace" },
  { value: "grace", title: "Grace Hopper" },
  { value: "linus", title: "Linus Torvalds" },
  { value: "margaret", title: "Margaret Hamilton" },
];

const titlesOf = (items: { value: string; title: string }[], values: string[]) =>
  values.map((v) => items.find((i) => i.value === v)?.title ?? v);

/** The text of a control: 13 with a pointer, the touch label size otherwise. */
const controlText = desktop ? 13 : size.label;

/** A popup at the trigger with a pointer, a sheet at the bottom on a touch screen. */
const surface = () =>
  desktop
    ? { ...ui().card, borderRadius: 12, padding: 4 }
    : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 10, paddingBottom: 30, maxHeight: "70%" as const };

const backdrop = { backgroundColor: "rgba(0,0,0,0.45)" };

/** The field: a bordered box around the input, the clear button and the chevron. */
const fieldStyle = (open: boolean) => ({
  flexDirection: "row" as const,
  alignItems: "center" as const,
  minHeight: size.control,
  paddingLeft: 10,
  paddingRight: 4,
  borderRadius: 8,
  borderWidth: 1,
  borderColor: open ? ui().faint : ui().border,
  backgroundColor: ui().card.backgroundColor,
  ...(desktop ? { width: 260 } : {}),
});
const inputClass = () => `${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`;
const inputStyle = { flex: 1, minWidth: 40, height: size.control - 2 };

/** The trigger button: a chevron that turns while the list is open. */
function Chevron(props: { open: boolean }) {
  return (
    <View
      class="transition-transform duration-150 motion-reduce:transition-none"
      style={{ width: 24, height: 24, alignItems: "center", justifyContent: "center", transform: [{ rotate: props.open ? "270deg" : "90deg" }] }}
    >
      <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
    </View>
  );
}

/**
 * A row's look, on the row itself (`itemClass`): the accent tint while
 * highlighted (the keys or the pointer, as the menus' rows), dimmed while
 * disabled. The states are the row's classes (`group`), so the list draws a
 * row's content once per item and the highlight moves without drawing.
 */
const rowClass =
  "group rounded-lg border border-transparent transition-colors duration-100 motion-reduce:transition-none " +
  "focus:border-brand/30 focus:bg-brand-tint active:opacity-70 disabled:opacity-40";

/** A row's content: the title, the check in its own lane (`group-selected:`). */
function Row(props: { item: SelectEntry }) {
  return (
    // The row's 1-point border on each side.
    <View style={{ height: size.rowSm - 2, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 7 }}>
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>{props.item.title}</Text>
      <View class="opacity-0 group-selected:opacity-100" style={{ width: 16 }}>
        <CheckMark size={16} tint={ui().accent} />
      </View>
    </View>
  );
}

function Header(props: { group: SelectGroup }) {
  return (
    <Text style={{ fontSize: 12, fontWeight: "500", color: ui().muted, paddingHorizontal: 9, paddingTop: 8, paddingBottom: 4 }}>
      {props.group.title}
    </Text>
  );
}

/** A label over its control, a quieter line under the label. */
function Field(props: { label: string; detail: string; children: JSX.Element }) {
  return (
    <View style={{ gap: 8 }}>
      <View style={{ gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
      {props.children}
    </View>
  );
}

function App() {
  const labels = signal<string[]>(["bug", "ios"]);
  const assignee = signal<string[]>([]);
  const labelsOpen = signal(false);
  const assigneeOpen = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 8));
  const opened = (open: (next: boolean) => void) => ({ open: next }: { open: boolean; reason: OpenChangeReason }) => open(next);
  const labelTitle = () => {
    const titles = titlesOf(LABELS, labels());
    return titles.length === 0 ? undefined : titles.length <= 2 ? titles.join(", ") : `${titles.length} labels`;
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>combobox</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Pick several labels without the list closing; Cmd+Return confirms" : "Tap labels to toggle them; tap outside to finish"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{`labels [${labels().join(", ")}]`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`assignee [${assignee().join(", ")}]`}</Text>
            </Panel>
            <Log title="onChange · onConfirm" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
          <Field label="Labels" detail="Several at once, with search and sections">
            <Combobox
              items={LABELS}
              multiple
              value={labels()}
              onChange={({ value }) => {
                labels.set(value);
                log(`Labels → ${titlesOf(LABELS, value).join(", ") || "none"}`);
              }}
              onConfirm={({ value }) => log(`Confirmed ${value.length} labels`)}
              onOpenChange={opened((next) => labelsOpen.set(next))}
              accessibilityLabel="Labels"
              style={fieldStyle(labelsOpen())}
              placeholder={labelTitle() ?? "Add labels"}
              inputClass={inputClass()}
              inputStyle={inputStyle}
              triggerButton={<Chevron open={labelsOpen()} />}
              empty={<Text style={{ paddingVertical: 20, textAlign: "center", fontSize: 13, color: ui().muted }}>No label matches</Text>}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              itemClass={rowClass}
              cleanable
              clearButtonAccessibilityLabel="Clear the labels"
              clearButtonClass="items-center justify-center rounded-full enabled:hover:opacity-70"
              clearButtonStyle={{ width: 20, height: 20 }}
              clearButton={<Icon name="close" size={14} tint={ui().muted} />}
              footerStyle={{ borderTopWidth: 1, borderColor: ui().border, marginTop: 4, paddingTop: 4 }}
              footer={
                <View style={{ paddingHorizontal: 8, paddingVertical: 6 }}>
                  <Text style={{ fontSize: 12, color: ui().muted }}>{`${labels().length} selected`}</Text>
                </View>
              }
              renderItem={(item) => <Row item={item} />}
              renderGroup={(group) => <Header group={group} />}
            />
          </Field>
          <Field label="Assignee" detail="One person: a choice replaces the last">
            <Combobox
              items={PEOPLE}
              value={assignee()}
              onChange={({ value }) => {
                assignee.set(value);
                log(`Assignee → ${titlesOf(PEOPLE, value)[0] ?? "none"}`);
              }}
              onOpenChange={opened((next) => assigneeOpen.set(next))}
              accessibilityLabel="Assignee"
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              itemClass={rowClass}
              style={fieldStyle(assigneeOpen())}
              placeholder="Assign someone"
              inputClass={inputClass()}
              inputStyle={inputStyle}
              triggerButton={<Chevron open={assigneeOpen()} />}
              renderItem={(item) => <Row item={item} />}
            />
          </Field>
        </View>
      </Split>
    </View>
  );
}

mount(App);
  • On the desktops and in desktop browsers, a popup under the field, as wide as the field (the app’s popupStyle / popupClass width sets another) and as tall as its rows up to the room beside it.
  • On phones and tablets, a sheet at the bottom of the screen over a backdrop.

The combobox owns the behavior: the value (the app’s, or its own), the open state, the keyboard, search, sections, the highlighted row and what screen readers hear. With multiple, a choice toggles the item in the value and the list stays open, so several are picked in one go. The look is the app’s: it styles the field, the popup, the list, the rows, the sections’ headers, the input and the footer, or draws the rows itself.

use nsh_base::{SelectEntry, SelectItem, combobox, theme};
use nsh_core::prelude::*;

const TAGS: [(&str, &str); 5] = [
    ("bug", "Bug"),
    ("design", "Design"),
    ("docs", "Docs"),
    ("perf", "Performance"),
    ("security", "Security"),
];

fn tag_picker() -> impl IntoElement {
    let tags = signal(Vec::<SharedString>::new());
    let title = move || match tags.get().len() {
        0 => "Add tags".to_string(),
        n => format!("{n} tags"),
    };
    combobox()
        .items(
            TAGS.iter()
                .map(|&(value, title)| SelectItem::new(value, title))
                .collect::<Vec<_>>(),
        )
        .multiple(true)
        .placeholder(title)
        .value(move || Some(tags.get()))
        .on_change(move |next: &Vec<SharedString>| tags.set(next.clone()))
        .accessibility_label("Tags")
        .offset(px(4.0))
        .popup_style(|s| {
            s.p_1()
                .rounded_lg()
                .border_1()
                .border_color(move || theme().border)
                .bg(move || theme().popover)
                .shadow_md()
        })
        .h_9()
        .w_48()
        .px_3()
        .flex_row()
        .items_center()
        .rounded_md()
        .border_1()
        .border_color(move || theme().input)
        .input_style(|s| s.flex_1().text_sm())
        .trigger_button(text("⌄"))
        .item_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
        .render_item(|item: &SelectEntry| view().px_2().py_1p5().child(text(item.title.clone()).text_sm()))
}

Combobox comes from nsh/base, the nsh-base crate in Rust. In Rust, combobox() makes one with a setter per prop (.items(..), .value(..), .multiple(..)) and a listener per event (.on_change(..)). Its items are Select’s: SelectItem::new(value, title), with .disabled(..) and .group(..), in sections as a select’s are (see Items).

#Value

The value is an array of the selected items’ values. With value, the app owns it: the combobox shows those items as selected and reports each change with onChange({ value }); the app sets value to follow it, or keeps its own. Left out, the combobox keeps its own value, from defaultValue at first.

  • With multiple, a press, Return or Space toggles the highlighted item: in the value if it was out, out if it was in. The list stays open.
  • Without it, a choice replaces the value with that item’s and closes the list; choosing the selected item again only closes it.

onConfirm({ value }) tells the value as the list closes: once per opening, however it closes (a choice, Escape, a press outside, the trigger button), with the value as the user left it. Cmd+Return on a Mac (Ctrl+Return elsewhere) closes the list and confirms.

cleanable adds a clear button while an item is selected: a press empties the value (onChange({ value: [] })). The button comes after the input, before the trigger button; a press on it never opens the list. The app fills it, as a select’s clear button.

#Input

The trigger is a text input in the combobox’s row (its class and style: the field’s border and fill), with the app’s prefix before it (the chosen items as chips, an icon) and, after it, the clear button, the app’s triggerButton (a chevron) and suffix.

  • Typing opens the list and filters the items by title, ignoring case; empty shows while nothing matches. Down and Up open it too, a press on the trigger button opens and closes it, and on touch screens a tap on the input opens it.
  • With one value, the input shows the chosen item’s title (placeholder while there is none). It is selected when the input gets the focus, so what is typed replaces it.
  • With multiple, the input shows the query; the app shows the chosen items, in prefix or beside the combobox.

The query is the combobox’s own, starting each opening from defaultQuery (else empty), or the app’s with query: onQueryChange({ query }) reports what is typed, and the app sets query to follow it, to filter its own way or to suggest a starting point.

footer is content below the rows, in a part of its own (footerClass, footerStyle): an action (“Manage tags”), a count, a hint. It stays in place while the rows scroll.

#Props

PropRustTypeDefaultDescription
items.items(..)SelectItem[][]The items, in order.
multiple.multiple(..)booleanfalseA choice toggles the item in the value and the list stays open.
value.value(..)string[]noneThe selected items’ values, owned by the app.
defaultValue.default_value(..)string[][]The value at first, without value.
open, defaultOpen.open(..), .default_open(..)booleanfalseThe open state, the app’s or the combobox’s own.
placeholder.placeholder(..)stringnoneThe input’s placeholder, while it is empty.
query.query(..)stringnoneThe query, owned by the app.
defaultQuery.default_query(..)stringnoneThe query each opening starts with, without query.
cleanable.cleanable(..)booleanfalseA clear button while an item is selected; a press empties the value.
disabled.disabled(..)booleanfalseIt stays closed and takes no presses or keyboard focus.
accessibilityLabel.accessibility_label(..)stringnoneWhat the choice is about, for screen readers.
placement, align, offset, margin.placement(..), …as Select’s"bottom", "start", 0, 0Desktops: where the popup goes against the field.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe popup (desktops) or the sheet (touch screens).
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>noneThe backdrop behind the sheet.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>noneThe list, the scroll view of the rows.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>noneEach item’s row: focus: while highlighted, selected: while selected, disabled: while it cannot be.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<ViewStyle>noneEach section’s header.
inputClass, inputStyle.input_class(..), .input_style(..)string, StyleProp<InputStyle>noneThe input: focus: while it edits, disabled: with the combobox.
footerClass, footerStyle.footer_class(..), .footer_style(..)string, StyleProp<ViewStyle>noneThe footer, below the rows.
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>noneThe clear button (cleanable).
clearButton.clear_button(..)JSX.ElementnoneThe content of the clear button.
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)stringnoneWhat screen readers call the clear button.
triggerButton.trigger_button(..)JSX.ElementnoneA button after the input (a chevron): a press opens and closes the list.
prefix.prefix(..)JSX.ElementnoneContent before the input: chips, an icon.
suffix.suffix(..)JSX.ElementnoneContent after the trigger button.
empty.empty(..)JSX.ElementnoneWhat the list shows while no item does.
footer.footer(..)JSX.ElementnoneWhat shows below the rows.
renderItem.render_item(..)(item: SelectEntry) => JSX.ElementnoneAn item’s row, drawn by the app from what the item is, never its state: the content follows the row’s group-focus:, group-selected: and group-disabled: (group in itemClass), so a move of the highlight or a toggle draws nothing again; the item’s title otherwise.
renderGroup.render_group(..)(group: SelectGroup) => JSX.ElementnoneA section’s header, drawn by the app; its title otherwise.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe field: the row of the prefix, the input, the buttons and the suffix; the list is as wide as it.

#Events

EventRustDescription
onChange.on_change(|value| ..)The value changed (a choice, a toggle, the clear button): { value }, the selected items’ values. Set value to follow it.
onConfirm.on_confirm(|value| ..)The list closed: { value } as it is then, once per opening.
onQueryChange.on_query_change(|query| ..)The user typed in the input: { query }. Set query to follow it.
onOpenChange.on_open_change(|e| ..)The list opened or closed, with why ({ open, reason }). Set open to follow it.
onOpen, onClose.on_open(..), .on_close(..)The list is shown, or gone.
onCancel.on_cancel(..)The user closed the list without a choice that closes it (Escape or back, a press outside or on the backdrop).

They come in this order: onChange for a choice, or onCancel for a close without one, then onOpenChange, then onConfirm as the list closes, then onOpen or onClose once the list is shown or gone.

#Parts

PartWhereStyled with
The fieldthe combobox’s view, a rowclass, style
The prefixthe field, firstthe app’s own element (prefix)
The inputthe field, after the prefixinputClass, inputStyle, placeholder
The clear buttonthe field, after the input (cleanable, while an item is selected)clearButtonClass, clearButtonStyle; its content clearButton
The trigger buttonthe field, after the clear buttonthe app’s own element (triggerButton)
The suffixthe field, lastthe app’s own element (suffix)
The popup or sheetover everything, under the field (desktops) or at the bottom (touch screens)popupClass, popupStyle
The backdropbehind the sheet (touch screens)backdropClass, backdropStyle
The listthe popuplistClass, listStyle
A section’s headerthe list, before its itemsgroupClass, groupStyle, renderGroup
An item’s rowthe listitemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: inside, under group in itemClass)
The empty statethe popup, while no item showsits own element (empty)
The footerthe popup, lastfooterClass, footerStyle; its content footer

#Keyboard

The keys are a select’s (see Keyboard) while the input keeps the focus, with these differences: letters and digits are typed into the input, which filters the list, rather than typeahead; Down and Up open the closed list; Return toggles the highlighted item with multiple and keeps the list open; Cmd+Return on a Mac (Ctrl+Return elsewhere) closes it and confirms. When the list closes on the desktops, the input keeps the focus.

#Accessibility

  • The input is a combobox for screen readers: its label is accessibilityLabel, its value the selected items’ titles, joined with commas, and it says whether the list is expanded. disabled dims it.
  • The list is a listbox and each row an option, named by its title; the selected options are selected and a disabled one dimmed. Sections’ headers are headers.
  • The clear button is a button named by clearButtonAccessibilityLabel; it is no stop of Tab. The trigger button is hidden from screen readers, as the input says whether the list is open, and is no stop of Tab either.

See Accessibility for labels, roles and announcements in general.