العناصر
التصفّح بين الصفحات (Pagination)
يتنقّل الترقيم (Pagination) بين صفحات مجموعة: نتائج بحث، أو صفوف جدول، أو أرشيف. ويعرض زر «السابق»، والصفحات المحيطة بالصفحة الحالية (الأولى والأخيرة تظهران دائمًا، مع علامة حذف لكل مقطع محذوف)، ثم العناصر الأبناء الخاصة بالتطبيق، ثم زر «التالي». وهو يتولى السلوك: أي الصفحات تظهر، والضغطات، ولوحة المفاتيح، وتعطيل «السابق» و«التالي» عند الطرفين، ومعلم التنقل (navigation landmark)، والصفحة الحالية لقارئات الشاشة. أما المظهر فمن شأن التطبيق: فلا لون للعناصر ولا حجم ولا تسمية خاصة بها، والتطبيق هو الذي ينسّقها أو يرسمها.
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 من nsh/base، وهي الحزمة (crate) nsh-base في Rust. في Rust، ينشئ pagination() عنصرًا واحدًا بدالة ضبط (setter) لكل خاصية (.page(..)، .total_pages(..))، و.on_change(..) للصفحة التي يختارها المستخدم، ودوال التنسيق المكتوبة الأنواع (typed style methods) للصف، و.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 |
عشرة آلاف صفحة تعرض العناصر السبعة نفسها. وقيمة visiblePages الفردية تجعل الصفحة الحالية في المنتصف.
#العناصر
يأخذ كل عنصر itemClass وitemStyle الخاصين بالتطبيق: الصفحة الحالية selected:، والعنصر الذي لا يقبل الضغط disabled:، والعنصر الذي عليه تركيز لوحة المفاتيح focus:. وتنسيق الصف من شأن التطبيق باستخدام class (صف افتراضيًا؛ 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: الخاصة بالتطبيق: transition-colors على itemClass يُخفت التمييز من الصفحة المغادَرة إلى الصفحة المقصودة. وتغيير الصفحة يُبقي كل عنصر في مكانه ويغيّر ما تعرضه العناصر، فلا يومض الصف أبدًا؛ وتتوقف انتقالات التطبيق مع تقليل الحركة (motion-reduce:transition-none).
#إمكانية الوصول
- الصف معلم تنقل (navigation landmark) يُسمّى بواسطة
accessibilityLabel. - كل صفحة، وكذلك «السابق» و«التالي»، زر. وتُعلَن الصفحة الحالية بوصفها الصفحة الحالية (
aria-current="page"في ARIA) وهي محددة؛ أما «السابق» في الصفحة الأولى و«التالي» في الأخيرة فمعتمان (dimmed). - على macOS والويب، تشكّل كل صفحة، وكذلك «السابق» و«التالي»، محطة لتركيز لوحة المفاتيح، ويضغطها Return وSpace. وتحتفظ الصفحة المضغوطة من لوحة المفاتيح بالتركيز بعد أن يعرضها التطبيق، أينما وقعت في الصف، وكذلك الصفحة الأولى أو الأخيرة عندما يصل إليها «السابق» أو «التالي».
انظر إمكانية الوصول (Accessibility) للاطلاع على التسميات والأدوار والإعلانات عمومًا.
#ملاحظات المنصات
- iOS وAndroid وMac Catalyst: الضغط هو نقرة بإصبع تنتهي على عنصر.
- أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: الضغط هو نقرة بالفأرة؛ وعلى macOS والويب يصل Tab إلى العناصر.
- ترسم كل المنصات ما ينسّقه التطبيق.