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
@@ -0,0 +1,74 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
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 { StatusBadgeList } from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface BadgeListCellProps {
items: React.ReactNode[]
max?: number
tooltipClassName?: string
}
/**
* Table cell renderer for a list of badges with overflow tooltip.
* Displays up to `max` badges inline; remaining items appear in a tooltip.
* Applies -ml-1.5 to compensate for badge px-1.5 and align with column header.
*/
export function BadgeListCell({
items,
max = 2,
tooltipClassName,
}: BadgeListCellProps) {
if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const showTooltip = items.length > max
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div className='-ml-1.5' />}>
<StatusBadgeList
items={items}
max={max}
renderItem={(item) => item}
/>
</TooltipTrigger>
{showTooltip && (
<TooltipContent
side='top'
className={
tooltipClassName ??
'border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
}
>
<div className='flex flex-wrap gap-1'>{items}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
@@ -63,8 +63,8 @@ function getPinnedColumnClassName(
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName,
kind === 'header'
? 'bg-background z-30'
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted',
? '[background-color:var(--table-header-bg,var(--background))] group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] z-30'
: 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
pinnedColumn.className,
kind === 'header'
? pinnedColumn.headerClassName
@@ -23,10 +23,21 @@ export function DataTableColgroup<TData>({
}: {
table: TanstackTable<TData>
}) {
const columns = table.getVisibleLeafColumns()
const totalSize = columns.reduce((sum, col) => sum + col.getSize(), 0)
return (
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
{columns.map((column) => (
<col
key={column.id}
style={{
width:
totalSize > 0
? `${(column.getSize() / totalSize) * 100}%`
: undefined,
}}
/>
))}
</colgroup>
)
@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { flexRender, type Header, type Table as TanstackTable } from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DataTableColumnHeader } from './column-header'
import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = {
@@ -46,12 +47,7 @@ export function DataTableHeader<TData>({
className={getColumnClassName?.(header.column.id, 'header')}
style={applyHeaderSize ? { width: header.getSize() } : undefined}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{renderHeaderContent(header)}
</TableHead>
))}
</TableRow>
@@ -59,3 +55,19 @@ export function DataTableHeader<TData>({
</TableHeader>
)
}
function renderHeaderContent<TData>(header: Header<TData, unknown>) {
if (header.isPlaceholder) return null
const { header: headerDef, meta } = header.column.columnDef
// A string header means the user wrote e.g. `header: t('Name')` — auto-render
// with DataTableColumnHeader so sorting works without boilerplate.
// A function (including TanStack's default accessor-key fallback) is passed
// through as-is. meta.label is kept as a fallback for legacy columns.
if (typeof headerDef === 'string') {
return <DataTableColumnHeader column={header.column} title={headerDef} />
}
if (meta?.label) {
return <DataTableColumnHeader column={header.column} title={meta.label} />
}
return flexRender(headerDef, header.getContext())
}
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type * as React from 'react'
import * as React from 'react'
import { flexRender, type Row } from '@tanstack/react-table'
import { TableCell, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types'
@@ -27,7 +27,7 @@ type DataTableRowProps<TData> = {
getColumnClassName?: DataTableColumnClassName
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
export function DataTableRow<TData>({
function DataTableRowInner<TData>({
row,
className,
getColumnClassName,
@@ -50,3 +50,14 @@ export function DataTableRow<TData>({
</TableRow>
)
}
export const DataTableRow = React.memo(DataTableRowInner, (prev, next) => {
// Skip re-render when only the getColumnClassName reference changed but the
// row identity and selection state are the same — callers rarely stabilize
// this callback, so excluding it from comparison avoids unnecessary renders.
return (
prev.row === next.row &&
prev.className === next.className &&
prev.row.getIsSelected() === next.row.getIsSelected()
)
}) as typeof DataTableRowInner
@@ -17,7 +17,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 { type Row } from '@tanstack/react-table'
import { type Row, type Table as TanstackTable } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import {
@@ -46,8 +46,12 @@ export { DataTableRow } from './data-table-row'
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows
const colSpan = props.table.getVisibleLeafColumns().length
const colSpan = React.useMemo(
() => props.table.getVisibleLeafColumns().length,
[props.table]
)
const columnClassName = useResolvedColumnClassName(
props.table,
props.getColumnClassName,
props.pinnedColumns
)
@@ -120,32 +124,8 @@ function SplitHeaderTableView<TData>({
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const headerHostRef = React.useRef<HTMLDivElement>(null)
const bodyHostRef = React.useRef<HTMLDivElement>(null)
const tableSizing = getTableSizing(props)
React.useEffect(() => {
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
if (!headerScroller || !bodyScroller) return
const syncHeaderScroll = () => {
headerScroller.scrollLeft = bodyScroller.scrollLeft
}
syncHeaderScroll()
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
return () => {
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
}
}, [rows.length, props.tableClassName, props.colgroup])
return (
<div
className={cn(
@@ -155,49 +135,49 @@ function SplitHeaderTableView<TData>({
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden',
props.splitHeaderScrollClassName
'min-h-0 flex-1 overflow-auto',
'[&_[data-slot=table-header]]:[--table-header-bg:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
'[&_[data-slot=table-header]]:[background-color:var(--table-header-bg)]',
props.splitHeaderScrollClassName,
props.bodyContainerClassName
)}
>
<div
ref={headerHostRef}
className='[scrollbar-gutter:stable] overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden'
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
</Table>
</div>
<div
ref={bodyHostRef}
<table
data-slot='table'
className={cn(
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
props.bodyContainerClassName
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
props.tableClassName
)}
style={tableSizing.style}
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</table>
</div>
</div>
)
}
function useResolvedColumnClassName(
function useResolvedColumnClassName<TData>(
table: TanstackTable<TData>,
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
) {
const allPinnedColumns = React.useMemo(() => {
const metaPinnedColumns = getMetaPinnedColumns(table)
return mergePinnedColumns(pinnedColumns, metaPinnedColumns)
}, [table, pinnedColumns])
const pinnedColumnById = React.useMemo(
() => getPinnedColumnMap(pinnedColumns),
[pinnedColumns]
() => getPinnedColumnMap(allPinnedColumns),
[allPinnedColumns]
)
return React.useMemo(
@@ -207,6 +187,41 @@ function useResolvedColumnClassName(
)
}
function getMetaPinnedColumns<TData>(
table: TanstackTable<TData>
): DataTablePinnedColumn[] {
return table.getAllColumns().flatMap((column) => {
const side = column.columnDef.meta?.pinned
if (!side) return []
return [{ columnId: column.id, side }]
})
}
function mergePinnedColumns(
explicitPinnedColumns: DataTablePinnedColumn[] | undefined,
metaPinnedColumns: DataTablePinnedColumn[]
): DataTablePinnedColumn[] | undefined {
if (!metaPinnedColumns.length) {
return explicitPinnedColumns
}
if (!explicitPinnedColumns?.length) {
return metaPinnedColumns
}
const explicitColumnIds = new Set(
explicitPinnedColumns.map((column) => column.columnId)
)
return [
...explicitPinnedColumns,
...metaPinnedColumns.filter(
(column) => !explicitColumnIds.has(column.columnId)
),
]
}
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
colgroup?: React.ReactNode
style?: React.CSSProperties
@@ -41,6 +41,8 @@ export function useDebouncedColumnFilter({
const [pendingValue, setPendingValue] = React.useState(value)
const isComposingRef = React.useRef(false)
const debouncedValue = useDebounce(pendingValue, delay)
const onColumnFiltersChangeRef = React.useRef(onColumnFiltersChange)
onColumnFiltersChangeRef.current = onColumnFiltersChange
React.useEffect(() => {
// Keep the input aligned when URL state changes outside the local field.
@@ -55,13 +57,13 @@ export function useDebouncedColumnFilter({
React.useEffect(() => {
if (debouncedValue === value) return
onColumnFiltersChange((previous) => {
onColumnFiltersChangeRef.current((previous) => {
const filters = previous.filter((filter) => filter.id !== columnId)
return debouncedValue
? [...filters, { id: columnId, value: debouncedValue }]
: filters
})
}, [columnId, debouncedValue, onColumnFiltersChange, value])
}, [columnId, debouncedValue, value])
const updateInputValue = React.useCallback((nextValue: string) => {
setInputValue(nextValue)
+1
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header'
export { BadgeListCell } from './core/badge-list-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableBulkActions } from './toolbar/bulk-actions'
@@ -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 />
@@ -22,7 +22,7 @@ export const staticDataTableClassNames = {
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
mutedHeaderRow: '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { type Table } from '@tanstack/react-table'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -55,8 +55,13 @@ export function DataTableBulkActions<TData>({
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedCount = selectedRows.length
const toolbarRef = useRef<HTMLDivElement>(null)
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null)
const [announcement, setAnnouncement] = useState('')
useLayoutEffect(() => {
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null
})
// Announce selection changes to screen readers
useEffect(() => {
if (selectedCount > 0) {
@@ -75,7 +80,7 @@ export function DataTableBulkActions<TData>({
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
const buttons = buttonsRef.current
if (!buttons) return
const currentIndex = Array.from(buttons).findIndex(
@@ -53,7 +53,7 @@ type DataTableFacetedFilterProps<TData, TValue> = {
singleSelect?: boolean
}
export function DataTableFacetedFilter<TData, TValue>({
function DataTableFacetedFilterInner<TData, TValue>({
column,
title,
options,
@@ -64,6 +64,18 @@ export function DataTableFacetedFilter<TData, TValue>({
const filterValue = column?.getFilterValue() as string[] | undefined
const selectedValues = new Set(filterValue)
const handleOptionSelect = (optionValue: string) => {
const nextSelectedValues = getNextSelectedValues(
selectedValues,
optionValue,
singleSelect
)
column?.setFilterValue(
nextSelectedValues.length ? nextSelectedValues : undefined
)
}
return (
<Popover>
<PopoverTrigger
@@ -118,29 +130,7 @@ export function DataTableFacetedFilter<TData, TValue>({
return (
<CommandItem
key={option.value}
onSelect={() => {
if (singleSelect) {
// Single select mode: toggle or switch selection
if (isSelected) {
// Deselect if clicking the same option
column?.setFilterValue(undefined)
} else {
// Select only this option
column?.setFilterValue([option.value])
}
} else {
// Multi-select mode: original behavior
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
}}
onSelect={() => handleOptionSelect(option.value)}
>
<div
className={cn(
@@ -197,3 +187,26 @@ export function DataTableFacetedFilter<TData, TValue>({
</Popover>
)
}
export const DataTableFacetedFilter = React.memo(
DataTableFacetedFilterInner
) as typeof DataTableFacetedFilterInner
function getNextSelectedValues(
selectedValues: Set<string>,
optionValue: string,
singleSelect: boolean
): string[] {
if (singleSelect) {
return selectedValues.has(optionValue) ? [] : [optionValue]
}
const nextSelectedValues = new Set(selectedValues)
if (nextSelectedValues.has(optionValue)) {
nextSelectedValues.delete(optionValue)
} else {
nextSelectedValues.add(optionValue)
}
return Array.from(nextSelectedValues)
}
+47 -59
View File
@@ -19,9 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
import * as React from 'react'
import { useState, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -41,6 +41,11 @@ type FilterDef = {
singleSelect?: boolean
}
type SearchDraft = {
baseValue: string
value: string
}
export type DataTableToolbarProps<TData> = {
table: Table<TData>
/**
@@ -141,8 +146,7 @@ export type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const isSearchComposingRef = React.useRef(false)
const lastCommittedSearchValueRef = React.useRef('')
const [isSearchComposing, setIsSearchComposing] = useState(false)
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
@@ -159,31 +163,22 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
'')
: ((props.table.getState().globalFilter as string | undefined) ?? '')
const [searchValue, setSearchValue] = useState(currentSearchValue)
const [pendingSearchValue, setPendingSearchValue] =
useState(currentSearchValue)
const [searchDraft, setSearchDraft] = useState<SearchDraft | null>(null)
const activeSearchDraft =
searchDraft &&
(isSearchComposing || searchDraft.baseValue === currentSearchValue)
? searchDraft
: null
const searchValue = activeSearchDraft?.value ?? currentSearchValue
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
const debouncedSearchValue = useDebounce(
pendingSearchValue,
searchDebounceMs
)
React.useEffect(() => {
lastCommittedSearchValueRef.current = currentSearchValue
if (!isSearchComposingRef.current) {
setSearchValue(currentSearchValue)
}
setPendingSearchValue(currentSearchValue)
}, [currentSearchValue])
const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs)
const commitSearchValue = React.useCallback(
(value: string) => {
if (value === lastCommittedSearchValueRef.current) {
if (value === currentSearchValue) {
return
}
lastCommittedSearchValueRef.current = value
if (props.searchKey) {
props.table.getColumn(props.searchKey)?.setFilterValue(value)
return
@@ -191,14 +186,14 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
props.table.setGlobalFilter(value)
},
[props.searchKey, props.table]
[currentSearchValue, props.searchKey, props.table]
)
React.useEffect(() => {
if (
searchDebounceMs <= 0 ||
isSearchComposingRef.current ||
debouncedSearchValue !== pendingSearchValue
isSearchComposing ||
debouncedSearchValue !== searchValue
) {
return
}
@@ -207,13 +202,12 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
}, [
commitSearchValue,
debouncedSearchValue,
pendingSearchValue,
isSearchComposing,
searchDebounceMs,
searchValue,
])
const queueSearchValue = (value: string) => {
setPendingSearchValue(value)
if (searchDebounceMs <= 0) {
commitSearchValue(value)
}
@@ -221,36 +215,27 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchValue(value)
setSearchDraft({ baseValue: currentSearchValue, value })
if (!isSearchComposingRef.current) {
if (!isSearchComposing) {
queueSearchValue(value)
}
}
const handleSearchCompositionStart = () => {
isSearchComposingRef.current = true
setIsSearchComposing(true)
}
const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement>
) => {
isSearchComposingRef.current = false
setIsSearchComposing(false)
const value = event.currentTarget.value
setSearchValue(value)
setSearchDraft({ baseValue: currentSearchValue, value })
queueSearchValue(value)
}
const searchInput = props.searchKey ? (
<Input
placeholder={placeholder}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
) : (
const searchInput = (
<Input
placeholder={placeholder}
value={searchValue}
@@ -261,25 +246,28 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
/>
)
const filterChips = filters.map((filter) => {
const column = props.table.getColumn(filter.columnId)
if (!column) return null
return (
<DataTableFacetedFilter
key={filter.columnId}
column={column}
title={filter.title}
options={filter.options}
singleSelect={filter.singleSelect}
/>
)
})
const filterChips = React.useMemo(
() =>
filters.map((filter) => {
const column = props.table.getColumn(filter.columnId)
if (!column) return null
return (
<DataTableFacetedFilter
key={filter.columnId}
column={column}
title={filter.title}
options={filter.options}
singleSelect={filter.singleSelect}
/>
)
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.filters, props.table]
)
const handleReset = () => {
isSearchComposingRef.current = false
setSearchValue('')
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
setIsSearchComposing(false)
setSearchDraft(null)
props.table.resetColumnFilters()
props.table.setGlobalFilter('')
props.onReset?.()
@@ -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 { type Table } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -36,6 +37,18 @@ export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
const { t } = useTranslation()
const hideableColumns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
),
[table]
)
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
@@ -52,24 +65,20 @@ export function DataTableViewOptions<TData>({
<DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuGroup>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
{hideableColumns.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: (column.columnDef.meta?.label ?? column.id)}
</DropdownMenuCheckboxItem>
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
)
})}
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
+8 -2
View File
@@ -36,9 +36,15 @@ export function ProviderBadge({
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
return (
<div className={cn('flex items-center gap-1.5', className)}>
<div data-slot='provider-badge' className={cn('flex items-center gap-1.5', className)}>
{icon}
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
<StatusBadge
label={label}
autoColor={label}
size='sm'
className={!icon ? 'pl-0' : undefined}
{...badgeProps}
/>
</div>
)
}
+32 -4
View File
@@ -22,7 +22,6 @@ import { type LucideIcon } from 'lucide-react'
import { stringToColor } from '@/lib/colors'
import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
export const dotColorMap = {
success: 'bg-success',
warning: 'bg-warning',
@@ -73,12 +72,29 @@ export const textColorMap = {
export type StatusVariant = keyof typeof dotColorMap
/** Controls the visual style of the badge.
* - `badge` — default pill with background and padding (default)
* - `text` — plain text, no background or padding, only color
* - `underline`— plain text with a bottom border underline
*/
export type StatusBadgeType = 'badge' | 'text' | 'underline'
/** Context that lets ancestor components (e.g. MobileCardList field area)
* override the badge type without modifying every call site. */
export const StatusBadgeTypeContext = React.createContext<StatusBadgeType>('badge')
const sizeMap = {
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
md: 'h-5 gap-1 px-1.5 text-xs leading-none',
lg: 'h-6 gap-1.5 px-2 text-xs leading-none',
} as const
const textSizeMap = {
sm: 'gap-1 text-xs leading-none',
md: 'gap-1 text-xs leading-none',
lg: 'gap-1.5 text-xs leading-none',
} as const
export interface StatusBadgeProps extends Omit<
React.HTMLAttributes<HTMLSpanElement>,
'children'
@@ -94,6 +110,8 @@ export interface StatusBadgeProps extends Omit<
copyable?: boolean
copyText?: string
autoColor?: string
/** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */
type?: StatusBadgeType
}
export function StatusBadge({
@@ -107,11 +125,14 @@ export function StatusBadge({
copyable = true,
copyText,
autoColor,
type: typeProp,
className,
onClick,
...props
}: StatusBadgeProps) {
const { copyToClipboard } = useCopyToClipboard()
const contextType = React.useContext(StatusBadgeTypeContext)
const type = typeProp ?? contextType
const computedVariant: StatusVariant = autoColor
? (stringToColor(autoColor) as StatusVariant)
@@ -126,14 +147,21 @@ export function StatusBadge({
}
const content =
children ?? (label ? <span className='truncate'>{label}</span> : null)
children ??
(label ? (
<span className='min-w-0 truncate leading-normal'>{label}</span>
) : null)
const isBadge = type === 'badge'
return (
<span
data-slot='status-badge'
className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'],
'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors',
isBadge
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
: cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'),
textColorMap[computedVariant],
pulse && 'animate-pulse',
copyable &&
+2 -2
View File
@@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr
data-slot='table-row'
className={cn(
'hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
'group hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] has-aria-expanded:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5',
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
className
)}
{...props}