قالب صفحهٔ فهرست (ListPage)
صفحهٔ یک مجموعه — منشنها، منابع، هشدارها — با سرِ صفحه، نوارابزار، فهرست، حالتها و صفحهبندی؛ همهٔ عددها و جای اقدامها در خود قالب
معرفی
ListPage قالب صفحهای است که فهرستی از موجودیتها را با جستوجو و فیلتر نشان میدهد. صفحه فقط جایگاهها را
پر میکند؛ هر تصمیمی که یک صفحهٔ فهرست میگیرد در خود قالب است:
- عرض از محتوا (
content): جدول در 1200 پیکسل (wideفقط برای جدول بیش از 8 ستون،fullبرای لاگ)، فید تکستونی در اندازهٔ خواندن (680) با ستون کناری اختیاری، شبکهٔ کارت پهن (1600)؛ - عنوان صفحه (تنها
h1) 48 پیکسل زیر نوار بالای قاب، توضیح در یک خط، و نوارابزار 24 پیکسل زیر سرِ صفحه؛ نوارابزار و سرِ جدول هنگام پیمایش بالای صفحه میچسبند؛ - یک اقدام اصلی، و جایش: با جستوجو یا فیلتر در انتهای نوارابزار، بی آنها در انتهای سرِ صفحه؛ اقدامهای ثانوی (همه
Buttonبیvariant) همیشه کنارش؛ - نوارابزار 16 پیکسل بالای فهرست، همهٔ کنترلها یک اندازه، عرض جستوجو از خود نوارابزار، و تراکم جدول؛
- بارگذاری، خطا و حالت خالی بهجای فهرست (هرگز داخلش)، و «نتیجهای یافت نشد» با دکمهٔ پاککردن فیلترها وقتی
filteredاست؛ - بارگذاری ادامهٔ فهرست با اسکرول (پیشفرض هر فهرست): شمار «24 از 120» زیر فهرست، دکمهٔ «نمایش بیشتر» برای صفحهکلید، و صفحهبندی شمارهدار فقط وقتی چیزی زیر فهرست هست؛
- انتخاب چند ردیف: نوار انتخاب (شمار، اقدامهای گروهی، «لغو انتخاب») بهجای ردیف نوارابزار، تا ردیفها انتخاب شدهاند.
چه زمانی استفاده کنیم:
- صفحهای که مجموعهای از یک نوع موجودیت را نشان میدهد: منشنها، منابع، هشدارها، کاربران، گزارشها.
- فهرست جدولی (
DataTable)، فید پست و نظر و حساب (EntityCollection) یا شبکهٔ کارتها.
چه زمانی استفاده نکنیم:
- صفحهٔ یک موجودیت با بخشها و زبانهها:
DetailPage. - شاخص و نمودار با بازهٔ زمانی:
DashboardPage. - فهرستی که هنوز هیچ موردی ندارد و کاربر باید اولین را بسازد، وقتی صفحه چیز دیگری ندارد:
UtilityPageنوعempty.
برای انتخاب قالب، انتخاب قالب صفحه را ببینید.
هر فهرست با اسکرول کاربر ادامه پیدا میکند (پیشفرض از 7.13، تصمیم مالک): loadMore={list.loadMore} که list
از useLoadMore(fetchBatch) میآید، دستهٔ بعد را نزدیک انتهای فهرست خودکار اضافه میکند. درخواست با تغییر عبارت یا فیلتر
از ابتدا شروع میشود. شمار «24 از 120» زیر فهرست است و دکمهٔ «نمایش بیشتر» برای صفحهکلید، صفحهخوان و مرورگرهای قدیمی
میماند. اگر ادامهٔ بارگذاری شکست بخورد، ردیفهای فعلی میمانند و فقط «تلاش مجدد» درخواست را تکرار میکند. صفحهبندی
شمارهدار (pagination) فقط وقتی است که زیر فهرست چیز دیگری در همان صفحه باشد، فهرست زنده باشد، یا پرش به صفحهٔ N
کار خود کاربر باشد؛ pagination و loadMore همزمان استفاده نمیشوند.
استفاده
قالبها فقط از مسیر @partodata/ui/templates وارد میشوند. صفحه ListPage را برمیگرداند و هیچ عدد، عرض، فاصله یا
h1ای از خودش نمینویسد:
'use client'
import * as React from 'react'
import Link from 'next/link'
import {
Button,
DataTable,
DataTableExportButton,
DataTableFacetedFilter,
DateRangePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
SearchInput,
SentimentBadge,
formatJalaliDate,
formatNumber,
toast,
useDebounce,
type DateRange,
type ExportableColumn,
} from '@partodata/ui'
import { Icons } from '@partodata/ui/icons'
import { PlatformMark } from '@partodata/ui/social'
import { ListPage, pageState, useLoadMore, type LoadMoreCursor } from '@partodata/ui/templates'
import { getMentions, type Mention, type Platform, type Sentiment } from '@/lib/data'
const PAGE_SIZE = 25
const PLATFORMS: { value: Platform; label: string }[] = [
{ value: 'instagram', label: 'اینستاگرام' },
{ value: 'telegram', label: 'تلگرام' },
{ value: 'website', label: 'وبسایت' },
{ value: 'facebook', label: 'فیسبوک' },
]
const SENTIMENTS: { value: Sentiment; label: string }[] = [
{ value: 'positive', label: 'مثبت' },
{ value: 'negative', label: 'منفی' },
{ value: 'neutral', label: 'خنثی' },
]
const labelOf = (options: { value: string; label: string }[], value: string) =>
options.find((option) => option.value === value)?.label ?? value
// روزهای انتخابگر 00:00 هستند: بازه تا پایانِ روز آخرش میرود، وگرنه API آن روز را جا میاندازد
const endOfDay = (day: Date) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), 23, 59, 59, 999)
async function copyLink(url: string) {
try {
await navigator.clipboard.writeText(url)
toast.success('پیوند پست کپی شد')
} catch {
toast.error('کپی پیوند ممکن نشد')
}
}
const engagementOf = (row: Mention) => row.engagement.likes + row.engagement.comments + row.engagement.shares
// هر نوع ستون یک خانه دارد (جدول «ستونها» پایینتر)
const columns: ExportableColumn<Mention>[] = [
{ id: 'author', header: 'نویسنده', cell: (row) => row.author.name, exportValue: (row) => row.author.name },
{
id: 'text',
header: 'متن',
cell: (row) => <span className="line-clamp-1">{row.text}</span>,
exportValue: (row) => row.text,
},
{
id: 'platform',
header: 'پلتفرم',
cell: (row) => <PlatformMark source={row.platform} variant="badge" showLabel />,
exportValue: (row) => labelOf(PLATFORMS, row.platform),
},
{
id: 'sentiment',
header: 'احساس',
cell: (row) => <SentimentBadge sentiment={row.sentiment} />,
exportValue: (row) => labelOf(SENTIMENTS, row.sentiment),
},
{
id: 'engagement',
header: 'تعامل',
align: 'end',
cell: (row) => formatNumber(engagementOf(row)),
exportValue: (row) => engagementOf(row),
},
{
id: 'date',
header: 'تاریخ',
cell: (row) => formatJalaliDate(new Date(row.publishedAt), 'd MMMM yyyy'),
exportValue: (row) => row.publishedAt,
},
// عملیات هر ردیف: ستون آخر — منویی پشت دکمهٔ آیکونی ghost که نام ردیف را دارد
{
id: 'actions',
header: <span className="sr-only">عملیات</span>,
align: 'end',
cell: (row) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" icon={<Icons.moreHorizontal />} aria-label={`عملیات منشن ${row.author.name}`} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => copyLink(row.url)}>کپی پیوند پست</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
exportValue: () => null,
},
]
export default function MentionsPage() {
const [q, setQ] = React.useState('')
// نوشتن 300 میلیثانیه صبر میکند و بعد جستوجو میکند؛ پاک کردن جستوجو فوری است
const debounced = useDebounce(q, 300)
const search = q === '' ? '' : debounced
// وضعیت هر فیلتر از نوع مقدارهای گزینههایش است: انتخاب بیتبدیل نوع به API میرسد
const [platforms, setPlatforms] = React.useState<Platform[]>([])
const [sentiments, setSentiments] = React.useState<Sentiment[]>([])
const [range, setRange] = React.useState<DateRange | undefined>()
// فهرست با اسکرول کاربر ادامه پیدا میکند؛ API با شمارهٔ صفحه کار میکند، پس cursor شمارهٔ صفحهٔ بعد است.
// مقدارهای درخواست در خود فراخوانی، همان وضعیتها در وابستگیها — هرگز شیئی که هنگام رندر ساخته شود
const fetchBatch = React.useCallback(
async (cursor: LoadMoreCursor | null) => {
const page = cursor === null ? 1 : Number(cursor)
const result = await getMentions({
q: search,
platforms,
sentiment: sentiments,
from: range?.from?.toISOString(),
to: range?.to && endOfDay(range.to).toISOString(),
page,
pageSize: PAGE_SIZE,
})
return { items: result.items, total: result.total, nextCursor: page * PAGE_SIZE < result.total ? page + 1 : null }
},
[search, platforms, sentiments, range]
)
// هر تغییر جستوجو یا فیلتر fetchBatch تازه میسازد و فهرست از دستهٔ اول بار میشود
const list = useLoadMore(fetchBatch)
const filterBy =
<T,>(set: (value: T) => void) =>
(value: T) =>
set(value)
const clear = () => {
setQ('')
setPlatforms([])
setSentiments([])
setRange(undefined)
}
const rows = list.items ?? []
return (
<ListPage
title="منشنها"
description="همهٔ منشنهای برند در شبکههای پایششده"
search={
<SearchInput
placeholder="جستوجو در منشنها"
aria-label="جستوجو در منشنها"
value={q}
onChange={(e) => filterBy(setQ)(e.target.value)}
onClear={() => filterBy(setQ)('')}
/>
}
filters={
<>
<DataTableFacetedFilter
title="پلتفرم"
options={PLATFORMS}
selected={platforms}
onSelectedChange={filterBy(setPlatforms)}
/>
<DataTableFacetedFilter
title="احساس"
options={SENTIMENTS}
selected={sentiments}
onSelectedChange={filterBy(setSentiments)}
/>
<DateRangePicker value={range} onChange={filterBy(setRange)} placeholder="بازهٔ تاریخ" />
</>
}
filtered={q !== '' || platforms.length > 0 || sentiments.length > 0 || range !== undefined}
onClearFilters={clear}
secondaryActions={
<DataTableExportButton columns={columns} data={rows} filename="mentions.csv" label="خروجی CSV" />
}
primaryAction={
<Button asChild iconStart={<Icons.plus />}>
<Link href="/settings/alerts">ساخت هشدار</Link>
</Button>
}
state={pageState({
data: list.items,
isLoading: list.isLoading,
error: list.error,
onRetry: list.reload,
emptyCopy: { title: 'هنوز منشنی ثبت نشده است' },
})}
// پیشفرض هر فهرست: دستهٔ بعد با اسکرول، «24 از 120» و «نمایش بیشتر» برای صفحهکلید
loadMore={list.loadMore}
>
<DataTable columns={columns} data={rows} />
</ListPage>
)
}DataTable را بدون size، pagination، emptyState و isLoading خودش بنویسید و آن را در Card نگذارید: قالب
تراکم جدول، بارگذاری ادامهٔ فهرست و حالتها را خودش تعیین میکند. کنترلهای search و filters بدون size، بدون کلاس عرض و
بدون label اند (متن خود دکمهشان نامشان است). نمای فهرست (بازه، تب، فیلترها، مرتبسازی، چیدمان، شمارهٔ صفحه) اگر باید با
پیوند به اشتراک گذاشته شود یا پس از «برگشت» بماند، فقط با useViewParams در نشانی صفحه میرود (هوک):
نه useSearchParams، نه useFilterParams/FilterProvider، نه history.replaceState (قاعدهٔ ESLint parto/page-template
در فایلی که ListPage یا DashboardPage دارد اینها را نشان میدهد). یک پاسخ در همهٔ صفحههای فهرست: نشانی صفحه تصمیمهایی را
میخواست — نام پارامترها، نوشتن آرایه و تاریخ، push یا replace، خواندن در اولین رندر — که در آزمایش X5 سه اجرا به سه شکل
گرفتند؛ حالا همهٔ آنها یک بار در DS گرفته شدهاند. متنِ تایپشده و هنوز ارسالنشدهٔ جستوجو، انتخاب ردیفها و جای یک فید
با مکاننما وضعیت جزء (React.useState) میمانند؛ جستوجوی ارسالشده useUrlQuery است. هر تغییر جستوجو یا فیلتر صفحه را به 1 برمیگرداند (filterBy
بالا)، تا نتیجهٔ کوچکتر هرگز روی صفحهای بعد از آخرینش باز نشود؛ درخواست جستوجو را با useDebounce(q, 300) میگیرد و
جستوجوی خالی را فوری. داده با useAsync میآید؛ load مقدارهای درخواست را در خود فراخوانی میگذارد و همان وضعیتها
را در وابستگیهایش — هرگز شیئی که هنگام رندر ساخته شود (const params = { q, page }): شیء تازه در هر رندر یعنی load
تازه در هر رندر و درخواست بیپایان. قالب هم صفحهای بعد از آخرین صفحهٔ نتیجهٔ بارشده را خودش به 1 برمیگرداند، اما
فقط بعد از درخواستِ صفحهٔ قدیم؛ پس ریست در خود هندلرهاست. بازهٔ تاریخ تا پایان روز آخرش به API میرود (endOfDay):
روزهای انتخابگر 00:00 هستند و to.toISOString() روز آخر را جا میاندازد.
هر جایگاه، یک پاسخ
| جایگاه | چه چیزی |
|---|---|
search | یک SearchInput با placeholder و aria-label که میگوید در چه میگردد؛ درخواست مقدارش را با useDebounce(q, 300) میگیرد (خالی: فوری) |
filters | برای هر انتخاب از مجموعهای ثابت یک DataTableFacetedFilter که گزینهها و وضعیتش با union همان مجموعه تایپ شدهاند (React.useState<Platform[]>([]))، تا انتخاب بیتبدیل نوع به API برسد؛ برای بازهٔ زمانی DateRangePicker با placeholder (هرگز DateRangePickerInline، هرگز label) |
filterPanel | بهجای filters، برای 5 بُعد یا بیشتر، شمارش هر مقدار، یا صفحهای که کارش فیلتر کردن است: panel یک FilterPanel از FilterSectionها و activeCount شمار فیلترهای فعال؛ سایدبار فیلتر در ستون دوم قاب با همهچیز داخلش؛ ستون اشغالشده: نوار افقی؛ در عرض کم Sheet — هرگز هر دو |
filtered | آیا جستوجو یا فیلتری فعال است؛ با search، filters یا filterPanel الزامی. هر تغییر جستوجو یا فیلتر صفحه را به 1 برمیگرداند |
onClearFilters | همه را پاک میکند و به صفحهٔ 1 برمیگردد؛ با search، filters یا filterPanel الزامی |
secondaryActions | خروجی جدول با DataTableExportButton (بی variant، label="خروجی CSV"، filename با پسوند .csv)؛ هر اقدام دیگر Button بی variant |
primaryAction | یک Button بی variant که کاری میکند؛ اگر به صفحهٔ دیگری میرود <Button asChild><Link href>…</Link></Button>؛ اقدامی که چیز تازهای میسازد iconStart={<Icons.plus />}؛ اگر کاربر اجازهاش را ندارد، همانجا غیرفعال با دلیل: GatedAction، هرگز پنهان |
activeFilters | با filterPanel: چیپ مقدارهای فعال که قالب بالای خود سایدبار رسم میکند (هرگز در نوار ابزار)؛ با filters: فقط فیلتری که ماشهٔ خودش را ندارد (کلیک روی نمودار)، هرگز برای فیلترهای خود نوار ابزار |
toolbarEnd | فقط ViewToggle (وقتی فهرست دو نما دارد)، EntityLayoutToggle برای چیدمان فید (با showLayoutToggle={false} روی EntityCollection) یا DataTableColumnVisibilityToggle (وقتی محصول انتخاب ستون میخواهد)؛ هرگز شمارش |
pagination | استثنا: فهرستی با محتوای دیگر زیرش، فهرست زنده یا پرش به صفحهٔ N، با دلیل در توضیح؛ هر پنج فیلد، totalRows و pageSize الزامیاند |
content | فهرست چیست: table (پیشفرض)، feed برای یک ستون پست یا نظر (680، با aside اختیاری)، grid برای کارتها — عرض و اسکلت از همین؛ width فقط برای جدول |
aside | فقط با feed: کارتهای کنار فید (خلاصهٔ نتیجه، منبعهای برتر)؛ هرگز فیلتر یا اقدام |
tabs | زیرصفحههای فهرست، هر کدام پیوند با نشانی خودش — زبانههای وضعیت («همه»، «فعال»، «متوقف») با شمارشان در badge؛ هر زبانه یک بخش مسیر (/sources/active) که زبانهٔ جاری را قاب از pathname پیدا میکند؛ هر صفحهٔ زبانه همین ListPage را با همین tabs رندر میکند |
summary | در حالت پیشفرض 2 تا 6 MetricCard؛ با summaryLayout="inline"، 2 تا 6 گروه دارای MetricGroup و اقدامهای اختیاری در یک کارت بومی؛ بالای نوارابزار و در همهٔ حالتهای فهرست؛ عدد نامعلوم «—» است، هرگز 0 |
summaryLayout | cards پیشفرض؛ inline برای خلاصهٔ فشرده با چند گروه شاخص و اقدام، در یک کارت با فاصلهٔ داخلی بومی، یک ستون زیر 36rem محتوا و سه ستون بالاتر |
loadMore | پیشفرض هر فهرست: useLoadMore(fetchBatch).loadMore، بارگذاری با اسکرول (با شمار کل API: «8 از 20») و دکمهٔ «نمایش بیشتر» برای صفحهکلید؛ برای API با cursor و با شمارهٔ صفحه؛ یا این یا pagination، هرگز هر دو |
query | فقط صفحهای که کار اصلیاش یک جستوجوست (جستوجوی پست، جستوجوی شواهد): فیلد پرسش برجسته و دکمهٔ «جستوجو» (اقدام اصلی صفحه، پس primaryAction ندارد)، پرسش پیشرفته پشت «جستوجوی پیشرفته»؛ جای search را میگیرد |
source | فقط صفحهٔ جستوجو یا رتبهبندی (جستوجوی پست، برترینها، مسائل): یک SourceScope — همان یک منبعی که صفحه در آن میگردد، چون هر منبع پایگاه دادهٔ جداست؛ اول زیر عنوان، بالای پرسش و فیلترها، در همهٔ حالتها. هرگز «همهٔ منابع» و هرگز فاست پلتفرم کنارش |
views | فقط با query: دو نگاه به همان نتیجه («نتایج»، «تحلیل») — وضعیت جزء، مثل خود پرسش؛ زیرصفحههای با نشانی tabs هستند |
selection | فقط وقتی ردیفها برای اقدامی روی چند ردیف انتخاب میشوند: همان یک وضعیت (React.useState<Set<string>>(new Set())) هم به selection جدول و هم به selection قالب؛ اقدامهای گروهی در bulkActions، هر کدام Button بی variant — هرگز در secondaryActions، toolbarEnd یا نواری از خود صفحه |
state | pageState({ data: data?.items, isLoading, error, onRetry: load, emptyCopy: { title } }) از useAsync؛ عنوان نام آنچه نیست را میبرد، بی action |
| ستونها | نام ردیف: متن، یا Link (prefetch={false}) به صفحهٔ ردیف اگر دارد؛ متن بلند: <span className="line-clamp-1">؛ پلتفرم: PlatformMark variant="badge" showLabel؛ احساس: SentimentBadge؛ عدد: formatNumber با align: 'end'؛ تاریخ: formatJalaliDate(d, 'd MMMM yyyy') (در جدول هرگز نسبی)؛ هر ستون exportValue با مقدار ساده |
| ستون عملیات ردیف | ستون آخر: سرستون sr-only «عملیات»، align: 'end'، DropdownMenu پشت Button variant="ghost" با Icons.moreHorizontal و aria-label ردیف؛ گزینهها: «مشاهدهٔ جزئیات» (یک Link) اول و فقط وقتی ردیف صفحهٔ خودش را دارد، بعد اقدامهای خود ردیف («کپی پیوند پست») |
حالتها و انواع
جای اقدام اصلی
| صفحه | اقدام اصلی و اقدامهای ثانوی |
|---|---|
با search یا filters (ردیف فیلتر دارد) | انتهای نوارابزار، اقدام اصلی آخر |
بی search و filters | انتهای سرِ صفحه، اقدام اصلی آخر |
این قاعدهٔ سوپابیس است، برای راستبهچپ آینهشده: اقدام همانجایی است که چشم کاربر هست. صفحه این را تصمیم
نمیگیرد؛ primaryAction و secondaryActions را بدهید و قالب جایشان را تعیین میکند. primaryAction یک Button
بدون variant است که کاری میکند (onClick، یا پیوند با asChild)؛ هر دکمه در secondaryActions در همهٔ صفحهها یک
شکل دارد: Button بی variant (خنثای پیشفرض؛ هرگز outline یا ghost). اقدامی که کاربر اجازهاش را ندارد پنهان نمیشود (نه
canCreate && <Button/>): همانجا غیرفعال با دلیلش میماند، با GatedAction دور
Button. DataTableExportButton و DataTableColumnVisibilityToggle در این جایگاهها بی variant خودشان
default رندر میشوند. خروجی جدول در همهٔ حالتها بی هیچ شرطی نوشته میشود: وقتی فهرست ردیفی ندارد (بارگذاری،
«نتیجهای یافت نشد»، خطا) قالب آن را غیرفعال میکند و دلیلش را میگوید — نه GatedAction روی تعداد ردیفها، نه disabled، نه
rows.length > 0 && …. قاعدهٔ ESLint parto/page-primary-action همهٔ اینها را بررسی میکند.
حالتها
state الزامی است و آن را همیشه با pageState() از خروجی درخواست بسازید: data خودِ فهرست است (data?.items؛ شیء
صفحهای که فهرست در آن است خطای نوع است)، بهعلاوهٔ isLoading، error و onRetry. فهرستی که ردیفهایش در خود کد
نوشته شده و هرگز بارگذاری نمیشود state={{ status: 'ready' }} میگیرد. اینکه فهرست خالی «نتیجهای یافت نشد» است را
filtered خود ListPage تعیین میکند، نه حالت: filtered و onClearFilters را اینجا به pageState ندهید. سرِ صفحه
و نوارابزار در همهٔ حالتها میمانند و فقط فهرست جایش را میدهد:
- بارگذاری — تا چیزی بارگذاری نشده، اسکلتی به شکل فهرست (
skeleton:tableپیشفرض،listبرای فید،cardsبرای شبکهٔ کارت). ردیفهایی که روی صفحهاند تا رسیدن صفحهٔ بعد یا نتیجهٔ جستوجو و فیلتر تازه کمرنگ میمانند و ردیف صفحهبندی سر جایش است (اسپینر یاisLoadingجدول لازم نیست)؛ - خطا —
ErrorStateبا «تلاش مجدد» و متن خود سیستم طراحی، حتی اگر دادهٔ قبلی روی صفحه بوده باشد (آن داده دیگر جواب درخواست نیست)؛ - خالی — با
filteredهمیشه «نتیجهای یافت نشد» با دکمهٔ «پاک کردن فیلترها»، با متن خود سیستم طراحی (اگر آن دکمه با صفحهکلید زده شود، فوکوس به جستوجو میرود، همانجا که دکمهٔ پاککردن نوارابزار میبرد)؛ وگرنه عنوانemptyCopyدرpageState: هر صفحهٔ فهرستemptyCopy: { title }میدهد که نام آنچه نیست را میبرد («هنوز منشنی ثبت نشده است»)، بیaction— اقدام اصلی صفحه همان بالا روی صفحه است (actionدرemptyCopyدر محیط توسعه هشدار میدهد).
انتخاب چند ردیف و اقدام گروهی
برای اقدامی روی چند ردیف با هم (بایگانی، برچسبزدن، حذف) صفحه یک وضعیت انتخاب نگه میدارد و آن را به هر دو
میدهد: selection جدول (selectedRows، onSelectionChange، همیشه getRowKey: (row) => row.id، و
isRowSelectable برای ردیفی که انتخابشدنی نیست) چکباکسها را میکشد، و selection خود ListPage
(selectedRows، onSelectionChange، bulkActions) بقیه را. بی getRowKey کلید هر ردیف جایگاهش در صفحه است
(0، 1، 2…): اقدام گروهی بهجای شناسهها جایگاهها را میگیرد، و قالب در محیط توسعه هشدار میدهد (قاعدهٔ ESLint
parto/page-template هم). قالب، در همهٔ صفحهها یکسان:
- تا ردیفی انتخاب شده، نوار انتخاب جای ردیف نوارابزار را میگیرد، روی همان ردیف (فهرست زیرش جابهجا نمیشود): «3 مورد انتخاب شده»، اقدامهای گروهی، «لغو انتخاب». جستوجو، فیلترها و اقدامهای صفحه با پاک شدن انتخاب برمیگردند؛
- «لغو انتخاب» یا Escape انتخاب را پاک میکند (فوکوس به نوارابزار برمیگردد؛ پس از اقدامی که در
ConfirmDialogتأیید شده هم)؛ - انتخاب مال ردیفهای روی صفحه است: وقتی فهرست دوباره بارگذاری میشود (جستوجو، فیلتر، بارگذاری پس از اقدام گروهی) یا صفحهٔ دیگری از آن نشان داده میشود (حتی صفحهبندی در مرورگر، بی بارگذاری) قالب آن را پاک میکند، تا اقدام گروهی هرگز به ردیفی نرسد که کاربر دیگر نمیبیند؛
- شمار انتخاب مؤدبانه اعلام میشود.
هر اقدام گروهی Button بی variant است (حذف هم؛ ConfirmDialog آن دکمهٔ destructive دارد و تعداد ردیفها
را میگوید)، و اقدامی که کاربر اجازهاش را ندارد GatedAction — حذفی که تأیید میخواهد با GatedAction در trigger
پنجره: <ConfirmDialog trigger={<GatedAction …><Button>حذف</Button></GatedAction>} … />
(GatedAction). اقدام گروهی هرگز در secondaryActions یا toolbarEnd نمیرود و
نواری از خود صفحه ساخته نمیشود؛ جدولی با چکباکس بی selection قالب، یا selection بی چکباکس جدول، در محیط توسعه
هشدار میدهد. selection فقط با search یا filters (نوار جای ردیف نوارابزار را میگیرد؛ بی آنها خطای نوع):
فهرستی که اقدام گروهی دارد جستوجو هم دارد، پس صفحهای که اقدام گروهی لازم دارد search خود را (یک SearchInput)
اضافه میکند. نمونهٔ کامل و کامپایلشده در AGENTS.md بخش 4 آمده است.
فهرستهای بلند: «N از M»، همهٔ نتایج، و انتخاب بین فیلترها و صفحهها
ناوگانی با هزاران اکانت که در مرورگر فیلتر میشود، بیش از «ردیفهای روی صفحه» میخواهد. همهٔ اینها اختیاری و افزودنیاند؛ بی آنها نوار همان است که بود:
total— شمار نتیجهها زیر جستوجو و فیلتر فعلی (نه کل ناوگان): شمار میشود «12 از 2,069 انتخاب شده».onSelectAllResults— وقتیtotalاز ردیفهای بارگذاریشده (صفحهٔ جاری، یاloadMore.loaded) بیشتر باشد نوار «انتخاب همهٔ 2,069 نتیجه» را نشان میدهد (الگوی Table Editor در Supabase). «همه» را صفحه تعریف میکند: همهٔ کلیدهای فهرست فیلترشده درSet(فهرست سمت مرورگر)، یا پرچمی که اقدام گروهی به API میفرستد (فهرست سمت سرور).allResultsSelectedشمار را «همهٔ 2,069 نتیجه انتخاب شده» میکند و دکمه را برمیدارد؛ با برداشتن تیک یک ردیف صفحه آن را دوبارهfalseمیکند.keepAcrossLoads— انتخاب با جستوجو، فیلتر، بارگذاری دوباره و صفحهٔ دیگر پاک نمیشود (نوار انتخاب جای نوارابزار را میگیرد، پس فیلتری که وسط انتخاب عوض میشود درfilterPanelاست، مثل نمونهٔ زیر). ردیفهای انتخابشدهای که نتایج فعلی نشان نمیدهند را صفحه میشمارد (hiddenCount) و نوار میگوید «12 انتخابشده · 4 خارج از نتایج فعلی» با «نمایش» (onShowHidden) و «حذف از انتخاب» (onRemoveHidden). اقدام گروهی به همان ردیفهای پنهان هم میرسد، پس تأیید اقدام مخرب باید همان شمار را بگوید؛ پاککردن انتخاب پس از اقدام هم با صفحه است.destructiveActions— تنها اقدام مخرب (حذف، آزادسازی) در انتهای نوار، جدا از اقدامهای خنثی. باز همButtonبیvariantکهConfirmDialogدکمهٔdestructiveدارد.
فید (EntityCollection از Post) همان وضعیت را میگیرد:
selection={{ selectedIds: [...selected], onSelectedIdsChange: (ids) => setSelected(new Set(ids)), bulkBar: false }}
— نوار انتخاب خود مجموعه جایش را به نوار انتخاب قالب میدهد و مجموعه bulkActions نمیگیرد (نوار خودش در ListPage
هشدار توسعه میدهد).
const [selected, setSelected] = React.useState<Set<string>>(new Set())
<ListPage
title="منابع"
search={<SearchInput placeholder="جستوجو در منابع" aria-label="جستوجو در منابع" value={q} onChange={(e) => filterBy(setQ)(e.target.value)} onClear={() => filterBy(setQ)('')} />}
filtered={q !== ''}
onClearFilters={() => filterBy(setQ)('')}
selection={{
selectedRows: selected,
onSelectionChange: setSelected,
bulkActions: (
<GatedAction allowed={canArchive} reason="بایگانی منبع فقط برای مدیران فضای کار ممکن است">
<Button onClick={archive}>بایگانی</Button>
</GatedAction>
),
}}
state={pageState({ data: data?.items, isLoading, error, onRetry: load, emptyCopy: { title: 'هنوز منبعی افزوده نشده است' } })}
pagination={{ currentPage: page, totalPages, onPageChange: setPage, totalRows: data?.total ?? 0, pageSize: 25 }}
>
<DataTable
columns={columns}
data={data?.items ?? []}
selection={{ selectedRows: selected, onSelectionChange: setSelected, getRowKey: (row) => row.id }}
/>
</ListPage>زبانههای وضعیت و نوار خلاصه
زبانههای فهرست پیونداند، هر کدام با نشانی خودش، دقیقاً مثل زبانههای DetailPage: زیر سرِ صفحه، شمار هر زبانه در
badge، و زبانهٔ جاری را قاب از pathname پیدا میکند (بخشهای کامل مسیر، طولانیترین href اول). هر زبانه یک بخش
مسیر است (/sources، /sources/active، /sources/paused): در Next.js هر وضعیت یک پوشهٔ ثابت است و page.tsx آن فهرست را
با وضعیت خودش رندر میکند (app/sources/active/page.tsx ← <Sources status="active" />)؛ هرگز بخش [status]، که کنار
[id] موجودیت نمینشیند، و هرگز useSearchParams خودتان. آنچه با عوض کردن زبانه باید بماند (شمارها، جستوجو و فیلترها) در
layout.tsx همان بخش بار میشود تا نشانهای زبانه و کارتهای خلاصه به «—» برنگردند. دو نگاه به
همان نتیجه زبانه نیست: views صفحهٔ جستوجومحور است.
وضعیت فقط وقتی زبانه میگیرد که صفحه صف کاریای است که بر اساس وضعیت رسیدگی میشود — 2 تا 5 وضعیت، هر کدام با شمار و
اقدامهای ردیف خودش (صف بازبینی، حسابهای قفلشده)؛ در غیر این صورت وضعیت یک DataTableFacetedFilter است مثل هر مجموعهٔ
ثابت دیگر، و هرگز هر دو. مثل هر قالب زبانهدار، ListPage در layout.tsx بخش مینشیند و page.tsx هر زبانه فقط
فهرست را میدهد. ارتفاع سرِ فهرستی با زبانه 245 پیکسل تا سرِ جدول است (بی زبانه 181).
summary نوار خلاصه است: 2 تا 6 MetricCard که قالب آنها را بالای نوارابزار، در شبکهای با عرض کمینهٔ
--layout-tile-min-width (کاشیها کش نمیآیند؛ auto-fill)، زیر سرعنوان پنهان «خلاصه» (h2) میچیند؛ زیر 36rem محتوا
(گوشی) دوتا در هر ردیف، تا چهار کاشی دو ردیف کوتاه باشند نه یک صفحه کارت. فید با aside شمارهایش را در aside میگذارد، نه
summary. نوار خلاصه در
بارگذاری، خطا و حالت خالی فهرست میماند: عددهایش ردیفهای فهرست نیستند. یک کاشی جای توضیح صفحه است و بیش از شش کاشی
یک داشبورد؛ هر دو در محیط توسعه هشدار میدهند.
برای خلاصهٔ فشرده همراه اقدامهای هر گروه، summaryLayout="inline" را انتخاب کنید. summary در این حالت 2 تا 6
عنصر گروه است؛ هر گروه یک MetricGroup layout="stacked" از @partodata/ui/social و اقدامهای اختیاری دارد.
خود قالب یک Card و CardContent با فاصلهٔ داخلی پیشفرض میسازد؛ کارت بیرونی، اندازه یا فاصلهٔ داخلی دیگری ندهید.
گروهها زیر 36rem محتوا در یک ستون و بالاتر در سه ستون قرار میگیرند. مخرج نسبت و دامنهٔ آمار را در برچسب یا توضیح
هر گروه بنویسید؛ صفرِ اندازهگیریشده 0 و عدد نامعلوم null است که MetricGroup آن را «—» نشان میدهد.
سرعنوان پنهان «خلاصه» و ماندگاری هنگام بارگذاری، خطا یا خالیشدن فهرست در هر دو حالت یکسان است.
import { Button, DataTable } from '@partodata/ui'
import { MetricGroup } from '@partodata/ui/social'
import { ListPage } from '@partodata/ui/templates'
;<ListPage
title="اکانتها"
summaryLayout="inline"
summary={[
<div key="queue">
<MetricGroup layout="stacked" items={[{ key: 'queued', label: 'در صف ورود', value: queued }]} />
<Button onClick={openQueue}>جزئیات صف</Button>
</div>,
<div key="activity">
<MetricGroup
layout="stacked"
items={[{ key: 'operational', label: activityCoverageLabel, value: operational }]}
/>
<Button onClick={openPhases}>مرحلههای فعالیت</Button>
</div>,
<div key="identity">
<MetricGroup layout="stacked" items={[{ key: 'applied', label: identityCoverageLabel, value: applied }]} />
<Button onClick={openIdentity}>وضعیت هویت</Button>
</div>,
]}
state={listState}
>
<DataTable columns={columns} data={accounts} />
</ListPage>مقدارها، برچسبهای دامنه و handlerها از داده و وضعیت همین صفحه میآیند؛ نمونهٔ کامل و تایپشده:
npx --no parto-ui example list-inline-summary. در محیط توسعه، شمار گروه خارج از 2 تا 6 یا گروه بدون MetricGroup
هشدار دارد. DetailPage.summary همچنان فقط نوار MetricCard است.
بارگذاری با اسکرول (پیشفرض)
فهرست دستهدسته و با اسکرول کاربر بلند میشود، چه API با cursor یا hasMore کار کند و چه با شمارهٔ صفحه (آنوقت cursor
همان شمارهٔ صفحهٔ بعد است و total شمار کلی که API میدهد). useLoadMore(fetchBatch) از @partodata/ui/templates هوک
دادهٔ همین فهرست است و loadMore آماده را برمیگرداند:
'use client'
import * as React from 'react'
import { DataTable } from '@partodata/ui'
import { ListPage, pageState, useLoadMore, type LoadMoreBatch, type LoadMoreCursor } from '@partodata/ui/templates'
type AuditEntry = { id: string; action: string }
async function getAudit(cursor: LoadMoreCursor | null): Promise<LoadMoreBatch<AuditEntry>> {
const response = await fetch(`/api/audit${cursor === null ? '' : `?cursor=${cursor}`}`)
if (!response.ok) throw new Error(`audit: ${response.status}`)
return response.json()
}
const columns = [{ id: 'action', header: 'رویداد', cell: (row: AuditEntry) => row.action }]
export function AuditScreen() {
// یک تابع ثابت؛ با جستوجو یا فیلتر: React.useCallback((cursor) => getAudit(…), [مقدارهای درخواست])
const list = useLoadMore(getAudit)
return (
<ListPage
title="گزارش تغییرات"
state={pageState({
data: list.items,
isLoading: list.isLoading,
error: list.error,
onRetry: list.reload,
emptyCopy: { title: 'هنوز تغییری ثبت نشده است' },
})}
loadMore={list.loadMore}
>
<DataTable columns={columns} data={list.items ?? []} />
</ListPage>
)
}زیر فهرست، در ابتدای خط، شمار آنچه نشان داده شده («24 مورد نمایش داده شده»؛ «24 از 120» فقط وقتی API شمار کل میدهد،
هرگز 0 ساختگی؛ در پایان «همهٔ 120 مورد») و در انتهای خط «نمایش بیشتر». وقتی کاربر به 240 پیکسلی انتهای فهرست میرسد،
دستهٔ بعد خودکار بار میشود (mode پیشفرض infinite). دکمه همیشه در صفحه هست، پس کاربر صفحهکلید و صفحهخوان هم
ادامه را دارند. هنگام بارگذاری ردیفهای روی صفحه کمرنگ نمیشوند و فوکوس روی دکمه میماند؛ شکست دستهٔ بعد ردیفها
را نگه میدارد و «تلاش مجدد» میدهد.
اسکرول خودکار فقط وقتی درست است که فهرست آخرین چیز صفحه باشد. ListPage زیر فهرستش چیزی ندارد. DetailSectionی که
بخش دیگری بعدش هست، خودش به دکمه برمیگردد (و در حالت توسعه یک بار میگوید)، چون آن بخش هرگز دیده نمیشد. دکمهٔ تنها را
با mode: 'button' و یک توضیح بنویسید (ESLint parto/list-paging).
صفحهٔ جستوجومحور
صفحهای که کار اصلیاش یک پرسش است — جستوجوی پستها، جستوجوی شواهد — query دارد، نه search: جستوجوی
نوارابزار فیلتر زندهای است که فهرست را کوچک میکند، ولی اینجا پرسش فرستاده میشود و دکمهٔ «جستوجو»یش اقدام اصلی
صفحه است (پس primaryAction ندارد). قالب فرم جستوجو را میسازد: فیلد تمامعرض (md) و دکمهاش در یک خط، و زیرش
«جستوجوی پیشرفته» (واژههای لازم و ممنوع، عبارت دقیق) که FormRowهای query.advanced را باز میکند؛ همه یک فرم است و
Enter در هر فیلدش پرسش را میفرستد. بعد نوارابزار با filters (فاستها)، و بعد نتیجهها. پیش از اولین پرسش، بهجای
فهرست «عبارتی را جستوجو کنید» میآید؛ نتیجهٔ خالی همیشه «نتیجهای یافت نشد» است. views دو نگاه به همان نتیجه است
(«نتایج»، «تحلیل»): زبانههایی بالای ناحیهٔ نتیجه که وضعیت جزءاند و پرسش را نگه میدارند. نمای نتایج اول میآید و
فقط همان پانویس فهرست (صفحهبندی یا «نمایش بیشتر») را دارد؛ نمای «تحلیل» DashboardSectionهایی از DashboardChart است با
content="table" (بی ستون فید و بی aside).
جستوجو همیشه در یک منبع است. هر منبع — اینستاگرام، تلگرام، ایکس، خبر و وب، تلویزیون … — پایگاه دادهٔ جداست؛ پس
صفحهٔ جستوجوی پست (و هر صفحهٔ رتبهبندی مثل «برترینها» و «مسائل») منبعها را با هم نمیآمیزد. منبع اول انتخاب میشود
و همیشه پیداست: source قالب، یک SourceScope که زیر عنوان و بالای فیلد پرسش میآید و
مقدارش از useSourceScope است (برای هر محصول نگه داشته میشود). گزینهٔ «همهٔ منابع» نیست و فاست پلتفرم هم کنارش نیست
(قاعدهٔ ESLint parto/single-source)؛ تغییر منبع همان پرسش را در منبع تازه اجرا میکند و به صفحهٔ 1 برمیگردد. منبع
فیلتر نیست: «پاک کردن فیلترها» آن را برنمیگرداند. محصولی که همهٔ شبکهها را با هم نشان میدهد (کامنتسنج، بولتن) source
ندارد و شبکه در آن یک فاست معمولی است.
پرسشی که باید پس از Back بماند یا از پیوندی باز شود («جستوجو با این عبارت»، href="/search/posts?q=…") از
useUrlQuery() میآید: تنها پارامتر نشانی که DS خودش میخواند و مینویسد (q، با replace)؛ فیلترها و بخش پیشرفته وضعیت
جزء میمانند یا با useViewParams در نشانی میروند، و صفحه هرگز useSearchParams خودش را نمیخواند.
پرسش هم مثل هر جستوجو وضعیت جزء است، هرگز نشانیِ خود صفحه (جز پارامتر q که useUrlQuery از آنِ DS است): value آنچه تایپ شده و پرسش فرستادهشده وضعیت دوم (همان که درخواست
میخواند)؛ onSubmit دومی را از اولی پر میکند و به صفحهٔ 1 برمیگردد.
بلوک جستوجوی پست همین صفحهٔ کامل است، برای کپی در یک مسیر تازه: «روش جستوجو» اولین ردیف
query.advanced است (RadioCards با ساده، پیشرفته و مفهومی)؛ روش پیشرفته واژههای لازم، اختیاری و ممنوع، عبارت دقیق و
عبارت بولین (AND، OR، NOT و پرانتز) را اضافه میکند، و روش مفهومی موضوعی به زبان ساده را با هممعناهایش میجوید و نتیجه را
به ترتیب ارتباط میچیند؛ سرِ فهرست میگوید نتیجه با چه پرسشی پیدا شده است. حساب و هشتگ فیلترهای خود پرسشاند و
تراشهشان هم مثل همهٔ فیلترها بالای سایدبار فیلتر میآید؛ منبع با شمار نتیجهٔ پرسش در هر منبع در source است و نوع
پست، زبان، استان و بازهٔ زمانی بخشهای سایدبار فیلتر (filterPanel) هستند که در ستون دوم قاب، کنار منو، میایستد.
بلوک آماده: جستوجوی پست
کد و نمای کاملفیلترها: سایدبار فیلتر یا نوار ابزار
فیلترهای صفحه فقط در یکی از دو جا هستند (تصمیم مالک، 9 اکتبر 2026). هر بُعد یک فیلتر است (پلتفرم، احساس، بازهٔ زمانی)؛ جستوجو و مرتبسازی بُعد فیلتر نیستند:
- نوار ابزار:
filters، برای هر بُعد یکDataTableFacetedFilterو برای بازهٔ زمانیDateRangePicker. صفحهٔ فهرست یا جدولی با حداکثر 4 بُعد که کاربر گاهبهگاه عوضشان میکند. هر دکمه مقدارش را خودش نشان میدهد، پس چیپ نمیخواهد. - سایدبار فیلتر:
filterPanel={{ panel, activeCount }}کهpanelیکFilterPanelاست. برای 5 بُعد یا بیشتر، بُعدهایی که کنار هر مقدار شمار دارند، یا صفحهای که کار اصلیاش فیلتر کردن است. قالب پنل را به ستون دومProductFrameمیبرد (placementپیشفرضframeدر همهٔ صفحهها، از 7.13) و همهچیز داخل همان ستون است: سربرگ «N فیلتر فعال · پاک کردن همه»، چیپهایactiveFiltersزیر آن، و نشانگر فعال هر بخش. نوار ابزار فقط جستوجو، مرتبسازی و اقدامها را دارد: نه دکمهٔ «فیلترها»، نه چیپ. محتوای صفحه تمام عرض خودش را دارد و state فیلترها مال صفحه است؛ قاب فقط ستون را قرض میدهد. - ستون دوم در هر صفحه یا ناوبری است یا فیلتر. اگر
secondaryNavهمان صفحه ستون را گرفته باشد، قاب نباشد یا کنار ستون کمتر از 40rem محتوا بماند، همان بخشها نوار افقی بالای فهرست میشوند: هرFilterSectionیک Popover که ماشهاش مقدار خودش را نشان میدهد؛ زیر 36rem محتوا Sheet. ستون سوم یا فیلتر زیر ناوبری ساخته نمیشود. - منسوخ (7.13، حذف در 8.0):
placement: 'page'، یعنی پنل کنار نتیجهها با دکمهٔ «فیلترها» در نوار ابزار. قالب هشدار توسعه میدهد و ESLint آن را نشان میدهد؛placementرا بردارید.
کنترل هر بُعد را از نوع داده انتخاب کنید: بازهٔ زمانی FilterSectionPeriod، دستهٔ کوتاه با معنا (هیجان، تازگی)
FilterSectionChoices با رنگ و آیکون، منبع و زبان کارت، عدد FilterSectionRange، فهرست بلند FilterSectionOptions.
<ListPage
title="جستوجوی پستها"
content="feed"
query={{ value: typed, onValueChange: setTyped, onSubmit: submit, submitted, label: 'جستوجو در پستها' }}
// The filter sidebar: the frame's second column, everything about the filters inside it.
filterPanel={{ panel: <FilterPanel>…</FilterPanel>, activeCount }}
// Drawn at the top of the sidebar, never in the toolbar.
activeFilters={chips}
filtered={activeCount > 0}
onClearFilters={clearFacets}
state={pageState({
data: list.items,
isLoading: list.isLoading,
error: list.error,
onRetry: list.reload,
emptyCopy: { title: 'پستی پیدا نشد' },
})}
loadMore={list.loadMore}
>
<EntityCollection entity="post" items={posts} getId={(post) => post.id} renderItem={…} />
</ListPage>همان پنل در صفحهای که ستون دومش ناوبری بخش است، نوار افقی میشود:
ستون جمعشدنی (اختیاری). collapsible پیشفرض خاموش است و در Prototype خاموش میماند: سایدبار فیلتر همیشه هست.
محصولی که عرض نتیجهها برایش مهم است filterPanel={{ …, collapsible: true }} میدهد: دکمهٔ «بستن فیلترها» در سر پنل و
میانبر Ctrl+B (⌘B در مک، روی صفحهکلید فارسی هم) ستون را میبندند. ستون بسته یک نوار 40 پیکسلی در قاب میگذارد با
زبانهٔ «فیلترها» و شمار فیلتر فعال، تا ستون از همانجا برگردد و فیلتر فعالِ پنهان گم نشود؛ هرگز دکمهای در نوار
ابزار. قاب انتخاب را در localStorage نگه میدارد و پنل از DOM نمیرود، پس state صفحه میماند. بستن از داخل پنل فوکوس
را به زبانه میبرد و بازکردن از زبانه به دکمهٔ «بستن فیلترها». Ctrl+B در ویرایشگر متن غنی برای «bold» میماند.
filters و filterPanel با هم نمیآیند (خطای نوع). قاعدهٔ parto/filter-placement در ESLint و هشدار زمان توسعهٔ
قالب، نوار فیلتری با بیش از 4 بُعد، صفحهٔ جدولیای که پنلش 2 بخش یا کمتر دارد، و جاهای منسوخ را نشان میدهند. جدول
کامل «کدام صفحه، کدام جا» در الگوی فیلترها است. صفحهٔ کاوش جدولی با همین سایدبار
(نمونهٔ قابلکامپایل: npx --no parto-ui example list-page-filter-panel):
صفحهبندی (استثنا)
صفحهبندی شمارهدار فقط وقتی است که زیر فهرست چیز دیگری در همان صفحه هست، فهرست زنده است (useLiveRefresh همان صفحهٔ
جاری را بازمیخواند)، یا پرش به صفحهٔ N کار خود کاربر است؛ دلیل را کنار prop بنویسید (ESLint parto/list-paging).
pagination همان فیلدهای صفحهبندی DataTable را دارد (currentPage، totalPages، onPageChange، totalRows،
pageSize، هر پنج الزامی) و قالب آن را زیر فهرست رندر میکند: محدوده («26 تا 50 از 60») در ابتدای خط و شمارهها در
انتهای همان خط. وقتی فهرست آماده است یا صفحهٔ بعد در حال بارگذاری است دیده میشود، و در خطا و حالت خالی نه.
برای جدول، همین ردیف فوتر خودِ جدول است: داخل قاب (همان سطح، مرز و سایه؛ خط ظریف بالا؛ گوشههای پایین جدول باز) و نه ردیفی شناور زیر کارت — مثل فوتر h-9 border-t شبکهٔ Supabase. با onPageSizeChange (و pageSizeOptions، پیشفرض 25/50/100) فوتر «در هر صفحه» هم دارد؛ صفحه اندازه را نگه میدارد و در handler به صفحهٔ 1 برمیگردد. تنظیم «ردیف در صفحه» جایی در منوی «بیشتر» نمیگیرد.
نتیجهای یافت نشد: چه چیزی فهرست را محدود کرد
با filteredCauses ({ id, label, onRemove?, countIfRemoved? }[]) حالت «نتیجهای یافت نشد» مقصرها را نام میبرد: عبارت جستوجو، تب فعال و هر فیلد فعال، هرکدام چیپی با ✕ که فقط همان را برمیدارد. با countIfRemoved زیرش مینویسد «با حذف «دسته: ورزش»، 4 نتیجه». «پاک کردن فیلترها» میماند.
نوار انتخاب با ردیفهای خارج از نتایج
با selection.hiddenCount، ردیفهای انتخابشدهای که نتایج فعلی نشان نمیدهند در یک دکمهٔ آرام «4 خارج از نتایج فعلی ▾» جمع میشوند که «نمایش» و «حذف از انتخاب» را در Popover دارد؛ نوار تکخط میماند.
نشان تب با لحن
PageTab.badge عدد یا { value, tone } است (warning، destructive، info؛ عدد 0 همیشه خنثی). لحن فقط برای عددی است که باید رویش کار کرد («نیازمند اقدام»)، نه برای واقعیتی مثل «سوخته».
عرض: از محتوا
content میگوید فهرست چیست، و عرض صفحه، چیدمان فهرست و اسکلت بارگذاری از همان میآید. انتخاب با کارِ خواننده است، نه
با نوع موجودیت (فهرست پستها میتواند جدول یا فید باشد): خواننده فیلدها را میان ردیفها مقایسه، مرتب یا خروجی میگیرد ←
table؛ متن یا رسانهٔ هر مورد را میخواند ← feed؛ کاشیهای تصویری را مرور میکند ← grid.
content | چیدمان و عرض | اسکلت |
|---|---|---|
table (پیشفرض) | DataTable در عرض width | table |
feed | یک ستون از موجودیتها (پست، نظر، گزارش پخش) در اندازهٔ خواندن --layout-content-feed (680)؛ با aside ستون کناری از 64rem محتوا، زیرش در صفحهٔ باریک؛ بی آن کل صفحه به همان عرض | list |
grid | کارتها (پروفایل، کاشی رسانه) در عرض پهن (1600)، در ستونهایی با عرض کمینهٔ --layout-tile-min-width که قالب میچیند | cards |
width (فقط table) | عرض محتوا | کی |
|---|---|---|
default | 1200 | جدول تا 8 ستون (مثل جدول منشنها) (پیشفرض) |
wide | 1600 | فقط جدول بیش از 8 ستون |
full | بیسقف | فقط لاگ و جدولی که افقی پیمایش میشود |
ستون فید هرگز تا عرض صفحه کش نمیآید: در 1200 پیکسل، ردیفهای پهن و خالی یک فید همان مشکلی بود که این اندازه حل میکند
(680 — ستون خبرخوان فیسبوک؛ متن فارسی کارت حدود 600 پیکسل میشود). aside کارتهایی کنار فید است — خلاصهٔ نتیجه (شمار به
تفکیک پلتفرم و احساس)، منبعهای برتر — زیر یک عنوان پنهان «خلاصه»؛ هرگز فیلترهای نوارابزار (آنها در نوارابزار
میمانند) و هرگز اقدام. ستون کناری دستکم --layout-aside-width (320) است و باقی صفحهٔ 1200 را میگیرد.
- شمارهای فید در
aside: فیدsummaryنمیگیرد؛ نوار خلاصه برای جدول و شبکه است. asideهمیشه رندر میشود: شمارها تا بارگذاری «—» هستند، هرگزloaded && …، چون بودن یا نبودنش عرض صفحه را تعیین میکند.- فید یک
EntityCollectionازPostاست باshowLayoutToggle={false}؛ کلید نما، اگر لازم است، درtoolbarEndاست، و شبکهٔ پستهاcontent="grid"است نه شبکهٔ کاشی خود مجموعه در فید. grid: خود کارتها فرزند قالباند (Post layout="tile"، کارتAccount)، هرگز شبکهای از خودتان.- عرض هر مسیر ثابت است،
contentبا نما عوض میشود: فهرستی با کلید فهرست و شبکه (ViewToggleدرtoolbarEnd) در نمای فهرستcontent="feed"باasideاست و در نمای شبکهcontent="grid" width="default"با خود کارتها — هر دو 1200؛ کلید فهرست و جدولfeedباasideوtableاست. هر زبانهٔ هابcontentخودش را در همان یک عرض هاب اعلام میکند: هاب 1200 (اگر زبانهای فید دارد) فید باaside،tableوgrid width="default"؛ هاب 1600 (کنار داشبورد)table width="wide"وgrid.viewsصفحهٔ جستوجو هم همین است: «نتایج» فید باasideو «تحلیل»table— هر دو 1200.
const [view, setView] = React.useState<'list' | 'grid'>('list')
const toggle = <ViewToggle value={view} onValueChange={setView} /* list | grid */ />
return view === 'list' ? (
<ListPage title="پستها" state={state} toolbarEnd={toggle} content="feed" aside={<PostNumbers />}>
<EntityCollection
entity="post"
items={posts}
getId={(post) => post.id}
density="compact"
showLayoutToggle={false}
renderItem={(post, item) => <Post post={post} {...item} />}
/>
</ListPage>
) : (
<ListPage title="پستها" state={state} toolbarEnd={toggle} content="grid" width="default">
{posts.map((post) => (
<Post key={post.id} post={post} layout="tile" />
))}
</ListPage>
)- هشدارهای محیط توسعه ناهمخوانی را میگویند: فهرست پست بی
content="feed"،DataTableدر فید یا شبکه، نوارابزار خود اقدام یا فیلتر درaside، و توضیحی بلندتر از یک خط.
سرِ کوتاه و چسبان
نوارابزار فهرست بخشی از سرِ صفحه است: 24 پیکسل (--layout-block-gap) زیر آن مینشیند، نه 48، و توضیح صفحه یک خط
است. هنگام پیمایش، نوارابزار با پسزمینهٔ صفحه بالای ناحیهٔ محتوا میچسبد و سرِ جدولی که در ستونش جا میشود زیر آن
(جدولی که افقی پیمایش میشود جعبهٔ پیمایش خودش را نگه میدارد). هر ردیف یک ارتفاع دارد. اندازهگیریشده در 1440 با
عنوان، یک خط توضیح و نوارابزار: از زیر نوار قاب تا ردیف سرِ جدول 181 پیکسل (پیش از این 205).
کنترلهای خود فهرست
toolbarEnd فقط کنترلهایی را میگیرد که مال خود فهرستاند — ViewToggle وقتی فهرست دو نما دارد،
EntityLayoutToggle برای چیدمان فید (با showLayoutToggle={false} روی EntityCollection)، یا
DataTableColumnVisibilityToggle وقتی محصول انتخاب ستون میخواهد؛ شمارش نه (برچسب محدودهٔ صفحهبندی همان شمارش است) —
و در انتهای نوارابزار، پیش از اقدامها، مینشیند. در صفحهای بی ردیف فیلتر، ردیف کوچک خودش را بالای فهرست میگیرد.
راهنمای استفاده
بکنید
- برای هر صفحهٔ فهرست
ListPageرا برگردانید و فقط جایگاههایش را پر کنید، هر جایگاه همانطور که در جدول بالا آمده. - حالت را با
pageState({ data: data?.items, isLoading, error, onRetry, emptyCopy: { title } })بسازید. - با
searchیاfilters،filtered(شرط فعال بودن فیلتر) وonClearFilters(پاک کردن همه) را بدهید، و در هر تغییر جستوجو یا فیلتر صفحه را به 1 برگردانید. - جدول را بی
sizeو مستقیم فرزند قالب کنید.
نکنید
- خودتان
PageContainer،PageHeader،PageToolbarیاh1نسازید و قالب را درونPageContainerنگذارید؛ قالب همه را دارد (قالبِ درونPageContainerدو بار فاصله میگیرد و در محیط توسعه هشدار میدهد؛ قاعدهٔparto/page-templateاین اجزا را در کد نشان میدهد). - اقدام اصلی را در
secondaryActionsنگذارید، دو اقدام اصلی ننویسید، و دکمهای که هیچ کاری نمیکند اقدام اصلی نکنید. - اقدام ثانوی را
outlineیاghostنکنید؛defaultاست. - اقدامی را که کاربر اجازهاش را ندارد پنهان نکنید (
canCreate && <Button/>) و بیدلیلdisabledنکنید:GatedAction. - نمای صفحه را با کد نشانیِ خودتان در URL نگذارید (
useSearchParams،useFilterParams،FilterProvider،history.replaceState):useViewParams. - اقدام گروهی را در
secondaryActionsنگذارید و نوار انتخاب خودتان را نسازید:selection. - برای فیلترهای نوارابزار چیپ (
activeFilters) نسازید و به کنترلهایشانlabelندهید. - پنل فیلتر را کنار نتیجهها (
placement: 'page') یا دکمهٔ «فیلترها» را در نوار ابزار نگذارید: سایدبار فیلتر. - فهرستی را که آخرین چیز صفحه است صفحهبندی نکنید:
loadMore. - جدول را در
Cardنگذارید، وemptyState،isLoadingیاpaginationخودDataTableرا به کار نبرید؛ ردیف خالی خود جدول هم یعنی حالت باpageStateساخته نشده است (در محیط توسعه برای هر سه هشدار میدهد). - شیء حالت (
{ status: … }) یا شرط سهتایی را دستی ننویسید. className،styleیا عرض عددی به قالب ندهید؛ قالب آنها را نمیپذیرد.
Props
ListPage
ListPageFilterPanel
ListPageSelection
K نوع کلید ردیف است (string، یا number)، همان که getRowKey جدول برمیگرداند: شناسهٔ ردیف، هرگز جایگاهش.
ListPagePagination
ListPageLoadMore
ListPageQuery
ListPageViews
ListPageView
دسترسیپذیری
- عنوان صفحه تنها
h1است و عنوان حالت خطا یا خالیh2، پس ترتیب سرعنوانها ازh1نمیپرد. - ترتیب Tab همان ترتیب خواندن است: سرِ صفحه، جستوجو، فیلترها، اقدامها، فهرست، «نمایش بیشتر» یا صفحهبندی.
- یک ناحیهٔ
statusمؤدب (aria-live="polite") که همیشه در صفحه است «در حال بارگذاری» (بارگذاری اول و تازهشدن ردیفها) و عنوان حالت خالی («نتیجهای یافت نشد») را اعلام میکند؛ حالت خطاrole="alert"خودش را دارد. - وقتی دکمهای که فوکوس داشت با تغییر حالت از صفحه میرود («پاک کردن فیلترها» در حالت خالی، «تلاش مجدد»)، فوکوس به
<body>نمیافتد: اگر با صفحهکلید زده شده بود، فوکوس به جستوجوی نوارابزار (یا اولین فیلتر) میرود — همانجا که دکمهٔ پاککردن خود نوارابزار میبرد. - ناحیهٔ صفحهبندی نام «صفحهبندی» دارد.
- نوار انتخاب گروهی با نام «اقدامهای گروهی» است؛ شمار انتخاب در یک ناحیهٔ
statusمؤدب که از اول در صفحه است اعلام میشود؛ نوارابزارِ زیر نوار تا انتخاب هست پنهان وinertاست؛ وقتی نوار با فوکوس روی خودش میرود («لغو انتخاب»، Escape)، فوکوس به جستوجوی نوارابزار برمیگردد. - اگر در محیط توسعه صفحه
h1دیگری رندر کند، قالب هشدار میدهد. - زبانههای وضعیت پیوندند و زبانهٔ جاری
aria-current="page"دارد. نوار خلاصه در هر دو چیدمان گروهی با نام «خلاصه» و سرعنوان پنهانh2است؛ در حالت کارت، برچسب هر کاشیh3است؛ در حالت inline نام شاخصها درMetricGroupدیده و خوانده میشود. - بارگذاری با اسکرول: شمار آنچه نشان داده شده در یک ناحیهٔ
statusاست، پس هر دسته اعلام میشود؛ دکمهٔ «نمایش بیشتر» همیشه در صفحه است، پس صفحهکلید و صفحهخوان بی اسکرول هم ادامه را دارند. فوکوس هنگام بارگذاری روی دکمه میماند و وقتی آخرین دسته دکمه را برمیدارد، به همان شمار («همهٔ 120 مورد») میرود. - صفحهٔ جستوجومحور: فرم با نقش
searchو نام پرسش؛ «جستوجوی پیشرفته» دکمهای باaria-expandedاست؛viewsفهرست زبانه (tablist) با پیمایش جهتدار (در راستبهچپ، فلش چپ زبانهٔ بعد) و ناحیهٔ نتیجهtabpanelآن است.
کامپوننتهای مرتبط
PageToolbar— نوارابزاری کهListPageباsearchوfiltersمیسازد؛ مستقیم به کارش نبرید.PageState— شیء حالتی کهstateمیگیرد.DataTable— جدول معمول فرزند قالب؛DataTableFacetedFilterفیلتر شمارشی آن است.GatedAction— اقدامی که کاربر اجازهاش را ندارد: غیرفعال، با دلیل.DetailPage— صفحهای که یک ردیف این فهرست به آن باز میشود.- قالب شروع — صفحهٔ «منشنها» با همین قالب، در یک اپ Next.js واقعی.