要素
Accordion
アコーディオンは、見出しの下で展開・折りたたみができるセクションを積み重ねたものです。FAQ の質問、設定ページのグループ、チェックアウトの手順などに使います。5 つのパーツで構成され、それぞれが振る舞いの一部だけを担い、見た目には関与しません。
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で通知します。開けるアイテムは 1 つだけですが、multipleを使うと複数開けます。AccordionItem: 1 つのセクションで、valueによって識別されます。アコーディオンの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 クレートです。値はアプリが持ちます。トリガーが押されると onChange で次の value が通知され、アコーディオンはアプリが返した値に対応するアイテムを開きます。各パーツは自分を囲むパーツを自動的に見つけるため、アプリは自由に配置できます。アイテム間の区切り線、見出し内のバッジ、タイトルの前のアイコンなどです。Rust では accordion()、accordion_item()、accordion_header()、accordion_trigger()、accordion_panel() でパーツを作成します。プロパティごとにセッターがあり、中に入れる要素には .child(..) を使います。
#Props
#Accordion
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
value | .value(..) | string[] | [] | 開いているアイテムの value。これらはアプリが管理します。トリガーが押されると次の値が onChange で通知され、アコーディオンはアプリが返した値のアイテムを表示します。 |
multiple | .multiple(..) | boolean | false | 複数のアイテムを同時に開きます。1 つ開いても他は開いたままです。指定しない場合、アイテムを開くと、開いていたアイテムは閉じます。 |
disabled | .disabled(..) | boolean | false | すべてのアイテムを無効にします。どのトリガーも押せず、スクリーンリーダーは各アイテムが淡色表示であると伝えます。 |
children | .child(..) | JSX.Element | なし | アイテム、およびアプリがその間に配置するもの(区切り線など)。 |
class, style | スタイルメソッド | string, StyleProp<ViewStyle> | なし | スタックのレイアウトと外観。アイテムを囲むカードや、アイテム間の間隔など。 |
#AccordionItem
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
value | .value(..) | string | 必須 | アコーディオンの value の中でアイテムを識別するもの。アプリ独自の ID を使い、言語によって変わるタイトルは使わないでください。 |
disabled | .disabled(..) | boolean | false | トリガーが押されなくなり、スクリーンリーダーは淡色表示であると伝えます。開閉の状態は、アコーディオンの 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: クラスで指定します。 |
accessibilityLabel | .accessibility_label(..) | string | なし | 子要素に読み上げに適したテキストがない場合に、スクリーンリーダーが読み上げる内容。 |
children | .child(..) | JSX.Element | なし | タイトルとシェブロン。内側の group-aria-expanded: クラスは、トリガーの class に group を指定するとトリガーに追従します。 |
class, style | スタイルメソッド | string, StyleProp<ViewStyle> | なし | トリガー自体。ユーザーが押す行です。 |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | なし | 押下中、ホバー中、フォーカス中、無効時のトリガーのスタイル。<state>: クラスで指定します。 |
#AccordionPanel
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
reveal | .reveal(..) | boolean | false | Collapsible と同様のリビールで開閉します。パネルはコンテンツをクリップし、高さがゼロからコンテンツ本来の高さまで、またその逆へと、臨界減衰のバネで変化します(約 265 ms、オーバーシュートなし)。後続のアイテムも一緒に動きます。視差効果を減らす設定では、即座に開閉します。 |
keepMounted | .keep_mounted(..) | boolean | false | アイテムが閉じている間も、コンテンツはツリーに残りますが、画面上、キーボードフォーカス、スクリーンリーダーからは見えません。動画は再生位置を保ち、Web ビューはページを保ちます。指定しない場合、パネルが閉じる際(リビールが閉じ終わった後)にコンテンツはツリーから外れ、再び開くと元の状態で戻ります。 |
accessibilityLabel | .accessibility_label(..) | string | なし | スクリーンリーダーがこのリージョンに付ける名前。通常は、ヘッダーが伝えるものと同じアイテムのタイトルです。 |
children | .child(..) | JSX.Element | なし | コンテンツ。 |
class, style | スタイルメソッド | string, StyleProp<ViewStyle> | なし | リージョンのレイアウトと外観。reveal を指定すると、閉じたパネルも高さゼロでレイアウトに残るため、パディングやボーダーはコンテンツを包むビューに指定してください。 |
#Events
| イベント | Rust | 説明 |
|---|---|---|
onChange({ value }) (Accordion) | .on_change(|value: &Vec<SharedString>| ..) | トリガーが押されました。タップ、クリック、キーボードフォーカス中の Space または Return、スクリーンリーダーによるアクティベーションが該当します。value は、そうなった場合の開いているアイテムの値です。開いているアイテムの値は取り除かれ、閉じているアイテムの値は末尾に追加されます。multiple がない場合は、開いていた値が置き換えられます。このイベントは何かが変わる前に実行されます。それを反映するには value を設定してください。 |
#Parts
| パーツ | 内側 | スタイル指定 | 状態 |
|---|---|---|---|
| スタック | 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 を変更したコミットでのレイアウトアニメーションになります(LayoutAnimation.configureNext と同様にネイティブで実行されるため、動いている間 JavaScript は実行されません)。開くパネルは高さゼロからコンテンツの高さまで広がり、閉じるパネルは元に縮みます。どちらも同じバネで動き、後続のアイテムも一緒に動きます。アプリ側のトランジションを組み合わせることもできます。たとえばシェブロンを回転させます (transition-transform group-aria-expanded:rotate-180)。
#Disabled
無効なアイテムのトリガーは押せず、スクリーンリーダーは淡色表示であると伝えます。トリガーの disabled: クラスと、その内側の group-disabled: もこれに追従します。アコーディオンの disabled を指定すると、すべてのアイテムが無効になります。無効なアイテムも、アプリの value が示すとおりに表示されます。アプリがそれを開くことは可能です。
#Accessibility
- スタックはグループ、各ヘッダーは
levelのレベルの見出し、各トリガーはその中のボタンとして扱われ、アイテムが展開されているかどうかを伝えます。スクリーンリーダーは見出しから見出しへ移動できます。 - 開いているパネルは
accessibilityLabelによって名前が付けられたリージョンです。閉じているパネルは、保持されているコンテンツも含めてスクリーンリーダーから隠されます。 - 開いているアイテムの見た目はアプリが決めるものですが、色だけに頼らないようにしてください。例のように、シェブロンを回転させます。
ラベル、ロール、アナウンス全般については Accessibility を参照してください。
#Keyboard
押下を受け付けるビューがキーボードフォーカスの停止位置になるプラットフォーム(macOS と Web)では、各トリガーがページの順序どおりにフォーカスの停止位置となり、Space と Return で押せます。無効なトリガーと、閉じたパネルのコンテンツはスキップされます。Tab を押すと、トリガーから、開いているパネル内のコントロールへ、さらに次のトリガーへと移動します。
#Platform notes
- すべてのプラットフォームで、開いているアイテムのパネルは見出しの下に表示され、閉じているパネルはレイアウトから外れます(
revealを指定した場合は高さゼロ)。 revealを指定すると、すべてのプラットフォームがそれぞれ独自のアニメーターでパネルの高さをバネのように動かします(iOS、Android、macOS、Windows、Linux、GPUI、Web)。システムが視差効果の低減を求めている間は、即座に開閉します。- 各プラットフォームのスクリーンリーダーは、見出しを読み上げる際に見出しとそのレベルを読み上げ、その中のトリガーを展開状態付きのボタンとして読み上げます。iOS では VoiceOver がトリガー自体を読み上げます。
- iOS、Android、Mac Catalyst: トリガーの押下はタップです。デスクトップ(macOS、Windows、Linux、GPUI)と Web: クリックです。macOS と Web では、フォーカス中のトリガーで Space または Return も使えます。