元素

Combobox

组合框从一组条目中选出值,一个,或者在 multiple 时选多个:一篇文章的标签、一个频道的成员、一份文档的语言。它的触发元素是一个输入框:输入会打开列表并筛选它,和 shadcn/ui 的组合框一样。它是 Select 的同类组件,和它共用同一个列表,并按每种设备的习惯呈现:

el-combobox.tsx
el-combobox.tsx运行中
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);
  • 在桌面平台和桌面浏览器上,是输入框下方的弹出层,和输入框所在的字段一样宽(应用的 popupStyle / popupClass 设置宽度时用它的),高度随条目增加,最多到字段旁边剩余的空间。
  • 在手机和平板上,是屏幕底部、覆盖在背景层之上的 sheet。

组合框负责行为:值(应用的,或它自己的)、展开状态、键盘、搜索、分组、高亮的行,以及屏幕阅读器听到的内容。在 multiple 时,一次选择会把该条目加入或移出值,列表保持打开,所以可以一次选好多个。外观由应用决定:应用为字段、弹出层、列表、行、分组标题、输入框和底部区域设置样式,或者自己绘制行。

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 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,combobox() 创建一个组合框,每个属性有一个设置方法(.items(..)、.value(..)、.multiple(..)),每个事件有一个监听方法(.on_change(..))。它的条目与 Select 的相同:SelectItem::new(value, title),可以用 .disabled(..) 和 .group(..),分组方式也和选择框一样(见条目)。

#值

值是已选条目的值组成的数组。有 value 时,值由应用持有:组合框把这些条目显示为已选,并用 onChange({ value }) 报告每次变化;应用把 value 设为报告的值以跟随它,或者保留自己的值。省略 value 时,组合框自己保存值,初始为 defaultValue。

  • 在 multiple 时,按压、Return 或 Space 切换高亮的条目:不在值里就加入,在就移出。列表保持打开。
  • 不设 multiple 时,一次选择把值替换为该条目的值并关闭列表;再次选择已选的条目只会关闭列表。

onConfirm({ value }) 在列表关闭时告知当时的值:每次打开报告一次,不论怎样关闭(一次选择、Escape、在外面按压、按触发按钮、列表打开时按 Tab 或 Shift+Tab,reason: "focus"),值是用户离开时的样子。Mac 上的 Cmd+Return(其他平台为 Ctrl+Return)关闭列表并确认。

cleanable 会在有已选条目时加一个清除按钮:按下它会清空值(onChange({ value: [] }))。这个按钮在输入框之后、触发按钮之前,按它不会打开列表;由应用填充内容,和选择框的清除按钮一样。

#输入框

触发元素是组合框这一行(它的 class 和 style:字段的边框和填充)中的一个文本输入框,前面是应用的 prefix(以标签形式显示的已选条目、一个图标),后面依次是清除按钮、应用的 triggerButton(一个箭头)和 suffix。

  • 输入会打开列表并按标题筛选条目,忽略大小写;没有匹配时显示 empty。Down 和 Up 也会打开列表,按输入框或触发按钮会打开或关闭它;在触屏设备上,让输入框获得焦点的那次点按会打开它。
  • 单个值时,输入框显示已选条目的标题(没有时显示 placeholder)。输入框获得焦点时标题被选中,所以输入的内容会替换它。
  • 在 multiple 时,输入框显示搜索词;已选条目由应用显示,放在 prefix 中或组合框旁边。

搜索词默认属于组合框自己,每次打开从 defaultQuery(没有则为空)开始;有 query 时由应用持有:onQueryChange({ query }) 报告输入的内容,应用把 query 设为它以跟随,用来按自己的方式筛选,或给出一个起始搜索词。

#底部区域

footer 是显示在行下方的内容,位于它自己的部件中(footerClass、footerStyle):一个操作(“管理标签”)、一个计数、一句提示。行滚动时它保持不动。

#属性

