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

元素

Accordion

Accordion 把多個區段疊在一起,每個區段都可以在其標題下展開和收合:例如常見問題的各條提問、設定頁面的各個群組、結帳流程的各個步驟。它由五個部分組成,每個部分只負責一項行為,外觀完全不管:

el-accordion.tsx
el-accordion.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
import { desktop, focusRing, hoverFill, Icon, Log, Panel, Segmented, size, Split, ui } from "./palette";

// An FAQ: each item a heading whose trigger (a button that says whether it
// is expanded) opens its answer. The app owns which items are open
// (`value`) and sets what `onChange` reports; one at a time, or several
// with `multiple`. The panels spring open and shut (`reveal`) and the
// items below move with them; the chevron turns with `group-aria-expanded:`.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the reveal and the chevron stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";

const FAQ: { value: string; question: string; answer: string; disabled?: boolean }[] = [
  { value: "shipping", question: "How long does shipping take?", answer: "Orders leave the warehouse within a day and arrive in two to five working days." },
  { value: "returns", question: "Can I return an item?", answer: "Within 30 days, unused and in its box: print the label from your order and drop it off." },
  { value: "warranty", question: "What does the warranty cover?", answer: "Two years against defects in materials and workmanship, parts and labour included." },
  { value: "gift", question: "Can I send it as a gift?", answer: "Gift wrapping opens with the holiday season.", disabled: true },
];

const MODES = ["one at a time", "several"] as const;

function App() {
  const open = signal<string[]>(["shipping"]);
  const mode = signal<(typeof MODES)[number]>("one at a time");
  const events = signal<string[]>([]);
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>accordion</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a question, or Tab to it and press Space" : "Tap a question to read its answer"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="multiple">
              <Segmented
                options={MODES}
                value={mode()}
                onChange={(value) => {
                  mode.set(value);
                  // One at a time keeps the item opened last.
                  if (value === "one at a time") open.set(open().slice(-1));
                }}
              />
            </Panel>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{open().length ? open().join(", ") : "none open"}</Text>
            </Panel>
            <Log title="onChange({ value })" lines={events()} minHeight={92} />
          </>
        }
      >
        <Accordion
          value={open()}
          multiple={mode() === "several"}
          onChange={({ value }) => {
            open.set(value);
            events.set([`[${value.join(", ")}]`, ...events()].slice(0, 4));
          }}
          style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}
        >
          {FAQ.map((item, i) => (
            <AccordionItem value={item.value} disabled={item.disabled}>
              <View style={{ height: i === 0 ? 0 : 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />
              <AccordionHeader>
                <AccordionTrigger
                  hoverStyle={item.disabled ? undefined : hoverFill()}
                  focusStyle={focusRing()}
                  class={`group flex-row items-center gap-3 ${pad} active:opacity-70 disabled:opacity-40`}
                  style={{ minHeight: size.row + 8 }}
                >
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{item.question}</Text>
                  <View class="transition-transform duration-200 motion-reduce:transition-none group-aria-expanded:rotate-180">
                    <Icon name="chevron-down" size={14} tint={ui().muted} />
                  </View>
                </AccordionTrigger>
              </AccordionHeader>
              <AccordionPanel reveal accessibilityLabel={item.question}>
                <View class={`${pad} pb-3`}>
                  <Text style={{ fontSize: size.label, lineHeight: size.label * 1.45, color: ui().muted }}>{item.answer}</Text>
                </View>
              </AccordionPanel>
            </AccordionItem>
          ))}
        </Accordion>
      </Split>
    </View>
  );
}

mount(App);
  • Accordion,即堆疊:它保存應用程式的 value(即已展開項目的值),並以 onChange 回報下一組值。一次只展開一個項目,或以 multiple 同時展開多個。
  • AccordionItem,即單一區段,由其 value 識別。只要 Accordion 的 value 包含它,它就是展開的。
  • AccordionHeader,即項目的標題,帶有屏幕閱讀器會朗讀的 level。
  • AccordionTrigger,即標題中用來展開和收合項目的按鈕,並會說明項目是否已展開。
  • AccordionPanel,即項目的內容,展開時顯示;配合 reveal,其高度會以彈簧動畫展開和收合。
use nsh_base::{accordion, accordion_header, accordion_item, accordion_panel, accordion_trigger, theme};
use nsh_core::prelude::*;

const FAQ: [(&str, &str, &str); 2] = [
    ("shipping", "When will my order ship?", "Orders placed before 2 pm leave the same day."),
    ("returns", "How do I return an item?", "Print the label from your order within 30 days."),
];

