perf(data-table): improve data table layout and badge display (#5460)

* perf(table): use percentage-based column widths

- compute each column's width as a percentage of total column size instead of a fixed pixel value, letting the colgroup scale fluidly with the table container.

* perf(data-table): reduce unnecessary re-renders across table components

- stabilize commitSearchValue in toolbar by reading table/searchKey via refs, eliminating recreation on every parent render
- store onColumnFiltersChange in a ref so debounce effect is not reset when the caller passes a new function reference each render
- wrap DataTableRow in React.memo with a custom comparator that ignores getColumnClassName reference churn
- memoize selectedValues Set in DataTableFacetedFilter and wrap with React.memo to prevent rerenders on unrelated state changes
- cache cell meta reads in CompactRow and FallbackRow to a single pass per row; memoize hasCompactMeta in MobileCardList
- memoize hideable columns list in DataTableViewOptions and colSpan in DataTableView
- remove tableClassName and colgroup from scroll-sync effect deps; cache toolbar button NodeList via useLayoutEffect to avoid per-keydown DOM queries

* perf(data-table): replace scroll-sync split header with CSS sticky

- remove JS scroll-sync effect and event listener between split header and body containers.
- merge separate header/body tables into a single scrollable table element, reducing DOM complexity.
- apply CSS sticky positioning to the header for a simpler, hardware-accelerated freeze effect.

* fix(data-table): replace opacity muted colors with color-mix

- switch from bg-muted/50 and bg-muted/30 to color-mix(in oklch) to produce opaque blended backgrounds that prevent scroll content from showing through pinned cells.
- expose --table-header-bg CSS variable so pinned header cells inherit the exact same computed color as the thead background.
- add group class to TableRow to enable group-hover selectors on pinned cell styles.

* feat(data-table): support column pinning via meta.pinned

- add pinned?: 'left' | 'right' to ColumnMeta so pinning is declared once in the column definition and applies to both header and body automatically
- DataTableView derives pinnedColumns from meta.pinned at runtime, merged with any explicit pinnedColumns prop; explicit entries take precedence
- add header and meta.pinned: 'right' to all actions columns across channels, users, api-keys, redemption-codes, models, deployments, and subscriptions tables

* style(row-actions): align action buttons to leading edge of column

* refactor(data-table): extract BadgeListCell and centralize badge alignment

- add BadgeListCell component to data-table for badge lists with overflow tooltip, replacing duplicated renderLimitedItems helpers in channels, models, and pricing columns
- move StatusBadge -ml-1.5 alignment into the component itself via a table-cell context selector, so callers no longer need manual offset wrappers
- remove the table-cell-level -ml-1.5 selector from TableCell now that alignment is handled by StatusBadge directly

* style(row-actions): offset action buttons to align with column header text

* refactor(data-table): consolidate mobile meta into ColumnMeta declaration

- move mobileTitle, mobileBadge, mobileHidden into the global ColumnMeta augmentation so the type is shared across the project
- remove the local MobileColumnMeta interface and getCellMeta helper from mobile-card-list.tsx
- direct col.columnDef.meta access is now type-safe without explicit casting

* refactor(data-table): simplify column header and meta config

- auto-render string `header` values via DataTableColumnHeader so sortable/non-sortable columns work without boilerplate function wrappers
- promote mobile layout hints (mobileTitle, mobileBadge, mobileHidden) into the global ColumnMeta type, removing the local MobileColumnMeta cast in mobile-card-list
- migrate all column files from `meta: { label }` to top-level `header: t('...')`, cutting ~180 lines of repetitive template code
- ViewOptions and MobileCardList label resolution now reads string header first, then meta.label as fallback

* feat(status-badge): add text and underline display types

- introduce StatusBadgeType ('badge' | 'text' | 'underline') and StatusBadgeTypeContext so ancestors can override rendering without touching call sites
- mobile card field rows now use the text type via context, showing badges as plain colored text instead of pills
- ProviderBadge gains data-slot='provider-badge' to enable targeted CSS resets in compact layouts
- replace the implicit [[data-slot=table-cell]>&]:-ml-1.5 rule with explicit -ml-1.5 at each column call site

* refactor(data-table): simplify table filtering internals

- derive toolbar search state from the active table filter to avoid render-time ref writes.
- extract faceted filter selection updates into a pure helper for clearer single and multi-select behavior.
- split pinned column resolution into focused helpers so explicit and meta pins merge predictably.

Co-authored-by: t0ng7u <dev@aiass.cc>
This commit is contained in:
QuentinHsu
2026-06-12 23:18:22 +08:00
committed by GitHub
co-authored by t0ng7u
parent 6f415428d3
commit 27b2b2c4b9
37 changed files with 694 additions and 877 deletions
@@ -136,7 +136,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<>
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -294,6 +294,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
user={{ id: user.id, username: user.username }}
onSuccess={triggerRefresh}
/>
</>
</div>
)
}
+19 -40
View File
@@ -27,7 +27,6 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
import { StatusBadge } from '@/components/status-badge'
@@ -67,26 +66,21 @@ export function useUsersColumns(): ColumnDef<User>[] {
enableSorting: false,
enableHiding: false,
size: 40,
meta: { label: t('Select') },
},
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
),
header: t('ID'),
cell: ({ row }) => {
return (
<TableId value={row.getValue('id') as number} className='w-[60px]' />
)
},
size: 80,
meta: { label: t('ID'), mobileHidden: true },
meta: { mobileHidden: true },
},
{
accessorKey: 'username',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Username')} />
),
header: t('Username'),
cell: ({ row }) => {
const username = row.getValue('username') as string
const displayName = row.original.display_name
@@ -121,13 +115,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
},
enableHiding: false,
size: 220,
meta: { label: t('Username'), mobileTitle: true },
meta: { mobileTitle: true },
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Status')} />
),
header: t('Status'),
cell: ({ row }) => {
const user = row.original
const requestCount = user.request_count
@@ -142,7 +134,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return (
<Tooltip>
<TooltipTrigger render={<div className='cursor-help' />}>
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
@@ -162,14 +154,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
},
enableSorting: false,
size: 120,
meta: { label: t('Status'), mobileBadge: true },
meta: { mobileBadge: true },
},
{
id: 'quota',
accessorKey: 'quota',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Quota')} />
),
header: t('Quota'),
cell: ({ row }) => {
const user = row.original
const used = user.used_quota
@@ -183,6 +173,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
@@ -225,13 +216,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
)
},
size: 170,
meta: { label: t('Quota') },
},
{
accessorKey: 'group',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Group')} />
),
header: t('Group'),
cell: ({ row }) => {
const group = row.getValue('group') as string
return <GroupBadge group={group} />
@@ -242,13 +230,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
return group.includes(searchValue)
},
size: 140,
meta: { label: t('Group') },
},
{
accessorKey: 'role',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Role')} />
),
header: t('Role'),
cell: ({ row }) => {
const roleValue = row.getValue('role') as number
const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES]
@@ -271,13 +256,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
},
enableSorting: false,
size: 120,
meta: { label: t('Role') },
},
{
id: 'invite_info',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Invite Info')} />
),
header: t('Invite Info'),
cell: ({ row }) => {
const user = row.original
const affCount = user.aff_count || 0
@@ -347,13 +329,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
},
size: 240,
enableSorting: false,
meta: { label: t('Invite Info'), mobileHidden: true },
meta: { mobileHidden: true },
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Created At')} />
),
header: t('Created At'),
cell: ({ row }) => {
const ts = row.getValue('created_at') as number | undefined
return (
@@ -363,13 +343,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
)
},
size: 180,
meta: { label: t('Created At'), mobileHidden: true },
meta: { mobileHidden: true },
},
{
accessorKey: 'last_login_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Last Login')} />
),
header: t('Last Login'),
cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined
return (
@@ -379,12 +357,13 @@ export function useUsersColumns(): ColumnDef<User>[] {
)
},
size: 180,
meta: { label: t('Last Login'), mobileHidden: true },
meta: { mobileHidden: true },
},
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { label: t('Actions') },
meta: { pinned: 'right' as const },
},
]
}