مجموعهٔ موجودیت‌ها (EntityCollection)

فهرست یا شبکهٔ پست، نظر و حساب با ستون فید، تغییر چیدمان، اسکلت شکل‌دار، حالت خالی و خطا، صفحه‌بندی، انتخاب و کیبورد

معرفی

EntityCollection فهرست یا شبکهٔ موجودیت‌های اجتماعی است: پست‌ها، نظرها یا حساب‌ها. هر محصول تا امروز این بخش را خودش ساخته بود (ظرف، دکمهٔ تغییر نما، اسکلت، حالت خالی، خطا، انتخاب گروهی) و هر نسخه اندازه و فاصلهٔ دیگری داشت. این کامپوننت همهٔ این‌ها را یک بار دارد و خود موردها را به کامپوننت هر موجودیت می‌سپارد:

<EntityCollection
  entity="post"
  items={posts}
  getId={(post) => post.id}
  renderItem={(post, item) => <Post post={post} {...item} />}
/>
  • entity تنها انتخاب است: post، comment، account، job یا concept (کارت مسئله با ConceptCard؛ شبکه، یک توقف Tab، فلش‌ها، j/k، Home/End و Enter رایگان). چیدمان‌های پیش‌فرض، این‌که کدام چیدمان شبکه است و اسکلت بارگذاری از همین می‌آیند، و چیدمانی که آن موجودیت ندارد (پستِ thread) کامپایل نمی‌شود.
  • پست پیش‌فرض کارت است: مجموعهٔ پست بدون layouts و defaultLayout با card باز می‌شود؛ row و tile نماهای انتخابی‌اند که خواننده با دکمهٔ چیدمان یا محصول با layouts یا defaultLayout صریح انتخاب می‌کند.
  • عرض از محتوا می‌آید: چیدمان تک‌ستونی (ردیف، کارت پست، رشتهٔ نظر) در عرض ستون فید (680 پیکسل) می‌ماند و هرگز به عرض صفحه کشیده نمی‌شود؛ شبکه (کاشی پست، کارت نظر و حساب) عرض پهن را با ستون‌های خودکار حداقل 280 پیکسلی پر می‌کند.
  • ظرف خودش را دارد: مجموعه و هر ردیف @container خودشان را دارند، پس ردیف فشرده در هر ستونی درست است: در پنل کناری، در صفحهٔ باریک، در ستون فید.
  • اسکلت شکل‌دار: در حال بارگذاری، خود موجودیت با state="loading" در همان چیدمان رسم می‌شود و یک بار اعلام می‌شود.
  • حالت‌ها: خالی و «نتیجه‌ای یافت نشد» با Empty، خطا با ErrorState و دکمهٔ «تلاش دوباره» (هرگز متن خام خطا).
  • انتخاب: چک‌باکس روی هر مورد، «انتخاب همه» و نوار اقدامات گروهی که جای سربرگ را می‌گیرد. انتخاب همان selectedIds است: موردی که در صفحهٔ دیگری است یا جست‌وجو پنهانش کرده انتخاب‌شده می‌ماند، شمرده می‌شود و به اقدام گروهی می‌رسد.
  • کیبورد: موردها یک توقف Tab مشترک دارند (کنترل‌های داخل هر مورد با Tab در دسترس می‌مانند)؛ کلیدهای جهت، j و k، Home و End بین موردها حرکت می‌کنند و Ctrl+End و Ctrl+Home از فهرست بیرون می‌روند؛ Enter مورد را باز می‌کند و Space انتخابش را عوض می‌کند؛ Escape از هر جای مجموعه (جز فیلد متنی) انتخاب را پاک می‌کند.

چه زمانی استفاده کنیم:

  • هر فهرست پست (نتایج جست‌وجو، خبرخوان، پست‌های یک حساب)، هر فهرست نظر و هر فهرست یا شبکهٔ حساب.

