قالب صفحهٔ فهرست (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
summaryLayoutcards پیش‌فرض؛ 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 یا نواری از خود صفحه
statepageState({ 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 در عرض widthtable
feedیک ستون از موجودیت‌ها (پست، نظر، گزارش پخش) در اندازهٔ خواندن --layout-content-feed (680)؛ با aside ستون کناری از 64rem محتوا، زیرش در صفحهٔ باریک؛ بی آن کل صفحه به همان عرضlist
gridکارت‌ها (پروفایل، کاشی رسانه) در عرض پهن (1600)، در ستون‌هایی با عرض کمینهٔ --layout-tile-min-width که قالب می‌چیندcards
width (فقط table)عرض محتواکی
default1200جدول تا 8 ستون (مثل جدول منشن‌ها) (پیش‌فرض)
wide1600فقط جدول بیش از 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

Prop

Type

ListPageFilterPanel

Prop

Type

ListPageSelection

K نوع کلید ردیف است (string، یا number)، همان که getRowKey جدول برمی‌گرداند: شناسهٔ ردیف، هرگز جایگاهش.

Prop

Type

ListPagePagination

Prop

Type

ListPageLoadMore

Prop

Type

ListPageQuery

Prop

Type

ListPageViews

Prop

Type

ListPageView

Prop

Type

دسترسی‌پذیری

  • عنوان صفحه تنها 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 واقعی.