مجموعهٔ موجودیتها (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).
استفاده
'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) | شبکه | کجا |
|---|---|---|---|
post | card، row، tile | tile | نتایج جستوجو، خبرخوان، پستهای یک حساب |
comment | thread | card | بحث زیر یک پست (بدون دکمهٔ چیدمان)؛ صفحهٔ تحلیل نظرها: layouts={['card', 'row']} |
account | row، card | card | نتایج جستوجوی حساب، حسابهای مرتبط، اینفلوئنسرها |
اولین چیدمان 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 | بارگذاری اول: اسکلت خود موجودیت در چیدمان فعلی (skeletonCount مورد) |
loadingMore | صفحهٔ بعد: موردها میمانند، تا سه اسکلت زیرشان، صفحهبندی سر جایش |
error | ErrorState با عنوان و پیام پیشفرض و «تلاش دوباره» وقتی 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" />
)}در نمونهٔ بالا یکی از سه پست انتخابشده در صفحهٔ دیگری است: نوار هر سه را میشمارد، میگوید یکی بیرون از این فهرست است و اقدام گروهی هر سه شناسه را میگیرد. «انتخاب همه» فقط موردهای همین فهرست را انتخاب یا رها میکند.
راهنمای استفاده
بکنید
entityرا بدهید و جز آن فقط اگر لازم استlayouts؛ فهرست تکستونی را در ستون فید و شبکه را در عرض پهن بگذارید (الگوی «در صفحه»).- حالتها را با
loading،loadingMore،errorوfilteredبدهید و اسکلت یا پیام خالی خودتان را نسازید. - اقدامات گروهی را در
selection.bulkActionsبدهید؛ شناسههایی که میگیرند کل انتخاب است، نه فقط موردهای روی صفحه.
نکنید
- فهرست پست را در تمام عرض صفحه (1500 پیکسل) نکشید؛ کارتها و ردیفها برای ستون فید طراحی شدهاند.
- متن خام خطای شبکه را به کاربر نشان ندهید؛
errorرا بدهید تا پیام درست نمایش داده شود. - ظرف، شبکه یا دکمهٔ تغییر نمای خودتان را دور
Postنسازید، و فهرست نظرها را باmapنسازید. - برای «بیشتر»
loadingرا روشن نکنید: کل فهرست به اسکلت برمیگردد و دکمه از زیر فوکوس کاربر برداشته میشود.
Props
EntityCollection
EntityLayoutToggle
EntityItemProps، EntityBulkAction و EntityCollectionSelection
EntityItemProps | نوع | توضیح |
|---|---|---|
layout | L | چیدمان فعلی مجموعه |
density | 'compact' | 'default' | تراکم |
locale | 'fa' | 'ar' | 'en' | زبان |
tabIndex | number | توقف Tab چرخشی: 0 برای مورد فعلی، 1- برای بقیه |
selectable | boolean | انتخاب فعال است |
selected | boolean | این مورد انتخاب شده است |
onSelectedChange | (selected: boolean) => void | تغییر انتخاب این مورد |
EntityBulkAction | نوع | توضیح |
|---|---|---|
id، label | string | شناسه و برچسب دکمه |
icon | React.ReactNode | آیکون |
onSelect | (ids: string[], optionId?: string) => void | اجرا با کل انتخاب |
options | { id; label; icon? }[] | زیرگزینهها: دکمه منو میشود |
tone، disabled | 'default' | 'destructive'، boolean | اقدام حذفکننده، غیرفعال |
EntityCollectionSelection | نوع | توضیح |
|---|---|---|
selectedIds | readonly string[] | کل انتخاب، از جمله شناسههایی که در items فعلی نیستند |
onSelectedIdsChange | (ids: string[]) => void | تغییر انتخاب |
bulkActions | readonly EntityBulkAction[] | اقدامات نوار گروهی |
bulkBar | boolean (پیشفرض true) | نوار اقدامات گروهی هنگام انتخاب |
selectAll | boolean (پیشفرض 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 از هر جای مجموعه (مورد، چکباکس، نوار گروهی) انتخاب را پاک میکند، جز در فیلد متنی و وقتی منو یا پنجرهای باز است.



