پرتوپرتو

جدول با مرتب‌سازی

الگوی جدول داده‌ای با ستون‌های قابل مرتب‌سازی و نشانگر aria-sort

معرفی

در صفحه‌های داده‌محور — لیست اینفلوئنسرها، نتایج پایش یک کمپین تخفیف فصلی، بازخوردهای بسته‌بندی محصول — کاربر باید بتواند ردیف‌ها را بر اساس معیار دلخواه خود بازچینی کند: بیشترین دنبال‌کننده، بالاترین نرخ تعامل، ترتیب الفبایی نام. مرتب‌سازی ستونی این نیاز را بدون خروج از جدول برطرف می‌کند، به شرط آن‌که هم برای کاربر بینا (آیکون جهت) و هم برای کاربر صفحه‌خوان (aria-sort) وضعیت مرتب‌سازی روشن باشد.

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

  • از TableSortHeader برای دکمه‌های مرتب‌سازی استفاده می‌کند
  • aria-sort را برای دسترسی‌پذیری روی ستون‌ها تنظیم می‌کند
  • وضعیت مرتب‌سازی را در state مدیریت می‌کند

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

  • کل داده در کلاینت است و مرتب‌سازی باید بدون رفت‌وبرگشت به سرور انجام شود
  • کنترل کامل روی ساختار جدول لازم دارید و کامپوننت آمادهٔ DataTable بیش از نیازتان است
  • جدول بخشی از یک ترکیب سفارشی است (مثلاً داخل کارت گزارش) و باید از اجزای پایه سرهم شود

چه زمانی سراغ جایگزین‌ها برویم:

  • داده صفحه‌بندی سمت سرور دارد یا به انتخاب ردیف، پنهان‌سازی ستون، و مرتب‌سازی چندستونه نیاز دارید — DataTable نسخهٔ آماده و سرور-محور همین الگوست
  • کل صفحهٔ لیست را با جستجو، فیلتر، و صفحه‌بندی می‌سازید — الگوی صفحه جدول داده را دنبال کنید
  • نمایش داده جدولی نیست (کارت یا لیست) — یک کنترل Select با گزینه‌های «مرتب‌سازی بر اساس» کافی است و نیازی به سرستون قابل کلیک نیست

نمونه بصری

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

پیاده‌سازی

نمونهٔ کامل و قابل کپی: سه ستون قابل مرتب‌سازی که وضعیت را در دو useState نگه می‌دارند و از یک helper واحد (getSortDir) هم برای آیکون بصری و هم برای aria-sort استفاده می‌کنند.

'use client'

import {  } from 'react'
import { , , , , , ,  } from '@partodata/ui'

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

interface Influencer {
  : number
  : string
  : number
  : number
}

const : Influencer[] = [
  { : 1, : 'محمد رضایی', : 125000, : 4.2 },
  { : 2, : 'سارا احمدی', : 89000, : 6.8 },
  { : 3, : 'علی محمدی', : 350000, : 2.1 },
]

export function () {
  const [, ] = <>(null)
  const [, ] = <>(null)

  function (: ) {
    if ( === ) {
      ( === 'asc' ? 'desc' : 'asc')
    } else {
      ()
      ('asc')
    }
  }

  const  = [...].((, ) => {
    if (! || !) return 0
    const  =  === 'asc' ? 1 : -1
    if ( === 'name') return ..(., 'fa') * 
    return ([] - []) * 
  })

  const  = (: ) => ( ===  ? ( ?? 'none') : 'none')

  return (
    <>
      <>
        <>
          < ={('name')}>
            <
              ={ === 'name' ? ( ?? false) : false}
              ={() => ('name')}
            >
              نام
            </>
          </>
          < ={('followers')}>
            <
              ={ === 'followers' ? ( ?? false) : false}
              ={() => ('followers')}
            >
              دنبال‌کننده‌ها
            </>
          </>
          < ={('engagementRate')}>
            <
              ={ === 'engagementRate' ? ( ?? false) : false}
              ={() => ('engagementRate')}
            >
              نرخ تعامل
            </>
          </>
        </>
      </>
      <>
        {.(() => (
          < ={.}>
            <>{.}</>
            <>{..('en-US')}</>
            <>{.}٪</>
          </>
        ))}
      </>
    </>
  )
}