属性Rust类型默认值说明
items.items(..)SelectItem[][]条目,按顺序。
multiple.multiple(..)booleanfalse一次选择把条目加入或移出值,列表保持打开。
value.value(..)string[]无已选条目的值,由应用持有。
defaultValue.default_value(..)string[][]没有 value 时的初始值。
open、defaultOpen.open(..)、.default_open(..)booleanfalse展开状态,由应用或组合框自己持有。
placeholder.placeholder(..)string无输入框为空时的占位文字。
query.query(..)string无搜索词,由应用持有。
defaultQuery.default_query(..)string无没有 query 时每次打开的初始搜索词。
cleanable.cleanable(..)booleanfalse有已选条目时显示清除按钮;按下会清空值。
disabled.disabled(..)booleanfalse保持关闭,不接受按压和键盘焦点。
accessibilityLabel.accessibility_label(..)string无这次选择是关于什么的,供屏幕阅读器使用。
placement、align、offset、margin.placement(..) 等同 Select"bottom"、"start"、0、0桌面平台:弹出层相对字段的位置。
popupClass、popupStyle.popup_class(..)、.popup_style(..)string、StyleProp<ViewStyle>无弹出层(桌面平台)或 sheet(触屏设备)。
backdropClass、backdropStyle.backdrop_class(..)、.backdrop_style(..)string、StyleProp<ViewStyle>无sheet 后面的背景层。
listClass、listStyle.list_class(..)、.list_style(..)string、StyleProp<ViewStyle>无列表,即行所在的滚动视图。
itemClass、itemStyle.item_class(..)、.item_style(..)string、StyleProp<TextStyle>无每个条目的行:高亮时应用 focus:,已选时应用 selected:,不可选时应用 disabled:。
groupClass、groupStyle.group_class(..)、.group_style(..)string、StyleProp<ViewStyle>无每个分组的标题。
inputClass、inputStyle.input_class(..)、.input_style(..)string、StyleProp<InputStyle>无输入框:编辑时应用 focus:,组合框禁用时应用 disabled:。
footerClass、footerStyle.footer_class(..)、.footer_style(..)string、StyleProp<ViewStyle>无行下方的底部区域。
clearButtonClass、clearButtonStyle.clear_button_class(..)、.clear_button_style(..)string、StyleProp<ViewStyle>无清除按钮(cleanable)。
clearButton.clear_button(..)JSX.Element无清除按钮的内容。
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)string无屏幕阅读器对清除按钮的称呼。
triggerButton.trigger_button(..)JSX.Element无输入框之后的按钮(一个箭头):按下打开或关闭列表。
prefix.prefix(..)JSX.Element无输入框之前的内容:标签、图标。
suffix.suffix(..)JSX.Element无触发按钮之后的内容。
empty.empty(..)JSX.Element无没有条目显示时列表显示的内容。
footer.footer(..)JSX.Element无显示在行下方的内容。
renderItem.render_item(..)(item: SelectEntry) => JSX.Element无由应用根据条目本身绘制的条目行,从不收到状态:内容用 group-focus:、group-selected:、group-disabled: 跟随行的状态(itemClass 里加 group),所以高亮移动或切换选中时不再重新绘制;否则显示条目标题。
renderGroup.render_group(..)(group: SelectGroup) => JSX.Element无由应用绘制的分组标题;否则显示其标题。
class、style样式方法string、StyleProp<ViewStyle>无字段:前缀、输入框、按钮和后缀所在的一行;列表和它一样宽。

#事件

事件Rust说明
onChange.on_change(|value| ..)值变化了(一次选择、一次切换、清除按钮):{ value },已选条目的值。设置 value 以跟随它。
onConfirm.on_confirm(|value| ..)列表关闭了:{ value } 为当时的值,每次打开报告一次。
onQueryChange.on_query_change(|query| ..)用户在输入框中输入了内容:{ query }。设置 query 以跟随它。
onOpenChange.on_open_change(|e| ..)列表打开或关闭了,以及原因({ open, reason })。设置 open 以跟随它。
onOpen、onClose.on_open(..)、.on_close(..)列表已显示,或已消失。
onCancel.on_cancel(..)用户没有通过会关闭列表的选择而关闭了它(Escape 或返回、在外面或背景层上按压)。

它们按这个顺序到来:选择时先是 onChange,没有选择就关闭时先是 onCancel,然后是 onOpenChange,列表关闭时是 onConfirm,列表显示或消失之后是 onOpen 或 onClose。

#部件

部件位置样式
字段组合框的视图,一行class、style
前缀字段中的第一个应用自己的元素(prefix)
输入框字段中,前缀之后inputClass、inputStyle、placeholder
清除按钮字段中,输入框之后(cleanable,有已选条目时)clearButtonClass、clearButtonStyle;内容 clearButton
触发按钮字段中,清除按钮之后应用自己的元素(triggerButton)
后缀字段中的最后一个应用自己的元素(suffix)
弹出层或 sheet在一切之上,位于字段下方(桌面平台)或底部(触屏设备)popupClass、popupStyle
背景层sheet 后面(触屏设备)backdropClass、backdropStyle
列表弹出层中listClass、listStyle
分组标题列表中,它的条目之前groupClass、groupStyle、renderGroup
条目的行列表中itemClass、itemStyle、renderItem(其中用 group-focus:、group-selected:、group-disabled:,itemClass 里加 group)
空状态弹出层中,没有条目显示时它自己的元素(empty)
底部区域弹出层中的最后一个footerClass、footerStyle;内容 footer

#键盘

输入框保持焦点时,按键与选择框的相同(见键盘),区别在于:字母和数字输入到输入框中并筛选列表,而不是输入跳转;Down 和 Up 打开关闭着的列表;在 multiple 时,Return 切换高亮的条目并保持列表打开;Mac 上的 Cmd+Return(其他平台为 Ctrl+Return)关闭列表并确认。Tab 和 Shift+Tab 关闭打开的列表,焦点移到下一个或上一个控件。在桌面平台上列表以其他方式关闭时,焦点留在输入框。

#无障碍

  • 输入框对屏幕阅读器来说是一个组合框(combobox):它的标签是 accessibilityLabel,值是已选条目的标题,以逗号连接,并说明列表是否展开。disabled 时它显示为不可用。
  • 列表是一个列表框(listbox),每一行是一个选项,以其标题命名;已选的选项为已选状态,不可选的显示为不可用。分组标题是标题。
  • 清除按钮是一个按钮,名称来自 clearButtonAccessibilityLabel;它不是 Tab 的一站。触发按钮对屏幕阅读器隐藏,因为输入框已说明列表是否打开,它也不是 Tab 的一站。

关于标签、角色和播报的一般说明,见无障碍。