要素
Pagination
ページネーションは、検索結果、テーブルの行、アーカイブなど、ひとまとまりのページ間を移動するコンポーネントです。「前へ」、現在のページの周辺のページ(最初と最後は常に表示し、省略した区間ごとに省略記号を表示)、アプリの子要素、「次へ」を表示します。動作は Native Shell が担当します。表示するページ、押下、キーボード、両端での「前へ」と「次へ」の無効化、ナビゲーションランドマーク、スクリーンリーダー向けの現在のページです。見た目はアプリ側の責任です。各アイテムは色、サイズ、ラベルを持たず、アプリがスタイルを指定するか、描画します。
import { mount, signal, Text, View } from "nsh";
import { Pagination } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, ui } from "./palette";
// Search results paged with a count, and a compact pager beside them. The
// look is all `itemClass` over the component's own numbers and labels:
// quiet items until hovered, the current page lit in the brand color
// (`selected:`) with `transition-colors` (still with reduced motion), the
// ends dimmed (`disabled:`).
const itemClass =
"min-w-8 h-8 px-2 items-center justify-center rounded-md text-sm text-foreground " +
"transition-colors duration-200 motion-reduce:transition-none hover:bg-hover active:opacity-70 " +
"selected:bg-brand selected:text-on-brand disabled:opacity-35 focus:outline-2 focus:outline-ring";
const total = 238;
const page = signal(1);
const perPage = signal<"10" | "25">("10");
const chapter = signal(1);
const events = signal<string[]>([]);
const pages = () => Math.ceil(total / Number(perPage()));
const first = () => (page() - 1) * Number(perPage()) + 1;
const last = () => Math.min(page() * Number(perPage()), total);
const changed = (next: number) => {
page.set(next);
events.set([`onChange({ page: ${next} })`, ...events()].slice(0, 4));
};
function App() {
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>pagination</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click a page, or Tab to one and press Return" : "Tap a page"}</Text>
<Split
gap={14}
inspector={
<>
<Panel title="page · totalPages">
<Text style={{ fontSize: 13, color: ui().text }}>{`${page()} of ${pages()}`}</Text>
</Panel>
<Log title="onChange({ page })" lines={events()} minHeight={110} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 18, gap: 14 }}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
<Text style={{ fontSize: 13, color: ui().muted }}>{`Showing ${first()}–${last()} of ${total} results`}</Text>
<Segmented
size="small"
options={[
{ value: "10", label: "10" },
{ value: "25", label: "25" },
]}
value={perPage()}
onChange={(next) => {
const row = first();
perPage.set(next);
page.set(Math.floor((row - 1) / Number(next)) + 1);
}}
/>
</View>
<Pagination
page={page()}
totalPages={pages()}
visiblePages={desktop ? 7 : 5}
accessibilityLabel="Search results pages"
previousAccessibilityLabel="Previous"
nextAccessibilityLabel="Next"
class="gap-1 flex-wrap"
itemClass={itemClass}
onChange={({ page: next }) => changed(next)}
/>
<View style={{ height: 1, backgroundColor: ui().line }} />
<Pagination
compact
page={chapter()}
totalPages={12}
accessibilityLabel="Chapters"
previousAccessibilityLabel="Previous chapter"
nextAccessibilityLabel="Next chapter"
class="items-center justify-between"
itemClass={`${itemClass} px-3 border border-border`}
onChange={({ page: next }) => chapter.set(next)}
>
<Text style={{ fontSize: 13, color: ui().muted }}>{`Chapter ${chapter()} of 12`}</Text>
</Pagination>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{pagination, theme};
use nsh_core::prelude::*;
fn results() -> impl IntoElement {
let page = signal(1u32);
pagination()
.page(move || page.get())
.total_pages(24)
.accessibility_label("Search results")
.previous_accessibility_label("Previous")
.next_accessibility_label("Next")
.item_class("min-w-9 h-9 px-2 items-center justify-center rounded-md hover:bg-accent selected:bg-primary selected:text-primary-foreground disabled:opacity-50")
.gap_1()
.on_change(move |p: &u32| page.set(*p))
}import { mount, signal } from "nsh";
import { Pagination } from "nsh/base";
function App() {
const page = signal(1);
return (
<Pagination
page={page()}
totalPages={24}
accessibilityLabel="Search results"
previousAccessibilityLabel="Previous"
nextAccessibilityLabel="Next"
class="gap-1"
itemClass="min-w-9 h-9 px-2 items-center justify-center rounded-md hover:bg-accent selected:bg-primary selected:text-primary-foreground disabled:opacity-50"
onChange={({ page: next }) => page.set(next)}
/>
);
}
mount(App);Pagination は Rust の nsh-base クレートである nsh/base に由来します。Rust では、pagination() がプロパティごとのセッター(.page(..)、.total_pages(..))、ユーザーが選んだページを受け取る .on_change(..)、行用の型付きスタイルメソッド、アプリのコンテンツ用の .child(..) を備えたものを作成します。
#ページ
ページはアプリが管理します。page は 1 から始まる現在のページで、onChange({ page }) はユーザーがページ、「前へ」、「次へ」を押して選んだページを通知します。アプリはそれを設定し、そのページを読み込んで行を表示します。totalPages はページの総数です。最後のページを超える page は最後のページとして表示されます。現在のページを押しても何も通知されず、最初のページでの「前へ」と最後のページでの「次へ」も同様です。
#表示されるページ
visiblePages(5 以上、デフォルト)の範囲に収まる間、行はすべてのページを表示します。それを超えると、最初と最後のページ、現在のページとその両側に収まるだけのページ、(visiblePages - 3) / 2、そして省略した区間ごとに省略記号を表示します。
page / 20、visiblePages={7} | 表示 |
|---|---|
| 1 | 1 2 3 4 5 … 20 |
| 6 | 1 … 4 5 6 7 8 … 20 |
| 20 | 1 … 16 17 18 19 20 |
1 万ページあっても、表示されるアイテムは同じ 7 つです。visiblePages が奇数の場合、現在のページが中央に配置されます。
#アイテム
各アイテムにはアプリの itemClass と itemStyle が適用されます。現在のページは selected:、押下を受け付けないアイテムは disabled:、キーボードフォーカスのあるアイテムは focus: です。行のレイアウトはアプリが class で決めます(デフォルトは row で、gap-*、justify-* も使えます)。
renderItem がない場合、ページは番号、省略記号は … を表示し、「前へ」と「次へ」はアクセシビリティラベルを表示します(ラベルがない場合は ‹ と ›)。renderItem は、PaginationItemState から各アイテムの内容を描画します。
| フィールド | 型 | 説明 |
|---|---|---|
kind | "previous" | "page" | "ellipsis" | "next" | アイテムの種類。 |
page | number | ページの番号。「前へ」または「次へ」の移動先のページ(両端では現在のページ)。省略記号の場合は、隠れている最初のページ。 |
start、end | number | 省略記号が隠しているページ。start は含まれ、end(次に表示されるページ)は含まれません。 |
current | boolean | 現在のページ。 |
disabled | boolean | 押下を受け付けません。最初のページでの「前へ」、最後のページでの「次へ」、無効化されたページネーションのすべてのアイテムが該当します。 |
<Pagination
page={page()}
totalPages={pages()}
previousAccessibilityLabel="Previous page"
nextAccessibilityLabel="Next page"
itemClass="size-9 items-center justify-center rounded-md selected:bg-primary"
renderItem={(item) =>
item.kind === "previous" ? <Icon name="chevron-left" /> :
item.kind === "next" ? <Icon name="chevron-right" /> :
item.kind === "ellipsis" ? <JumpMenu from={item.start!} to={item.end!} onPick={(p) => page.set(p)} /> :
<Text class={item.current ? "font-semibold text-primary-foreground" : "text-foreground"}>{item.page}</Text>
}
onChange={({ page: next }) => page.set(next)}
/>
省略記号自体は押下を受け付けません。その動作はアプリが決めます。省略記号の中に描画した Menu は、上の JumpMenu と同様に、隠れているページを一覧表示します。
#コンパクト
compact は「前へ」、子要素、「次へ」だけを表示します。狭い画面向けのページャーで、ページ情報は子要素で伝えます。
<Pagination compact page={page()} totalPages={12} onChange={({ page: next }) => page.set(next)} class="items-center gap-3">
<Text class="text-sm text-muted-foreground">{`${page()} of 12`}</Text>
</Pagination>
子要素は、どちらの形式でもページの後、「次へ」の前に配置されます。ページ数の表示や、ページへ移動するための入力欄などに使えます。
#プロパティ
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
page | .page(..) | number | 必須 | 1 から始まる現在のページ。アプリが onChange で設定します。 |
totalPages | .total_pages(..) | number | 必須 | ページの総数。1 以上。 |
visiblePages | .visible_pages(..) | number | 5 | 一度に表示するページの最大数(最初と最後を含む)。5 以上。 |
compact | .compact(..) | boolean | false | 「前へ」、子要素、「次へ」のみを表示します。 |
disabled | .disabled(..) | boolean | false | どのアイテムも押下やキーボードフォーカスを受け付けません。disabled: のスタイルは適用され、スクリーンリーダーには無効(dimmed)と通知されます。 |
accessibilityLabel | .accessibility_label(..) | string | なし | スクリーンリーダーがナビゲーションを呼ぶ名前。何をページ送りするものかを表します。 |
previousAccessibilityLabel、nextAccessibilityLabel | .previous_accessibility_label(..)、.next_accessibility_label(..) | string | なし | スクリーンリーダーが「前へ」と「次へ」について読み上げる内容。renderItem がない場合は、その表示内容にもなります。 |
itemClass、itemStyle | .item_class(..)、.item_style(|s| ..) | string、StyleProp<TextStyle> | なし | 各アイテムの見た目。現在のページでは selected:、さらに disabled:、focus: が適用されます。テキストスタイルは内容に引き継がれます。 |
renderItem | .render_item(|s: &PaginationItemState| ..) | (item: PaginationItemState) => JSX.Element | なし | 各アイテムの内容をその状態から描画します。 |
children | .child(..) | JSX.Element | なし | ページの後、「次へ」の前に置くアプリのコンテンツ。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | 行の見た目とレイアウト。 |
#イベント
| イベント | Rust | 説明 |
|---|---|---|
onChange | .on_change(|page: &u32| ..) | { page }: ユーザーがページ、「前へ」、「次へ」で選んだページ。現在のページは通知されません。 |
#モーション
現在のページは、アプリの selected: クラスの指定に従ってアイテム間を移動します。itemClass に transition-colors を指定すると、ハイライトが移動元のページから移動先のページへフェードします。ページを変更しても各アイテムの位置は保たれ、表示内容だけが変わるため、行がちらつくことはありません。モーションを減らす設定(motion-reduce:transition-none)では、アプリのトランジションは停止します。
#アクセシビリティ
- 行はナビゲーションランドマークであり、
accessibilityLabelで名前が付けられます。 - 各ページ、「前へ」、「次へ」はボタンです。現在のページは現在のページとして読み上げられ(ARIA の
aria-current="page")、選択状態になります。最初のページでの「前へ」と最後のページでの「次へ」は無効(dimmed)になります。 - macOS とウェブでは、各ページ、「前へ」、「次へ」がキーボードフォーカスの停止位置となり、Return と Space で押下できます。キーボードで押したページは、アプリがそのページを表示した後も、行内のどこに移動してもフォーカスを保持します。「前へ」や「次へ」で最初または最後のページに到達した場合も同様です。
ラベル、ロール、アナウンス全般については、Accessibility を参照してください。
#プラットフォームに関する注意
- iOS、Android、Mac Catalyst: 押下は、アイテム上で終了するタップです。
- デスクトップ(macOS、Windows、Linux、GPUI)とウェブ: 押下はクリックです。macOS とウェブでは、Tab キーでアイテムに移動できます。
- すべてのプラットフォームで、アプリがスタイルを指定したとおりに描画されます。