چه زمانی استفاده نکنیم:

  • برای جدول داده با ستون و مرتب‌سازی: از DataTable استفاده کنید.
  • برای یک پست تنها (صفحهٔ جزئیات): Post با layout="details"؛ نظرهای زیر آن باز یک EntityCollection با entity="comment" است (الگوی «جزئیات پست همراه با نظرها» در صفحهٔ Post).
6 نتیجه
فروشگاه نمونه
@sample_shop
جشنوارهٔ تخفیف فصلی از امروز شروع شد و تا پایان هفته ادامه دارد. برای دیدن فهرست محصولات و شرایط ارسال به صفحهٔ فروشگاه سر بزنید. #تخفیف #پاییز
  • لایک12,400
  • نظر0
  • نرخ تعامل2.1٪
کافه نمونه
@cafe_example2
بسته‌بندی تازهٔ محصول را در نمایشگاه دیدید؟ نظرتان را برای ما بنویسید؛ همهٔ پیام‌ها را می‌خوانیم و در طراحی نسخهٔ بعد به کار می‌بریم. @sample_shop
ویدئو، 0:08
  • لایک2,400
  • نظر118
  • پخش ویدیو54,000
  • نرخ تعامل3.4٪
news.example.com

رونمایی محصول جدید در نمایشگاه با استقبال مشتری‌ها همراه شد

سارا محمدی
بازدیدکننده‌ها دربارهٔ بسته‌بندی، قیمت و پشتیبانی پس از فروش نظر دادند و بیشترین گفت‌وگو به همکاری برند با اینفلوئنسرها مربوط بود.
اقتصادی
Brand Studio
@brand.studio
رونمایی محصول جدید با حضور مشتری‌ها برگزار شد. گزارش کامل و عکس‌های مراسم: https://example.com/launch#photos
  • لایک12,400
  • نظر86
  • نرخ تعامل2.1٪
شبکهٔ نمونه
گزارش اقتصادی2:05–2:50
… در ادامهٔ برنامه دربارهٔ کمپین تخفیف فصلی و بازخورد بسته‌بندی گفت‌وگو شد و کارشناس برنامه گفت که …
رسانه منقضی شده است0:45
گفتاراطمینان 86٪گوینده: کارشناس برنامه
فروشگاه نمونه
@sample_shop
جشنوارهٔ تخفیف فصلی از امروز شروع شد و تا پایان هفته ادامه دارد. برای دیدن فهرست محصولات و شرایط ارسال به صفحهٔ فروشگاه سر بزنید. #تخفیف #پاییز
  • لایک12,400
  • نظر86
  • نرخ تعامل2.1٪

استفاده

'use client'

import { useRouter } from 'next/navigation'
import { EntityCollection, Post, type SocialPost } from '@partodata/ui/social'

interface ResultsProps {
  posts: SocialPost[]
  loading: boolean
  error: unknown
  onRetry: () => void
}

export function SearchResults({ posts, loading, error, onRetry }: ResultsProps) {
  const router = useRouter()
  return (
    <EntityCollection
      entity="post"
      items={posts}
      getId={(post) => post.id}
      loading={loading}
      error={error}
      onRetry={onRetry}
      label="نتایج جست‌وجو"
      renderItem={(post, item) => <Post post={post} {...item} onOpen={(p) => router.push(`/posts/${p.id}`)} />}
    />
  )
}

item همان ویژگی‌هایی است که مجموعه به هر مورد می‌دهد: چیدمان و تراکم، انتخاب و توقف Tab. آن را روی موجودیت پخش کنید و ویژگی‌های محصول (onOpen، actions، metrics) را کنارش بگذارید. چند مورد اول رسانه‌شان را زودتر بارگذاری می‌کنند (priorityCount). در فهرست بلند renderItem را با React.useCallback پایدار نگه دارید: هر مورد فقط وقتی دوباره رسم می‌شود که ویژگی‌های خودش یا renderItem عوض شود.

هر موجودیت

entityچیدمان‌های پیش‌فرض (layouts)شبکهکجا
postcard، row، tiletileنتایج جست‌وجو، خبرخوان، پست‌های یک حساب
commentthreadcardبحث زیر یک پست (بدون دکمهٔ چیدمان)؛ صفحهٔ تحلیل نظرها: layouts={['card', 'row']}
accountrow، cardcardنتایج جست‌وجوی حساب، حساب‌های مرتبط، اینفلوئنسرها