fn faq() -> impl IntoElement {
    let open = signal(vec![SharedString::from("shipping")]);
    let mut list = accordion()
        .value(open)
        .on_change(move |value: &Vec<SharedString>| open.set(value.clone()));
    for (value, question, answer) in FAQ {
        list = list.child(
            accordion_item()
                .value(value)
                .border_b_1()
                .border_color(move || theme().border)
                .child(
                    accordion_header().child(
                        accordion_trigger()
                            .flex_row()
                            .items_center()
                            .justify_between()
                            .py_3()
                            .child(text(question).font_weight(FontWeight::MEDIUM))
                            .child(text("⌄").transition_transform().rotate(move || {
                                let open = open.get().iter().any(|v| &**v == value);
                                if open { deg(180.0) } else { deg(0.0) }
                            })),
                    ),
                )
                .child(
                    accordion_panel()
                        .reveal(true)
                        .accessibility_label(question)
                        .child(view().pb_3().child(text(answer).text_color(move || theme().muted_foreground))),
                ),
        );
    }
    list
}

這些部分來自 nsh/base,即 Rust 的 nsh-base crate。值由應用程式持有:按下觸發器時,會以 onChange 回報下一個 value,而 Accordion 會展開應用程式傳回的值所對應的項目。每個部分都會自行找到包住它的上一層,因此應用程式可以自由編排:在項目之間加分隔線、在標題中加徽章、在標題文字前加圖示。在 Rust 中,accordion()、accordion_item()、accordion_header()、accordion_trigger() 和 accordion_panel() 負責建立這些部分,每個屬性都有對應的 setter,內部內容則以 .child(..) 設定。

#屬性

#Accordion

屬性 Rust 類型 預設值 說明
value .value(..) string[] [] 已展開項目的 value。這些值由應用程式持有:按下觸發器時,會以 onChange 回報下一組值,而 Accordion 只顯示應用程式傳回的那些項目。
multiple .multiple(..) boolean false 允許同時展開多個項目:展開一個項目時,其他項目保持展開。未設定時,展開一個項目會收合原本展開的那個。
disabled .disabled(..) boolean false 停用所有項目:沒有觸發器會接受按下操作,屏幕閱讀器會將每個項目讀作已變暗。
children .child(..) JSX.Element 無 各個項目,以及應用程式在它們之間排版的任何內容(例如分隔線)。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 堆疊的版面和外觀:例如包住各項目的卡片、項目之間的間距。

#AccordionItem

屬性 Rust 類型 預設值 說明
value .value(..) string 必填 在 Accordion 的 value 中識別此項目的值:應是應用程式自己的 id,而不是會隨語言改變的標題。
disabled .disabled(..) boolean false 其觸發器不接受按下操作,屏幕閱讀器會將它讀作已變暗。它仍依 Accordion 的 value 保持展開或收合。
children .child(..) JSX.Element 無 項目的標題和面板,以及應用程式在其中排版的任何其他內容。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 該區段:例如其下方的分隔線、包住它的卡片。

#AccordionHeader

屬性 Rust 類型 預設值 說明
level .level(..) number 3 此標題在頁面所有標題中的層級,供屏幕閱讀器使用(1 為最高層)。
children .child(..) JSX.Element 無 項目的觸發器,以及標題內的任何其他內容(例如徽章,或其自有的操作)。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 標題的版面。

#AccordionTrigger

屬性 Rust 類型 預設值 說明
expandedStyle .expanded_style(|s| ..) StyleProp<ViewStyle> 無 其項目展開時觸發器的樣式,以 aria-expanded: class 設定。
accessibilityLabel .accessibility_label(..) string 無 當子元素沒有可供朗讀的文字時,屏幕閱讀器會讀出的內容。
children .child(..) JSX.Element 無 標題文字和箭咀圖示(chevron);內部的 group-aria-expanded: class 會跟隨觸發器,方法是在其 class 上設定 group。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 觸發器本身:即用戶按下的那一行。
activeStyle、hoverStyle、focusStyle、disabledStyle .active(|s| ..)、.hover(..)、.focus(..)、.disabled_style(..) StyleProp<ViewStyle> 無 觸發器在被按下、懸停、取得焦點或停用時的樣式,以 <state>: class 設定。

#AccordionPanel

