本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Select

Select 讓使用者從項目清單中選出一個值:設定、篩選條件、國家、時區。應用程式的觸發元件會開啟清單,並依各裝置的慣例呈現:

el-select.tsx
el-select.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Button, type OpenChangeReason, Select, type SelectEntry, type SelectGroup, Switch } from "nsh/base";
import { button, buttonText, CheckMark, desktop, focusRing, hoverFill, Icon, Log, Panel, size, Split, ui } from "./palette";

// Two selects in a settings card: a plain one (typeahead jumps by letter)
// and a searchable one with sections, a disabled item and an empty state.
// The trigger is the shared button look (hover, pressed, focus ring,
// disabled) with the chosen title 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. The highlighted row (the keys or the pointer) is lit with
// the accent tint, the chosen one is bolder with a check in its own lane.

const LANGUAGES = [
  { value: "en", title: "English" },
  { value: "de", title: "Deutsch" },
  { value: "es", title: "Español" },
  { value: "fr", title: "Français" },
  { value: "it", title: "Italiano" },
  { value: "nl", title: "Nederlands" },
  { value: "pl", title: "Polski" },
  { value: "pt", title: "Português" },
  { value: "sv", title: "Svenska" },
  { value: "tr", title: "Türkçe" },
  { value: "ja", title: "日本語" },
  { value: "ko", title: "한국어" },
  { value: "zh", title: "中文" },
];

const REGIONS = [
  { value: "us-east-1", title: "US East (Virginia)", group: "Americas" },
  { value: "us-west-2", title: "US West (Oregon)", group: "Americas" },
  { value: "ca-central-1", title: "Canada (Montréal)", group: "Americas" },
  { value: "sa-east-1", title: "South America (São Paulo)", group: "Americas" },
  { value: "eu-west-1", title: "Europe (Ireland)", group: "Europe" },
  { value: "eu-central-1", title: "Europe (Frankfurt)", group: "Europe" },
  { value: "eu-north-1", title: "Europe (Stockholm)", group: "Europe" },
  { value: "ap-northeast-1", title: "Asia Pacific (Tokyo)", group: "Asia Pacific" },
  { value: "ap-northeast-2", title: "Asia Pacific (Seoul)", group: "Asia Pacific", disabled: true },
  { value: "ap-southeast-1", title: "Asia Pacific (Singapore)", group: "Asia Pacific" },
  { value: "ap-east-1", title: "Asia Pacific (Hong Kong)", group: "Asia Pacific" },
  { value: "ap-southeast-2", title: "Asia Pacific (Sydney)", group: "Asia Pacific" },
];

const titleOf = (items: { value: string; title: string }[], value: string | null) => items.find((i) => i.value === value)?.title;

/** 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 trigger: the shared button's look, the chosen title at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; disabled?: boolean; clearable?: boolean }) {
  const look = button("secondary", {
    style: () => ({
      flexDirection: "row",
      justifyContent: "space-between",
      gap: 8,
      ...(desktop ? { width: 260 } : {}),
      // Open: the border stays lit while the list is up; disabled, dimmed.
      ...(props.open ? { borderColor: ui().faint } : {}),
      ...(props.disabled ? { opacity: 0.4 } : {}),
    }),
  });
  return (
    // No hover while disabled.
    <View {...look} hoverStyle={props.disabled ? undefined : look.hoverStyle}>
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: props.title ? ui().text : ui().muted }}>
        {props.title ?? props.placeholder}
      </Text>
      {/* With a clear button over the end (`cleanable`), an empty lane as wide as the button in place of the chevron. */}
      <View
        class="transition-transform duration-150 motion-reduce:transition-none"
        style={{
          width: props.clearable ? 20 : 10,
          height: 20,
          alignItems: "center",
          justifyContent: "center",
          opacity: props.clearable ? 0 : 1,
          transform: [{ rotate: props.open ? "270deg" : "90deg" }],
        }}
      >
        <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
      </View>
    </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 (bolder while chosen), a quieter detail at the
 * end, the check in its own lane (`group-selected:`).
 */
