پرتوپرتو

صفحه جدول داده

الگوی ساخت صفحه لیست با جستجو، فیلتر، مرتب‌سازی، و صفحه‌بندی

معرفی

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

این الگو ترکیب کامپوننت‌های زیر را نشان می‌دهد:

  • ساختار صفحه: PageContainer و PageHeader برای چیدمان یکنواخت
  • نوار ابزار: SearchInput و FilterChip برای جستجو و فیلتر
  • جدول: Table با ستون‌های قابل مرتب‌سازی و کامپوننت‌های دامنه‌ای
  • صفحه‌بندی: PaginationControlled برای ناوبری بین صفحات
  • حالت‌های صفحه: بارگذاری، خالی، خطا و ناقص — مدل واحد حالت‌ها

نمونه بصری

نسخه‌ی کارکننده و کپی‌بردار این الگو بلاک فید پایش پست‌ها است — در اندازه‌های مختلف صفحه و هر دو تم قابل بررسی.

رضا کریمی۲۳۴٬۰۰۰۲٫۸٪
سارا احمدی۸۷٬۳۰۰۳٫۱٪
امیر رضایی۴۵٬۸۰۰۳٫۵٪
علی محمدی۱۲٬۵۰۰۴٫۲٪
مریم حسینی۵٬۲۰۰۶٫۷٪

ساختار صفحه

'use client'

import { useState, useMemo, useCallback } from 'react'
import {
  Button,
  SearchInput,
  FilterChip,
  FilterChipGroup,
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
  Table,
  TableHeader,
  TableBody,
  TableHead,
  TableRow,
  TableCell,
  TableSortHeader,
  Avatar,
  AvatarImage,
  AvatarFallback,
  Badge,
  SocialPlatformBadge,
  EngagementRateBar,
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  Empty,
  EmptyIcon,
  EmptyTitle,
  EmptyDescription,
  Skeleton,
  PaginationControlled,
  PageContainer,
  PageHeader,
} from '@partodata/ui'
import { MoreHorizontal, Plus, Users, Search } from 'lucide-react'

export function InfluencerListPage() {
  return (
    <PageContainer size="large">
      <PageHeader
        title="اینفلوئنسرها"
        actions={
          <Button size="sm">
            <Plus className="size-4" />
            افزودن اینفلوئنسر
          </Button>
        }
      />

      {/* نوار ابزار: جستجو + فیلترها */}
      <InfluencerToolbar />

      {/* جدول داده */}
      <InfluencerTable />

      {/* صفحه‌بندی */}
      <div className="flex items-center justify-between pt-4">
        <span className="text-sm text-muted-foreground">۲۴ اینفلوئنسر</span>
        <PaginationControlled currentPage={1} totalPages={3} onPageChange={(page) => console.log(page)} />
      </div>
    </PageContainer>
  )
}

الگوی نوار ابزار

نوار ابزار شامل فیلد جستجو، فیلترهای فعال، و گزینه‌های مرتب‌سازی است. از flex-wrap استفاده کنید تا در صفحات کوچک‌تر به خوبی شکسته شود.

function InfluencerToolbar() {
  const [search, setSearch] = useState('')
  const [activeFilters, setActiveFilters] = useState([
    { id: 'platform', label: 'اینستاگرام', variant: 'default' as const },
    { id: 'engagement', label: 'نرخ تعامل بالا', variant: 'brand' as const },
  ])

  function removeFilter(id: string) {
    setActiveFilters((prev) => prev.filter((f) => f.id !== id))
  }

  return (
    <div className="flex flex-wrap items-center gap-3">
      <SearchInput
        placeholder="جستجوی اینفلوئنسر..."
        className="w-[280px]"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        onClear={() => setSearch('')}
      />
      <FilterChipGroup>
        {activeFilters.map((filter) => (
          <FilterChip
            key={filter.id}
            label={filter.label}
            variant={filter.variant}
            onRemove={() => removeFilter(filter.id)}
          />
        ))}
      </FilterChipGroup>
      <Select defaultValue="newest">
        <SelectTrigger className="w-[160px]" size="sm">
          <SelectValue placeholder="مرتب‌سازی" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="newest">جدیدترین</SelectItem>
          <SelectItem value="followers">بیشترین دنبال‌کننده</SelectItem>
          <SelectItem value="engagement">بیشترین تعامل</SelectItem>
        </SelectContent>
      </Select>
    </div>
  )
}