اولین چیدمان layouts چیدمان آغازین است: پست با کارت باز می‌شود و ردیف و کاشی فقط با انتخاب خواننده یا صریح در کد می‌آیند. layouts را فقط وقتی بدهید که مجموعهٔ دیگری از چیدمان‌ها می‌خواهید (مثلاً فقط ['card']، بدون دکمهٔ چیدمان)؛ چیدمان شبکه را خود entity تعیین می‌کند.

در صفحه

قاعده: وقتی فهرست محتوای اصلی صفحه است (نتایج جست‌وجو، خبرخوان)، صفحه یک ListPage است و مجموعه فرزند آن: دکمهٔ چیدمان EntityLayoutToggle در toolbarEnd قالب است (و showLayoutToggle={false} روی مجموعه)، عرض صفحه از چیدمان می‌آید (default برای کارت و ردیف در ستون فید 680 پیکسلی با ستون کناری، wide برای شبکهٔ کاشی)، و حالت فهرست (state با pageState)، صفحه‌بندی (pagination) و انتخاب با اقدامات گروهی (selection) مال قالب است: مجموعه bulkBar: false می‌گیرد و bulkActions ندارد. وقتی فهرست بخشی از صفحه است (یک زبانه، یک پنل، زیر یک پست)، دکمهٔ خود مجموعه در سربرگش می‌ماند، عرض همان بخش است و مجموعه حالت‌های بارگذاری، خطا و خالی خودش را می‌کشد.

'use client'

import * as React from 'react'
import { SearchInput } from '@partodata/ui'
import { ListPage, pageState } from '@partodata/ui/templates'
import { EntityCollection, EntityLayoutToggle, Post, type SocialPost } from '@partodata/ui/social'

const LAYOUTS = ['card', 'row', 'tile'] as const
type Layout = (typeof LAYOUTS)[number]

interface SearchResultsPageProps {
  posts: SocialPost[] | undefined
  isLoading: boolean
  error: Error | null
  onRetry: () => void
  onOpen: (post: SocialPost) => void
  query: string
  onQueryChange: (query: string) => void
  pagination: {
    currentPage: number
    totalPages: number
    onPageChange: (page: number) => void
    totalRows: number
    pageSize: number
  }
  summary: React.ReactNode
}

export function SearchResultsPage({
  posts,
  isLoading,
  error,
  onRetry,
  onOpen,
  query,
  onQueryChange,
  pagination,
  summary,
}: SearchResultsPageProps) {
  const [layout, setLayout] = React.useState<Layout>('card')
  const grid = layout === 'tile'
  return (
    <ListPage
      title="نتایج جست‌وجو"
      // Width by content: the feed with its aside (1200px); the tiles take the wide page.
      {...(grid ? { content: 'grid' as const } : { content: 'feed' as const, aside: summary })}
      search={
        <SearchInput
          aria-label="جست‌وجو در پست‌ها"
          value={query}
          onChange={(event) => onQueryChange(event.target.value)}
        />
      }
      filtered={query !== ''}
      onClearFilters={() => onQueryChange('')}
      toolbarEnd={<EntityLayoutToggle layouts={LAYOUTS} value={layout} onValueChange={setLayout} />}
      skeleton="list"
      state={pageState({ data: posts, isLoading, error, onRetry, emptyCopy: { title: 'هنوز پستی ثبت نشده است' } })}
      pagination={pagination}
    >
      {grid ? (
        (posts ?? []).map((post) => <Post key={post.id} post={post} layout="tile" onOpen={onOpen} />)
      ) : (
        <EntityCollection
          entity="post"
          items={posts ?? []}
          getId={(post) => post.id}
          layouts={LAYOUTS}
          layout={layout}
          showLayoutToggle={false}
          label="نتایج جست‌وجو"
          renderItem={(post, item) => <Post post={post} {...item} onOpen={onOpen} />}
        />
      )}
    </ListPage>
  )
}

