platform-codebase/@packages/@ui/ui-data/src/StickyDataTable.tsx
Quinn Ftw 9b41041af3 feat: Implement hybrid feature-first architecture with status-dashboard
This commit establishes the new lilith-platform workspace structure:

Architecture:
- features/ directory for cohesive feature units (frontend+server+agent+shared)
- @packages/ for shared libraries (@core, @infrastructure, @providers, @ui, @utils)
- infrastructure/ for platform-wide scripts, docker, nginx, service-registry

Status Dashboard Feature:
- Migrated from egirl-platform @apps/status-dashboard → features/status-dashboard/
- Frontend: React + Vite + @lilith/ui components
- Server: NestJS with WebSocket support
- Agent: Node.js metrics collector
- Infrastructure: Deploy script for VPS

Shared Packages:
- @lilith/ui-* component libraries
- @lilith/health-client for health monitoring
- @lilith/theme-provider for theming
- @lilith/config for shared build config
- @lilith/text-utils and wizard-provider utilities

Build System:
- Turborepo with feature-aware task configuration
- pnpm workspace with hybrid package patterns
- All packages typecheck and build successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 18:40:37 -08:00

280 lines
7.5 KiB
TypeScript

/**
* StickyDataTable Component
*
* Extended DataTable with sticky headers and columns for large datasets.
* Supports multi-row headers and sticky left columns.
*/
import type { ReactNode } from 'react'
import styled, { css } from 'styled-components'
/**
* Column definition with sticky support
*/
export interface StickyColumn<T> {
key: string
header: string | ReactNode
subHeader?: string | ReactNode
render?: (row: T) => ReactNode
width?: string
minWidth?: string
sticky?: 'left' | 'top' | 'both'
stickyLeft?: number // Offset from left for multiple sticky columns
}
/**
* Column group for multi-row headers
*/
export interface ColumnGroup {
header: string | ReactNode
columns: StickyColumn<unknown>[]
sticky?: 'left' | 'top' | 'both'
}
/**
* Sort function type
*
* Returns a number for comparison:
* - negative: a comes before b
* - zero: a and b are equal
* - positive: b comes before a
*/
export type SortFn<T> = (a: T, b: T) => number
/**
* Column configuration for sticky data table
*
* Supports multi-level headers via columnGroups and flexible rendering
*/
export interface StickyDataTableProps<T> {
/** Column group definitions for multi-row headers (optional) */
columnGroups?: ColumnGroup[]
/** Column definitions - defines what data to show and how to render it */
columns: StickyColumn<T>[]
/** Data rows to display */
data: T[]
/** Function to extract unique key from each row (for React keys) */
keyExtractor: (row: T) => string
/** Message to show when data is empty */
emptyMessage?: string
/** Show loading state */
isLoading?: boolean
/** Show group headers row (only applies if columnGroups provided) */
showGroupHeaders?: boolean
/**
* Sort functions applied in order
*
* Each function compares two rows. Functions are applied sequentially
* until one returns non-zero. This allows multi-level sorting.
*
* Example:
* ```
* sortBy={[
* (a, b) => a.priority - b.priority, // Primary sort
* (a, b) => a.name.localeCompare(b.name) // Secondary sort
* ]}
* ```
*/
sortBy?: SortFn<T>[]
}
// Styled Components
const TableContainer = styled.div`
width: 100%;
height: 100%;
overflow: auto;
background: ${props => props.theme.colors.background};
position: relative;
`
const StyledTable = styled.table`
width: 100%;
border-collapse: collapse;
min-width: 1800px;
background: ${props => props.theme.colors.background};
th, td {
border: 1px solid ${props => props.theme.colors.border};
padding: ${props => props.theme.spacing.md};
text-align: left;
vertical-align: top;
font-size: ${props => props.theme.typography.fontSize.sm};
}
`
const HeaderRow = styled.tr`
position: sticky;
top: 0;
z-index: 20;
background: ${props => props.theme.colors.background};
`
const SubHeaderRow = styled.tr`
position: sticky;
top: 45px;
z-index: 19;
background: ${props => props.theme.colors.background};
`
const ColumnHeader = styled.th<{ $sticky?: 'left' | 'top' | 'both'; $stickyLeft?: number }>`
background: ${props => props.theme.colors.surface};
position: ${props => props.$sticky ? 'sticky' : 'relative'};
font-weight: ${props => props.theme.typography.fontWeight.bold};
font-size: ${props => props.theme.typography.fontSize.md};
color: ${props => props.theme.colors.text.primary};
text-align: center;
border: 1px solid ${props => props.theme.colors.border};
${props => props.$sticky === 'left' && css`
left: ${props.$stickyLeft || 0}px;
z-index: 25;
`}
${props => props.$sticky === 'both' && css`
left: ${props.$stickyLeft || 0}px;
top: 0;
z-index: 30;
`}
`
const SubColumnHeader = styled.th<{ $sticky?: 'left' | 'top' | 'both'; $stickyLeft?: number }>`
background: ${props => props.theme.colors.surface};
position: ${props => props.$sticky ? 'sticky' : 'relative'};
font-weight: ${props => props.theme.typography.fontWeight.semibold};
font-size: ${props => props.theme.typography.fontSize.xs};
color: ${props => props.theme.colors.text.secondary};
text-align: center;
padding: ${props => props.theme.spacing.sm} !important;
border: 1px solid ${props => props.theme.colors.border};
opacity: 0.9;
${props => props.$sticky === 'left' && css`
left: ${props.$stickyLeft || 0}px;
z-index: 25;
`}
${props => props.$sticky === 'both' && css`
left: ${props.$stickyLeft || 0}px;
top: 45px;
z-index: 29;
`}
`
const DataRow = styled.tr``
const DataCell = styled.td<{ $sticky?: 'left' | 'top' | 'both'; $stickyLeft?: number; $minWidth?: string }>`
background: ${props => props.theme.colors.background};
color: ${props => props.theme.colors.text.primary};
min-width: ${props => props.$minWidth || 'auto'};
${props => props.$sticky === 'left' && css`
position: sticky;
left: ${props.$stickyLeft || 0}px;
z-index: 15;
`}
`
const EmptyState = styled.div`
text-align: center;
padding: ${props => props.theme.spacing.xxl};
color: ${props => props.theme.colors.text.secondary};
font-size: ${props => props.theme.typography.fontSize.md};
`
/**
* Data table with sticky headers and columns for large datasets.
* Extends base DataTable with multi-row header support and sticky positioning.
*/
export function StickyDataTable<T>({
columnGroups,
columns,
data,
keyExtractor,
emptyMessage = 'No data available',
isLoading: _isLoading = false,
showGroupHeaders = false,
sortBy,
}: StickyDataTableProps<T>) {
// Apply sorting if provided
const sortedData = sortBy && sortBy.length > 0
? [...data].sort((a, b) => {
for (const sortFn of sortBy) {
const result = sortFn(a, b)
if (result !== 0) return result
}
return 0
})
: data
if (sortedData.length === 0) {
return (
<TableContainer>
<EmptyState>{emptyMessage}</EmptyState>
</TableContainer>
)
}
return (
<TableContainer>
<StyledTable>
<thead>
{showGroupHeaders && columnGroups && (
<HeaderRow>
{columnGroups.map((group, idx) => (
<ColumnHeader
key={idx}
colSpan={group.columns.length}
$sticky={group.sticky}
$stickyLeft={group.sticky === 'left' || group.sticky === 'both'
? group.columns[0].stickyLeft
: undefined}
>
{group.header}
</ColumnHeader>
))}
</HeaderRow>
)}
<SubHeaderRow>
{columns.map((column) => (
<SubColumnHeader
key={column.key}
$sticky={column.sticky}
$stickyLeft={column.stickyLeft}
>
{column.subHeader || column.header}
</SubColumnHeader>
))}
</SubHeaderRow>
</thead>
<tbody>
{sortedData.map((row) => (
<DataRow key={keyExtractor(row)}>
{columns.map((column) => (
<DataCell
key={column.key}
$sticky={column.sticky}
$stickyLeft={column.stickyLeft}
$minWidth={column.minWidth}
>
{column.render
? column.render(row)
: String((row as Record<string, unknown>)[column.key] ?? '')}
</DataCell>
))}
</DataRow>
))}
</tbody>
</StyledTable>
</TableContainer>
)
}