屬性 Rust 類型 預設值 說明
reveal .reveal(..) boolean false 以展開動畫開合,與 Collapsible 的相同:面板會裁切其內容,高度以彈簧動畫由零展開至內容本身的高度,並可收回,採用臨界阻尼(約 265 毫秒,不會過衝),其後的項目會隨之移動。若系統設定為減少動態效果,則會即時開合。
keepMounted .keep_mounted(..) boolean false 項目收合時,內容仍保留在樹中,但看不見,也不會取得鍵盤焦點,屏幕閱讀器也讀不到:影片會保留播放位置,Web 視圖會保留其頁面。未設定時,面板收合時內容會離開樹(待展開動畫收合完畢後),再展開時會恢復原狀。
accessibilityLabel .accessibility_label(..) string 無 屏幕閱讀器對此區域的稱呼:通常是項目的標題,與其標題所讀出的一致。
children .child(..) JSX.Element 無 內容。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 此區域的版面和外觀。設定 reveal 後,面板在收合時仍留在版面中,高度為零,因此其內距和邊框應設在內容的視圖上。

#事件

事件 Rust 說明
onChange({ value })(Accordion) .on_change(|value: &Vec<SharedString>| ..) 觸發器被按下:包括輕點、點擊、在其取得鍵盤焦點時按 Space 或 Return,或屏幕閱讀器的啟動操作。value 是假設操作生效後已展開項目的值:已展開項目的值會從中移除,已收合項目的值會加到末端;若未設定 multiple,則會取代原有的值。此事件在任何變更發生之前執行:請設定 value 以跟進。

#部分

部分 所在位置 樣式設定 狀態
堆疊 Accordion 的 class、style
項目 堆疊 AccordionItem 的 class、style 應用程式的值
標題 項目 AccordionHeader 的 class、style
觸發器 標題 AccordionTrigger 的 class、style aria-expanded:、disabled:、active:、hover:、focus:(或 expandedStyle、disabledStyle……)
標題文字和箭咀圖示 觸發器 它們自己的元素(children) group-aria-expanded:、group-disabled:,方法是在觸發器的 class 上設定 group
面板 項目,位於標題之後 AccordionPanel 的 class、style
內容 面板 其自己的元素(children)

未設定 reveal 時,收合的面板不在版面中,其內容也不在樹中。設定 reveal 後,這項變化是改變 value 的那次 commit 的版面動畫(與 LayoutAnimation.configureNext 一樣,由原生層執行,所以移動期間不會執行任何 JavaScript):展開的面板由零增長至其內容的高度,收合的面板則縮回,兩者使用同一個彈簧,其後的項目會隨之移動。應用程式自己的 transition 亦會一併進行:箭咀圖示會轉動(transition-transform group-aria-expanded:rotate-180)。

#停用

已停用項目的觸發器不接受按下操作,屏幕閱讀器會將它讀作已變暗;觸發器上的 disabled: class 和其內部的 group-disabled: 會隨之變化。Accordion 的 disabled 會停用所有項目。已停用的項目仍會按應用程式的 value 所指示的顯示:應用程式可以將它展開。

#無障礙

  • 堆疊是一個群組,每個標題都是依其 level 而定的標題,每個觸發器則是其中的按鈕,並會說明其項目是否已展開。屏幕閱讀器可以在標題之間逐個跳轉。
  • 展開的面板是一個區域,以其 accessibilityLabel 命名;收合的面板對屏幕閱讀器隱藏,包括保留在其中的內容。
  • 展開項目的外觀由應用程式決定,而且不應只依靠顏色:請像範例一樣讓箭咀圖示轉動。

有關標籤、角色和朗讀內容的一般說明,請參閱 Accessibility。

#鍵盤

在可讓接受按下操作的視圖成為鍵盤焦點停駐點的平台(macOS 和 Web),每個觸發器都是一個停駐點,順序依頁面而定,可按 Space 和 Return 觸發。已停用的觸發器會被跳過,收合面板的內容也一樣。Tab 會由觸發器移入其展開面板的控制項,再移到下一個觸發器。

#平台備註

  • 所有平台都會把展開項目的面板顯示在其標題之下,並讓收合的面板不佔用版面(以 reveal 設定時高度為零)。
  • 設定 reveal 後,每個平台都會以自己的動畫器為面板高度加上彈簧動畫(iOS、Android、macOS、Windows、Linux、GPUI 和 Web),而在系統要求減少動態效果時,會即時開合。
  • 各平台的屏幕閱讀器在逐個閱讀標題時,都會讀出標題及其層級,並將其中的觸發器讀作帶有展開狀態的按鈕;在 iOS 上,VoiceOver 會直接朗讀觸發器本身。
  • iOS、Android 和 Mac Catalyst:觸發器的按下操作是輕點。桌面平台(macOS、Windows、Linux、GPUI)和 Web:點擊;在 macOS 和 Web 上,亦可在取得焦點的觸發器上按 Space 或 Return。