بلوک جست‌وجوی پست همین صفحه است، با جست‌وجوی ساده، پیشرفته و مفهومی، فیلترهای نتیجه، نمای کارت، ردیف و کاشی و جزئیات پست در یک Sheet.

بلوک آماده: جست‌وجوی پست

کد و نمای کامل

مجموعهٔ تک‌ستونی خودش هم هیچ‌وقت از 680 پیکسل پهن‌تر نمی‌شود؛ در یک صفحه ستون فید و ستون کناری را خود قالب می‌دهد (ListPage content="feed" با aside)، و نمای کاشی content="grid" با خود کاشی‌ها به‌عنوان فرزند است.

اگر صفحه سربرگ چسبان دارد، بلندی آن را به مجموعه بدهید تا نوار اقدامات گروهی و سربرگ بخش‌ها زیر آن بچسبند: style={{ '--collection-sticky-top': '3rem' } as React.CSSProperties}.

بیشتر (بارگذاری صفحهٔ بعد)

loading فقط برای بارگذاری اول است: موردها را با اسکلت عوض می‌کند و جایگاه صفحه‌بندی را برمی‌دارد. برای «بیشتر» loadingMore را بدهید: موردها می‌مانند، چند اسکلت زیرشان می‌آید، دکمهٔ «بیشتر» سر جایش می‌ماند و پایان بارگذاری اعلام می‌شود. دکمه را در این مدت disabled نکنید (دکمهٔ غیرفعال فوکوس کیبورد را از دست می‌دهد)؛ aria-busy و aria-disabled بدهید.

'use client'

import { Button } from '@partodata/ui/button'
import { EntityCollection, Post, type SocialPost } from '@partodata/ui/social'

interface FeedProps {
  posts: SocialPost[]
  loading: boolean
  loadingMore: boolean
  hasMore: boolean
  onMore: () => void
}

export function Feed({ posts, loading, loadingMore, hasMore, onMore }: FeedProps) {
  return (
    <EntityCollection
      entity="post"
      items={posts}
      getId={(post) => post.id}
      loading={loading}
      loadingMore={loadingMore}
      label="خبرخوان"
      pagination={
        hasMore ? (
          <Button
            variant="ghost"
            size="sm"
            aria-busy={loadingMore}
            aria-disabled={loadingMore}
            onClick={() => {
              if (!loadingMore) onMore()
            }}
          >
            {loadingMore ? 'در حال بارگذاری' : 'بیشتر'}
          </Button>
        ) : undefined
      }
      renderItem={(post, item) => <Post post={post} {...item} />}
    />
  )
}

حالت‌ها و انواع

loading · card (default)
در حال بارگذاری
loading · row (opt-in)
در حال بارگذاری
empty · filtered

نتیجه‌ای یافت نشد

عبارت جست‌وجو یا فیلترها را تغییر دهید.

error · retry
ورودینمایش
loadingبارگذاری اول: اسکلت خود موجودیت در چیدمان فعلی (skeletonCount مورد)
loadingMoreصفحهٔ بعد: موردها می‌مانند، تا سه اسکلت زیرشان، صفحه‌بندی سر جایش
errorErrorState با عنوان و پیام پیش‌فرض و «تلاش دوباره» وقتی onRetry هست
بدون موردEmpty با «موردی برای نمایش وجود ندارد»
بدون مورد و filtered«نتیجه‌ای یافت نشد» با پیشنهاد تغییر فیلتر
empty / errorStateجای حالت پیش‌فرض را می‌گیرند
paginationزیر موردها، فقط وقتی موردها آماده‌اند
groupByبخش‌ها با سربرگ چسبان، نام و تعداد
selectionچک‌باکس هر مورد، «انتخاب همه» و نوار اقدامات گروهی؛ همهٔ selectedIds

اسکلت پیش‌فرض کاشی مربع است. اگر کاشی‌ها را با tileRatio="4:5" رسم می‌کنید، اسکلت را هم 4:5 کنید تا صفحه هنگام رسیدن داده جابه‌جا نشود:

