جدول
جدول HTML حقيقي يُعرض على الخادم: الترتيب رابط في كل عنوان عمود، والعمود الأول يمكن تثبيته، ولا يشحن أي JavaScript.
split - الحجم لم يُقَس بعد
جدول
split - الحجم لم يُقَس بعدجدول HTML حقيقي يُعرض على الخادم: الترتيب رابط في كل عنوان عمود، والعمود الأول يمكن تثبيته، ولا يشحن أي JavaScript.
جرّبه
captionVisible
striped
stickyFirstColumn
visibleRows
loadMoreThreshold
الشيفرة
table.tsx
import { Table, type TableDesignProps } from 'topclassui/table'
<Table
caption="Quarterly revenue by region"
items={regions}
getKey={(region) => region.name}
columns={[
{ key: 'name', header: 'Region', rowHeader: true, cell: (region) => region.name },
{ key: 'q1', header: 'Q1', align: 'end', cell: (region) => region.q1 },
{ key: 'q2', header: 'Q2', align: 'end', cell: (region) => region.q2 },
{ key: 'q3', header: 'Q3', align: 'end', cell: (region) => region.q3 },
{ key: 'q4', header: 'Q4', align: 'end', cell: (region) => region.q4 },
{ key: 'total', header: 'Full year', align: 'end', cell: (region) => region.total },
]}
/>الترتيب رابط يجيب عنه الخادم بالصفوف في ترتيبها الجديد، فلا يكلّف الجدول المرتّب أي JavaScript.
كل الحالات، بدون تمرير الفأرة
7 حالاتأو استورده من الحزمة
topclassui/table
import { Table } from 'topclassui/table'ثبّت مرة واحدة:npm i topclassui@beta zod
أمثلة
captionمطلوب، بكلمات حقيقية: هو الاسم الوحيد للجدول. يُقرأ ولا يُرسم، وcaptionVisibleيرسمه.- العمود الذي يحمل
sortHrefيصبح عنوانه رابطًا. رتّب الصفوف على الخادم ومرّرsortلتمييز العمود، وفي Next.js مرّرlink={<Link href="" />}. - يعرض الجدول
itemsكما تصله، مرتّبة ومقسّمة إلى صفحات مسبقًا، وPagination هو ما ينتقل بين الصفحات. - تبقى الخلايا على سطر واحد، فيُمرَّر الجدول أفقيًا على الشاشة الضيقة بدل أن تتكدّس الكلمات. عمود النص الطويل يستثني نفسه بـ
classNameخاص به. stickyFirstColumnيُبقي العمود الأول ظاهرًا، ويُقصّ بعلامة حذف بعد--tc-table-sticky-width.columnsوgetKeyيحملان دوالّ، فابنِ الأعمدة حيث تعرض الجدول، لا في مكوّن خادم يمرّرها إلى مكوّن عميل.
basic
basic.tsx
import { Button } from 'topclassui/button'
import { Table } from 'topclassui/table'
interface Invoice { id: string; number: string; customer: string; status: string; amount: string }
const invoices: Invoice[] = [
{ id: 'inv_1042', number: 'INV-1042', customer: 'Northwind Traders', status: 'Paid', amount: '$1,250.00' },
{ id: 'inv_1043', number: 'INV-1043', customer: 'Contoso Ltd', status: 'Due in 3 days', amount: '$480.00' },
{ id: 'inv_1044', number: 'INV-1044', customer: 'Fabrikam Inc', status: 'Overdue', amount: '$9,120.50' },
{ id: 'inv_1045', number: 'INV-1045', customer: 'Adventure Works', status: 'Paid', amount: '$76.00' },
]
/** The drawn table: a caption read out but not drawn, the invoice as the header of its row, and a link per row named by its invoice. */
export default function Demo() {
return (
<Table
caption="Invoices"
items={invoices}
getKey={(invoice) => invoice.id}
columns={[
{ key: 'number', header: 'Invoice', rowHeader: true, cell: (invoice) => invoice.number },
{ key: 'customer', header: 'Customer', cell: (invoice) => invoice.customer },
{ key: 'status', header: 'Status', cell: (invoice) => invoice.status },
{ key: 'amount', header: 'Amount', align: 'end', cell: (invoice) => invoice.amount },
{
key: 'open',
header: <span className="sr-only">Open</span>,
align: 'end',
cell: (invoice) => (
<Button
size="sm"
variant="outline"
aria-label={`View invoice ${invoice.number}`}
render={<a href={`/invoices/${invoice.id}`} />}
>
View
</Button>
),
},
]}
/>
)
}
sorted
sorted.tsx
import { Table } from 'topclassui/table'
interface Customer { id: number; name: string; plan: string; seats: number }
const byName: Customer[] = [
{ id: 3, name: 'Adventure Works', plan: 'Team', seats: 12 },
{ id: 1, name: 'Contoso Ltd', plan: 'Enterprise', seats: 240 },
{ id: 4, name: 'Fabrikam Inc', plan: 'Free', seats: 1 },
{ id: 2, name: 'Northwind Traders', plan: 'Team', seats: 38 },
]
const bySeats = [...byName].sort((a, b) => b.seats - a.seats)
/** Each header is a link the server answers; the sorted one carries aria-sort and a caret. */
export default function Demo() {
return (
<div className="grid gap-6">
<Table
caption="Customers, by name"
captionVisible
items={byName}
getKey={(customer) => String(customer.id)}
sort={{ key: 'name', direction: 'ascending' }}
columns={[
{ key: 'name', header: 'Customer', sortHref: '?sort=name&dir=desc', cell: (customer) => customer.name },
{ key: 'plan', header: 'Plan', cell: (customer) => customer.plan },
{ key: 'seats', header: 'Seats', align: 'end', sortHref: '?sort=seats&dir=desc', cell: (customer) => customer.seats },
]}
/>
<Table
caption="Customers, by seats"
captionVisible
items={bySeats}
getKey={(customer) => String(customer.id)}
sort={{ key: 'seats', direction: 'descending' }}
columns={[
{ key: 'name', header: 'Customer', sortHref: '?sort=name&dir=asc', cell: (customer) => customer.name },
{ key: 'plan', header: 'Plan', cell: (customer) => customer.plan },
{ key: 'seats', header: 'Seats', align: 'end', sortHref: '?sort=seats&dir=asc', cell: (customer) => customer.seats },
]}
/>
</div>
)
}
sticky-first-column
sticky-first-column.tsx
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Region { name: string; closed: boolean; q1: string; q2: string; q3: string; q4: string; total: string }
const regions: Region[] = [
{ name: 'Middle East', closed: true, q1: '$412,000', q2: '$455,300', q3: '$498,120', q4: '$530,900', total: '$1,896,320' },
{ name: 'North Africa', closed: false, q1: '$208,400', q2: '$214,950', q3: '$240,010', q4: '$262,700', total: '$926,060' },
{ name: 'Western Europe', closed: true, q1: '$890,150', q2: '$874,600', q3: '$910,480', q4: '$955,230', total: '$3,630,460' },
{ name: 'North America', closed: false, q1: '$1,204,800', q2: '$1,260,340', q3: '$1,315,900', q4: '$1,402,650', total: '$5,183,690' },
]
const columns: TableColumn<Region>[] = [
{ key: 'name', header: 'Region', rowHeader: true, cell: (region) => region.name },
{
key: 'report',
header: 'Report',
cell: (region) => (
<Button size="sm" variant="outline" disabled={!region.closed} aria-label={`Export ${region.name}`}>Export</Button>
),
},
{ key: 'q1', header: 'Q1', align: 'end', sortHref: '?sort=q1', cell: (region) => region.q1 },
{ key: 'q2', header: 'Q2', align: 'end', sortHref: '?sort=q2', cell: (region) => region.q2 },
{ key: 'q3', header: 'Q3', align: 'end', sortHref: '?sort=q3', cell: (region) => region.q3 },
{ key: 'q4', header: 'Q4', align: 'end', sortHref: '?sort=q4', cell: (region) => region.q4 },
{ key: 'total', header: 'Full year', align: 'end', sortHref: '?sort=total', cell: (region) => region.total },
]
/** A box narrower than its table: the region names stay in view, over tinted rows and a dimmed button alike. */
export default function Demo() {
return (
<div className="grid max-w-sm gap-6">
<Table
stickyFirstColumn
caption="Revenue by region"
items={regions}
getKey={(region) => region.name}
columns={columns}
/>
<Table
stickyFirstColumn
striped
caption="Revenue by region, striped"
items={regions}
getKey={(region) => region.name}
columns={columns}
/>
</div>
)
}
row-actions
row-actions.tsx
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Order { number: number; customer: string; status: string; placed: string; total: string }
const customers = ['Northwind Traders', 'Contoso Ltd', 'Fabrikam Inc', 'Adventure Works', 'Tailspin Toys']
const statuses = ['Shipped', 'Awaiting payment', 'Delivered']
/** One page of 25, as a server would send it: the newest order first. */
const orders: Order[] = Array.from({ length: 25 }, (_, index) => ({
number: 1042 - index,
customer: customers[index % customers.length] ?? '',
status: statuses[index % statuses.length] ?? '',
placed: `${String(28 - index)} Mar 2026`,
total: `$${String((index + 1) * 120)}.00`,
}))
const columns: TableColumn<Order>[] = [
{
key: 'number',
header: 'Order',
rowHeader: true,
sortHref: '?sort=number&dir=asc',
cell: (order) => (
<Button size="sm" variant="link" render={<a href={`/orders/${String(order.number)}`} />}>
{`#${String(order.number)}`}
</Button>
),
},
{ key: 'customer', header: 'Customer', sortHref: '?sort=customer&dir=asc', cell: (order) => order.customer },
{ key: 'status', header: 'Status', cell: (order) => order.status },
{ key: 'placed', header: 'Placed', sortHref: '?sort=placed&dir=asc', cell: (order) => order.placed },
{ key: 'total', header: 'Total', align: 'end', sortHref: '?sort=total&dir=asc', cell: (order) => order.total },
{
key: 'refund',
header: <span className="sr-only">Actions</span>,
align: 'end',
// The name says which row: a list of buttons would otherwise read "Refund" 25 times.
cell: (order) => (
<Button size="sm" variant="outline" aria-label={`Refund order ${String(order.number)}`}>Refund</Button>
),
},
]
/** A page of orders in a narrow box: a link in the stuck column, and an action per row named by its row. */
export default function Demo() {
return (
<div className="max-w-sm">
<Table
stickyFirstColumn
caption="Orders"
items={orders}
getKey={(order) => String(order.number)}
sort={{ key: 'number', direction: 'descending' }}
columns={columns}
/>
</div>
)
}
striped
striped.tsx
import { Table } from 'topclassui/table'
interface Deploy { id: string; branch: string; author: string; duration: string }
const deploys: Deploy[] = [
{ id: 'dpl_7f3a', branch: 'main', author: 'Layla Haddad', duration: '48 s' },
{ id: 'dpl_7f39', branch: 'fix/checkout-total', author: 'Omar Abed', duration: '51 s' },
{ id: 'dpl_7f38', branch: 'main', author: 'Sara Nasser', duration: '47 s' },
{ id: 'dpl_7f37', branch: 'feat/table', author: 'Omar Abed', duration: '1 min 12 s' },
{ id: 'dpl_7f36', branch: 'main', author: 'Layla Haddad', duration: '46 s' },
{ id: 'dpl_7f35', branch: 'chore/deps', author: 'Yousef Karim', duration: '2 min 3 s' },
]
/** Every second row is tinted, so the eye can follow a row across. */
export default function Demo() {
return (
<Table
striped
caption="Recent deploys"
items={deploys}
getKey={(deploy) => deploy.id}
columns={[
{ key: 'id', header: 'Deploy', cell: (deploy) => deploy.id },
{ key: 'branch', header: 'Branch', cell: (deploy) => deploy.branch },
{ key: 'author', header: 'Author', cell: (deploy) => deploy.author },
{ key: 'duration', header: 'Duration', align: 'end', cell: (deploy) => deploy.duration },
]}
/>
)
}
caption
caption.tsx
import { Table } from 'topclassui/table'
interface ApiKey { id: string; label: string; created: string; lastUsed: string }
const apiKeys: ApiKey[] = [
{ id: 'key_live_a81f', label: 'Production', created: '12 Mar 2026', lastUsed: '2 minutes ago' },
{ id: 'key_test_33c0', label: 'Staging', created: '4 Jan 2026', lastUsed: 'Yesterday' },
{ id: 'key_test_9d2e', label: 'Local development', created: '19 Nov 2025', lastUsed: 'Never' },
]
/** captionVisible draws the caption above the header row; it names the table either way. */
export default function Demo() {
return (
<Table
captionVisible
caption="API keys"
items={apiKeys}
getKey={(apiKey) => apiKey.id}
columns={[
{ key: 'label', header: 'Name', cell: (apiKey) => apiKey.label },
{ key: 'id', header: 'Key', cell: (apiKey) => apiKey.id },
{ key: 'created', header: 'Created', cell: (apiKey) => apiKey.created },
{ key: 'lastUsed', header: 'Last used', align: 'end', cell: (apiKey) => apiKey.lastUsed },
]}
/>
)
}
column-classes
column-classes.tsx
import { Table } from 'topclassui/table'
interface Ticket { id: string; subject: string; note: string; opened: string; assignee: string; replies: number }
const tickets: Ticket[] = [
{
id: 'T-2041',
subject: 'Invoice total is wrong',
note: 'The customer was charged twice for the March renewal. Finance has refunded one payment and is waiting for the bank.',
opened: '12 Mar 2026',
assignee: 'Layla Haddad',
replies: 14,
},
{
id: 'T-2038',
subject: 'Cannot invite a member',
note: 'The invitation email never arrives at addresses on the company domain.',
opened: '9 Mar 2026',
assignee: 'Sara Nasser',
replies: 3,
},
{
id: 'T-2030',
subject: 'Export is empty',
note: 'Fixed in the last release. Waiting for the customer to confirm.',
opened: '2 Mar 2026',
assignee: 'Yousef Karim',
replies: 120,
},
]
/** Whoever is signed in: an agent sees no Assignee column. */
const viewer = { name: 'Omar Abed', canAssign: false }
/** A class per column: prose that wraps at a width of its own, a date hidden on a phone, and a column left out by a flag. */
export default function Demo() {
return (
<Table
caption="Open tickets"
items={tickets}
getKey={(ticket) => ticket.id}
columns={[
{ key: 'id', header: 'Ticket', rowHeader: true, cell: (ticket) => ticket.id },
{ key: 'subject', header: 'Subject', cell: (ticket) => ticket.subject },
{ key: 'note', header: 'Last note', className: 'min-w-64 whitespace-normal', cell: (ticket) => ticket.note },
{ key: 'opened', header: 'Opened', className: 'hidden sm:table-cell', cell: (ticket) => ticket.opened },
viewer.canAssign && { key: 'assignee', header: 'Assignee', cell: (ticket) => ticket.assignee },
{ key: 'replies', header: 'Replies', align: 'end', cell: (ticket) => ticket.replies },
]}
/>
)
}
empty
empty.tsx
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Member { id: number; name: string; role: string }
const nobody: Member[] = []
const columns: TableColumn<Member>[] = [
{ key: 'name', header: 'Name', cell: (member) => member.name },
{ key: 'role', header: 'Role', cell: (member) => member.role },
{ key: 'id', header: 'Member number', align: 'end', cell: (member) => member.id },
]
/** No items: one row across every column, with the built-in words or the caller's own. */
export default function Demo() {
return (
<div className="grid gap-6">
<Table caption="Pending invitations" items={nobody} getKey={(member) => String(member.id)} columns={columns} />
<Table
caption="Team members"
captionVisible
items={nobody}
getKey={(member) => String(member.id)}
columns={columns}
emptyFallback={(
<div className="grid justify-items-center gap-3 py-4">
<p>Nobody has joined this team yet.</p>
<Button size="sm" render={<a href="/team/invite" />}>Invite a member</Button>
</div>
)}
/>
</div>
)
}
skeleton
skeleton.tsx
import { Table, TableSkeleton } from 'topclassui/table'
interface Deploy { id: string; branch: string; author: string; duration: string }
const deploys: Deploy[] = [
{ id: 'dpl_7f3a', branch: 'main', author: 'Layla Haddad', duration: '48 s' },
{ id: 'dpl_7f39', branch: 'fix/checkout-total', author: 'Omar Abed', duration: '51 s' },
{ id: 'dpl_7f38', branch: 'main', author: 'Sara Nasser', duration: '47 s' },
{ id: 'dpl_7f37', branch: 'feat/table', author: 'Omar Abed', duration: '1 min 12 s' },
]
/** The placeholder a Suspense boundary shows, above the table it stands in for: the same box and the same rows. */
export default function Demo() {
return (
<div className="grid gap-6">
<TableSkeleton rows={4} columns={4} />
<Table
caption="Latest deploys"
items={deploys}
getKey={(deploy) => deploy.id}
columns={[
{ key: 'id', header: 'Deploy', rowHeader: true, cell: (deploy) => deploy.id },
{ key: 'branch', header: 'Branch', cell: (deploy) => deploy.branch },
{ key: 'author', header: 'Author', cell: (deploy) => deploy.author },
{ key: 'duration', header: 'Duration', align: 'end', cell: (deploy) => deploy.duration },
]}
/>
</div>
)
}
in-a-layout
Recent orders
This week
128 orders and 4 refunds.
Orders that still wait for a payment.
in-a-layout.tsx
import { Table, type TableColumn } from 'topclassui/table'
interface Order { id: string; customer: string; placed: string; status: string; items: number; total: string }
const orders: Order[] = [
{ id: 'ORD-5521', customer: 'Northwind Traders', placed: '12 Mar 2026', status: 'Awaiting payment', items: 14, total: '$1,250.00' },
{ id: 'ORD-5520', customer: 'Contoso Ltd', placed: '11 Mar 2026', status: 'Shipped', items: 3, total: '$480.00' },
{ id: 'ORD-5519', customer: 'Fabrikam Inc', placed: '11 Mar 2026', status: 'Awaiting payment', items: 120, total: '$9,120.50' },
{ id: 'ORD-5518', customer: 'Adventure Works', placed: '9 Mar 2026', status: 'Delivered', items: 1, total: '$76.00' },
]
const columns: TableColumn<Order>[] = [
{ key: 'id', header: 'Order', rowHeader: true, cell: (order) => order.id },
{ key: 'customer', header: 'Customer', cell: (order) => order.customer },
{ key: 'placed', header: 'Placed', cell: (order) => order.placed },
{ key: 'status', header: 'Status', cell: (order) => order.status },
{ key: 'items', header: 'Items', align: 'end', cell: (order) => order.items },
{ key: 'total', header: 'Total', align: 'end', cell: (order) => order.total },
]
/** The two layouts a dashboard is made of, a card in a grid and a column beside a sidebar: on a phone the table scrolls inside each, and the page does not. */
export default function Demo() {
return (
<div className="flex flex-col gap-8">
<div className="grid gap-6 lg:grid-cols-3">
<section className="rounded-box bg-surface p-4 lg:col-span-2">
<h2 className="pb-3 text-sm font-semibold">Recent orders</h2>
<Table caption="Recent orders" items={orders} getKey={(order) => order.id} columns={columns} />
</section>
<section className="rounded-box bg-surface p-4">
<h2 className="pb-3 text-sm font-semibold">This week</h2>
<p className="text-sm text-muted">128 orders and 4 refunds.</p>
</section>
</div>
<div className="flex gap-6">
<p className="hidden w-40 shrink-0 text-sm text-muted sm:block">Orders that still wait for a payment.</p>
<div className="flex-1">
<Table
striped
caption="Orders awaiting payment"
items={orders.filter((order) => order.status === 'Awaiting payment')}
getKey={(order) => order.id}
columns={columns}
/>
</div>
</div>
</div>
)
}
بالعربية، من اليمين إلى اليسار
rtl.tsx
import { ar } from 'topclassui/labels/ar'
import { Table, type TableColumn } from 'topclassui/table'
interface Invoice { id: string; customer: string; status: string; due: string; amount: string }
const invoices: Invoice[] = [
{ id: 'فاتورة-١٠٤٢', customer: 'شركة الشمال للتجارة', status: 'مدفوعة', due: '١٢ مارس ٢٠٢٦', amount: '١٬٢٥٠٫٠٠ د.أ' },
{ id: 'فاتورة-١٠٤٣', customer: 'مؤسسة النخيل', status: 'تستحق بعد ٣ أيام', due: '١٨ مارس ٢٠٢٦', amount: '٤٨٠٫٠٠ د.أ' },
{ id: 'فاتورة-١٠٤٤', customer: 'دار الياسمين للنشر', status: 'متأخرة', due: '١ مارس ٢٠٢٦', amount: '٩٬١٢٠٫٥٠ د.أ' },
{ id: 'فاتورة-١٠٤٥', customer: 'مخبز الأمل', status: 'مدفوعة', due: '٢٥ فبراير ٢٠٢٦', amount: '٧٦٫٠٠ د.أ' },
]
const nobody: Invoice[] = []
const columns: TableColumn<Invoice>[] = [
{ key: 'id', header: 'الفاتورة', sortHref: '?sort=id&dir=asc', rowHeader: true, cell: (invoice) => invoice.id },
{ key: 'customer', header: 'العميل', sortHref: '?sort=customer&dir=asc', cell: (invoice) => invoice.customer },
{ key: 'status', header: 'الحالة', cell: (invoice) => invoice.status },
{ key: 'due', header: 'تاريخ الاستحقاق', cell: (invoice) => invoice.due },
{ key: 'amount', header: 'المبلغ', align: 'end', sortHref: '?sort=amount&dir=asc', cell: (invoice) => invoice.amount },
]
/** Arabic: the first column sticks to the right edge, the amounts end on the left, and the built-in words are Arabic. */
export default function Demo() {
return (
<div dir="rtl" lang="ar" className="grid gap-6">
<Table
labels={ar}
captionVisible
caption="الفواتير"
items={invoices}
getKey={(invoice) => invoice.id}
sort={{ key: 'amount', direction: 'descending' }}
columns={columns}
/>
<div className="max-w-sm">
<Table
labels={ar}
stickyFirstColumn
striped
caption="الفواتير في مساحة ضيقة"
items={invoices}
getKey={(invoice) => invoice.id}
sort={{ key: 'id', direction: 'ascending' }}
columns={columns}
/>
</div>
<Table labels={ar} caption="فواتير الشهر القادم" items={nobody} getKey={(invoice) => invoice.id} columns={columns} />
</div>
)
}
الخصائص
لوحة المفاتيح وإمكانية الوصول
- جدول
<table>حقيقي: يتنقّل فيه قارئ الشاشة صفًا صفًا وعمودًا عمودًا. ميّز العمود الذي يعرّف الصف بـrowHeader: true. - العمود المرتّب يحمل
aria-sort، والسهم فيه يقول الاتجاه بالكلمات، لأنaria-sortلا يُقرأ في كل مكان. - سمِّ إجراء الصف باسم صفه، مثل «استرداد الطلب 1042»، لا «استرداد» وحدها مكرّرة في كل صف.
من اليمين إلى اليسار
يلتصق العمود الأول بالحافة اليمنى، ويقع المبلغ المحاذى إلى النهاية على اليسار، والكلمات المدمجة تأتي من labels.
الخادم والمتصفح
يُعرض على الخادم، والجزء التفاعلي مكوّن متصفح لا يُرسل إلا عند استخدامه.
الملفات: index.tsx, client.tsx.
على الشارة: split - الحجم لم يُقَس بعد.