جدول با کامپوننت‌های دامنه‌ای

هر ردیف جدول از کامپوننت‌های تخصصی طراحی‌شده برای حوزه رصد اجتماعی استفاده می‌کند: Avatar برای تصویر پروفایل، SocialPlatformBadge برای پلتفرم، Badge برای وضعیت، EngagementRateBar برای نرخ تعامل، و DropdownMenu برای اقدامات.

type SortDirection = 'asc' | 'desc' | null
type SortColumn = 'name' | 'followers' | 'engagementRate' | null

interface Influencer {
  id: number
  name: string
  avatar: string
  platform: 'instagram' | 'twitter' | 'tiktok' | 'youtube'
  followers: number
  engagementRate: number
  status: 'active' | 'pending' | 'inactive'
}

const influencers: Influencer[] = [
  {
    id: 1,
    name: 'سارا احمدی',
    avatar: '/avatars/sara.jpg',
    platform: 'instagram',
    followers: 125000,
    engagementRate: 0.042,
    status: 'active',
  },
  {
    id: 2,
    name: 'محمد رضایی',
    avatar: '/avatars/mohammad.jpg',
    platform: 'twitter',
    followers: 89000,
    engagementRate: 0.068,
    status: 'active',
  },
  {
    id: 3,
    name: 'نازنین کریمی',
    avatar: '/avatars/nazanin.jpg',
    platform: 'tiktok',
    followers: 350000,
    engagementRate: 0.021,
    status: 'pending',
  },
  {
    id: 4,
    name: 'علی محمدی',
    avatar: '/avatars/ali.jpg',
    platform: 'youtube',
    followers: 1200000,
    engagementRate: 0.015,
    status: 'inactive',
  },
]

const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
  active: { label: 'فعال', variant: 'default' },
  pending: { label: 'در انتظار', variant: 'secondary' },
  inactive: { label: 'غیرفعال', variant: 'destructive' },
}

