refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+17
View File
@@ -0,0 +1,17 @@
# Data Table Components
This package keeps a stable public API through `index.ts`; feature code should
continue importing from `@/components/data-table`.
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
loading, empty states, and pinned-column behavior.
- `layout/`: responsive page-level composition that combines toolbar, desktop
table, mobile list, bulk actions, and pagination placement.
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
- `static/`: lightweight table rendering for local/static arrays that do not
need TanStack state.
- `hooks/`: table state and filter hooks.
Keep feature-specific columns, actions, and dialogs inside their feature
folders. Shared table code belongs here only when it is reusable across more
than one feature.
@@ -0,0 +1,36 @@
/*
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 { cn } from '@/lib/utils'
type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
export function BadgeCell({ className, ...props }: BadgeCellProps) {
return (
<div
data-slot='badge-cell'
className={cn(
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
className
)}
{...props}
/>
)
}
@@ -0,0 +1,75 @@
/*
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 max-w-full' />}>
<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>
)
}
@@ -0,0 +1,97 @@
/*
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 { type Column } from '@tanstack/react-table'
import {
ArrowDown as ArrowDownIcon,
ArrowUp as ArrowUpIcon,
ChevronsUpDown as CaretSortIcon,
EyeOff as EyeNoneIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
type DataTableColumnHeaderProps<TData, TValue> =
React.HTMLAttributes<HTMLDivElement> & {
column: Column<TData, TValue>
title: React.ReactNode
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
const { t } = useTranslation()
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='sm'
className='data-popup-open:bg-accent -ms-3 h-8'
/>
}
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ms-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ms-2 h-4 w-4' />
) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='text-muted-foreground/70 size-3.5' />
{t('Asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='text-muted-foreground/70 size-3.5' />
{t('Desc')}
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='text-muted-foreground/70 size-3.5' />
{t('Hide')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,78 @@
/*
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 { cn } from '@/lib/utils'
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
export function getResolvedColumnClassName(
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
): DataTableColumnClassName {
return getResolvedColumnClassNameFromMap(
getColumnClassName,
getPinnedColumnMap(pinnedColumns)
)
}
export function getResolvedColumnClassNameFromMap(
getColumnClassName?: DataTableColumnClassName,
pinnedColumnById?: Map<string, DataTablePinnedColumn>
): DataTableColumnClassName {
return (columnId, kind) => {
const customClassName = getColumnClassName?.(columnId, kind)
const pinnedColumn = pinnedColumnById?.get(columnId)
if (!pinnedColumn) {
return customClassName
}
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
}
}
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
if (!pinnedColumns?.length) {
return undefined
}
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
}
function getPinnedColumnClassName(
pinnedColumn: DataTablePinnedColumn,
kind: 'header' | 'cell'
) {
const edgeClassName =
pinnedColumn.side === 'left'
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
return cn(
'sticky whitespace-nowrap',
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName,
kind === 'header'
? '[background-color:var(--table-header-bg,var(--table-header))] group-hover:[background-color:var(--table-header-hover)] 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
: pinnedColumn.cellClassName
)
}
@@ -0,0 +1,21 @@
/*
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
*/
export function isContentSizedColumn(columnId: string): boolean {
return columnId === 'actions'
}
@@ -0,0 +1,69 @@
/*
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 type { Table as TanstackTable } from '@tanstack/react-table'
import { isContentSizedColumn } from './content-sized-columns'
export function DataTableColgroup<TData>({
table,
}: {
table: TanstackTable<TData>
}) {
const columns = table.getVisibleLeafColumns()
const sizedColumns = columns.filter(
(column) => !isContentSizedColumn(column.id)
)
const totalSize = sizedColumns.reduce((sum, col) => sum + col.getSize(), 0)
return (
<colgroup>
{columns.map((column) => {
const width = getColumnWidth(
table,
column.id,
column.getSize(),
totalSize
)
return <col key={column.id} style={{ width }} />
})}
</colgroup>
)
}
function getColumnWidth<TData>(
table: TanstackTable<TData>,
columnId: string,
columnSize: number,
totalSize: number
) {
if (isContentSizedColumn(columnId)) {
return undefined
}
if (table.options.enableColumnResizing === true) {
return `${columnSize}px`
}
if (totalSize <= 0) {
return undefined
}
return `${(columnSize / totalSize) * 100}%`
}
@@ -0,0 +1,277 @@
/*
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 {
flexRender,
type Header,
type Table as TanstackTable,
} from '@tanstack/react-table'
import type { KeyboardEvent, MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { DataTableColumnHeader } from './column-header'
import { isContentSizedColumn } from './content-sized-columns'
import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = {
table: TanstackTable<TData>
applyHeaderSize?: boolean
className?: string
rowClassName?: string
getColumnClassName?: DataTableColumnClassName
}
export function DataTableHeader<TData>({
table,
applyHeaderSize,
className,
rowClassName,
getColumnClassName,
}: DataTableHeaderProps<TData>) {
const { t } = useTranslation()
return (
<TableHeader className={className}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className={rowClassName}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
data-column-id={header.column.id}
className={cn(
'relative',
getColumnClassName?.(header.column.id, 'header')
)}
style={getHeaderSizeStyle(header, applyHeaderSize)}
>
{renderHeaderContent(header)}
{shouldRenderColumnResizer(table, header) && (
<div
role='separator'
aria-orientation='vertical'
aria-label={t('Resize column')}
data-column-resizer
tabIndex={0}
onDoubleClick={(event) =>
handleColumnAutoSize(event, table, header)
}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onKeyDown={(event) =>
handleColumnResizeKeyDown(event, table, header)
}
className={cn(
'absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none',
'after:bg-border hover:after:bg-primary after:absolute after:top-2 after:right-0 after:h-[calc(100%-1rem)] after:w-px after:transition-colors',
header.column.getIsResizing() && 'after:bg-primary'
)}
/>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
function handleColumnResizeKeyDown<TData>(
event: KeyboardEvent<HTMLDivElement>,
table: TanstackTable<TData>,
header: Header<TData, unknown>
) {
const step = event.shiftKey ? 50 : 10
if (event.key === 'ArrowLeft') {
event.preventDefault()
resizeColumnByKeyboard(table, header, -step)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
resizeColumnByKeyboard(table, header, step)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
autoSizeColumn(event.currentTarget, table, header)
}
}
function resizeColumnByKeyboard<TData>(
table: TanstackTable<TData>,
header: Header<TData, unknown>,
delta: number
) {
table.setColumnSizing((previous) => ({
...previous,
[header.column.id]: getClampedColumnSize(
header,
header.column.getSize() + delta
),
}))
}
function handleColumnAutoSize<TData>(
event: MouseEvent<HTMLDivElement>,
table: TanstackTable<TData>,
header: Header<TData, unknown>
) {
event.preventDefault()
autoSizeColumn(event.currentTarget, table, header)
}
function autoSizeColumn<TData>(
resizerElement: HTMLElement,
table: TanstackTable<TData>,
header: Header<TData, unknown>
) {
const measuredSize = measureColumnContentWidth(
resizerElement,
header.column.id
)
if (measuredSize === undefined) {
return
}
table.setColumnSizing((previous) => ({
...previous,
[header.column.id]: getClampedColumnSize(header, measuredSize),
}))
}
function getClampedColumnSize<TData>(
header: Header<TData, unknown>,
nextSize: number
) {
const { minSize, maxSize } = header.column.columnDef
if (typeof minSize === 'number' && nextSize < minSize) {
return minSize
}
if (typeof maxSize === 'number' && nextSize > maxSize) {
return maxSize
}
return nextSize
}
function measureColumnContentWidth(
resizerElement: HTMLElement,
columnId: string
) {
const tableElement = resizerElement.closest('table')
if (!tableElement) {
return undefined
}
const cells = tableElement.querySelectorAll<HTMLElement>(
getColumnElementSelector(columnId)
)
if (cells.length === 0) {
return undefined
}
const measuredWidth = [...cells].reduce(
(maxWidth, cell) => Math.max(maxWidth, measureElementWidth(cell)),
0
)
return measuredWidth > 0 ? Math.ceil(measuredWidth) : undefined
}
function measureElementWidth(element: HTMLElement) {
const clone = element.cloneNode(true) as HTMLElement
clone.querySelectorAll('[data-column-resizer]').forEach((resizer) => {
resizer.remove()
})
clone.style.position = 'absolute'
clone.style.visibility = 'hidden'
clone.style.pointerEvents = 'none'
clone.style.left = '-10000px'
clone.style.top = '0'
clone.style.width = 'max-content'
clone.style.minWidth = '0'
clone.style.maxWidth = 'none'
clone.style.height = 'auto'
clone.style.whiteSpace = 'nowrap'
document.body.append(clone)
const width = clone.scrollWidth
clone.remove()
return width
}
function getColumnElementSelector(columnId: string) {
const escapedColumnId =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(columnId)
: columnId.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
return `[data-column-id="${escapedColumnId}"]`
}
function shouldRenderColumnResizer<TData>(
table: TanstackTable<TData>,
header: Header<TData, unknown>
) {
return (
table.options.enableColumnResizing === true &&
!header.isPlaceholder &&
header.column.getCanResize() &&
!isContentSizedColumn(header.column.id)
)
}
function getHeaderSizeStyle<TData>(
header: Header<TData, unknown>,
applyHeaderSize: boolean | undefined
) {
if (!applyHeaderSize || isContentSizedColumn(header.column.id)) {
return undefined
}
return { width: header.getSize() }
}
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())
}
@@ -0,0 +1,148 @@
/*
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 {
flexRender,
type Cell,
type Row,
type Table as TanstackTable,
} from '@tanstack/react-table'
import * as React from 'react'
import { TableCell, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { TruncatedCell } from './truncated-cell'
import type { DataTableColumnClassName } from './types'
type DataTableRowProps<TData> = {
row: Row<TData>
className?: string
getColumnClassName?: DataTableColumnClassName
cellRenderColumns?: TanstackTable<TData>['options']['columns']
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
isSelected: boolean
/**
* Stable signature of currently visible leaf columns for this row.
* Captured outside the memo comparator so visibility toggles re-render
* even when the TanStack row object reference stays the same.
*/
visibleColumnIds: string
}
function DataTableRowInner<TData>({
row,
isSelected,
className,
getColumnClassName,
cellRenderColumns,
visibleColumnIds,
...rowProps
}: DataTableRowInnerProps<TData>) {
// Destructured only to keep them out of `rowProps` (not valid DOM attrs)
// and to feed the memo comparator below; intentionally unused here.
void cellRenderColumns
void visibleColumnIds
return (
<TableRow
data-state={isSelected ? 'selected' : undefined}
className={className}
{...rowProps}
>
{row.getVisibleCells().map((cell) => {
const renderedCell = renderCellContent(cell)
return (
<TableCell
key={cell.id}
data-column-id={cell.column.id}
className={cn(
'max-w-full min-w-0',
renderedCell.isPrimitive && 'overflow-hidden',
getColumnClassName?.(cell.column.id, 'cell')
)}
>
{renderedCell.content}
</TableCell>
)
})}
</TableRow>
)
}
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
// Do not read row.getIsSelected() / row.getVisibleCells() inside the
// comparator: TanStack row objects keep a stable reference while selection
// and columnVisibility mutate on the table instance. Reading them here would
// compare identical live values and miss those updates. Both are lifted to
// explicit props, captured per render in DataTableRow.
//
// Column cell renderers (and getColumnClassName) can close over external
// state while the row stays stable, so column definitions and the class
// resolver are part of the render identity and must be compared too.
return (
prev.row === next.row &&
prev.className === next.className &&
prev.isSelected === next.isSelected &&
prev.visibleColumnIds === next.visibleColumnIds &&
prev.getColumnClassName === next.getColumnClassName &&
prev.cellRenderColumns === next.cellRenderColumns
)
}) as typeof DataTableRowInner
export function DataTableRow<TData>(props: DataTableRowProps<TData>) {
const visibleColumnIds = props.row
.getVisibleCells()
.map((cell) => cell.column.id)
.join('\0')
return (
<MemoizedDataTableRow
{...props}
isSelected={props.row.getIsSelected()}
visibleColumnIds={visibleColumnIds}
/>
)
}
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
const textContent = getPrimitiveTextContent(content)
if (!textContent) {
return { content, isPrimitive: false }
}
return {
content: (
<TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
),
isPrimitive: true,
}
}
function getPrimitiveTextContent(content: React.ReactNode): string | null {
if (typeof content === 'string' || typeof content === 'number') {
return String(content)
}
return null
}
@@ -0,0 +1,331 @@
/*
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 type { Row, Table as TanstackTable } from '@tanstack/react-table'
import * as React from 'react'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import {
getPinnedColumnMap,
getResolvedColumnClassNameFromMap,
} from './column-pinning'
import { DataTableColgroup } from './data-table-colgroup'
import { DataTableHeader } from './data-table-header'
import { DataTableRow } from './data-table-row'
import { TableEmpty } from './table-empty'
import { getTableSizeStyle } from './table-sizing'
import { TableSkeleton } from './table-skeleton'
import type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableViewProps,
} from './types'
export type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableRenderRowHelpers,
DataTableViewProps,
} from './types'
export { DataTableRow } from './data-table-row'
export { DataTableRowActionMenu } from './row-action-menu'
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows
const colSpan = React.useMemo(
() => props.table.getVisibleLeafColumns().length,
[props.table]
)
const columnClassName = useResolvedColumnClassName(
props.table,
props.getColumnClassName,
props.pinnedColumns
)
return (
<div
className={cn(
'overflow-hidden rounded-lg border',
props.containerClassName
)}
{...props.containerProps}
>
{props.splitHeader ? (
<SplitHeaderTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
) : (
<UnifiedTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
)}
</div>
)
}
function UnifiedTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const tableSizing = getTableSizing(props)
return (
<div className={props.tableContainerClassName}>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
)
}
function SplitHeaderTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const tableSizing = getTableSizing(props)
return (
<div
className={cn(
'flex h-full min-h-0 flex-col',
props.tableContainerClassName
)}
>
<div
className={cn(
'min-h-0 flex-1 overflow-auto',
'**:data-[slot=table-header]:[--table-header-bg:var(--table-header)]',
'**:data-[slot=table-header]:bg-(--table-header-bg)',
props.splitHeaderScrollClassName,
props.bodyContainerClassName
)}
>
<table
data-slot='table'
className={cn(
'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}
>
{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<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(allPinnedColumns),
[allPinnedColumns]
)
return React.useMemo(
() =>
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
[getColumnClassName, pinnedColumnById]
)
}
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
} {
if (props.colgroup) {
return { colgroup: props.colgroup }
}
if (!props.splitHeader && !props.applyHeaderSize) {
return {}
}
return {
colgroup: <DataTableColgroup table={props.table} />,
style: getTableSizeStyle(props.table),
}
}
function renderTableBody<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
return (
<TableBody className={props.tableBodyClassName}>
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
</TableBody>
)
}
function renderTableBodyContent<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
if (props.isLoading) {
return (
<TableSkeleton
table={props.table}
keyPrefix={props.skeletonKeyPrefix}
rowHeight={props.skeletonRowHeight}
/>
)
}
if (rows.length === 0) {
return renderEmptyState(props, colSpan)
}
return rows.map((row) =>
props.renderRow
? props.renderRow(row, {
getCellClassName: (columnId, className) =>
cn(getColumnClassName(columnId, 'cell'), className),
})
: renderDefaultRow(props, row, getColumnClassName)
)
}
function renderEmptyState<TData>(
props: DataTableViewProps<TData>,
colSpan: number
) {
if (props.emptyContent) {
return (
<TableRow>
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
{props.emptyContent}
</TableCell>
</TableRow>
)
}
return (
<TableEmpty
colSpan={colSpan}
title={props.emptyTitle}
description={props.emptyDescription}
icon={props.emptyIcon}
>
{props.emptyAction}
</TableEmpty>
)
}
function renderDefaultRow<TData>(
props: DataTableViewProps<TData>,
row: Row<TData>,
getColumnClassName: DataTableColumnClassName
) {
return (
<DataTableRow
key={row.id}
row={row}
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
getColumnClassName={getColumnClassName}
cellRenderColumns={props.table.options.columns}
/>
)
}
@@ -0,0 +1,169 @@
/*
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 { type Table } from '@tanstack/react-table'
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
ChevronsLeft as DoubleArrowLeftIcon,
ChevronsRight as DoubleArrowRightIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn, getPageNumbers } from '@/lib/utils'
type DataTablePaginationProps<TData> = {
table: Table<TData>
}
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
value: `${pageSize}`,
label: pageSize,
}))
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
const pagination = table.getState().pagination
const currentPage = pagination.pageIndex + 1
const pageSize = pagination.pageSize
const totalPages = table.getPageCount()
const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages)
return (
<div
className={cn(
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
)}
style={{ overflowClipMargin: 1 }}
>
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
<span className='text-muted-foreground/80'>{t('Total:')}</span>
<span className='text-foreground tabular-nums'>
{totalRows.toLocaleString()}
</span>
</div>
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
{t('Rows per page')}
</p>
<Select
items={PAGE_SIZE_SELECT_ITEMS}
value={`${pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top' alignItemWithTrigger={false}>
<SelectGroup>
{PAGE_SIZE_OPTIONS.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
<Button
variant='outline'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t('Go to first page')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t('Go to previous page')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
...
</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className={cn(
'h-8 min-w-8 px-2 tabular-nums',
currentPage === pageNumber
? 'font-semibold'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
>
<span className='sr-only'>
{t('Go to page {{page}}', { page: pageNumber })}
</span>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t('Go to next page')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t('Go to last page')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
/*
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 { MoreHorizontal } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
type DataTableRowActionMenuProps = {
children: React.ReactNode
ariaLabel: string
contentClassName?: string
modal?: boolean
onOpenChange?: (open: boolean) => void
}
export function DataTableRowActionMenu(props: DataTableRowActionMenuProps) {
return (
<DropdownMenu modal={props.modal} onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='icon'
className='data-popup-open:bg-muted'
aria-label={props.ariaLabel}
/>
}
>
<MoreHorizontal aria-hidden='true' />
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className={cn('w-48', props.contentClassName)}
>
{props.children}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,88 @@
/*
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 { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { TableRow, TableCell } from '@/components/ui/table'
interface TableEmptyProps {
/**
* Number of columns to span
*/
colSpan: number
/**
* Custom title for empty state
* @default 'No Data'
*/
title?: string
/**
* Custom description for empty state
* @default 'No records found. Try adjusting your filters.'
*/
description?: string
/**
* Custom icon component
* @default Database icon
*/
icon?: React.ReactNode
/**
* Additional content to display (e.g., buttons)
*/
children?: React.ReactNode
}
/**
* Generic table empty state component
* Displays a centered empty state message when table has no data
*/
export function TableEmpty({
colSpan,
title,
description,
icon,
children,
}: TableEmptyProps) {
const { t } = useTranslation()
const resolvedTitle = title ?? t('No Data')
const resolvedDescription =
description ?? t('No records found. Try adjusting your filters.')
return (
<TableRow>
<TableCell colSpan={colSpan} className='h-[400px] p-0'>
<Empty>
<EmptyHeader>
<EmptyMedia variant='icon'>
{icon || <Database className='size-6' />}
</EmptyMedia>
<EmptyTitle>{resolvedTitle}</EmptyTitle>
<EmptyDescription>{resolvedDescription}</EmptyDescription>
</EmptyHeader>
{children}
</Empty>
</TableCell>
</TableRow>
)
}
@@ -0,0 +1,37 @@
/*
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 type { Table as TanstackTable } from '@tanstack/react-table'
import type * as React from 'react'
import { isContentSizedColumn } from './content-sized-columns'
export function getTableSizeStyle<TData>(
table: TanstackTable<TData>
): React.CSSProperties {
const width = table
.getVisibleLeafColumns()
.filter((column) => !isContentSizedColumn(column.id))
.reduce((total, column) => total + column.getSize(), 0)
return {
minWidth: `max(100%, ${width}px)`,
tableLayout: 'auto',
width: '100%',
}
}
@@ -0,0 +1,89 @@
/*
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 type { Table } from '@tanstack/react-table'
import { Skeleton } from '@/components/ui/skeleton'
import { TableRow, TableCell } from '@/components/ui/table'
import { cn } from '@/lib/utils'
const SKELETON_WIDTHS = [
'75%',
'60%',
'85%',
'50%',
'70%',
'90%',
'55%',
'80%',
'65%',
'45%',
]
interface TableSkeletonProps<TData> {
table: Table<TData>
rowCount?: number
rowHeight?: string
keyPrefix?: string
}
export function TableSkeleton<TData>({
table,
rowCount,
rowHeight = 'h-[52px]',
keyPrefix = 'skeleton',
}: TableSkeletonProps<TData>) {
const visibleColumns = table.getVisibleLeafColumns()
const finalRowCount =
rowCount ?? Math.min(table.getState().pagination?.pageSize || 20, 20)
return (
<>
{Array.from({ length: finalRowCount }, (_, rowIndex) => (
<TableRow
key={`${keyPrefix}-${rowIndex}`}
className={cn(rowHeight, 'border-b')}
>
{visibleColumns.map((column, colIndex) => {
const isSelectColumn = column.id === 'select'
const widthIndex =
(rowIndex * visibleColumns.length + colIndex) %
SKELETON_WIDTHS.length
return (
<TableCell key={column.id} className='py-3'>
<Skeleton
className={cn(
'h-4 rounded-sm',
isSelectColumn ? 'size-4' : undefined
)}
style={
isSelectColumn
? undefined
: { width: SKELETON_WIDTHS[widthIndex] }
}
/>
</TableCell>
)
})}
</TableRow>
))}
</>
)
}
@@ -0,0 +1,92 @@
/*
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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
type TruncatedCellProps = {
children: React.ReactNode
cellClassName?: string
className?: string
contentClassName?: string
side?: 'top' | 'bottom' | 'left' | 'right'
tooltipClassName?: string
tooltipContent?: React.ReactNode
}
export function TruncatedCell({
children,
cellClassName,
className,
contentClassName,
side = 'top',
tooltipClassName,
tooltipContent,
}: TruncatedCellProps) {
const content = tooltipContent ?? getTextContent(children)
if (!content) {
return (
<div
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
className
)}
>
{children}
</div>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<div
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
className
)}
/>
}
>
<div className={cn('truncate', contentClassName)}>{children}</div>
</TooltipTrigger>
<TooltipContent
side={side}
className={cn('max-w-xs break-all', tooltipClassName)}
>
{content}
</TooltipContent>
</Tooltip>
)
}
function getTextContent(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(getTextContent).join('')
return ''
}
@@ -0,0 +1,71 @@
/*
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 type { Row, Table as TanstackTable } from '@tanstack/react-table'
import type * as React from 'react'
export type DataTableColumnClassName = (
columnId: string,
kind: 'header' | 'cell'
) => string | undefined
export type DataTablePinnedColumn = {
columnId: string
side: 'left' | 'right'
className?: string
headerClassName?: string
cellClassName?: string
}
export type DataTableRenderRowHelpers = {
getCellClassName: (columnId: string, className?: string) => string | undefined
}
export type DataTableViewProps<TData> = {
table: TanstackTable<TData>
isLoading?: boolean
rows?: Row<TData>[]
emptyTitle?: string
emptyDescription?: string
emptyIcon?: React.ReactNode
emptyAction?: React.ReactNode
emptyContent?: React.ReactNode
emptyCellClassName?: string
skeletonKeyPrefix?: string
skeletonRowHeight?: string
renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
getRowClassName?: (row: Row<TData>) => string | undefined
getColumnClassName?: DataTableColumnClassName
pinnedColumns?: DataTablePinnedColumn[]
applyHeaderSize?: boolean
tableClassName?: string
tableHeaderClassName?: string
tableHeaderRowClassName?: string
tableBodyClassName?: string
tableBodyRowClassName?: string
splitHeader?: boolean
splitHeaderScrollClassName?: string
bodyContainerClassName?: string
containerClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableContainerClassName?: string
colgroup?: React.ReactNode
}
@@ -0,0 +1,103 @@
/*
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'
export const DATA_TABLE_VIEW_MODES = {
TABLE: 'table',
CARD: 'card',
} as const
export type DataTableViewMode =
(typeof DATA_TABLE_VIEW_MODES)[keyof typeof DATA_TABLE_VIEW_MODES]
function isViewMode(value: unknown): value is DataTableViewMode {
return (
value === DATA_TABLE_VIEW_MODES.TABLE ||
value === DATA_TABLE_VIEW_MODES.CARD
)
}
function readViewMode(
storageKey: string | undefined,
fallback: DataTableViewMode
): DataTableViewMode {
if (!storageKey || typeof window === 'undefined') {
return fallback
}
try {
const raw = window.localStorage.getItem(storageKey)
return isViewMode(raw) ? raw : fallback
} catch {
return fallback
}
}
type UseDataTableViewModeOptions = {
/**
* localStorage key for persisting the selected view mode. When omitted the
* selection lives only in memory (resets on reload).
*/
storageKey?: string
/** Initial mode used when nothing is persisted. Defaults to `'table'`. */
defaultMode?: DataTableViewMode
}
/**
* View-mode (table vs. card) state with optional per-table localStorage
* persistence. Mirrors the SSR/try-catch guarded approach used for column
* visibility persistence in {@link useDataTable}.
*/
export function useDataTableViewMode(
options: UseDataTableViewModeOptions = {}
): [DataTableViewMode, (mode: DataTableViewMode) => void] {
const defaultMode = options.defaultMode ?? DATA_TABLE_VIEW_MODES.TABLE
const storageKey = options.storageKey
const [viewMode, setViewModeState] = React.useState<DataTableViewMode>(() =>
readViewMode(storageKey, defaultMode)
)
// Re-hydrate when the storage key changes (e.g. switching tables).
const hydratedStorageKeyRef = React.useRef(storageKey)
React.useEffect(() => {
if (storageKey === hydratedStorageKeyRef.current) {
return
}
hydratedStorageKeyRef.current = storageKey
setViewModeState(readViewMode(storageKey, defaultMode))
}, [storageKey, defaultMode])
const setViewMode = React.useCallback(
(mode: DataTableViewMode) => {
setViewModeState(mode)
if (!storageKey || typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(storageKey, mode)
} catch {
// Storage can be unavailable in private mode; controls still work.
}
},
[storageKey]
)
return [viewMode, setViewMode]
}
@@ -0,0 +1,527 @@
/*
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 {
type ColumnDef,
type ColumnFiltersState,
type ColumnSizingState,
type ExpandedState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
type VisibilityState,
getCoreRowModel,
getExpandedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import * as React from 'react'
type DataTableFeatureOptions<TData> = Pick<
TableOptions<TData>,
| 'enableRowSelection'
| 'getRowId'
| 'getSubRows'
| 'globalFilterFn'
| 'autoResetPageIndex'
| 'manualFiltering'
| 'manualPagination'
| 'manualSorting'
| 'enableSorting'
| 'enableColumnResizing'
>
type DataTableStateOptions = {
initialSorting?: SortingState
sorting?: SortingState
onSortingChange?: OnChangeFn<SortingState>
initialColumnVisibility?: VisibilityState
columnVisibilityStorageKey?: string | false
columnVisibility?: VisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
initialColumnSizing?: ColumnSizingState
columnSizingStorageKey?: string | false
columnSizing?: ColumnSizingState
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
initialRowSelection?: RowSelectionState
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
initialExpanded?: ExpandedState
expanded?: ExpandedState
onExpandedChange?: OnChangeFn<ExpandedState>
columnFilters?: ColumnFiltersState
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
globalFilter?: string
onGlobalFilterChange?: OnChangeFn<string>
initialPagination?: PaginationState
pagination?: PaginationState
onPaginationChange?: OnChangeFn<PaginationState>
}
type DataTableRowModelOptions = {
withFilteredRowModel?: boolean
withPaginationRowModel?: boolean
withSortedRowModel?: boolean
withFacetedRowModel?: boolean
withExpandedRowModel?: boolean
}
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
DataTableStateOptions &
DataTableRowModelOptions & {
data: TData[]
columns: ColumnDef<TData, unknown>[]
totalCount?: number
pageCount?: number
ensurePageInRange?: (pageCount: number) => void
}
type ColumnSizingBounds = Record<
string,
{
minSize?: number
maxSize?: number
}
>
type ColumnWithSizing<TData> = ColumnDef<TData, unknown> & {
accessorKey?: string | number
columns?: ColumnDef<TData, unknown>[]
}
const COLUMN_SIZING_PERSIST_DELAY_MS = 250
function resolveUpdater<TValue>(
updater: Updater<TValue>,
previous: TValue
): TValue {
return typeof updater === 'function'
? (updater as (old: TValue) => TValue)(previous)
: updater
}
function useControllableTableState<TValue>(
controlledValue: TValue | undefined,
defaultValue: TValue,
onChange: OnChangeFn<TValue> | undefined
): [TValue, OnChangeFn<TValue>] {
const [uncontrolledValue, setUncontrolledValue] =
React.useState<TValue>(defaultValue)
const value = controlledValue ?? uncontrolledValue
const setValue = React.useCallback<OnChangeFn<TValue>>(
(updater) => {
if (controlledValue === undefined) {
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
}
onChange?.(updater)
},
[controlledValue, onChange]
)
return [value, setValue]
}
function readColumnVisibility(storageKey: string | undefined): VisibilityState {
if (!storageKey || typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return {}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.entries(parsed).reduce<VisibilityState>(
(visibility, [key, value]) => {
if (typeof value === 'boolean') {
visibility[key] = value
}
return visibility
},
{}
)
} catch {
return {}
}
}
function getColumnId<TData>(column: ColumnDef<TData, unknown>) {
const columnWithSizing = column as ColumnWithSizing<TData>
if (typeof columnWithSizing.id === 'string') {
return columnWithSizing.id
}
if (typeof columnWithSizing.accessorKey === 'string') {
return columnWithSizing.accessorKey.replaceAll('.', '_')
}
if (typeof columnWithSizing.accessorKey === 'number') {
return String(columnWithSizing.accessorKey)
}
return undefined
}
function buildColumnSizingBounds<TData>(
columns: ColumnDef<TData, unknown>[]
): ColumnSizingBounds {
return columns.reduce<ColumnSizingBounds>((bounds, column) => {
const columnWithSizing = column as ColumnWithSizing<TData>
const columnId = getColumnId(column)
if (columnId) {
const minSize =
typeof columnWithSizing.minSize === 'number' &&
Number.isFinite(columnWithSizing.minSize)
? columnWithSizing.minSize
: undefined
const maxSize =
typeof columnWithSizing.maxSize === 'number' &&
Number.isFinite(columnWithSizing.maxSize)
? columnWithSizing.maxSize
: undefined
if (minSize !== undefined || maxSize !== undefined) {
bounds[columnId] = { minSize, maxSize }
}
}
if (Array.isArray(columnWithSizing.columns)) {
Object.assign(bounds, buildColumnSizingBounds(columnWithSizing.columns))
}
return bounds
}, {})
}
function getBoundedColumnSize(
columnId: string,
value: unknown,
bounds: ColumnSizingBounds
) {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return undefined
}
const columnBounds = bounds[columnId]
let size = value
if (columnBounds?.minSize !== undefined && size < columnBounds.minSize) {
size = columnBounds.minSize
}
if (columnBounds?.maxSize !== undefined && size > columnBounds.maxSize) {
size = columnBounds.maxSize
}
return size > 0 ? size : undefined
}
function readColumnSizing(
storageKey: string | undefined,
bounds: ColumnSizingBounds
): ColumnSizingState {
if (!storageKey || typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return {}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.entries(parsed).reduce<ColumnSizingState>(
(sizing, [key, value]) => {
const boundedSize = getBoundedColumnSize(key, value, bounds)
if (boundedSize !== undefined) {
sizing[key] = boundedSize
}
return sizing
},
{}
)
} catch {
return {}
}
}
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
const {
data,
columns,
totalCount,
pageCount: explicitPageCount,
ensurePageInRange,
manualFiltering,
manualPagination,
manualSorting,
initialSorting = [],
initialColumnVisibility = {},
initialColumnSizing = {},
initialRowSelection = {},
initialExpanded = {},
initialPagination = { pageIndex: 0, pageSize: 20 },
withFilteredRowModel = !manualFiltering,
withPaginationRowModel = !manualPagination,
withSortedRowModel = !manualSorting && !manualPagination,
withFacetedRowModel = !manualFiltering,
withExpandedRowModel = false,
} = options
const columnVisibilityStorageKey =
typeof options.columnVisibilityStorageKey === 'string'
? options.columnVisibilityStorageKey
: undefined
const columnSizingStorageKey =
typeof options.columnSizingStorageKey === 'string'
? options.columnSizingStorageKey
: undefined
const resolvedInitialColumnVisibility = React.useMemo(
() => ({
...initialColumnVisibility,
...readColumnVisibility(columnVisibilityStorageKey),
}),
[columnVisibilityStorageKey, initialColumnVisibility]
)
const columnSizingBounds = React.useMemo(
() => buildColumnSizingBounds(columns),
[columns]
)
const resolvedInitialColumnSizing = React.useMemo(
() => ({
...initialColumnSizing,
...readColumnSizing(columnSizingStorageKey, columnSizingBounds),
}),
[columnSizingBounds, columnSizingStorageKey, initialColumnSizing]
)
const [sorting, onSortingChange] = useControllableTableState(
options.sorting,
initialSorting,
options.onSortingChange
)
const [columnVisibility, onColumnVisibilityChange] =
useControllableTableState(
options.columnVisibility,
resolvedInitialColumnVisibility,
options.onColumnVisibilityChange
)
const [columnSizing, onColumnSizingChange] = useControllableTableState(
options.columnSizing,
resolvedInitialColumnSizing,
options.onColumnSizingChange
)
const hydratedColumnVisibilityStorageKeyRef = React.useRef(
columnVisibilityStorageKey
)
const hydratedColumnSizingStorageKeyRef = React.useRef(columnSizingStorageKey)
const skipNextColumnVisibilityPersistRef = React.useRef(false)
const skipNextColumnSizingPersistRef = React.useRef(false)
const columnSizingPersistTimerRef = React.useRef<number | undefined>(
undefined
)
const [rowSelection, onRowSelectionChange] = useControllableTableState(
options.rowSelection,
initialRowSelection,
options.onRowSelectionChange
)
const [expanded, onExpandedChange] = useControllableTableState(
options.expanded,
initialExpanded,
options.onExpandedChange
)
const [pagination, onPaginationChange] = useControllableTableState(
options.pagination,
initialPagination,
options.onPaginationChange
)
const resolvedPageCount =
explicitPageCount ??
(totalCount !== undefined
? Math.ceil(totalCount / pagination.pageSize)
: undefined)
const resolvedEnableSorting =
options.enableSorting ??
(!manualPagination ||
Boolean(options.sorting) ||
Boolean(options.onSortingChange))
const table = useReactTable({
data,
columns,
rowCount: totalCount,
pageCount: resolvedPageCount,
state: {
sorting,
columnVisibility,
columnSizing,
rowSelection,
expanded,
columnFilters: options.columnFilters,
globalFilter: options.globalFilter,
pagination,
},
enableRowSelection: options.enableRowSelection,
enableSorting: resolvedEnableSorting,
getRowId: options.getRowId,
getSubRows: options.getSubRows,
globalFilterFn: options.globalFilterFn,
autoResetPageIndex: options.autoResetPageIndex,
manualFiltering,
manualPagination,
manualSorting,
enableColumnResizing: options.enableColumnResizing,
columnResizeMode: 'onChange',
onSortingChange,
onColumnVisibilityChange,
onColumnSizingChange,
onRowSelectionChange,
onExpandedChange,
onColumnFiltersChange: options.onColumnFiltersChange,
onGlobalFilterChange: options.onGlobalFilterChange,
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: withFilteredRowModel
? getFilteredRowModel()
: undefined,
getPaginationRowModel: withPaginationRowModel
? getPaginationRowModel()
: undefined,
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
getFacetedUniqueValues: withFacetedRowModel
? getFacetedUniqueValues()
: undefined,
getExpandedRowModel: withExpandedRowModel
? getExpandedRowModel()
: undefined,
})
const actualPageCount = table.getPageCount()
React.useEffect(() => {
ensurePageInRange?.(actualPageCount)
}, [actualPageCount, ensurePageInRange])
React.useEffect(() => {
if (
options.columnVisibility !== undefined ||
columnVisibilityStorageKey ===
hydratedColumnVisibilityStorageKeyRef.current
) {
return
}
hydratedColumnVisibilityStorageKeyRef.current = columnVisibilityStorageKey
skipNextColumnVisibilityPersistRef.current = true
onColumnVisibilityChange(() => resolvedInitialColumnVisibility)
}, [
columnVisibilityStorageKey,
onColumnVisibilityChange,
options.columnVisibility,
resolvedInitialColumnVisibility,
])
React.useEffect(() => {
if (
options.columnSizing !== undefined ||
columnSizingStorageKey === hydratedColumnSizingStorageKeyRef.current
) {
return
}
hydratedColumnSizingStorageKeyRef.current = columnSizingStorageKey
skipNextColumnSizingPersistRef.current = true
onColumnSizingChange(() => resolvedInitialColumnSizing)
}, [
columnSizingStorageKey,
onColumnSizingChange,
options.columnSizing,
resolvedInitialColumnSizing,
])
React.useEffect(() => {
if (!columnVisibilityStorageKey || typeof window === 'undefined') return
if (skipNextColumnVisibilityPersistRef.current) {
skipNextColumnVisibilityPersistRef.current = false
return
}
try {
window.localStorage.setItem(
columnVisibilityStorageKey,
JSON.stringify(columnVisibility)
)
} catch {
// Storage can be unavailable in private mode; table controls still work.
}
}, [columnVisibility, columnVisibilityStorageKey])
React.useEffect(() => {
if (!columnSizingStorageKey || typeof window === 'undefined') return
if (skipNextColumnSizingPersistRef.current) {
skipNextColumnSizingPersistRef.current = false
return
}
if (columnSizingPersistTimerRef.current !== undefined) {
window.clearTimeout(columnSizingPersistTimerRef.current)
}
columnSizingPersistTimerRef.current = window.setTimeout(() => {
try {
window.localStorage.setItem(
columnSizingStorageKey,
JSON.stringify(columnSizing)
)
} catch {
// Storage can be unavailable in private mode; table controls still work.
} finally {
columnSizingPersistTimerRef.current = undefined
}
}, COLUMN_SIZING_PERSIST_DELAY_MS)
return () => {
if (columnSizingPersistTimerRef.current !== undefined) {
window.clearTimeout(columnSizingPersistTimerRef.current)
columnSizingPersistTimerRef.current = undefined
}
}
}, [columnSizing, columnSizingStorageKey])
return {
table,
}
}
@@ -0,0 +1,113 @@
/*
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 type { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
import * as React from 'react'
import { useDebounce } from '@/hooks/use-debounce'
type UseDebouncedColumnFilterOptions = {
columnFilters: ColumnFiltersState
columnId: string
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
delay?: number
}
export function useDebouncedColumnFilter({
columnFilters,
columnId,
onColumnFiltersChange,
delay = 500,
}: UseDebouncedColumnFilterOptions) {
const value =
(columnFilters.find((filter) => filter.id === columnId)?.value as
| string
| undefined) ?? ''
const [inputValue, setInputValue] = React.useState(value)
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.
if (!isComposingRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setInputValue(value)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setPendingValue(value)
}, [value])
React.useEffect(() => {
if (debouncedValue === value) return
onColumnFiltersChangeRef.current((previous) => {
const filters = previous.filter((filter) => filter.id !== columnId)
return debouncedValue
? [...filters, { id: columnId, value: debouncedValue }]
: filters
})
}, [columnId, debouncedValue, value])
const updateInputValue = React.useCallback((nextValue: string) => {
setInputValue(nextValue)
if (!isComposingRef.current) {
setPendingValue(nextValue)
}
}, [])
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
updateInputValue(event.target.value)
},
[updateInputValue]
)
const handleCompositionStart = React.useCallback(() => {
isComposingRef.current = true
}, [])
const handleCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
isComposingRef.current = false
const nextValue = event.currentTarget.value
setInputValue(nextValue)
setPendingValue(nextValue)
},
[]
)
const resetInput = React.useCallback(() => {
isComposingRef.current = false
setInputValue('')
setPendingValue('')
}, [])
return {
value,
inputValue,
setInputValue: updateInputValue,
onChange: handleChange,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
resetInput,
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
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
*/
export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header'
export { BadgeCell } from './core/badge-cell'
export { BadgeListCell } from './core/badge-list-cell'
export { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableBulkActions } from './toolbar/bulk-actions'
export {
StaticDataTable,
type StaticDataTableColumn,
} from './static/static-data-table'
export { StaticRowActions } from './static/static-row-actions'
export { staticDataTableClassNames } from './static/static-data-table-classnames'
export {
DataTableRow,
DataTableRowActionMenu,
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from './core/data-table-view'
export { MobileCardList } from './layout/mobile-card-list'
export {
DataTableCardGrid,
type DataTableCardGridProps,
type DataTableCardHelpers,
} from './layout/card-grid'
export { CardRowContent } from './layout/card-row-content'
export { tableHasCompactMeta } from './layout/card-cell-utils'
export {
DataTablePage,
type DataTablePageProps,
} from './layout/data-table-page'
export {
DataTableViewModeToggle,
type DataTableViewModeToggleProps,
} from './toolbar/view-mode-toggle'
export { useDataTable } from './hooks/use-data-table'
export {
useDataTableViewMode,
DATA_TABLE_VIEW_MODES,
type DataTableViewMode,
} from './hooks/use-data-table-view-mode'
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP =
'[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] data-[state=selected]:hover:![--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
export const DISABLED_ROW_MOBILE =
'[--data-table-card-bg:var(--table-disabled)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)]'
@@ -0,0 +1,59 @@
/*
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 { flexRender, type Cell, type Table } from '@tanstack/react-table'
import type { ReactNode } from 'react'
/**
* Shared cell helpers for the column-meta-driven card content used by both the
* mobile list and the desktop card grid. Kept separate from the card content
* component so the module exports only non-component utilities.
*/
export function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
const { header, meta } = cell.column.columnDef
if (typeof header === 'string') {
return header
}
if (meta?.label) {
return meta.label
}
return null
}
export function renderCellContent<TData>(
cell: Cell<TData, unknown>
): ReactNode {
const cellRenderer = cell.column.columnDef.cell
if (cellRenderer) {
return flexRender(cellRenderer, cell.getContext())
}
return cell.getValue() as ReactNode
}
/**
* Whether any visible column declares `mobileTitle`/`mobileBadge` meta. When
* true the compact two-tier layout is used; otherwise the condensed
* label:value fallback layout is used.
*/
export function tableHasCompactMeta<TData>(table: Table<TData>): boolean {
return table.getVisibleLeafColumns().some((col) => {
const meta = col.columnDef.meta
return Boolean(meta?.mobileTitle || meta?.mobileBadge)
})
}
@@ -0,0 +1,202 @@
/*
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 type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
/*
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 { useTranslation } from 'react-i18next'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content'
/** Helpers passed to a custom {@link DataTableCardGridProps.renderCard}. */
export type DataTableCardHelpers = {
/**
* Whether the table declares compact card meta (`mobileTitle`/`mobileBadge`).
* Provided so custom renderers can match the default layout decision.
*/
compact: boolean
/**
* Row selection state captured before entering memoized custom card renderers.
*/
isSelected: boolean
}
export interface DataTableCardGridProps<TData> {
table: Table<TData>
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
emptyIcon?: React.ReactNode
getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
/**
* Custom card renderer. When omitted, cards render generically from the
* column definitions via {@link CardRowContent} (driven by column meta).
*/
renderCard?: (
row: Row<TData>,
helpers: DataTableCardHelpers
) => React.ReactNode
/**
* Responsive grid className override. Defaults to a 1/2/3-column grid.
*/
gridClassName?: string
/** Stable key prefix for skeleton cards. */
skeletonKeyPrefix?: string
}
const DEFAULT_GRID_CLASSNAME =
'grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'
function CardGridSkeleton(props: {
gridClassName?: string
keyPrefix?: string
}) {
const prefix = props.keyPrefix ?? 'card-skeleton'
return (
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={`${prefix}-${i}`}
className='space-y-3 rounded-lg border bg-(--table-row) p-3'
>
<div className='flex items-center justify-between gap-2'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
{[1, 2, 3, 4].map((j) => (
<div key={j}>
<Skeleton className='mb-1 h-2 w-8' />
<Skeleton className='h-4 w-full' />
</div>
))}
</div>
</div>
))}
</div>
)
}
/**
* Desktop card view for table data — a responsive grid of bordered cards.
*
* Renders the same per-row content as {@link MobileCardList} (via
* {@link CardRowContent}) unless a custom `renderCard` is supplied. This keeps
* the card view reusable across any table with zero per-feature work while
* still allowing a bespoke card design when desired.
*
* The default generic card omits the `select` column. Custom `renderCard`
* implementations can use `helpers.isSelected` to keep selection UI in sync.
*/
export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
const { t } = useTranslation()
const resolvedEmptyTitle = props.emptyTitle ?? t('No Data')
const resolvedEmptyDescription =
props.emptyDescription ?? t('No data available')
const visibleColumns = props.table.getVisibleLeafColumns()
const compact = React.useMemo(
() => tableHasCompactMeta(props.table),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleColumns]
)
if (props.isLoading) {
return (
<CardGridSkeleton
gridClassName={props.gridClassName}
keyPrefix={props.skeletonKeyPrefix}
/>
)
}
const rows = props.table.getRowModel().rows
if (!rows || rows.length === 0) {
return (
<div className='rounded-lg border p-6'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
{props.emptyIcon ?? <Database className='size-6' />}
</EmptyMedia>
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
{rows.map((row) => {
const key = props.getRowKey ? props.getRowKey(row) : row.id
const isSelected = row.getIsSelected()
return (
<div
key={key}
data-slot='data-table-card'
data-state={isSelected ? 'selected' : undefined}
className={cn(
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
props.getRowClassName?.(row)
)}
>
{props.renderCard ? (
props.renderCard(row, { compact, isSelected })
) : (
<CardRowContent row={row} compact={compact} />
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,217 @@
/*
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 type { Cell, Row } from '@tanstack/react-table'
import * as React from 'react'
import { StatusBadgeTypeContext } from '@/components/status-badge'
import { getCellLabel, renderCellContent } from './card-cell-utils'
function orderCardCells<TData>(
cells: Cell<TData, unknown>[]
): Cell<TData, unknown>[] {
return [...cells].sort((a, b) => {
const aOrder = a.column.columnDef.meta?.mobileOrder
const bOrder = b.column.columnDef.meta?.mobileOrder
if (aOrder == null && bOrder == null) return 0
if (aOrder == null) return 1
if (bOrder == null) return -1
return aOrder - bOrder
})
}
/**
* Shared, column-meta-driven card content rendering for TanStack rows.
*
* Both {@link MobileCardList} (mobile) and {@link DataTableCardGrid} (desktop
* card view) render the same inner content; only the surrounding container
* differs (single bordered list vs. responsive grid of cards). Keeping the
* per-row content here guarantees the two stay visually consistent.
*
* Column meta extensions (see `card-cell-utils.ts`):
* - `mobileTitle` — card header (left, larger text)
* - `mobileBadge` — inline with title (right, e.g. status badge)
* - `mobileHidden` — hidden in card content
*/
/**
* Compact content — structured layout with title header + side-by-side fields.
* Used when columns define mobileTitle or mobileBadge meta.
*
* Visual structure:
* [Title content] [Badge]
* [Field1 label] [Field2 label]
* [Field1 value] [Field2 value]
* [Actions ⋯]
*/
function CompactContent<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.getVisibleCells()
.filter((cell) => cell.column.id !== 'select')
// 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 = orderCardCells(
allCells.filter(
(c, i) =>
c !== titleCell &&
c !== badgeCell &&
c !== actionsCell &&
!cellMetas[i]?.mobileHidden
)
)
return (
<>
{/* Row 1: Title + Badge */}
<div className='flex items-center justify-between gap-2'>
{titleCell && (
<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='flex-none [&_[data-slot=status-badge]]:max-w-none'>
{renderCellContent(badgeCell)}
</div>
)}
</div>
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
{fieldCells.length > 0 && (
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
{fieldCells.map((cell) => {
const label = getCellLabel(cell)
return (
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
{label && (
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
{label}
</div>
)}
<div className='min-w-0 overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
</div>
)}
{/* Actions */}
{actionsCell && (
<div className='mt-1 -mb-0.5 flex justify-end'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Fallback content — condensed label:value pairs for tables without
* mobileTitle/mobileBadge. Still respects mobileHidden.
*/
function FallbackContent<TData>({ row }: { row: Row<TData> }) {
const allCells = row
.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 = orderCardCells(
allCells.filter(
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
)
)
return (
<>
{contentCells.map((cell) => {
const label = getCellLabel(cell)
if (!label) {
return (
<div
key={cell.id}
className='flex justify-end overflow-hidden [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'
>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
</StatusBadgeTypeContext.Provider>
</div>
)
}
return (
<div
key={cell.id}
className='flex items-start justify-between gap-2 overflow-hidden'
>
<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 [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div>
</div>
)
})}
{actionsCell && (
<div className='-mb-0.5 flex justify-end pt-0.5'>
{renderCellContent(actionsCell)}
</div>
)}
</>
)
}
/**
* Renders a single row's card content, auto-selecting the compact or fallback
* layout. Callers compute `compact` once per table (via `tableHasCompactMeta`)
* and pass it down to avoid recomputation per row.
*/
export function CardRowContent<TData>(props: {
row: Row<TData>
compact: boolean
}) {
return props.compact ? (
<CompactContent row={props.row} />
) : (
<FallbackContent row={props.row} />
)
}
@@ -0,0 +1,549 @@
/*
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 type {
ColumnDef,
Row,
Table as TanstackTable,
} from '@tanstack/react-table'
/*
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 { PageFooterPortal } from '@/components/layout/components/page-footer'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from '../core/data-table-view'
import { DataTablePagination } from '../core/pagination'
import {
DATA_TABLE_VIEW_MODES,
useDataTableViewMode,
type DataTableViewMode,
} from '../hooks/use-data-table-view-mode'
import { DataTableToolbar } from '../toolbar/toolbar'
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
import { DataTableCardGrid } from './card-grid'
import { MobileCardList } from './mobile-card-list'
/**
* Pass-through configuration for the default {@link DataTableToolbar}.
* Pass `toolbar` (ReactNode) instead to fully replace the default toolbar.
*/
export type DataTablePageToolbarProps<TData> = Omit<
React.ComponentProps<typeof DataTableToolbar<TData>>,
'table'
>
export type DataTablePageProps<TData> = {
/**
* TanStack Table instance returned from `useReactTable`.
*/
table: TanstackTable<TData>
/**
* Column definitions. Used for skeleton column count and empty-state colSpan.
*/
columns: ColumnDef<TData, unknown>[]
/**
* Initial loading state — renders {@link TableSkeleton} or mobile skeleton.
*/
isLoading?: boolean
/**
* Refetch / background loading — dims the table without removing rows.
*/
isFetching?: boolean
/**
* Empty-state title (used for both desktop {@link TableEmpty} and mobile fallback).
*/
emptyTitle?: string
/**
* Empty-state description.
*/
emptyDescription?: string
/**
* Empty-state icon override (desktop only; mobile uses default Database icon).
*/
emptyIcon?: React.ReactNode
/**
* Empty-state extra content — e.g. a "Create" button below the message.
*/
emptyAction?: React.ReactNode
/**
* Custom toolbar node — fully replaces the default {@link DataTableToolbar}.
* Useful for layouts like "primary buttons + toolbar" or feature-specific filter cards.
* If provided, `toolbarProps` is ignored.
*/
toolbar?: React.ReactNode
/**
* Pass-through props for the default {@link DataTableToolbar}.
* Ignored if `toolbar` is provided. Pass `null` to omit the toolbar entirely.
*/
toolbarProps?: DataTablePageToolbarProps<TData> | null
/**
* Bulk action bar — typically a wrapped {@link DataTableBulkActions} component.
* Rendered only on desktop (mobile selection is uncommon).
*/
bulkActions?: React.ReactNode
/**
* Custom mobile list node — fully replaces the default {@link MobileCardList}.
*/
mobile?: React.ReactNode
/**
* Pass-through props for the default {@link MobileCardList}.
* Ignored if `mobile` is provided.
*/
mobileProps?: {
getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
}
/**
* Disable the mobile-specific layout entirely — always renders desktop table.
* Useful for pages where the table is read-only and short.
*/
hideMobile?: boolean
/**
* Row className resolver — applied to both desktop `TableRow` and mobile card.
* Composes with the default `data-state="selected"` styling on desktop.
* The `ctx.isMobile` flag is provided so consumers can return the
* appropriate variant (e.g. `DISABLED_ROW_DESKTOP` vs `DISABLED_ROW_MOBILE`)
* without having to re-call `useMediaQuery` themselves.
*/
getRowClassName?: (
row: Row<TData>,
ctx: { isMobile: boolean }
) => string | undefined
/**
* Custom desktop row renderer — replaces the default `<TableRow>`/`<TableCell>` mapping.
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
*/
renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
/**
* Desktop column className resolver. Use for semantic alignment/spacing only;
* fixed-column behavior should be configured with `pinnedColumns`.
*/
getColumnClassName?: DataTableColumnClassName
/**
* Fixed desktop columns. The shared table component owns sticky position,
* layering, shadows, and row-state backgrounds.
*/
pinnedColumns?: DataTablePinnedColumn[]
/**
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
* Enable this when your column definitions include `size` and you want it honored.
* Off by default (TanStack Table assigns a default size of 150 to all columns
* which would unintentionally constrain layouts that don't define sizes).
*/
applyHeaderSize?: boolean
/**
* Optional skeleton key prefix for stable React keys across re-renders.
*/
skeletonKeyPrefix?: string
/**
* Whether to render pagination. Defaults to `true`.
*/
showPagination?: boolean
/**
* Render pagination via `PageFooterPortal` (sticks to page footer).
* Defaults to `true`. Set `false` to render inline below the table.
*/
paginationInFooter?: boolean
/**
* Extra content rendered between the table/mobile list and the pagination.
* E.g. summary stats, helper text.
*/
afterTable?: React.ReactNode
/**
* Outer wrapper className (applied to the toolbar+table column).
*/
className?: string
/**
* Make the desktop table consume the available page height and scroll inside
* the table body while keeping the header fixed. Defaults to `true`.
*/
fixedHeight?: boolean
/**
* Desktop table container className (the bordered scroll wrapper).
*/
tableClassName?: string
/**
* Desktop `<TableHeader>` className override.
* Use for header color/spacing overrides. Fixed-height pages keep the header
* outside the scrollable body automatically.
*/
tableHeaderClassName?: string
/**
* Opt into the table/card view toggle. Defaults to `false`, so existing
* pages render the table only and behave exactly as before. When enabled, a
* {@link DataTableViewModeToggle} is injected into the default toolbar
* (requires `toolbarProps`; ignored when a fully custom `toolbar` is used)
* and the view switches between the table and a card grid on desktop and
* mobile. Mobile card mode reuses the same card renderer in a single column.
*/
enableCardView?: boolean
/**
* Controlled view mode. When provided, `onViewModeChange` should update it.
* Leave unset to let the page manage view mode internally (optionally
* persisted via `viewModeStorageKey`).
*/
viewMode?: DataTableViewMode
/**
* Change handler for the controlled `viewMode`.
*/
onViewModeChange?: (mode: DataTableViewMode) => void
/**
* localStorage key for persisting the (uncontrolled) view mode per table.
* Ignored when `viewMode` is controlled.
*/
viewModeStorageKey?: string
/**
* Initial (uncontrolled) view mode. When unset, defaults to `'card'` if
* `enableCardView` is `true`, otherwise `'table'`. A persisted selection
* (via `viewModeStorageKey`) always takes precedence over this default.
*/
defaultViewMode?: DataTableViewMode
/**
* Custom card renderer for card view. When omitted, cards are generated
* generically from the column definitions (driven by column meta).
*/
renderCard?: React.ComponentProps<
typeof DataTableCardGrid<TData>
>['renderCard']
/**
* Responsive grid className override for the card view.
*/
cardGridClassName?: string
}
/**
* Unified table page wrapper. Encapsulates the canonical structure used across
* all list pages: toolbar → desktop table / mobile list → pagination, plus
* loading/empty states and an opt-in bulk action bar.
*
* Most pages should be expressible as:
* ```tsx
* <DataTablePage
* table={table}
* columns={columns}
* isLoading={isLoading}
* isFetching={isFetching}
* emptyTitle={t('No X Found')}
* toolbarProps={{ searchPlaceholder: t('Filter...'), filters }}
* bulkActions={<MyBulkActions table={table} />}
* />
* ```
*
* For complex layouts (custom mobile, expanded rows, custom toolbar), use the
* `toolbar` / `mobile` / `renderRow` slots instead of the `*Props` variants.
*/
export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const isMobile = useMediaQuery('(max-width: 640px)')
const showMobile = isMobile && !props.hideMobile
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
storageKey: props.viewModeStorageKey,
// When card view is enabled, prefer it as the default unless the consumer
// explicitly opts into a different initial mode. A persisted choice (via
// `viewModeStorageKey`) still takes precedence over this default.
defaultMode:
props.defaultViewMode ??
(props.enableCardView ? DATA_TABLE_VIEW_MODES.CARD : undefined),
})
const viewMode = props.viewMode ?? internalViewMode
const setViewMode = props.onViewModeChange ?? setInternalViewMode
const cardViewActive = !!props.enableCardView
const viewToggle = cardViewActive ? (
<DataTableViewModeToggle value={viewMode} onChange={setViewMode} />
) : undefined
const toolbarNode = renderToolbar(props, viewToggle)
const mobileNode = renderMobile(props, showMobile, cardViewActive, viewMode)
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
const paginationNode = renderPagination(props)
return (
<>
<div
className={cn(
props.fixedHeight !== false
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
: 'space-y-2.5 sm:space-y-3',
props.className
)}
>
{toolbarNode}
{mobileNode}
{desktopNode}
{props.afterTable}
</div>
{/* Bulk actions are typically a fixed-position toolbar; let the consumer
handle its own visibility, we just gate it to non-mobile. */}
{!showMobile && props.bulkActions}
{paginationNode}
</>
)
}
function renderToolbar<TData>(
props: DataTablePageProps<TData>,
viewToggle: React.ReactNode
): React.ReactNode {
if (props.toolbar !== undefined) {
// Fully custom toolbar: the consumer owns layout, including any toggle.
return props.toolbar
}
if (props.toolbarProps === null) {
return null
}
if (props.toolbarProps) {
return (
<DataTableToolbar
table={props.table}
{...props.toolbarProps}
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
/>
)
}
return null
}
function renderPagination<TData>(
props: DataTablePageProps<TData>
): React.ReactNode {
if (props.showPagination === false) {
return null
}
const pagination = <DataTablePagination table={props.table} />
return props.paginationInFooter !== false ? (
<PageFooterPortal>{pagination}</PageFooterPortal>
) : (
<div className='pt-2'>{pagination}</div>
)
}
function renderMobile<TData>(
props: DataTablePageProps<TData>,
showMobile: boolean,
cardViewActive: boolean,
viewMode: DataTableViewMode
): React.ReactNode {
if (!showMobile) {
return null
}
const isFetchingOnly = props.isFetching && !props.isLoading
const ownGetRowClassName = props.getRowClassName
const mobileGetRowClassName =
props.mobileProps?.getRowClassName ??
(ownGetRowClassName
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
: undefined)
let mobileContent = props.mobile
if (mobileContent === undefined) {
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.TABLE) {
mobileContent = (
<DataTableView
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
emptyAction={props.emptyAction}
skeletonKeyPrefix={props.skeletonKeyPrefix}
renderRow={props.renderRow}
applyHeaderSize={props.applyHeaderSize}
tableHeaderClassName={cn(
'[background-color:var(--table-header)]',
props.tableHeaderClassName
)}
getColumnClassName={props.getColumnClassName}
pinnedColumns={props.pinnedColumns}
containerClassName={cn(
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName
)}
getRowClassName={(row) =>
props.getRowClassName?.(row, { isMobile: false })
}
/>
)
} else if (cardViewActive) {
mobileContent = (
<DataTableCardGrid
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
renderCard={props.renderCard}
gridClassName={props.cardGridClassName ?? 'grid grid-cols-1 gap-3'}
skeletonKeyPrefix={props.skeletonKeyPrefix}
getRowKey={props.mobileProps?.getRowKey}
getRowClassName={mobileGetRowClassName}
/>
)
} else {
mobileContent = (
<MobileCardList
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
getRowKey={props.mobileProps?.getRowKey}
getRowClassName={mobileGetRowClassName}
/>
)
}
}
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
}
function renderDesktop<TData>(
props: DataTablePageProps<TData>,
showMobile: boolean,
cardViewActive: boolean,
viewMode: DataTableViewMode
): React.ReactNode {
if (showMobile) {
return null
}
const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.CARD) {
return (
<div
className={cn(
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60'
)}
>
<DataTableCardGrid
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
renderCard={props.renderCard}
gridClassName={props.cardGridClassName}
skeletonKeyPrefix={props.skeletonKeyPrefix}
getRowClassName={(row) =>
props.getRowClassName?.(row, { isMobile: false })
}
/>
</div>
)
}
return (
<DataTableView
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
emptyAction={props.emptyAction}
skeletonKeyPrefix={props.skeletonKeyPrefix}
renderRow={props.renderRow}
applyHeaderSize={props.applyHeaderSize}
splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn(
fixedHeight && '[background-color:var(--table-header)]',
props.tableHeaderClassName
)}
getColumnClassName={props.getColumnClassName}
pinnedColumns={props.pinnedColumns}
containerClassName={cn(
fixedHeight && 'min-h-0 flex-1',
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName
)}
getRowClassName={(row) =>
props.getRowClassName?.(row, { isMobile: false })
}
/>
)
}
@@ -0,0 +1,177 @@
/*
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 type { Row, Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
/*
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 { useTranslation } from 'react-i18next'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { tableHasCompactMeta } from './card-cell-utils'
import { CardRowContent } from './card-row-content'
interface MobileCardListProps<TData> {
table: Table<TData>
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
getRowKey?: (row: Row<TData>) => string | number
getRowClassName?: (row: Row<TData>) => string | undefined
}
function ListSkeleton() {
return (
<div className='divide-y overflow-hidden rounded-lg border'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='px-3 py-2.5'>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='mt-1.5 grid grid-cols-2 gap-2'>
<div className='flex-1'>
<Skeleton className='mb-1 h-2 w-8' />
<Skeleton className='h-4 w-full' />
</div>
<div className='flex-1'>
<Skeleton className='mb-1 h-2 w-8' />
<Skeleton className='h-4 w-full' />
</div>
</div>
</div>
))}
</div>
)
}
function FallbackListSkeleton() {
return (
<div className='divide-y overflow-hidden rounded-lg border'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='space-y-1.5 px-3 py-2.5'>
{[1, 2, 3].map((j) => (
<div key={j} className='flex items-center justify-between'>
<Skeleton className='h-2.5 w-16' />
<Skeleton className='h-3.5 w-28' />
</div>
))}
</div>
))}
</div>
)
}
/**
* Mobile-optimized list view for table data.
*
* Renders rows inside a single bordered container with dividers —
* a Vercel/Stripe-style list rather than individual cards.
*
* Per-row content is shared with the desktop card view via
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
*/
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
const {
table,
isLoading = false,
emptyTitle,
emptyDescription,
getRowKey,
getRowClassName,
} = props
const { t } = useTranslation()
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
const visibleColumns = table.getVisibleLeafColumns()
const hasCompactMeta = React.useMemo(
() => tableHasCompactMeta(table),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleColumns]
)
if (isLoading) {
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
}
const rows = table.getRowModel().rows
if (!rows || rows.length === 0) {
return (
<div className='rounded-lg border p-6'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-y overflow-hidden rounded-lg border'>
{rows.map((row) => {
const key = getRowKey ? getRowKey(row) : row.id
return (
<div
key={key}
className={cn(
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
getRowClassName?.(row)
)}
>
<CardRowContent row={row} compact={hasCompactMeta} />
</div>
)
})}
</div>
)
}
@@ -0,0 +1,47 @@
/*
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
*/
export const staticDataTableClassNames = {
container: 'overflow-hidden rounded-md border',
sectionContainer: 'border-border/60 rounded-lg',
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow:
'[background-color:var(--table-header)] hover:[background-color:var(--table-header-hover)]',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
compactCell: 'py-2.5',
compactTopCell: 'py-2.5 align-top',
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
compactMutedCell: 'text-muted-foreground py-2.5',
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
compactNumericCell: 'py-2.5 text-right font-mono',
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
topCell: 'py-2 align-top',
topMutedCell: 'text-muted-foreground py-2 align-top',
codeCell: 'font-mono text-sm',
mutedCell: 'text-muted-foreground text-sm',
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
topNumericCell: 'py-2 text-right font-mono',
mediumCell: 'font-medium',
actionHeaderCell: 'w-auto max-w-none text-right',
actionCell: 'w-auto max-w-none text-right',
} as const
@@ -0,0 +1,241 @@
/*
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { TruncatedCell } from '../core/truncated-cell'
import { staticDataTableClassNames } from './static-data-table-classnames'
type StaticDataTableBaseProps = {
className?: string
tableClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableProps?: Omit<
React.ComponentProps<typeof Table>,
'className' | 'children'
>
}
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
columns: StaticDataTableColumn<TData>[]
data: TData[]
getRowKey?: (row: TData, index: number) => React.Key
getRowClassName?: (row: TData, index: number) => string | undefined
renderRow?: (row: TData, index: number) => React.ReactNode
empty?: boolean
emptyContent?: React.ReactNode
emptyClassName?: string
headerRowClassName?: string
}
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
children: React.ReactNode
columns?: never
data?: never
}
type StaticDataTableProps<TData = unknown> =
| StaticDataTableDataProps<TData>
| StaticDataTableChildrenProps
export type StaticDataTableColumn<TData = unknown> = {
id: string
header: React.ReactNode
className?: string
cellClassName?: string | ((row: TData, index: number) => string | undefined)
cell?: (row: TData, index: number) => React.ReactNode
}
export function StaticDataTable<TData = unknown>(
props: StaticDataTableProps<TData>
) {
const { className, tableClassName, containerProps, tableProps } = props
return (
<div
className={cn(staticDataTableClassNames.container, className)}
{...containerProps}
>
<Table className={tableClassName} {...tableProps}>
{props.columns !== undefined ? (
<StaticDataTableWithColumns {...props} />
) : (
props.children
)}
</Table>
</div>
)
}
function StaticDataTableWithColumns<TData>({
columns,
data,
getRowKey,
getRowClassName,
renderRow,
empty,
emptyContent,
emptyClassName,
headerRowClassName,
}: StaticDataTableDataProps<TData>) {
const isEmpty = empty ?? (data !== undefined && data.length === 0)
const bodyRows = data.map((row, index) => (
<StaticDataTableRow
key={getRowKey?.(row, index) ?? index}
row={row}
index={index}
columns={columns}
getRowClassName={getRowClassName}
renderRow={renderRow}
/>
))
return (
<>
<TableHeader>
<TableRow className={headerRowClassName}>
{columns.map((column) => (
<TableHead key={column.id} className={column.className}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{isEmpty ? (
<StaticDataTableEmptyRow
colSpan={columns.length}
className={emptyClassName}
>
{emptyContent}
</StaticDataTableEmptyRow>
) : (
bodyRows
)}
</TableBody>
</>
)
}
type StaticDataTableRowProps<TData> = Required<
Pick<StaticDataTableDataProps<TData>, 'columns'>
> &
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
row: TData
index: number
}
function StaticDataTableRow<TData>({
row,
index,
columns,
getRowClassName,
renderRow,
}: StaticDataTableRowProps<TData>) {
if (renderRow) {
return <>{renderRow(row, index)}</>
}
return (
<TableRow className={getRowClassName?.(row, index)}>
{columns.map((column) => (
<TableCell
key={column.id}
className={cn(
'max-w-full min-w-0 overflow-hidden',
getStaticCellClassName(column, row, index)
)}
>
{renderStaticCellContent(column, row, index)}
</TableCell>
))}
</TableRow>
)
}
function renderStaticCellContent<TData>(
column: StaticDataTableColumn<TData>,
row: TData,
index: number
) {
const content = column.cell?.(row, index)
const textContent = getPrimitiveTextContent(content)
if (!textContent) return content
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
}
function getPrimitiveTextContent(content: React.ReactNode): string | null {
if (typeof content === 'string' || typeof content === 'number') {
return String(content)
}
if (
React.isValidElement<{ children?: React.ReactNode }>(content) &&
(typeof content.props.children === 'string' ||
typeof content.props.children === 'number')
) {
return String(content.props.children)
}
return null
}
function getStaticCellClassName<TData>(
column: StaticDataTableColumn<TData>,
row: TData,
index: number
) {
return typeof column.cellClassName === 'function'
? column.cellClassName(row, index)
: column.cellClassName
}
type StaticDataTableEmptyRowProps = {
colSpan: number
children: React.ReactNode
className?: string
}
function StaticDataTableEmptyRow({
colSpan,
children,
className,
}: StaticDataTableEmptyRowProps) {
return (
<TableRow>
<TableCell
colSpan={colSpan}
className={cn('h-24 text-center', className)}
>
{children}
</TableCell>
</TableRow>
)
}
@@ -0,0 +1,65 @@
/*
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 { Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '../core/row-action-menu'
type StaticRowActionsProps = {
editLabel: string
deleteLabel: string
menuLabel: string
onEdit: () => void
onDelete: () => void
editDisabled?: boolean
deleteDisabled?: boolean
}
export function StaticRowActions(props: StaticRowActionsProps) {
return (
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='icon-sm'
onClick={props.onEdit}
disabled={props.editDisabled}
aria-label={props.editLabel}
>
<Pencil />
</Button>
<DataTableRowActionMenu ariaLabel={props.menuLabel}>
<DropdownMenuItem
onClick={props.onDelete}
disabled={props.deleteDisabled}
className='text-destructive focus:text-destructive'
>
{props.deleteLabel}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
@@ -0,0 +1,238 @@
/*
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 { type Table } from '@tanstack/react-table'
import { X } from 'lucide-react'
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
entityName: string
children: React.ReactNode
}
/**
* A modular toolbar for displaying bulk actions when table rows are selected.
*
* @template TData The type of data in the table.
* @param {object} props The component props.
* @param {Table<TData>} props.table The react-table instance.
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
*/
export function DataTableBulkActions<TData>({
table,
entityName,
children,
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
const { t } = useTranslation()
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) {
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnnouncement(message)
// Clear announcement after a delay
const timer = setTimeout(() => setAnnouncement(''), 3000)
return () => clearTimeout(timer)
}
}, [selectedCount, entityName])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = buttonsRef.current
if (!buttons) return
const currentIndex = Array.from(buttons).findIndex(
(button) => button === document.activeElement
)
switch (event.key) {
case 'ArrowRight': {
event.preventDefault()
const nextIndex = (currentIndex + 1) % buttons.length
buttons[nextIndex]?.focus()
break
}
case 'ArrowLeft': {
event.preventDefault()
const prevIndex =
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
buttons[prevIndex]?.focus()
break
}
case 'Home':
event.preventDefault()
buttons[0]?.focus()
break
case 'End':
event.preventDefault()
buttons[buttons.length - 1]?.focus()
break
case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content
// We can't check dropdown state because the menu closes before our handler runs.
const target = event.target as HTMLElement
const activeElement = document.activeElement as HTMLElement
// Check if the event target or currently focused element is a dropdown trigger
const isFromDropdownTrigger =
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
activeElement?.getAttribute('data-slot') ===
'dropdown-menu-trigger' ||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
// Check if the focused element is inside dropdown content (which is portaled)
const isFromDropdownContent =
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
target?.closest('[data-slot="dropdown-menu-content"]')
if (isFromDropdownTrigger || isFromDropdownContent) {
// Escape was meant for the dropdown - don't clear selection
return
}
// Escape was meant for the toolbar - clear selection
event.preventDefault()
handleClearSelection()
break
}
}
}
if (selectedCount === 0) {
return null
}
return (
<>
{/* Live region for screen reader announcements */}
<div
aria-live='polite'
aria-atomic='true'
className='sr-only'
role='status'
>
{announcement}
</div>
<div
ref={toolbarRef}
role='toolbar'
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
aria-describedby='bulk-actions-description'
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl',
'rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='outline'
size='icon'
onClick={handleClearSelection}
className='size-6'
aria-label={t('Clear selection')}
title={t('Clear selection (Escape)')}
/>
}
>
<X />
<span className='sr-only'>{t('Clear selection')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Clear selection (Escape)')}</p>
</TooltipContent>
</Tooltip>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
<div
className='flex items-center gap-x-1 text-sm'
id='bulk-actions-description'
>
<Badge
variant='default'
className='min-w-8 rounded-lg'
aria-label={`${selectedCount} selected`}
>
{selectedCount}
</Badge>{' '}
<span className='hidden sm:inline'>
{entityName}
{selectedCount > 1 ? 's' : ''}
</span>{' '}
{t('selected')}
</div>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
{children}
</div>
</div>
</>
)
}
@@ -0,0 +1,213 @@
/*
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 { type Column } from '@tanstack/react-table'
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
type DataTableFacetedFilterProps<TData, TValue> = {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
iconNode?: React.ReactNode
count?: number
}[]
/** Enable single select mode (only one option can be selected at a time) */
singleSelect?: boolean
}
function DataTableFacetedFilterInner<TData, TValue>({
column,
title,
options,
singleSelect = false,
}: DataTableFacetedFilterProps<TData, TValue>) {
const { t } = useTranslation()
const facets = column?.getFacetedUniqueValues()
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
render={
<Button variant='outline' size='sm' className='h-8 border-dashed' />
}
>
<PlusCircledIcon className='size-4' />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden space-x-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} {t('selected')}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{t(option.label)}
</Badge>
))
)}
</div>
</>
)}
</PopoverTrigger>
<PopoverContent className='max-w-[360px] min-w-[200px] p-0' align='start'>
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>{t('No results found.')}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => handleOptionSelect(option.value)}
>
<div
className={cn(
'border-primary flex size-4 items-center justify-center rounded-sm border',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon className={cn('text-background h-4 w-4')} />
</div>
{option.iconNode ? (
<span className='text-muted-foreground flex size-4 items-center justify-center'>
{option.iconNode}
</span>
) : option.icon ? (
<option.icon className='text-muted-foreground size-4' />
) : null}
<span
className='min-w-0 flex-1 truncate'
title={t(option.label)}
>
{t(option.label)}
</span>
{typeof option.count === 'number' ? (
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
{option.count}
</span>
) : facets?.get(option.value) ? (
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
{facets.get(option.value)}
</span>
) : null}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className='justify-center text-center'
>
{t('Clear filters')}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</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)
}
@@ -0,0 +1,398 @@
/*
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 type { Table } from '@tanstack/react-table'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import * as React from 'react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils'
import { DataTableFacetedFilter } from './faceted-filter'
import { DataTableViewOptions } from './view-options'
type FilterDef = {
columnId: string
title: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
iconNode?: React.ReactNode
count?: number
}[]
singleSelect?: boolean
}
type SearchDraft = {
baseValue: string
value: string
}
export type DataTableToolbarProps<TData> = {
table: Table<TData>
/**
* Placeholder for the default search input. Defaults to `t('Filter...')`.
*/
searchPlaceholder?: string
/**
* Delay committing the default search input. Defaults to immediate updates.
*/
searchDebounceMs?: number
/**
* Column id to filter on. When provided, the search input filters
* a specific column. When omitted, the search input updates the
* table's `globalFilter`.
*/
searchKey?: string
/**
* Column-level filter chips (faceted multi-select / single-select).
*/
filters?: FilterDef[]
/**
* Replaces the default search input entirely. Use when the primary
* "search" is something custom — e.g. a date-time range picker.
*/
customSearch?: ReactNode
/**
* Extra inputs/selects displayed in the primary row alongside the
* search input and filter chips.
*/
additionalSearch?: ReactNode
/**
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
* inputs) are currently active. Controls Reset button visibility
* when no column filters are set.
*/
hasAdditionalFilters?: boolean
/**
* Callback invoked when the user clicks Reset.
*/
onReset?: () => void
/**
* Additional filter inputs hidden behind an Expand/Collapse toggle.
* Inputs flow inline with the primary row when expanded.
*/
expandable?: ReactNode
/**
* When `expandable` is collapsed, highlights the toggle if any of
* the expandable inputs currently hold a value.
*/
hasExpandedActiveFilters?: boolean
/**
* Custom action buttons rendered BEFORE the built-in
* Reset / Search / View buttons.
*/
preActions?: ReactNode
/**
* Explicit "Search" / "Apply" callback. When provided the toolbar
* shows a primary Search button. Filters are committed only on click
* (form-mode workflow).
*/
onSearch?: () => void
/**
* Loading state for the explicit Search button.
*/
searchLoading?: boolean
/**
* Hide the View Options (column visibility) dropdown.
*/
hideViewOptions?: boolean
/**
* Optional view-mode toggle (e.g. table vs. card) rendered in the right
* action cluster, before the View Options dropdown. Typically a
* {@link DataTableViewModeToggle}. Omitted by default.
*/
viewToggle?: ReactNode
/**
* Content rendered on the LEFT side of the secondary action row. When
* provided the toolbar splits into two visual rows:
* Row 1: search inputs / filter chips …… Expand
* Row 2: expanded filters
* Row 3: leftActions …… Reset / Search / ViewOptions
*/
leftActions?: ReactNode
/**
* Outer wrapper className override.
*/
className?: string
}
/**
* Unified data-table filter panel — Ant Design Pro inspired.
*
* Layout (single flex-wrap row):
* - Filters (search input + additional inputs + filter chips + expandable
* inputs) flow horizontally and wrap as needed.
* - The action cluster (Reset / Search / View / Expand) hugs the right
* edge via `ms-auto`. When filters fill a row, the cluster naturally
* wraps to the next line — still right-aligned — matching the
* collapsed/expanded states from the user's reference design.
*
* No background panel, no row separators — relies on whitespace and the
* adjacent table border for visual hierarchy.
*/
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [isSearchComposing, setIsSearchComposing] = useState(false)
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
const hasSearch = props.onSearch != null
const isFiltered =
props.table.getState().columnFilters.length > 0 ||
!!props.table.getState().globalFilter ||
!!props.hasAdditionalFilters
const placeholder = props.searchPlaceholder ?? t('Filter...')
const currentSearchValue = props.searchKey
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
'')
: ((props.table.getState().globalFilter as string | undefined) ?? '')
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(searchValue, searchDebounceMs)
const commitSearchValue = React.useCallback(
(value: string) => {
if (value === currentSearchValue) {
return
}
if (props.searchKey) {
props.table.getColumn(props.searchKey)?.setFilterValue(value)
return
}
props.table.setGlobalFilter(value)
},
[currentSearchValue, props.searchKey, props.table]
)
React.useEffect(() => {
if (
searchDebounceMs <= 0 ||
isSearchComposing ||
debouncedSearchValue !== searchValue
) {
return
}
commitSearchValue(debouncedSearchValue)
}, [
commitSearchValue,
debouncedSearchValue,
isSearchComposing,
searchDebounceMs,
searchValue,
])
const queueSearchValue = (value: string) => {
if (searchDebounceMs <= 0) {
commitSearchValue(value)
}
}
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchDraft({ baseValue: currentSearchValue, value })
if (!isSearchComposing) {
queueSearchValue(value)
}
}
const handleSearchCompositionStart = () => {
setIsSearchComposing(true)
}
const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement>
) => {
setIsSearchComposing(false)
const value = event.currentTarget.value
setSearchDraft({ baseValue: currentSearchValue, value })
queueSearchValue(value)
}
const searchInput = (
<Input
placeholder={placeholder}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
)
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 = () => {
setIsSearchComposing(false)
setSearchDraft(null)
props.table.resetColumnFilters()
props.table.setGlobalFilter('')
props.onReset?.()
}
// Reset: outline text-only for form mode (always visible, disabled when
// nothing to reset); ghost text + X for filter-as-you-type mode (only
// visible when active filters exist).
let resetButton: ReactNode = null
if (hasSearch) {
resetButton = (
<Button variant='outline' onClick={handleReset} disabled={!isFiltered}>
{t('Reset')}
</Button>
)
} else if (isFiltered) {
resetButton = (
<Button
variant='ghost'
onClick={handleReset}
className='text-muted-foreground hover:text-foreground gap-1 px-2'
>
{t('Reset')}
<Cross2Icon />
</Button>
)
}
const searchButton = hasSearch ? (
<Button onClick={props.onSearch} disabled={props.searchLoading}>
{props.searchLoading && <Loader2 className='animate-spin' />}
{t('Search')}
</Button>
) : null
const viewOptionsNode = !props.hideViewOptions ? (
<DataTableViewOptions table={props.table} />
) : null
const viewToggleNode = props.viewToggle ?? null
const expandToggle = hasExpandable ? (
<Button
variant='ghost'
onClick={() => setExpanded((p) => !p)}
aria-expanded={expanded}
className={cn(
'text-muted-foreground hover:text-foreground gap-1 px-2',
props.hasExpandedActiveFilters &&
!expanded &&
'text-primary hover:text-primary'
)}
>
{expanded ? t('Collapse') : t('Expand')}
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
</Button>
) : null
const hasLeftActions = props.leftActions != null
if (hasLeftActions) {
return (
<div className={cn('flex flex-col gap-2', props.className)}>
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{expandToggle}
</div>
</div>
{expanded && hasExpandable && (
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.expandable}
</div>
)}
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.leftActions}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
</div>
</div>
</div>
)
}
return (
<div
className={cn(
'flex flex-wrap items-center gap-2 sm:gap-3',
props.className
)}
>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
{expanded && hasExpandable && props.expandable}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
{expandToggle}
</div>
</div>
)
}
@@ -0,0 +1,106 @@
/*
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 { Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
DATA_TABLE_VIEW_MODES,
type DataTableViewMode,
} from '../hooks/use-data-table-view-mode'
export type DataTableViewModeToggleProps = {
value: DataTableViewMode
onChange: (mode: DataTableViewMode) => void
className?: string
}
type Segment = {
value: DataTableViewMode
icon: React.ComponentType<{ className?: string }>
tooltip: string
}
/**
* Reusable icon segmented control for switching a data table between table and
* card views. Shared, accessible version of the local control used by the
* model square (`pricing-toolbar.tsx`).
*/
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
const { t } = useTranslation()
const segments: Segment[] = [
{
value: DATA_TABLE_VIEW_MODES.CARD,
icon: Grid2X2,
tooltip: t('Card view'),
},
{
value: DATA_TABLE_VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]
return (
<div
role='group'
aria-label={t('View mode')}
className={cn(
'bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5',
props.className
)}
>
{segments.map((segment) => {
const Icon = segment.icon
const isActive = segment.value === props.value
return (
<Tooltip key={segment.value}>
<TooltipTrigger
render={
<button
type='button'
onClick={() => props.onChange(segment.value)}
aria-pressed={isActive}
className={cn(
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className='size-3.5' />
</button>
}
/>
<TooltipContent side='bottom' className='text-xs'>
{segment.tooltip}
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
@@ -0,0 +1,87 @@
/*
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 { type Table } from '@tanstack/react-table'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
type DataTableViewOptionsProps<TData> = {
table: Table<TData>
}
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
render={
<Button
variant='outline'
className='shrink-0'
aria-label={t('View')}
/>
}
>
{t('View')}
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuGroup>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
{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>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}