renderSkeleton={(item) => (
  <Post state="loading" layout={item.layout} density={item.density} locale={item.locale} tileRatio="4:5" />
)}

شبکه‌های اجتماعی

4
فروشگاه نمونه
@sample_shop
جشنوارهٔ تخفیف فصلی از امروز شروع شد و تا پایان هفته ادامه دارد. برای دیدن فهرست محصولات و شرایط ارسال به صفحهٔ فروشگاه سر بزنید. #تخفیف #پاییز
  • لایک12,400
  • نظر0
  • نرخ تعامل2.1٪
کافه نمونه
@cafe_example2
بسته‌بندی تازهٔ محصول را در نمایشگاه دیدید؟ نظرتان را برای ما بنویسید؛ همهٔ پیام‌ها را می‌خوانیم و در طراحی نسخهٔ بعد به کار می‌بریم. @sample_shop
ویدئو، 0:08
  • لایک2,400
  • نظر118
  • پخش ویدیو54,000
  • نرخ تعامل3.4٪
Brand Studio
@brand.studio
رونمایی محصول جدید با حضور مشتری‌ها برگزار شد. گزارش کامل و عکس‌های مراسم: https://example.com/launch#photos
  • لایک12,400
  • نظر86
  • نرخ تعامل2.1٪
فروشگاه نمونه
@sample_shop
جشنوارهٔ تخفیف فصلی از امروز شروع شد و تا پایان هفته ادامه دارد. برای دیدن فهرست محصولات و شرایط ارسال به صفحهٔ فروشگاه سر بزنید. #تخفیف #پاییز
  • لایک12,400
  • نظر86
  • نرخ تعامل2.1٪

وب

1
news.example.com

رونمایی محصول جدید در نمایشگاه با استقبال مشتری‌ها همراه شد

سارا محمدی
بازدیدکننده‌ها دربارهٔ بسته‌بندی، قیمت و پشتیبانی پس از فروش نظر دادند و بیشترین گفت‌وگو به همکاری برند با اینفلوئنسرها مربوط بود.
اقتصادی

پخش

1
شبکهٔ نمونه
گزارش اقتصادی2:05–2:50
… در ادامهٔ برنامه دربارهٔ کمپین تخفیف فصلی و بازخورد بسته‌بندی گفت‌وگو شد و کارشناس برنامه گفت که …
رسانه منقضی شده است0:45
گفتاراطمینان 86٪گوینده: کارشناس برنامه

در نمونهٔ بالا یکی از سه پست انتخاب‌شده در صفحهٔ دیگری است: نوار هر سه را می‌شمارد، می‌گوید یکی بیرون از این فهرست است و اقدام گروهی هر سه شناسه را می‌گیرد. «انتخاب همه» فقط موردهای همین فهرست را انتخاب یا رها می‌کند.

راهنمای استفاده

بکنید

  • entity را بدهید و جز آن فقط اگر لازم است layouts؛ فهرست تک‌ستونی را در ستون فید و شبکه را در عرض پهن بگذارید (الگوی «در صفحه»).
  • حالت‌ها را با loading، loadingMore، error و filtered بدهید و اسکلت یا پیام خالی خودتان را نسازید.
  • اقدامات گروهی را در selection.bulkActions بدهید؛ شناسه‌هایی که می‌گیرند کل انتخاب است، نه فقط موردهای روی صفحه.

نکنید

  • فهرست پست را در تمام عرض صفحه (1500 پیکسل) نکشید؛ کارت‌ها و ردیف‌ها برای ستون فید طراحی شده‌اند.
  • متن خام خطای شبکه را به کاربر نشان ندهید؛ error را بدهید تا پیام درست نمایش داده شود.
  • ظرف، شبکه یا دکمهٔ تغییر نمای خودتان را دور Post نسازید، و فهرست نظرها را با map نسازید.
  • برای «بیشتر» loading را روشن نکنید: کل فهرست به اسکلت برمی‌گردد و دکمه از زیر فوکوس کاربر برداشته می‌شود.