قرارداد الگو دو prop مکمل است که هر دو باید از یک state مشتق شوند: sorted روی TableSortHeader آیکون جهت را کنترل می‌کند و sortDirection روی TableHead مقدار aria-sort را می‌سازد. جزئیات این قرارداد در بخش «دسترسی‌پذیری» همین صفحه آمده است.

الگوهای رایج

چرخهٔ سه‌حالته

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

function handleSort(column: SortColumn) {
  if (sortColumn !== column) {
    setSortColumn(column)
    setSortDirection('asc')
  } else if (sortDirection === 'asc') {
    setSortDirection('desc')
  } else {
    // بازگشت به حالت بدون مرتب‌سازی
    setSortColumn(null)
    setSortDirection(null)
  }
}

در حالت پایانی، sorted مقدار false می‌گیرد (آیکون خنثی ChevronsUpDown نمایش داده می‌شود) و getSortDir مقدار 'none' برمی‌گرداند.

مرتب‌سازی پیش‌فرض

جدول را با یک ترتیب معنادار شروع کنید، نه با state خالی — کاربر باید در اولین نگاه بداند ردیف‌ها بر چه اساسی چیده شده‌اند:

const [sortColumn, setSortColumn] = useState<SortColumn>('followers')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')

ستون‌های متریک (دنبال‌کننده، نرخ تعامل) معمولاً نزولی شروع می‌شوند (بزرگ‌ترین مقدار اول)؛ ستون‌های متنی مانند نام، صعودی. چون آیکون جهت و aria-sort از همین state مشتق می‌شوند، ترتیب پیش‌فرض از همان ابتدا برای همهٔ کاربران قابل مشاهده است.

واگذاری مرتب‌سازی به سرور

وقتی داده از سرور صفحه‌بندی می‌شود، همین ساختار UI حفظ می‌شود؛ فقط به‌جای sort سمت کلاینت، وضعیت مرتب‌سازی به سرور ارسال می‌شود و سرور صفحهٔ مرتب‌شده را برمی‌گرداند:

useEffect(() => {
  fetchInfluencers({ page, sortColumn, sortDirection })
}, [page, sortColumn, sortDirection])

کامپوننت آمادهٔ DataTable دقیقاً همین قرارداد کنترل‌شده را از طریق prop sort می‌پذیرد ({ column, direction, onSort }) — اگر به صفحه‌بندی سرور رسیدید، به‌جای گسترش این الگو به سراغ آن بروید.

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

رفتار زیر مستقیماً از پیاده‌سازی table.tsx در سیستم طراحی می‌آید:

  • کیبورد: TableSortHeader یک <button> واقعی رندر می‌کند — با Tab فوکوس می‌گیرد و با Enter یا Space فعال می‌شود. نیازی به role یا مدیریت دستی keydown نیست؛ حلقهٔ فوکوس نیز داخلی است و از توکن --ring رنگ می‌گیرد (focus-visible:ring-2 focus-visible:ring-ring).
  • aria-sort: مقدار sortDirection روی TableHead وضعیت aria-sort عنصر <th> را تنظیم می‌کند: 'asc' به ascending، 'desc' به descending، و 'none' به none نگاشت می‌شود. اگر این prop را ندهید، ویژگی aria-sort اصلاً رندر نمی‌شود و صفحه‌خوان از قابل مرتب‌سازی بودن ستون باخبر نخواهد شد.
  • از getSortDir برای تعیین وضعیت هر ستون استفاده کنید؛ وقتی ستونی مرتب نشده، مقدار 'none' برمی‌گرداند تا صفحه‌خوان بداند ستون قابل مرتب‌سازی است ولی در حال حاضر فعال نیست.
  • صفحه‌خوان: آیکون‌های جهت (ArrowUp، ArrowDown، ChevronsUpDown) صرفاً بصری‌اند؛ صفحه‌خوان جهت مرتب‌سازی را از aria-sort روی <th> می‌خواند و نام دسترس‌پذیر دکمه همان متن عنوان ستون است. بنابراین sorted و sortDirection باید همیشه با هم و از یک state به‌روزرسانی شوند.