function Row(props: { item: SelectEntry; detail?: string }) {
  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 class="font-normal group-selected:font-semibold" style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>
        {props.item.title}
      </Text>
      <If when={props.detail}>
        <Text style={{ fontSize: 12, color: ui().faint }}>{props.detail}</Text>
      </If>
      <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 language = signal<string | null>("en");
  const region = signal<string | null>(null);
  const languageOpen = signal(false);
  const regionOpen = signal(false);
  const locked = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 8));
  const opened = (name: string, open: (next: boolean) => void) => ({ open: next, reason }: { open: boolean; reason: OpenChangeReason }) => {
    open(next);
    log(`${name} ${next ? "opened" : "closed"} · ${reason}`);
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>select</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a select, or Tab to it and press Down; type a letter to jump" : "Tap a select to choose from a sheet"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`language ${language() ?? "null"}`}</Text>
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`region ${region() ?? "null"}`}</Text>
              <Button
                disabled={region() === null}
                onPress={() => region.set(null)}
                {...button("secondary", { size: "sm", style: { marginTop: 6 } })}
              >
                <Text style={buttonText("secondary", { size: "sm" })}>Clear region</Text>
              </Button>
              <Switch
                checked={locked()}
                onChange={({ checked }) => locked.set(checked)}
                class={`-mx-2 mt-1 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
                trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
                trackStyle={locked() ? ui().accentFill : ui().inset}
                thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
                thumbStyle={locked() ? { backgroundColor: ui().onAccent } : ui().raised}
              >
                <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Region disabled</Text>
              </Switch>
            </Panel>
            <Log title="onChange · onOpenChange" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
          <Field label="Language" detail={desktop ? "Typing a letter on the trigger chooses by title" : "Thirteen languages in a sheet"}>
            <Select
              items={LANGUAGES}
              value={language()}
              onChange={({ value: next }) => {
                language.set(next);
                log(`Language → ${titleOf(LANGUAGES, next)}`);
              }}
              onOpenChange={opened("Language", (next) => languageOpen.set(next))}
              accessibilityLabel="Language"
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              itemClass={rowClass}
              trigger={<Trigger title={titleOf(LANGUAGES, language())} placeholder="Choose a language" open={languageOpen()} />}
              renderItem={(item) => <Row item={item} />}
            />
          </Field>
          <Field label="Region" detail="Search by name; one region is at capacity">
            <Select
              items={REGIONS}
              value={region()}
              onChange={({ value: next }) => {
                region.set(next);
                log(next === null ? "Region cleared" : `Region → ${titleOf(REGIONS, next)}`);
              }}
              onOpenChange={opened("Region", (next) => regionOpen.set(next))}
              disabled={locked()}
              accessibilityLabel="Region"
              searchable
              searchPlaceholder="Search regions"
              searchAccessibilityLabel="Search regions"
              searchClass={`${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`}
              searchStyle={{ ...ui().inset, borderRadius: 8, height: size.control, paddingHorizontal: 8, marginBottom: 4 }}
              empty={<Text style={{ paddingVertical: 20, textAlign: "center", fontSize: 13, color: ui().muted }}>No region matches</Text>}
              offset={4}
              margin={8}
              // Wider than the trigger on the desktops: the titles and their codes side by side.
              popupStyle={desktop ? { ...surface(), width: 340 } : surface()}
              backdropStyle={backdrop}
              itemClass={rowClass}
              // As wide as the trigger, so the clear button lands over its end.
              style={desktop ? { width: 260 } : undefined}
              cleanable
              clearButtonAccessibilityLabel="Clear the region"
              clearButtonClass="absolute items-center justify-center rounded-full enabled:hover:opacity-70"
              clearButtonStyle={{ top: (size.control - 20) / 2, right: 10, width: 20, height: 20 }}
              clearButton={<Icon name="close" size={14} tint={ui().muted} />}
              trigger={<Trigger title={titleOf(REGIONS, region())} placeholder="Choose a region" open={regionOpen()} disabled={locked()} clearable={region() !== null} />}
              renderItem={(item) => <Row item={item} detail={item.disabled ? "At capacity" : item.value} />}
              renderGroup={(group) => <Header group={group} />}
            />
          </Field>
        </View>
      </Split>
    </View>
  );
}

mount(App);
  • 在桌面平台與桌面瀏覽器上,清單是位於觸發元件上方的彈出層,已選取的列會疊在觸發元件上,讓所選標題維持在原位(shadcn/ui 的 item-aligned 定位)。彈出層的寬度與觸發元件相同(應用程式的 popupStyle / popupClass 寬度可另行設定,如 gpui-component 的 menu_width),高度則依列數而定,最高不超過螢幕上的可用空間;未選取任何項目時,會在觸發元件下方開啟。鍵盤可在項目間移動並選取其中一項。
  • 在手機與平板上,清單是位於螢幕底部、置於背景遮罩之上的底部面板。

行為由 select 負責:值(應用程式的值,或 select 自身的值)、開啟狀態、鍵盤、輸入即跳轉(typeahead)、搜尋、分區、反白列,以及螢幕閱讀器所朗讀的內容。外觀則由應用程式決定:它為觸發元件、彈出層、清單、列、分區標題與搜尋欄位設定樣式,或自行繪製列。

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

const FRUITS: [(&str, &str); 5] = [
    ("apple", "Apple"),
    ("banana", "Banana"),
    ("blueberry", "Blueberry"),
    ("grapes", "Grapes"),
    ("pineapple", "Pineapple"),
];

fn fruit_select() -> impl IntoElement {
    let fruit = signal(None::<SharedString>);
    let title = move || {
        let chosen = fruit.get();
        FRUITS
            .iter()
            .find(|(value, _)| Some(*value) == chosen.as_deref())
            .map_or("Pick a fruit", |(_, title)| *title)
            .to_string()
    };
    select()
        .items(
            FRUITS
                .iter()
                .map(|&(value, title)| SelectItem::new(value, title).disabled(value == "grapes"))
                .collect::<Vec<_>>(),
        )
        .value(move || fruit.get())
        .on_change(move |next: &Option<SharedString>| fruit.set(next.clone()))
        .accessibility_label("Fruit")
        .offset(px(4.0))
        .popup_style(|s| {
            s.p_1()
                .rounded_lg()
                .border_1()
                .border_color(move || theme().border)
                .bg(move || theme().popover)
                .text_color(move || theme().popover_foreground)
                .shadow_md()
        })
        .item_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
        .trigger(
            view()
                .h_9()
                .w_48()
                .flex_row()
                .items_center()
                .justify_between()
                .px_3()
                .rounded_md()
                .border_1()
                .border_color(move || theme().input)
                .hover(|s| s.bg(move || theme().accent))
                .child(text(title))
                .child(text("▾").text_color(move || theme().muted_foreground)),
        )
        .render_item(|item: &SelectEntry| {
            view()
                .flex_row()
                .items_center()
                .px_2()
                .py_1p5()
                .child(text(item.title.clone()).text_sm())
        })
}

Select 來自 nsh/base,即 Rust 中的 nsh-base crate。在 Rust 中,select() 會建立一個 select,每個屬性有一個 setter(.items(..)、.value(..)、.item_style(..)),每個事件有一個監聽器(.on_change(..));SelectItem::new(value, title) 則建立一個項目,並提供 .disabled(..) 與 .group(..)。

#項目

items 是以資料表示的清單:每個項目都有 value(在所有項目中唯一,選取該項目時會設定的值)、title(其列所顯示的內容,也是搜尋與輸入即跳轉所比對的對象,以及螢幕閱讀器所朗讀的內容),並可選擇性地帶有 disabled 與 group。相鄰且 group 相同的項目會組成一個分區,置於以該名稱為標題的標頭之下:

<Select
  items={[
    { value: "lisbon", title: "Lisbon", group: "Europe" },
    { value: "london", title: "London", group: "Europe" },
    { value: "tokyo", title: "Tokyo", group: "Asia" },
    { value: "seoul", title: "Seoul", group: "Asia", disabled: true },
  ]}
  groupClass="px-2 pb-1 pt-2 text-xs text-muted-foreground"
  trigger={trigger}
/>

各列是彈出層捲動檢視中的虛擬化清單:即使有數千個項目,也只會建立可見範圍內的列;當列的總高度超過可用空間時,彈出層會捲動。

#值

有 value 時,所選項目由應用程式持有:select 會顯示該值所對應的項目,並以 onChange({ value }) 回報選取結果;應用程式設定 value 來跟隨,或保留自己的值。value={null} 表示未選取任何項目。若省略,select 會保有自己的值,起初取自 defaultValue。onChange 回報變更:再次選取已選的項目只會關閉清單。

cleanable 會在已選取項目時加上清除按鈕:按下後會清除值,並以 onChange({ value: null }) 回報,清單則保持關閉。按鈕位於 select 的 view 中觸發元件之後,而非其內部,因此按下它不會開啟清單;應用程式負責放置它(以 absolute 疊在觸發元件的末端,也就是通常放置箭頭符號的位置)並填入內容:

<Select
  items={fonts}
  value={font()}
  onChange={({ value }) => font.set(value)}
  cleanable
  clearButtonAccessibilityLabel="Clear the font"
  clearButtonClass="absolute right-2 top-2 size-5 items-center justify-center rounded-full hover:bg-accent"
  clearButton={<Text class="text-xs text-muted-foreground">✕</Text>}
  trigger={trigger}
/>

觸發元件是應用程式自己的元素,因此可以為所選項目顯示任何內容:標題、圖示,或在未選取時顯示的預設提示文字。在 TSX 中,它就是你傳入的元素:它本身即是 combobox,並自行接收按壓、鍵盤焦點、展開狀態與 disabled,因此它自己的 active:、focus: 與 disabled: class 會跟隨 select,它自己的 onPress 也會執行,清單則以它為基準定位,且寬度與它相同。

#開啟狀態

沒有 open 時,select 會保有自己的開啟狀態(起初取自 defaultOpen)。有 open 時,開啟狀態由應用程式持有,並在 onOpenChange({ open, reason }) 中變更:"triggerPress"(按下觸發元件,或按下其按鍵)、"select"(選取)、"pressOutside"、"backdropPress" 或 "cancel"(Escape 或返回)。onOpen 與 onClose 表示清單已顯示與已消失(以底部面板呈現時,分別指面板滑入完成與滑出完成之後),onCancel 表示使用者未選取就關閉了清單。

#搜尋

searchable 會在項目上方放置一個搜尋欄位:輸入文字時會依標題篩選項目(不分大小寫),沒有任何符合項目的分區會連同其標頭一併消失。反白會移到第一個符合的項目,欄位持有鍵盤時,按鍵仍可移動反白,按下 Return 則選取該項目。empty 是沒有任何項目符合時清單所顯示的內容:

<Select
  items={zones}
  searchable
  searchPlaceholder="Search time zones"
  searchAccessibilityLabel="Search time zones"
  searchClass="mx-1 mb-1 rounded-md border border-input px-2 py-1.5 text-sm"
  empty={<Text class="px-2 py-6 text-center text-sm text-muted-foreground">No time zone found</Text>}
  trigger={trigger}
/>

在桌面平台上,清單開啟時搜尋欄位會取得鍵盤;在觸控螢幕上,則是按下欄位時才會彈出鍵盤。每次開啟時,搜尋內容都是空的。

#由應用程式繪製的列

itemClass 為每個項目的列設定樣式,列預設顯示項目的標題,直到由 renderItem 來繪製:focus: 套用於反白的列(按鍵或指標所在之處),selected: 套用於已選取的列,disabled: 套用於停用的列。若要自行繪製列,請提供 renderItem:它會取得項目的資訊(SelectEntry),並為可見範圍內的列回傳列的內容。它永遠不會取得列的狀態:在 itemClass 中搭配 group,內容會以 group-focus:、group-selected: 與 group-disabled: class 跟隨列的各種狀態,因此每個項目只會繪製一次,反白的移動或選取都不會觸發重新繪製。renderGroup 以相同方式繪製分區的標頭:

<Select
  items={fonts}
  itemClass="group rounded-md focus:bg-accent disabled:opacity-50"
  renderItem={(item) => (
    <View class="flex-row items-center gap-2 px-2 py-1.5">
      <Text class="flex-1 text-sm" style={{ fontFamily: item.value }}>{item.title}</Text>
      <Text class="w-4 text-right text-sm opacity-0 group-selected:opacity-100">✓</Text>
    </View>
  )}
  renderGroup={(group) => <Text class="px-2 pt-2 text-xs font-medium text-muted-foreground">{group.title}</Text>}
  trigger={trigger}
/>

#屬性

屬性Rust型別預設值說明
items.items(..)SelectItem[][]項目,依順序排列。
value.value(..)string | null無所選項目的 value,由應用程式持有;null 表示未選取。省略時,select 會保有自己的值。
defaultValue.default_value(..)string無沒有 value 時,起初的值。
open.open(..)boolean無開啟狀態,由應用程式持有。
defaultOpen.default_open(..)booleanfalse沒有 open 時,起初是否開啟。
searchable.searchable(..)booleanfalse項目上方的搜尋欄位,依標題篩選項目。
searchPlaceholder.search_placeholder(..)string無搜尋欄位的預設提示文字。
searchAccessibilityLabel.search_accessibility_label(..)string無螢幕閱讀器對搜尋欄位的稱呼:它所搜尋的內容。
cleanable.cleanable(..)booleanfalse已選取項目時顯示的清除按鈕;按下會清除值(onChange({ value: null }))。
disabled.disabled(..)booleanfalse保持關閉:觸發元件不接受按壓,也不接受鍵盤焦點,並套用 disabled: 樣式。
accessibilityLabel.accessibility_label(..)string無螢幕閱讀器對它的稱呼:這個選擇是關於什麼。
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"桌面平台:清單位於觸發元件的哪一側;若只有另一側有空間,則改放另一側。
align.align(..)"start" | "center" | "end""start"桌面平台:與觸發元件的對齊方式。
offset.offset(..)number0桌面平台:觸發元件與清單之間的間距。
margin.margin(..)number0桌面平台:與安全區域邊緣保持的距離。
popupClass、popupStyle.popup_class(..)、.popup_style(..)string、StyleProp<ViewStyle>無清單所在的彈出層(桌面平台)或底部面板(觸控螢幕)。
backdropClass、backdropStyle.backdrop_class(..)、.backdrop_style(..)string、StyleProp<ViewStyle>無觸控螢幕:底部面板後方的背景遮罩。
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>無每個分區的標頭。
searchClass、searchStyle.search_class(..)、.search_style(..)string、StyleProp<InputStyle>無搜尋欄位:其外框與文字(color、fontSize、placeholderColor)。
clearButtonClass、clearButtonStyle.clear_button_class(..)、.clear_button_style(..)string、StyleProp<ViewStyle>無清除按鈕(cleanable);disabled: 與 select 一同生效。
clearButton.clear_button(..)JSX.Element無清除按鈕的內容:叉號或圖示。
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)string無螢幕閱讀器對清除按鈕的稱呼。
trigger.trigger(..)JSX.Element無按下後會開啟清單的元素;清單以它為基準定位。
empty.empty(..)JSX.Element無沒有任何項目可顯示時(例如搜尋沒有符合項目)清單所顯示的內容。
renderItem.render_item(..)(item: SelectEntry) => JSX.Element無項目的列,由應用程式依項目的資訊繪製(其狀態即列的 class);未提供時顯示項目的標題。
renderGroup.render_group(..)(group: SelectGroup) => JSX.Element無分區的標頭,由應用程式繪製;未提供時顯示其標題。
class、style樣式方法string、StyleProp<ViewStyle>無包住觸發元件的 view。

#事件

事件Rust說明
onChange.on_change(|value| ..)使用者選取了另一個項目(按壓、Return 或 Space,或在觸發元件上輸入即跳轉):回報其 value;若是由清除按鈕清除,則為 null。設定 value 即可跟隨。
onOpenChange.on_open_change(|e| ..)清單已開啟或關閉,並附上原因。設定 open 即可跟隨。
onOpen.on_open(|| ..)清單已顯示;以底部面板呈現時,指面板滑入完成之後。
onClose.on_close(|| ..)清單已消失;以底部面板呈現時,指面板滑出完成之後。應用程式自行關閉清單時也會觸發。
onCancel.on_cancel(|| ..)使用者未選取就關閉了清單(Escape 或返回、在外部按壓或按壓背景遮罩)。

事件依下列順序發生:選取時先是 onChange,未選取就關閉時則是 onCancel,接著是 onOpenChange,最後在清單顯示或消失之後,分別是 onOpen 或 onClose。

#型別

型別成員說明
SelectItemvalue: string、title: string、disabled?: boolean、group?: stringitems 的一個項目。
SelectEntryvalue、title、disabled、group?(項目的)、index: numberrenderItem 所取得的內容:項目及其在 items 中的索引,絕不包含項目的狀態(列的 focus:、selected: 與 disabled:)。
SelectGrouptitle: stringrenderGroup 所取得的內容:分區的 group。

在 Rust 中,它們分別是 nsh_base::SelectItem、SelectEntry 與 SelectGroup;value 是一個 SelectValue(值用 Controlled(Some(..)),null 用 Controlled(None)),.value(..) 可從 Option<SharedString> 或字串建立它。

#組成部分

部分位置樣式設定方式
觸發元件select 的 view應用程式自己的元素(trigger)
清除按鈕select 的 view,位於觸發元件之後(cleanable,已選取項目時)clearButtonClass、clearButtonStyle;其內容為 clearButton
彈出層或底部面板位於所有內容之上,在觸發元件處(桌面平台)或螢幕底部(觸控螢幕)popupClass、popupStyle
背景遮罩底部面板後方(觸控螢幕)backdropClass、backdropStyle
搜尋欄位彈出層中,最前面(searchable)searchClass、searchStyle、searchPlaceholder、searchAccessibilityLabel
清單彈出層中,位於搜尋欄位之後listClass、listStyle
分區的標頭清單中,位於其項目之前groupClass、groupStyle、renderGroup
項目的列清單中itemClass、itemStyle、renderItem(內部的 group-focus:、group-selected:、group-disabled:,位於 itemClass 的 group 之下)
空狀態彈出層中,沒有任何項目顯示時其自己的元素(empty)

彈出層是一個直欄:其高度隨搜尋欄位與各列而定,因此在 popupClass 上設定 max-h-* 即可限制其高度,清單則在其中捲動。

#鍵盤

在桌面平台上,按 Tab 可移到未 disabled 的 select 的觸發元件。清單關閉時,按 Down、Up、Return 與 Space 會在已選取的項目上開啟清單(未選取任何項目時,Up 會開啟在最後一個啟用的項目上,其他按鍵則開啟在第一個項目上),輸入字母與數字則會選取標題以所輸入內容開頭的項目,就像原生 select 一樣。清單開啟時:

按鍵動作
Down、Up下一個或上一個啟用的項目,到頭尾時會循環。
Home、End第一個或最後一個啟用的項目(搜尋欄位持有鍵盤時,則由欄位自行處理)。
Page Down、Page Up向後或向前翻一頁的列。
Return選取反白的項目並關閉清單。
Space選取反白的項目(沒有搜尋欄位時)。
字母、數字標題以最近一秒內所輸入內容開頭的下一個項目(沒有搜尋欄位時)。
Escape不選取並關閉清單。

清單關閉後,鍵盤焦點會回到觸發元件,除非在外部的按壓已將焦點帶到其他地方。指標會反白它移經的列;按鍵移動時,反白的列會被捲動到可見處。

#各裝置上的行為

桌面平台與桌面瀏覽器手機與平板
清單位於觸發元件上方的彈出層,已選取的列疊在觸發元件上(未選取任何項目時則在其下方),寬度與觸發元件相同,除非 popupStyle 設定了寬度,高度依列數而定,最高不超過螢幕上的可用空間位於螢幕底部、置於背景遮罩之上的底部面板,高度依列數而定,最高到狀態列為止
關閉選取、Escape、在外部按壓(按壓會繼續作用於指標下方的元素)選取、背景遮罩、Android 返回鍵
搜尋清單開啟時,欄位會取得鍵盤按下欄位時才會彈出鍵盤
選取按壓落下時即選取,因此搜尋欄位會保有鍵盤點一下

popupClass 在桌面平台上為彈出層設定樣式,在手機上則為底部面板設定樣式;若樣式在兩者之間有所不同,可以用 "desktop" 來比較 platform.idiom(nsh/device)。

#無障礙功能

  • 對螢幕閱讀器而言,觸發元件是一個 combobox:其標籤為 accessibilityLabel,其值為所選項目的標題,並會告知清單是否已展開。disabled 會使其呈現淡化狀態。
  • 清除按鈕是一個以 clearButtonAccessibilityLabel 命名的 button;它不是 Tab 鍵的停駐點。
  • 搜尋欄位是一個以 searchAccessibilityLabel 命名的文字欄位;其預留位置文字(placeholder)是欄位為空時顯示的提示。
  • 清單是一個 listbox,每一列是一個 option,並以其標題命名;已選取的選項會標示為已選取,已停用的選項則呈現淡化狀態。各區段(section)的標頭則視為標頭(header)。
  • 當按鍵移動醒目提示時,螢幕閱讀器的焦點會移至被醒目提示的列;當搜尋欄位正在接收鍵盤輸入時,則改為朗讀被醒目提示項目的標題,讓使用者能繼續輸入。

有關標籤、角色與朗讀內容的一般說明,請參閱無障礙功能。