元素
Accordion
Accordion 把多個區段疊在一起,每個區段都可以在其標題下展開和收合:例如常見問題的各條提問、設定頁面的各個群組、結帳流程的各個步驟。它由五個部分組成,每個部分只負責一項行為,外觀完全不管:
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
}import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
const FAQ = [
{ value: "shipping", question: "When will my order ship?", answer: "Orders placed before 2 pm leave the same day." },
{ value: "returns", question: "How do I return an item?", answer: "Print the label from your order within 30 days." },
];
function App() {
const open = signal(["shipping"]);
return (
<Accordion value={open()} onChange={({ value }) => open.set(value)}>
{FAQ.map((item) => (
<AccordionItem value={item.value} class="border-b border-border">
<AccordionHeader>
<AccordionTrigger class="group flex-row items-center justify-between py-3 active:opacity-70">
<Text class="font-medium">{item.question}</Text>
<Text class="transition-transform group-aria-expanded:rotate-180">⌄</Text>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel reveal accessibilityLabel={item.question}>
<View class="pb-3">
<Text class="text-muted-foreground">{item.answer}</Text>
</View>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
);
}
mount(App);這些部分來自 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。