بهترین روش‌ها و دام‌های رایج

بکنید

  • sorted و sortDirection را از یک state واحد مشتق کنید (الگوی getSortDir) - ستون‌های عددی را با text-end و pe-* (خواص منطقی) تراز کنید - اعداد سلول‌ها را با formatNumber قالب‌بندی کنید و ارقام را تبدیل نکنید — فونت این کار را می‌کند - جدول را با مرتب‌سازی پیش‌فرض معنادار شروع کنید

نکنید

  • سرستون قابل کلیک را با div و onClick دست‌ساز نسازید - متن فارسی را با مقایسهٔ کدپوینتی مرتب نکنید - روی دادهٔ صفحه‌بندی‌شدهٔ سرور، مرتب‌سازی سمت کلاینت انجام ندهید - ستون فعال را با رنگ hardcode برجسته نکنید

سرستون دست‌ساز به‌جای TableSortHeader

اشتباه: ساختن کنترل مرتب‌سازی با div قابل کلیک و فلش یونیکد.

// ❌ غلط — با ماوس کار می‌کند اما برای کیبورد و صفحه‌خوان وجود ندارد
<TableHead>
  <div className="flex cursor-pointer items-center gap-1" onClick={() => handleSort('name')}>
    نام {sortColumn === 'name' && (sortDirection === 'asc' ? '▲' : '▼')}
  </div>
</TableHead>

// ✅ درست — دکمهٔ واقعی با فوکوس کیبورد + aria-sort روی سرستون
<TableHead sortDirection={getSortDir('name')}>
  <TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
    نام
  </TableSortHeader>
</TableHead>

چرا دردسرساز است: div فوکوس کیبورد نمی‌گیرد و با Enter/Space فعال نمی‌شود، حلقهٔ فوکوس ندارد، و aria-sort هم تنظیم نمی‌شود — یعنی کاربر کیبورد اصلاً نمی‌تواند مرتب کند و کاربر صفحه‌خوان از وضعیت مرتب‌سازی بی‌خبر می‌ماند. TableSortHeader همهٔ این‌ها را داخلی دارد: <button> واقعی، حلقهٔ فوکوس با توکن --ring، و آیکون جهت انیمیت‌شده.

به‌روزرسانی sorted بدون sortDirection

اشتباه: فقط prop بصری (sorted) را با state همگام کردن و sortDirection سرستون را جا انداختن.

// ❌ غلط — فلش نمایش داده می‌شود اما aria-sort هرگز رندر نمی‌شود
<TableHead>
  <TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
    نام
  </TableSortHeader>
</TableHead>

// ✅ درست — هر دو prop از یک state مشتق می‌شوند
<TableHead sortDirection={getSortDir('name')}>
  <TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
    نام
  </TableSortHeader>
</TableHead>

چرا دردسرساز است: این دو prop مستقل‌اند: sorted فقط آیکون را عوض می‌کند و sortDirection فقط aria-sort را می‌سازد. اگر یکی جا بماند، کاربر بینا و کاربر صفحه‌خوان دو واقعیت متفاوت می‌بینند — دامی که در بازبینی بصری هرگز دیده نمی‌شود چون خروجی ظاهری کاملاً درست است. helper واحد getSortDir (نمونهٔ بخش پیاده‌سازی) این واگرایی را ناممکن می‌کند.

مقایسهٔ کدپوینتی متن فارسی