function InfluencerTable() {
  const [sortColumn, setSortColumn] = useState<SortColumn>(null)
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)

  function handleSort(column: SortColumn) {
    if (sortColumn === column) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
    } else {
      setSortColumn(column)
      setSortDirection('asc')
    }
  }

  const sorted = useMemo(() => {
    return [...influencers].sort((a, b) => {
      if (!sortColumn || !sortDirection) return 0
      const dir = sortDirection === 'asc' ? 1 : -1
      if (sortColumn === 'name') return a.name.localeCompare(b.name, 'fa') * dir
      return (a[sortColumn] - b[sortColumn]) * dir
    })
  }, [sortColumn, sortDirection])

  const getSortDir = (col: SortColumn) => (sortColumn === col ? (sortDirection ?? 'none') : 'none')

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead sortDirection={getSortDir('name')}>
            <TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
              نام
            </TableSortHeader>
          </TableHead>
          <TableHead>پلتفرم</TableHead>
          <TableHead sortDirection={getSortDir('followers')}>
            <TableSortHeader
              sorted={sortColumn === 'followers' ? (sortDirection ?? false) : false}
              onClick={() => handleSort('followers')}
            >
              دنبال‌کننده‌ها
            </TableSortHeader>
          </TableHead>
          <TableHead sortDirection={getSortDir('engagementRate')}>
            <TableSortHeader
              sorted={sortColumn === 'engagementRate' ? (sortDirection ?? false) : false}
              onClick={() => handleSort('engagementRate')}
            >
              نرخ تعامل
            </TableSortHeader>
          </TableHead>
          <TableHead>وضعیت</TableHead>
          <TableHead className="w-[50px]" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {sorted.map((influencer) => (
          <TableRow key={influencer.id}>
            <TableCell>
              <div className="flex items-center gap-3">
                <Avatar className="size-8">
                  <AvatarImage src={influencer.avatar} alt={influencer.name} />
                  <AvatarFallback>{influencer.name[0]}</AvatarFallback>
                </Avatar>
                <span className="font-medium">{influencer.name}</span>
              </div>
            </TableCell>
            <TableCell>
              <SocialPlatformBadge platform={influencer.platform} size="sm" showLabel />
            </TableCell>
            <TableCell>{influencer.followers.toLocaleString('en-US')}</TableCell>
            <TableCell>
              <EngagementRateBar currentRate={influencer.engagementRate} followers={influencer.followers} locale="fa" />
            </TableCell>
            <TableCell>
              <Badge variant={STATUS_MAP[influencer.status].variant}>{STATUS_MAP[influencer.status].label}</Badge>
            </TableCell>
            <TableCell>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button variant="ghost" size="icon">
                    <MoreHorizontal className="h-4 w-4" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem>مشاهده پروفایل</DropdownMenuItem>
                  <DropdownMenuItem>ویرایش</DropdownMenuItem>
                  <DropdownMenuItem variant="destructive">حذف</DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

حالت خالی

هنگامی که جدول نتیجه‌ای ندارد، حالت خالی را نمایش دهید. از کامپوننت‌های Empty، EmptyIcon، EmptyTitle، و EmptyDescription استفاده کنید.

function InfluencerEmptyState() {
  return (
    <Empty>
      <EmptyIcon>
        <Search className="h-6 w-6" />
      </EmptyIcon>
      <EmptyTitle>اینفلوئنسری یافت نشد</EmptyTitle>
      <EmptyDescription>فیلترهای خود را تغییر دهید یا اینفلوئنسر جدید اضافه کنید.</EmptyDescription>
      <Button className="mt-4" size="sm">
        <Plus className="h-4 w-4" />
        افزودن اینفلوئنسر
      </Button>
    </Empty>
  )
}

ترکیب با جدول به صورت شرطی:

{
  data.length === 0 ? <InfluencerEmptyState /> : <InfluencerTable data={data} />
}

حالت بارگذاری

برای حالت بارگذاری، از کامپوننت Skeleton در سلول‌های جدول استفاده کنید. ساختار جدول را حفظ کنید تا انتقال بین حالت بارگذاری و داده روان باشد.

function InfluencerTableSkeleton() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>نام</TableHead>
          <TableHead>پلتفرم</TableHead>
          <TableHead>دنبال‌کننده‌ها</TableHead>
          <TableHead>نرخ تعامل</TableHead>
          <TableHead>وضعیت</TableHead>
          <TableHead className="w-[50px]" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {Array.from({ length: 5 }).map((_, i) => (
          <TableRow key={i}>
            <TableCell>
              <div className="flex items-center gap-3">
                <Skeleton className="h-8 w-8 rounded-full" />
                <Skeleton className="h-4 w-24" />
              </div>
            </TableCell>
            <TableCell>
              <Skeleton className="h-5 w-20" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-4 w-16" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-4 w-32" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-5 w-14 rounded-full" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-8 w-8" />
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

ترکیب حالت‌ها در صفحه. ترتیب اهمیت دارد، و شاخهٔ خطا نباید جا بیفتد:

{
  isLoading ? (
    <InfluencerTableSkeleton />
  ) : error && !data.length ? (
    <ErrorState message="داده‌ها بارگذاری نشد" onRetry={refetch} />
  ) : data.length === 0 ? (
    <InfluencerEmptyState />
  ) : (
    <InfluencerTable data={data} />
  )
}

شرط error && !data.length عمدی است. اگر داده‌ای روی صفحه هست و فقط درخواست تازه شکست خورده، این حالت ناقص است نه خطا: جدول را نگه دارید و خطا را inline نشان دهید. جایگزین‌کردن کل جدول با ErrorState داده‌ای را که کاربر داشت از او می‌گیرد.


صفحه‌بندی

از PaginationControlled برای صفحه‌بندی سمت سرور استفاده کنید. همیشه تعداد نتایج را نیز نمایش دهید.

function InfluencerPagination({
  currentPage,
  totalPages,
  totalCount,
  onPageChange,
}: {
  currentPage: number
  totalPages: number
  totalCount: number
  onPageChange: (page: number) => void
}) {
  return (
    <div className="flex items-center justify-between pt-4">
      <span className="text-sm text-muted-foreground">{totalCount.toLocaleString('en-US')} اینفلوئنسر</span>
      <PaginationControlled
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={onPageChange}
        showPrevNext
      />
    </div>
  )
}

الگوی مدیریت وضعیت

برای مدیریت وضعیت جستجو، فیلتر، مرتب‌سازی، و صفحه‌بندی به صورت یکپارچه، از یک useState واحد استفاده کنید. این روش ساده‌ترین و خواناترین رویکرد است.

'use client'

import { useState, useEffect, useCallback } from 'react'

interface TableState {
  search: string
  filters: { id: string; label: string; variant: 'default' | 'brand' }[]
  sortColumn: string | null
  sortDirection: 'asc' | 'desc' | null
  page: number
  pageSize: number
}

const initialState: TableState = {
  search: '',
  filters: [],
  sortColumn: null,
  sortDirection: null,
  page: 1,
  pageSize: 10,
}

export function useTableState() {
  const [state, setState] = useState<TableState>(initialState)

  // بازگشت به صفحه اول هنگام تغییر جستجو یا فیلتر
  const setSearch = useCallback((search: string) => {
    setState((prev) => ({ ...prev, search, page: 1 }))
  }, [])

  const addFilter = useCallback((filter: TableState['filters'][number]) => {
    setState((prev) => ({
      ...prev,
      filters: [...prev.filters, filter],
      page: 1,
    }))
  }, [])

  const removeFilter = useCallback((id: string) => {
    setState((prev) => ({
      ...prev,
      filters: prev.filters.filter((f) => f.id !== id),
      page: 1,
    }))
  }, [])

  const setSort = useCallback((column: string) => {
    setState((prev) => {
      if (prev.sortColumn === column) {
        return {
          ...prev,
          sortDirection: prev.sortDirection === 'asc' ? 'desc' : 'asc',
        }
      }
      return { ...prev, sortColumn: column, sortDirection: 'asc' }
    })
  }, [])

  const setPage = useCallback((page: number) => {
    setState((prev) => ({ ...prev, page }))
  }, [])

  return { state, setSearch, addFilter, removeFilter, setSort, setPage }
}

نکته مهم: هنگام تغییر جستجو یا فیلتر، همیشه صفحه را به 1 بازنشانی کنید. در غیر این صورت کاربر ممکن است در صفحه‌ای باشد که دیگر نتیجه‌ای ندارد.


بهترین شیوه‌ها

نمایش تعداد نتایج

همیشه تعداد نتایج را نمایش دهید. اگر نتیجه‌ای وجود ندارد، متن مناسب نشان دهید:

<span className="text-sm text-muted-foreground">
  {totalCount > 0 ? `${totalCount.toLocaleString('en-US')} اینفلوئنسر` : 'نتیجه‌ای یافت نشد'}
</span>

جستجو با تاخیر

از SearchInput با تاخیر (debounce) استفاده کنید تا با هر کلید فشرده‌شده درخواست به سرور ارسال نشود:

const [searchValue, setSearchValue] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')

useEffect(() => {
  const timer = setTimeout(() => {
    setDebouncedSearch(searchValue)
  }, 300)
  return () => clearTimeout(timer)
}, [searchValue])

دسترسی‌پذیری مرتب‌سازی

از TableSortHeader با aria-sort استفاده کنید. TableHead مقدار sortDirection را به صورت خودکار به aria-sort تبدیل می‌کند:

<TableHead sortDirection={sortColumn === 'name' ? sortDirection : 'none'}>
  <TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
    نام
  </TableSortHeader>
</TableHead>

محدود کردن ستون‌ها

تعداد ستون‌های جدول را بین ۵ تا ۷ ستون نگه دارید. اگر اطلاعات بیشتری لازم است، از صفحه جزئیات یا DropdownMenu برای اقدامات استفاده کنید.

فیلترهای فعال

فیلترهای فعال را همیشه به صورت FilterChip نمایش دهید تا کاربر بتواند آن‌ها را ببیند و حذف کند:

<FilterChipGroup>
  {activeFilters.map((filter) => (
    <FilterChip
      key={filter.id}
      label={filter.label}
      variant={filter.variant}
      onRemove={() => removeFilter(filter.id)}
    />
  ))}
</FilterChipGroup>

اقدام اصلی در هدر صفحه

دکمه اقدام اصلی (مانند «افزودن اینفلوئنسر») را در PageHeaderAside قرار دهید تا همیشه در دسترس باشد و با اسکرول صفحه حرکت نکند.

صفحه‌بندی سمت سرور

از PaginationControlled برای صفحه‌بندی سمت سرور استفاده کنید. این کامپوننت وضعیت خود را مدیریت نمی‌کند و صفحه فعلی را از طریق currentPage دریافت می‌کند:

<PaginationControlled
  currentPage={page}
  totalPages={Math.ceil(totalCount / pageSize)}
  onPageChange={setPage}
  showPrevNext
/>

مدیریت همه حالت‌ها

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


دام‌های رایج

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

مرتب‌سازی سمت کلاینت روی دادهٔ صفحه‌بندی‌شدهٔ سرور

اشتباه: صفحه‌بندی سمت سرور است، اما مرتب‌سازی با useMemo فقط روی ردیف‌های بارگذاری‌شدهٔ صفحهٔ فعلی اجرا می‌شود.

// ❌ غلط — داده از سرور صفحه‌بندی شده، اما فقط ۱۰ ردیف صفحهٔ فعلی مرتب می‌شود
const sorted = useMemo(() => {
  const dir = sortDirection === 'asc' ? 1 : -1
  return [...pageRows].sort((a, b) => (a.followers - b.followers) * dir)
}, [pageRows, sortDirection])

// ✅ درست — وضعیت مرتب‌سازی به سرور ارسال می‌شود و سرور صفحهٔ مرتب‌شده را برمی‌گرداند
useEffect(() => {
  fetchInfluencers({
    page: state.page,
    sortColumn: state.sortColumn,
    sortDirection: state.sortDirection,
    search: debouncedSearch,
  })
}, [state.page, state.sortColumn, state.sortDirection, debouncedSearch])

چرا دردسرساز است: «بیشترین دنبال‌کننده» به بزرگ‌ترین مقدارِ همان صفحه تبدیل می‌شود، نه کل مجموعه داده؛ ترتیب با هر تغییر صفحه عوض می‌شود و کاربر به نتایج گمراه‌کننده می‌رسد. مرتب‌سازی سمت کلاینت (مانند نمونهٔ ابتدای همین صفحه) فقط زمانی درست است که کل داده در کلاینت باشد. کامپوننت آمادهٔ DataTable سیستم طراحی نیز به همین دلیل سرور-محور طراحی شده و totalCount را از سرور دریافت می‌کند — از روی داده‌های یک صفحه نمی‌توان آن را استنتاج کرد.

خواص فیزیکی CSS در ستون‌های جدول

اشتباه: تراز کردن ستون عددی با کلاس‌های فیزیکی مانند text-left یا فاصله‌گذاری با pl-*، معمولاً با کپی‌کردن نمونه‌های LTR.

// ❌ غلط — در پیش‌نمایش RTL درست به نظر می‌رسد اما جهت‌محور است
<TableCell className="text-left pl-6">{influencer.followers.toLocaleString('en-US')}</TableCell>

// ✅ درست — Logical Properties در هر دو جهت درست کار می‌کند
<TableCell className="text-end pe-6">{influencer.followers.toLocaleString('en-US')}</TableCell>

چرا دردسرساز است: این دام پنهان است چون در صفحهٔ RTL ظاهر هر دو یکسان است و از بازبینی بصری رد می‌شود. اما سرستون‌های TableHead با text-start (منطقی) تراز می‌شوند؛ به محض این‌که همان جدول در بستری LTR رندر شود، سلول‌های فیزیکی خلاف جهت سرستون می‌روند و ستون دوپاره می‌شود. سیستم طراحی RTL-first است و همه‌جا از Logical Properties استفاده می‌کند: ms به‌جای ml، pe به‌جای pr، text-start/text-end به‌جای text-left/text-right.

رنگ وضعیت hardcode در سلول‌ها

اشتباه: نمایش وضعیت ردیف با رنگ مستقیم Tailwind یا مقدار hex به‌جای واریانت‌های Badge یا توکن‌های معنایی.

// ❌ غلط — سبزِ hardcode با تم پیش‌فرض تیره هماهنگ نیست
<TableCell>
  <span className="text-green-600">فعال</span>
</TableCell>

// ✅ درست — واریانت Badge از توکن‌های تم رنگ می‌گیرد
<TableCell>
  <Badge variant={STATUS_MAP[influencer.status].variant}>{STATUS_MAP[influencer.status].label}</Badge>
</TableCell>

چرا دردسرساز است: سیستم طراحی dark-first است — تم پایهٔ :root تیره است و رنگی که روی پس‌زمینهٔ روشن انتخاب شده، در تم تیره کنتراست کافی ندارد؛ با تغییر تم به روشن هم به‌روزرسانی نمی‌شود. توکن‌های معنایی (text-destructive، bg-brand/10، واریانت‌های Badge) در هر دو تم مقدار درست را می‌گیرند. قانون no-hardcoded-colors در ESLint خود سیستم طراحی نیز همین را اجبار می‌کند — در کد مصرف‌کننده هم آن را رعایت کنید.

تبدیل ارقام در جاوااسکریپت

اشتباه: «فارسی‌کردن» اعداد ستون‌ها در کد، یا رها کردن مقدار خام بدون جداکننده.

import { formatNumber } from '@partodata/ui'

// ❌ غلط — کدپوینت واقعی U+06Fx می‌سازد، و بدون جداکننده هم خوانا نیست
<TableCell>{influencer.followers.toLocaleString('fa-IR')}</TableCell>
<TableCell>{influencer.followers}</TableCell>

// ✅ درست — کدپوینت لاتین با جداکنندهٔ هزار؛ فونت آن را «۱,۲۳۴,۵۶۷» نشان می‌دهد
<TableCell>{formatNumber(influencer.followers)}</TableCell>

چرا دردسرساز است: ارقام فارسی از ویژگی ss01 فونت می‌آیند، نه از جاوااسکریپت. تبدیل در کد همان ظاهر را می‌دهد ولی چهار چیز را می‌شکند: کاربر با تایپ 1234 در Ctrl+F سلول را پیدا نمی‌کند، کپی به Excel عدد نیست، رفتار صفحه‌خوان‌ها روی U+06Fx یکدست نیست، و مرتب‌سازی رشته‌ای «۱۰» را قبل از «۹» می‌گذارد. جزئیات و اثبات در فارسی‌محور بودن.

لحن محاوره‌ای در متن‌های جدول

اشتباه: نوشتن پیام حالت خالی یا برچسب‌های صفحه با فارسی محاوره‌ای.

// ❌ غلط — لحن محاوره‌ای
<EmptyDescription>چیزی پیدا نشد! فیلترها رو عوض کن.</EmptyDescription>

// ✅ درست — فارسی رسمی
<EmptyDescription>نتیجه‌ای یافت نشد. فیلترهای خود را تغییر دهید یا اینفلوئنسر جدید اضافه کنید.</EmptyDescription>

چرا دردسرساز است: لحن سیستم طراحی در همهٔ محصولات پرتو فارسی رسمی است («تغییر دهید» نه «عوض کن»). حالت خالی معمولاً آخرین متنی است که نوشته می‌شود و اولین جایی است که لحن محاوره‌ای به آن نشت می‌کند؛ ناهماهنگی لحن در یک صفحه، اعتماد کاربر سازمانی را کم می‌کند. راهنمای کامل در صفحهٔ محتوا و لحن آمده است.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • جدول داده (DataTable) — اگر نمی‌خواهید جدول را از اجزای پایه سرهم کنید، این نسخهٔ آماده و سرور-محورِ همین الگوست: صفحه‌بندی، مرتب‌سازی، انتخاب ردیف، و حالت‌های خالی و بارگذاری داخلی.
  • جدول با مرتب‌سازی — اگر فقط جدول قابل مرتب‌سازی لازم دارید نه کل صفحهٔ لیست، جزئیات کامل الگوی aria-sort در این صفحه است.
  • حالت‌های خالی — برای طراحی متن و اقدام حالت صفر نتیجه، فراتر از نمونهٔ کوتاه این صفحه.
  • الگوهای بارگذاری — برای انتخاب بین Skeleton و Spinner و بارگذاری تدریجی در جدول‌های سنگین.

دیزاین‌سیستم دو خانوادهٔ سربرگ دارد

PageHeader تخت و prop-محور است: title اجباری است، و actions/description/ breadcrumbs/onBack اسلات‌های آن‌اند — نه فرزند، و size هم ندارد. PageHeaderTitle/PageHeaderAside به خانوادهٔ دیگر تعلق دارند (PageHeaderRootPageHeaderMetaPageHeaderSummary). قاطی‌کردن این دو، خطای «title اجباری است» می‌دهد — همان چیزی که صفحهٔ چیدمان درباره‌اش هشدار می‌دهد.