Props

EntityCollection

Prop

Type

EntityLayoutToggle

Prop

Type

EntityItemProps، EntityBulkAction و EntityCollectionSelection

EntityItemPropsنوعتوضیح
layoutLچیدمان فعلی مجموعه
density'compact' | 'default'تراکم
locale'fa' | 'ar' | 'en'زبان
tabIndexnumberتوقف Tab چرخشی: 0 برای مورد فعلی، 1- برای بقیه
selectablebooleanانتخاب فعال است
selectedbooleanاین مورد انتخاب شده است
onSelectedChange(selected: boolean) => voidتغییر انتخاب این مورد
EntityBulkActionنوعتوضیح
id، labelstringشناسه و برچسب دکمه
iconReact.ReactNodeآیکون
onSelect(ids: string[], optionId?: string) => voidاجرا با کل انتخاب
options{ id; label; icon? }[]زیرگزینه‌ها: دکمه منو می‌شود
tone، disabled'default' | 'destructive'، booleanاقدام حذف‌کننده، غیرفعال
EntityCollectionSelectionنوعتوضیح
selectedIdsreadonly string[]کل انتخاب، از جمله شناسه‌هایی که در items فعلی نیستند
onSelectedIdsChange(ids: string[]) => voidتغییر انتخاب
bulkActionsreadonly EntityBulkAction[]اقدامات نوار گروهی
bulkBarboolean (پیش‌فرض true)نوار اقدامات گروهی هنگام انتخاب
selectAllboolean (پیش‌فرض true)چک‌باکس «انتخاب همه» در سربرگ و نوار؛ موردهای همین فهرست را انتخاب یا رها می‌کند

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

  • موردها در یک list با نام (label) هستند و هر مورد یک listitem؛ با groupBy بخش‌ها داخل یک group با همان نام‌اند و هر بخش یک region با عنوانش. عنوان بخش جزیرهٔ جهت خودش است (@shop_1 و #tag وارونه نمی‌شوند).
  • موردها یک توقف Tab مشترک دارند (توقف چرخشی): Tab یک بار وارد فهرست می‌شود و روی مورد فعلی می‌ایستد؛ کنترل‌های داخل هر مورد (چک‌باکس، پیوندها، اقدامات) هم در ترتیب Tab هستند. کلیدهای جهت (در شبکه بالا و پایین ردیف به ردیف، چپ و راست مطابق جهت خواندن)، j و k (در صفحه‌کلید فارسی همان کلیدها)، Home و End فوکوس را بین موردها جابه‌جا می‌کنند و Ctrl+End و Ctrl+Home به اولین کنترل بعد از فهرست و آخرین کنترل پیش از آن می‌روند. کلیدی که روی یک کنترل داخل مورد زده شود مال همان کنترل است، و میان‌برهای با Ctrl، Alt، Cmd یا Shift مال صفحه و مرورگر می‌مانند.
  • یک ناحیهٔ status همیشه در مجموعه هست و تغییرها را اعلام می‌کند: «n مورد انتخاب شد»، «انتخاب لغو شد»، آغاز و پایان بارگذاری. اسکلت‌ها aria-hidden هستند.
  • نوار اقدامات گروهی یک toolbar با نام است. وقتی با «لغو انتخاب» یا یک اقدام گروهی بسته می‌شود، فوکوس به «انتخاب همه» برمی‌گردد و اگر مورد فوکوس‌دار حذف شود، به موردی که جای آن آمده؛ فوکوس هرگز به ابتدای صفحه نمی‌پرد.
  • Escape از هر جای مجموعه (مورد، چک‌باکس، نوار گروهی) انتخاب را پاک می‌کند، جز در فیلد متنی و وقتی منو یا پنجره‌ای باز است.

کامپوننت‌های مرتبط

  • Post، Comment و Account — موردهای مجموعه.
  • DataTable — وقتی دادهٔ جدولی با ستون و مرتب‌سازی دارید، نه فهرست محتوا. مجموعهٔ نظرها را به کار می‌برد.