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:
@@ -344,7 +344,7 @@ function renderDesktop<TData>(
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && 'bg-muted/30',
|
||||
fixedHeight && '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
@@ -43,24 +45,11 @@ interface MobileCardListProps<TData> {
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
interface MobileColumnMeta {
|
||||
label?: string
|
||||
mobileTitle?: boolean
|
||||
mobileBadge?: boolean
|
||||
mobileHidden?: boolean
|
||||
}
|
||||
|
||||
function getCellMeta<TData>(
|
||||
cell: Cell<TData, unknown>
|
||||
): MobileColumnMeta | undefined {
|
||||
return cell.column.columnDef.meta as MobileColumnMeta | undefined
|
||||
}
|
||||
|
||||
function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
|
||||
const meta = getCellMeta(cell)
|
||||
const { header, meta } = cell.column.columnDef
|
||||
if (typeof header === 'string') return header
|
||||
if (meta?.label) return meta.label
|
||||
const header = cell.column.columnDef.header
|
||||
return typeof header === 'string' ? header : null
|
||||
return null
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
|
||||
@@ -128,16 +117,22 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle)
|
||||
const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
// Read each cell's meta once, then reuse for all categorisation checks.
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
|
||||
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const fieldCells = allCells.filter(
|
||||
(c) =>
|
||||
(c, i) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!getCellMeta(c)?.mobileHidden
|
||||
!cellMetas[i]?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -145,12 +140,14 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 overflow-hidden text-sm font-medium'>
|
||||
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
|
||||
{renderCellContent(titleCell)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='shrink-0'>{renderCellContent(badgeCell)}</div>
|
||||
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -166,8 +163,10 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
<div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -194,21 +193,28 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const contentCells = allCells.filter(
|
||||
(c) => c.column.id !== 'actions' && !getCellMeta(c)?.mobileHidden
|
||||
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
const content = renderCellContent(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden'>
|
||||
{content}
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell)}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -221,8 +227,10 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
|
||||
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'>
|
||||
{content ?? '-'}
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -265,10 +273,15 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
|
||||
|
||||
const hasCompactMeta = table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta as MobileColumnMeta | undefined
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
})
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const hasCompactMeta = React.useMemo(
|
||||
() =>
|
||||
visibleColumns.some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
}),
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
|
||||
|
||||
Reference in New Issue
Block a user