اشتباه: مرتب‌سازی ستون متنی با عملگر مقایسهٔ ساده به‌جای localeCompare.

// ❌ غلط — بر اساس کدپوینت یونیکد مرتب می‌شود، نه الفبای فارسی
const sorted = [...data].sort((a, b) => (a.name > b.name ? 1 : -1) * dir)

// ✅ درست — ترتیب الفبای فارسی
const sorted = [...data].sort((a, b) => a.name.localeCompare(b.name, 'fa') * dir)

چرا دردسرساز است: حروف خاص فارسی مانند «پ»، «چ»، «ژ»، «گ» و «ی» در جدول یونیکد جای الفبایی خود را ندارند؛ مقایسهٔ کدپوینتی آن‌ها را به انتهای لیست می‌فرستد یا با شکل عربی حروف («ي»، «ك») قاطی می‌کند. نتیجه جدولی است که «مرتب» به نظر می‌رسد اما ترتیبش برای کاربر فارسی‌زبان غلط است. localeCompare با آرگومان 'fa' ترتیب درست الفبای فارسی را اعمال می‌کند — سیستم طراحی «فارسی اول» است و مرتب‌سازی هم بخشی از همین تعهد است.

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

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

// ❌ غلط — فقط ۱۰ ردیف صفحهٔ فعلی مرتب می‌شود، نه کل مجموعه داده
const sorted = [...pageRows].sort((a, b) => (a.followers - b.followers) * dir)

// ✅ درست — وضعیت مرتب‌سازی به سرور ارسال می‌شود
useEffect(() => {
  fetchInfluencers({ page, sortColumn, sortDirection })
}, [page, sortColumn, sortDirection])

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

رنگ hardcode برای برجسته‌کردن ستون فعال

اشتباه: رنگ مستقیم Tailwind یا مقدار hex برای متمایز کردن سرستونِ مرتب‌شده.

// ❌ غلط — آبیِ انتخاب‌شده روی تم روشن، در تم تیرهٔ پیش‌فرض کنتراست ندارد
<TableSortHeader
  className="text-blue-600"
  sorted={sortColumn === 'name' ? (sortDirection ?? false) : false}
  onClick={() => handleSort('name')}
>
  نام
</TableSortHeader>

// ✅ درست — برجسته‌سازی داخلی است؛ هنگام فعال بودن خودش text-foreground می‌گیرد
<TableSortHeader sorted={sortColumn === 'name' ? (sortDirection ?? false) : false} onClick={() => handleSort('name')}>
  نام
</TableSortHeader>

چرا دردسرساز است: سیستم طراحی dark-first است — تم پایهٔ :root تیره است و رنگ hardcode با تغییر تم به‌روزرسانی نمی‌شود. مهم‌تر این‌که برجسته‌سازی از قبل داخلی است: TableSortHeader هنگام فعال بودن، خودش text-foreground را اعمال می‌کند و آیکون جهت را نشان می‌دهد. اگر تأکید بیشتری لازم دارید، از توکن‌های معنایی (مانند text-brand) استفاده کنید — همان قاعده‌ای که قانون no-hardcoded-colors در ESLint خود سیستم طراحی اجبار می‌کند.

صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • صفحه جدول داده — اگر جدول قابل مرتب‌سازی فقط بخشی از یک صفحهٔ لیست کامل است، الگوی ترکیب آن با جستجو، فیلتر، صفحه‌بندی، و حالت‌های خالی و بارگذاری آن‌جاست.
  • جدول داده (DataTable) — اگر نمی‌خواهید مرتب‌سازی و صفحه‌بندی را دستی سرهم کنید، این نسخهٔ آمادهٔ سرور-محور همان state کنترل‌شده را از طریق prop sort می‌پذیرد و مرتب‌سازی چندستونه هم دارد.
  • جدول (Table) — برای جدول Props کامل همهٔ زیرکامپوننت‌هایی که این الگو روی آن‌ها سوار است (اندازه‌ها، striped، stickyHeader، edgeFade).