diff --git a/.gitignore b/.gitignore index b6fd468..2a9bb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ dist +# Keep top-level demo folder ignored, but include src/demo used by the app demo +!src/demo/ +!src/demo/** diff --git a/src/App.tsx b/src/App.tsx index b4f4f39..a8df00c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,42 @@ -import Chance from 'chance'; -import Prism from 'prismjs'; import * as React from 'react'; -import 'prismjs/components/prism-typescript'; -import 'prismjs/components/prism-jsx'; -import 'prismjs/components/prism-tsx'; -import MassiveTable from './lib/MassiveTable'; -import baseClasses from './lib/styles/base.module.css'; -import darkTheme from './lib/styles/dark.module.css'; -import lightTheme from './lib/styles/light.module.css'; -import type { ColumnDef, ColumnPath, GetRowsResult, RowsRequest, Sort } from './lib/types'; -import { getByPath } from './lib/utils'; - -type Row = { - index: number; - firstName: string; - lastName: string; - category: 'one' | 'two' | null; - favourites: { colour: string; number: number }; -}; - -type GroupHeader = { - __group: true; - key: string; - depth: number; - value: unknown; - count: number; - path: ColumnPath; -}; - -const SEED = 1337; -// Default row count (user-selectable) -const DEFAULT_ROW_COUNT = 10_000; - -// makeRow moved to worker for off-thread generation - -const columns: ColumnDef[] = [ - { path: ['index'], title: '#', width: 80, align: 'right' }, - { path: ['category'], title: 'Category', width: 200, align: 'left' }, - { path: ['favourites', 'colour'], title: 'Favourite Colour', width: 200 }, - { path: ['favourites', 'number'], title: 'Favourite Number', width: 140, align: 'right' }, - { path: ['lastName'], title: 'Last Name', width: 220 }, - { path: ['firstName'], title: 'First Name' }, +import { DemoProvider, useDemo } from './context/DemoContext'; +import AllFeaturesPage from './pages/All'; +import BasicPage from './pages/Basic'; +import CustomPage from './pages/Custom'; +import GroupingPage from './pages/Grouping'; +import LayoutPage from './pages/Layout'; +import LogsPage from './pages/Logs'; +import ReorderPage from './pages/Reorder'; +import ResizePage from './pages/Resize'; +import SortingPage from './pages/Sorting'; +import VisibilityPage from './pages/Visibility'; +import { useHashRoute } from './router'; + +const rowOptions = [ + { label: '10 rows', value: 10 }, + { label: '100 rows', value: 100 }, + { label: '1,000 rows', value: 1_000 }, + { label: '10,000 rows (default)', value: 10_000 }, + { label: '100,000 rows', value: 100_000 }, + { label: '250,000 rows (slow)', value: 250_000 }, + { label: '500,000 rows (slow)', value: 500_000 }, + { label: '1,000,000 rows (slow)', value: 1_000_000 }, ]; -// Inline-group logs example types and data -type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; -type LogRow = { - id: number; // unique id - ts: number; // timestamp (ms) - level: LogLevel; - message: string; - trace_id: string | null; - // for demo table columns - index: number; -}; - -type LogRowWithMeta = LogRow & { - __inlineGroupKey?: string; - __inlineGroupAnchor?: boolean; - __inlineGroupMember?: boolean; - __inlineGroupExpanded?: boolean; - __inlineGroupSize?: number; -}; - -// Helper to build demo logs dataset: 20 normal logs, 5 + 5 spans across 2 traces -function buildLogs(): LogRow[] { - const base = Date.now() - 1000 * 60 * 60; // 1h ago - const total = 30; - const traceAidx = [3, 6, 12, 18, 24]; - const traceBidx = [5, 11, 15, 21, 27]; - const levels: LogLevel[] = ['DEBUG', 'INFO', 'WARN', 'ERROR']; - const rows: LogRow[] = []; - let id = 1; - for (let i = 0; i < total; i++) { - const ts = base + i * 60_000; // each minute - let trace: string | null = null; - if (traceAidx.includes(i)) trace = '1111111'; - else if (traceBidx.includes(i)) trace = '2222222'; - - const level = levels[i % levels.length]; - const message = trace - ? `Span ${trace === '1111111' ? traceAidx.indexOf(i) + 1 : traceBidx.indexOf(i) + 1} of 5 for trace ${trace}` - : `Log message ${i + 1}`; - rows.push({ id: id++, ts, level, message, trace_id: trace, index: i + 1 }); - } - return rows; -} - -export default function App() { +const routes = [ + { key: 'basic', title: 'Basic' }, + { key: 'visibility', title: 'Column Visibility' }, + { key: 'logs', title: 'Logs (inline group)' }, + { key: 'custom', title: 'Custom Render' }, + { key: 'sorting', title: 'Sorting' }, + { key: 'reorder', title: 'Column Reorder' }, + { key: 'resize', title: 'Column Resize' }, + { key: 'grouping', title: 'Grouping' }, + { key: 'all', title: 'All Features' }, + { key: 'layout', title: 'Layout (grid + flex)' }, +] as const; + +function Shell() { const [sidebarOpen, setSidebarOpen] = React.useState(false); const topbarRef = React.useRef(null); const [topbarH, setTopbarH] = React.useState(56); @@ -107,916 +56,9 @@ export default function App() { ro?.disconnect(); }; }, []); - const [mode, setMode] = React.useState<'light' | 'dark'>(() => { - try { - const saved = localStorage.getItem('massive-table-mode') as 'light' | 'dark' | null; - if (saved === 'light' || saved === 'dark') return saved; - } catch {} - try { - const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false; - return prefersDark ? 'dark' : 'light'; - } catch {} - return 'light'; - }); - React.useEffect(() => { - // Persist and apply theme at the document level so global CSS vars resolve - try { - localStorage.setItem('massive-table-mode', mode); - } catch {} - try { - const root = document.documentElement; - root.setAttribute('data-theme', mode); - // Also mirror on body (defensive in case of component portals) - document.body?.setAttribute('data-theme', mode); - } catch {} - }, [mode]); - - // Row count picker options - const rowOptions = React.useMemo( - () => [ - { label: '10 rows', value: 10 }, - { label: '100 rows', value: 100 }, - { label: '1,000 rows', value: 1_000 }, - { label: '10,000 rows (default)', value: 10_000 }, - { label: '100,000 rows', value: 100_000 }, - { label: '250,000 rows (slow)', value: 250_000 }, - { label: '500,000 rows (slow)', value: 500_000 }, - { label: '1,000,000 rows (slow)', value: 1_000_000 }, - ], - [], - ); - - // If the user hasn't explicitly chosen a theme, follow system changes - React.useEffect(() => { - let hasSaved = false; - try { - hasSaved = !!localStorage.getItem('massive-table-mode'); - } catch {} - if (hasSaved) return; - const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; - if (!mql) return; - const handler = (ev: MediaQueryListEvent) => setMode(ev.matches ? 'dark' : 'light'); - try { - mql.addEventListener('change', handler); - } catch { - // Safari <14 legacy API - mql.addListener?.(handler); - } - return () => { - try { - mql.removeEventListener('change', handler); - } catch { - // Safari <14 legacy API - mql.removeListener?.(handler); - } - }; - }, []); - // Dataset state driven by Web Worker generation - const [rowCount, setRowCount] = React.useState(DEFAULT_ROW_COUNT); - const [data, setData] = React.useState([]); - const [dataVersion, setDataVersion] = React.useState(0); - const [isGenerating, setIsGenerating] = React.useState(false); - const workerRef = React.useRef(null); - const generateRowsSync = React.useCallback((count: number): Row[] => { - const rows: Row[] = new Array(count); - for (let i = 0; i < count; i++) { - const c = new Chance(`${SEED}-${i}`); - rows[i] = { - index: i + 1, - firstName: c.first(), - lastName: c.last(), - category: c.pick(['one', 'two', null]), - favourites: { - colour: c.color({ format: 'name' }), - number: c.integer({ min: 1, max: 100 }), - }, - }; - } - return rows; - }, []); - - const startGeneration = React.useCallback((count: number) => { - setIsGenerating(true); - setRowCount(count); - try { - if (workerRef.current) { - workerRef.current.terminate(); - workerRef.current = null; - } - } catch {} - // Fallback to synchronous generation in non-worker environments (tests/SSR) - if (typeof Worker === 'undefined') { - const rows = generateRowsSync(count); - setData(rows); - setDataVersion((v) => v + 1); - setIsGenerating(false); - return; - } - try { - const w = new Worker(new URL('./dataWorker.ts', import.meta.url), { type: 'module' }); - workerRef.current = w; - w.onmessage = (ev: MessageEvent<{ type: string; rows: Row[] }>) => { - if (ev.data?.type === 'generated') { - setData(ev.data.rows); - setDataVersion((v) => v + 1); - setIsGenerating(false); - } - }; - w.postMessage({ type: 'generate', count, seed: SEED }); - } catch (_err) { - // If worker creation fails (e.g., test env), do sync generation - const rows = generateRowsSync(count); - setData(rows); - setDataVersion((v) => v + 1); - setIsGenerating(false); - } - }, []); - - // Kick off initial generation - React.useEffect(() => { - startGeneration(DEFAULT_ROW_COUNT); - return () => { - try { - workerRef.current?.terminate(); - } catch {} - }; - }, [startGeneration]); - - // Cache sorted arrays per sorts signature - const sortedCacheRef = React.useRef>(new Map()); - const groupedCacheRef = React.useRef>(new Map()); - // Clear caches when data changes - React.useEffect(() => { - sortedCacheRef.current.clear(); - groupedCacheRef.current.clear(); - }, [data]); - const getRows = React.useCallback( - async ( - start: number, - end: number, - req?: RowsRequest, - ): Promise> => { - const len = Math.max(0, end - start); - const sorts = req?.sorts ?? []; - const groupBy = req?.groupBy ?? []; - const expandedSet = new Set(req?.groupState?.expandedKeys ?? []); - - // Sort stage (cache by sorts signature) - const sortSig = sorts.map((s) => `${JSON.stringify(s.path)}:${s.dir}`).join('|'); - let sorted = sortedCacheRef.current.get(sortSig); - if (!sorted) { - if (sorts.length === 0) { - sorted = data; - } else { - const cmp = (a: Row, b: Row) => { - for (const s of sorts) { - const va = getByPath(a, s.path); - const vb = getByPath(b, s.path); - if (va == null && vb == null) continue; - if (va == null) return s.dir === 'asc' ? 1 : -1; // nulls last - if (vb == null) return s.dir === 'asc' ? -1 : 1; - let d = 0; - if (typeof va === 'number' && typeof vb === 'number') { - d = va - vb; - } else { - const sa = String(va).toLocaleString(); - const sb = String(vb).toLocaleString(); - d = sa < sb ? -1 : sa > sb ? 1 : 0; - } - if (d !== 0) return s.dir === 'asc' ? d : -d; - } - return 0; - }; - sorted = data.slice().sort(cmp); - } - sortedCacheRef.current.set(sortSig, sorted); - if (sortedCacheRef.current.size > 8) { - const firstKey = sortedCacheRef.current.keys().next().value as string | undefined; - if (firstKey) sortedCacheRef.current.delete(firstKey); - } - } - - // If no grouping, return slice of sorted data - if (groupBy.length === 0) { - return { rows: sorted.slice(start, start + len), total: sorted.length }; - } - - // Grouping stage (cache by sorts + groupBy + expanded keys) - const gbSig = groupBy.map((g) => JSON.stringify(g.path)).join('|'); - const exSig = Array.from(expandedSet).sort().join('|'); - const key = `${sortSig}::${gbSig}::${exSig}`; - let flattened = groupedCacheRef.current.get(key); - if (!flattened) { - const paths = groupBy.map((g) => g.path); - // Build a recursive grouping tree and flatten according to expandedSet - type GroupNode = { - key: string; - depth: number; - value: unknown; - rows: Row[]; - children?: Map; - }; - const root: GroupNode = { key: 'root', depth: 0, value: null, rows: sorted }; - const build = (node: GroupNode, depth: number) => { - if (depth >= paths.length) return; - const path = paths[depth]; - const map = new Map(); - for (const r of node.rows) { - const v = getByPath(r, path); - const k = JSON.stringify([ - ...(JSON.parse(node.key === 'root' ? '[]' : node.key) as unknown[]), - v, - ]); - let child = map.get(k); - if (!child) { - child = { key: k, depth, value: v, rows: [] } as GroupNode; - map.set(k, child); - } - child.rows.push(r); - } - node.children = map; - for (const c of map.values()) build(c, depth + 1); - }; - build(root, 0); - - const out: (Row | GroupHeader)[] = []; - const flatten = (node: GroupNode) => { - if (!node.children) return; - for (const child of node.children.values()) { - const header = { - __group: true, - key: child.key, - depth: child.depth, - value: child.value, - count: child.rows.length, - path: paths[child.depth], - } as GroupHeader; - out.push(header); - const isExpanded = expandedSet.has(child.key); - if (isExpanded) { - if (child.children) { - flatten(child); - } else { - out.push(...child.rows); - } - } - } - }; - flatten(root); - flattened = out; - groupedCacheRef.current.set(key, flattened); - if (groupedCacheRef.current.size > 8) { - const firstKey = groupedCacheRef.current.keys().next().value as string | undefined; - if (firstKey) groupedCacheRef.current.delete(firstKey); - } - } - return { rows: flattened.slice(start, start + len), total: flattened.length }; - }, - [data], - ); - - // ------------------------ - // Logs inline-group example - // ------------------------ - const logsData = React.useMemo(() => buildLogs(), []); - const [logsExpandedKeys, setLogsExpandedKeys] = React.useState([]); - - // generic compare using sorts; nulls last - const makeComparator = React.useCallback( - (sorts: Sort[]) => - (a: T, b: T) => { - for (const s of sorts) { - const va = getByPath(a as unknown as Record, s.path); - const vb = getByPath(b as unknown as Record, s.path); - if (va == null && vb == null) continue; - if (va == null) return s.dir === 'asc' ? 1 : -1; - if (vb == null) return s.dir === 'asc' ? -1 : 1; - let d = 0; - if (typeof va === 'number' && typeof vb === 'number') d = va - vb; - else { - const sa = String(va).toLocaleString(); - const sb = String(vb).toLocaleString(); - d = sa < sb ? -1 : sa > sb ? 1 : 0; - } - if (d !== 0) return s.dir === 'asc' ? d : -d; - } - return 0; - }, - [], - ); - - type AnyRow = Record & { trace_id?: string | null; ts?: number }; - const getRowsLogs = React.useCallback( - async ( - start: number, - end: number, - req?: RowsRequest, - ): Promise> => { - const sorts = (req?.sorts as Sort[]) ?? []; - // default to index desc (visible column) if no sorts - const effectiveSorts = - sorts.length > 0 ? sorts : ([{ path: ['index'], dir: 'desc' }] as Sort[]); - const cmp = makeComparator(effectiveSorts); - - // Sort base data per user sorts - const sorted = logsData - .map((r) => r as AnyRow) - .slice() - .sort(cmp); - - // Build trace groups by trace_id (non-null) in the current sorted order - const byTrace = new Map(); - for (const r of sorted) { - const tid = r.trace_id as string | null; - if (tid) { - const arr = byTrace.get(tid) ?? []; - arr.push(r); - byTrace.set(tid, arr); - } - } - - // Compute units: either a non-trace row unit, or a trace block unit positioned by earliest ts - type Unit = - | { kind: 'row'; row: AnyRow } - | { kind: 'trace'; id: string; rows: AnyRow[]; anchor: AnyRow }; - const usedInTrace = new Set(); - const traceUnits: Unit[] = []; - for (const [id, rows] of byTrace.entries()) { - // Anchor at the first occurrence in the current sort (Option A) - const anchor = rows[0]; - traceUnits.push({ kind: 'trace', id, rows, anchor }); - for (const r of rows) usedInTrace.add(r); - } - const rowUnits: Unit[] = sorted - .filter((r) => !usedInTrace.has(r)) - .map((r) => ({ kind: 'row', row: r })); - - // Merge units and sort using user sorts applied to the anchor/row values - const units = [...rowUnits, ...traceUnits]; - const unitCmp = (ua: Unit, ub: Unit) => { - const a = ua.kind === 'trace' ? ua.anchor : ua.row; - const b = ub.kind === 'trace' ? ub.anchor : ub.row; - return cmp(a, b); - }; - units.sort(unitCmp); - - // Flatten, collapsing/expanding trace units according to expanded keys - const expandedSet = new Set(req?.groupState?.expandedKeys ?? logsExpandedKeys); - const out: AnyRow[] = []; - for (const u of units) { - if (u.kind === 'row') { - out.push(u.row); - } else { - const key = `trace:${u.id}`; - const isExpanded = expandedSet.has(key); - if (isExpanded) { - // Output all rows (already in user-sorted order). Keep the anchor row clickable. - for (const r of u.rows) { - out.push({ - ...r, - __inlineGroupKey: key, - __inlineGroupMember: r !== u.anchor, - __inlineGroupAnchor: r === u.anchor, - __inlineGroupExpanded: true, - __inlineGroupSize: u.rows.length, - }); - } - } else { - // Output only the anchor row, mark it as anchor - out.push({ - ...u.anchor, - __inlineGroupKey: key, - __inlineGroupAnchor: true, - __inlineGroupSize: u.rows.length, - }); - } - } - } - - const len = Math.max(0, end - start); - return { rows: out.slice(start, start + len), total: out.length }; - }, - [logsData, logsExpandedKeys, makeComparator], - ); - - // Columns for logs example - const logsColumns: ColumnDef[] = React.useMemo( - () => [ - { path: ['index'], title: '#', width: 80 }, - { path: ['level'], title: 'Level', width: 200 }, - { path: ['message'], title: 'Message' }, - { path: ['trace_id'], title: 'Trace ID', inlineGroup: true }, - ], - [], - ); - - // Storybook-like example registry - type Variant = { - name: string; - props: Partial>>; - note?: string; - }; - type Example = { key: string; title: string; variants: Variant[] }; - - const examples: Example[] = React.useMemo( - () => [ - { - key: 'basic', - title: 'Basic', - variants: [ - { - name: 'Basic Table', - props: {}, // rely on component defaults (all features off) - note: 'No sort, no reorder, no resize, no group bar.', - }, - ], - }, - { - key: 'visibility', - title: 'Column Visibility', - variants: [{ name: 'Toggle Columns', props: {} }], - }, - { - key: 'logs', - title: 'Logs (inline group)', - variants: [ - { - name: 'Trace collapse/expand by Trace ID', - props: { - columns: logsColumns as unknown as ColumnDef[], - getRows: getRowsLogs as unknown as ( - start: number, - end: number, - req?: RowsRequest, - ) => GetRowsResult, - rowCount: logsData.length, - enableSort: true, - defaultSorts: [{ path: ['index'], dir: 'desc' }] as unknown as Sort[], - expandedKeys: logsExpandedKeys, - onExpandedKeysChange: setLogsExpandedKeys, - }, - note: '30 logs; traces inlined under the first occurrence.', - }, - { - name: 'Inline group + Index Asc', - props: { - columns: logsColumns as unknown as ColumnDef[], - getRows: getRowsLogs as unknown as ( - start: number, - end: number, - req?: RowsRequest, - ) => GetRowsResult, - rowCount: logsData.length, - enableSort: true, - defaultSorts: [{ path: ['index'], dir: 'asc' }] as unknown as Sort[], - expandedKeys: logsExpandedKeys, - onExpandedKeysChange: setLogsExpandedKeys, - }, - note: 'Same data but sorted by index ascending by default.', - }, - ], - }, - { - key: 'custom', - title: 'Custom Render', - variants: [ - { - name: 'Red pill cell', - props: { - columns: (() => { - const pillColumns: ColumnDef[] = columns.map((c) => ({ ...c })); - // Add a render override to show a red pill for a specific cell - const idx = pillColumns.findIndex( - (c) => JSON.stringify(c.path) === JSON.stringify(['favourites', 'number']), - ); - if (idx >= 0) { - pillColumns[idx] = { - ...pillColumns[idx], - render: (v: unknown, row: Row | GroupHeader, rowIndex: number) => { - const isGroupHeader = (r: Row | GroupHeader): r is GroupHeader => - '__group' in r; - // Only decorate one specific cell in this demo: rowIndex === 10 - if (rowIndex === 10 && row && !isGroupHeader(row) && typeof v === 'number') { - return ( - - - {String(v)} - - - ); - } - // default rendering - return String(v ?? ''); - }, - } as ColumnDef; - } - return pillColumns; - })() as ColumnDef[], - }, - note: 'Shows a red pill for one specific cell using a column render override.', - }, - ], - }, - { - key: 'sorting', - title: 'Sorting', - variants: [ - { name: 'Enable Sorting', props: { enableSort: true } }, - { - name: 'Default Sorts', - props: { - enableSort: true, - defaultSorts: [{ path: ['lastName'], dir: 'asc' }] as Sort[], - }, - note: 'Pre-sorted by Last Name ascending.', - }, - ], - }, - { - key: 'reorder', - title: 'Column Reorder', - variants: [{ name: 'Enable Reorder', props: { enableReorder: true } }], - }, - { - key: 'resize', - title: 'Column Resize', - variants: [{ name: 'Enable Resize', props: { enableResize: true } }], - }, - { - key: 'grouping', - title: 'Grouping', - variants: [ - { name: 'Show Group Bar', props: { showGroupByDropZone: true } }, - { - name: 'Preset Group By Category', - props: { showGroupByDropZone: true, defaultGroupBy: [{ path: ['category'] }] }, - }, - { - name: 'Preset Group + Expanded', - props: { - showGroupByDropZone: true, - defaultGroupBy: [{ path: ['category'] }], - defaultExpandedKeys: ['["one"]', '["two"]', '[null]'], - }, - }, - ], - }, - { - key: 'all', - title: 'All Features', - variants: [ - { - name: 'Sortable + Reorder + Resize + Group Bar', - props: { - enableSort: true, - enableReorder: true, - enableResize: true, - showGroupByDropZone: true, - }, - }, - ], - }, - { - key: 'layout', - title: 'Layout (grid + flex)', - variants: [ - { - name: 'Grid 2x2 + Flex 3 (auto height)', - props: {}, - note: 'Each cell flex/grow with MassiveTable filling 100% height.', - }, - ], - }, - ], - [logsColumns, getRowsLogs, logsData.length, logsExpandedKeys], - ); - - // Basic hash router: #/exampleKey or #/exampleKey/variantIndex - const [activeExampleKey, setActiveExampleKey] = React.useState('basic'); - const activeExample = examples.find((e) => e.key === activeExampleKey) ?? - examples[0] ?? { key: 'fallback', title: 'Fallback', variants: [] }; - const [activeVariantIndex, setActiveVariantIndex] = React.useState(0); - // Sync from URL hash on load and when it changes - React.useEffect(() => { - const parse = () => { - const hash = window.location.hash.replace(/^#/, ''); - const parts = hash.split('/').filter(Boolean); // [exampleKey, variantIdx?] - const nextKey = parts[0] || 'basic'; - const found = examples.find((e) => e.key === nextKey); - if (!found) { - setActiveExampleKey('basic'); - setActiveVariantIndex(0); - return; - } - setActiveExampleKey(found.key); - const idx = parts[1] ? Number(parts[1]) : 0; - const safeIdx = Number.isFinite(idx) - ? Math.max(0, Math.min(idx, found.variants.length - 1)) - : 0; - setActiveVariantIndex(Number.isNaN(safeIdx) ? 0 : safeIdx); - }; - window.addEventListener('hashchange', parse); - parse(); - return () => window.removeEventListener('hashchange', parse); - }, [examples]); - - // Navigate helper - const navigate = React.useCallback((key: string, variant?: number) => { - const v = typeof variant === 'number' ? `/${variant}` : ''; - const next = `#/${key}${v}`; - if (window.location.hash !== next) window.location.hash = next; - // Close sidebar on mobile after navigation - setSidebarOpen(false); - }, []); - - const activeVariant = activeExample.variants[activeVariantIndex] ?? - activeExample.variants[0] ?? { name: 'Variant', props: {} }; - - // Keep order state to display in reorder example - const [_order, setOrder] = React.useState([]); - const [_previewOrder, setPreviewOrder] = React.useState(null); - - // Helper: build a concise usage snippet per variant - const usageCode = React.useMemo(() => { - const v = activeVariant; - const isLogs = activeExample.key === 'logs'; - const baseHeader = `import MassiveTable from 'react-massive-table';\n`; - const baseRow = isLogs - ? `type Row = { id: number; ts: number; level: 'DEBUG'|'INFO'|'WARN'|'ERROR'; message: string; trace_id?: string|null; index: number };\n` - : `type Row = { index: number; firstName: string; lastName: string; category: 'one'|'two'|null; favourites: { colour: string; number: number } };\n`; - const cols = isLogs - ? `const columns: ColumnDef[] = [\n { path: ['index'], title: '#', width: 80 },\n { path: ['level'], title: 'Level', width: 200 },\n { path: ['message'], title: 'Message' },\n { path: ['trace_id'], title: 'Trace ID', inlineGroup: true },\n];\n` - : `const columns: ColumnDef[] = [\n { path: ['index'], title: '#', width: 80, align: 'right' },\n { path: ['category'], title: 'Category', width: 200 },\n { path: ['favourites','colour'], title: 'Favourite Colour', width: 200 },\n { path: ['favourites','number'], title: 'Favourite Number', width: 140, align: 'right' },\n { path: ['lastName'], title: 'Last Name', width: 220 },\n { path: ['firstName'], title: 'First Name' },\n];\n`; - const getRowsSig = isLogs - ? `// Example: inline-grouped logs (flatten as needed)\nconst logs: Row[] = /* your log rows */ [];\n\nconst getRows = (start: number, end: number, req?: RowsRequest) => {\n // Optionally sort and inline-group by trace using req\n const flat = logs;\n return { rows: flat.slice(start, end), total: flat.length };\n};\n` - : `// Example: in-memory data\nconst rows: Row[] = /* your rows */ [];\n\nconst getRows = (start: number, end: number, req?: RowsRequest) => {\n // Optionally sort/filter using req\n const src = rows;\n return { rows: src.slice(start, end), total: src.length };\n};\n`; - const props: string[] = []; - // Always include core props - if (isLogs) { - props.push('columns={columns}'); - props.push('getRows={getRows}'); - props.push(`rowCount={${isLogs ? 'logs.length' : 'rowCount'}}`); - } else { - props.push('columns={columns}'); - props.push('getRows={getRows}'); - props.push('rowCount={rowCount}'); - } - // Variant props - type VariantPropsExtras = { - defaultSorts?: Sort[]; - defaultGroupBy?: { path: ColumnPath }[]; - rowHeight?: unknown; - }; - const p = (v.props ?? {}) as Partial< - React.ComponentProps> - > & - VariantPropsExtras; - if (p.enableSort) props.push('enableSort'); - if (p.enableReorder) props.push('enableReorder'); - if (p.enableResize) props.push('enableResize'); - if (p.showGroupByDropZone) props.push('showGroupByDropZone'); - if (p.defaultSorts) { - const ds = p.defaultSorts as Sort[]; - props.push( - `defaultSorts={[${ds - .map((s) => `{ path: ${JSON.stringify(s.path)}, dir: '${s.dir}' }`) - .join(', ')}]}`, - ); - } - if (p.defaultGroupBy) { - const dg = p.defaultGroupBy as { path: ColumnPath }[]; - props.push( - `defaultGroupBy={[${dg.map((g) => `{ path: ${JSON.stringify(g.path)} }`).join(', ')}]}`, - ); - } - if (p.rowHeight !== undefined) props.push(`rowHeight={${JSON.stringify(p.rowHeight)}}`); - - const open = `\n `; - const body = props.map((line) => ` ${line}`).join('\n'); - const close = `\n/>`; - return [ - baseHeader, - baseRow, - `/* Columns */\n${cols}`, - `/* Data fetching */\n${getRowsSig}`, - `/* Usage */\n${open}${body}${close}`, - ].join('\n'); - }, [activeExample.key, activeVariant]); - - const copyUsage = React.useCallback(async () => { - try { - await navigator.clipboard.writeText(usageCode); - } catch {} - }, [usageCode]); - const usageHtml = React.useMemo( - () => Prism.highlight(usageCode, Prism.languages.tsx, 'tsx'), - [usageCode], - ); - const usageCodeRef = React.useRef(null); - React.useEffect(() => { - if (usageCodeRef.current) { - usageCodeRef.current.innerHTML = usageHtml; - } - }, [usageHtml]); - - // ------------------------ - // Layout demo (Grid + Flex) - // ------------------------ - const LayoutDemo: React.FC = () => { - // Build distinct datasets by slicing the main demo data - const gridA = React.useMemo(() => data.slice(0, 2500), [data]); - const gridB = React.useMemo(() => data.slice(2500, 5000), [data]); - const gridC = React.useMemo(() => data.slice(5000, 7500), [data]); - const gridD = React.useMemo(() => data.slice(7500, 10000), [data]); - - const flexA = React.useMemo(() => data.filter((r) => r.category === 'one'), [data]); - const flexB = React.useMemo(() => data.filter((r) => r.category === 'two'), [data]); - const flexC = React.useMemo(() => data.filter((r) => r.favourites.number <= 50), [data]); - - const makeGetRows = React.useCallback( - (arr: T[]) => - async (start: number, end: number): Promise> => { - const len = Math.max(0, end - start); - return { rows: arr.slice(start, start + len), total: arr.length }; - }, - [], - ); - - const themeClass = mode === 'dark' ? darkTheme.theme : lightTheme.theme; - - return ( -
-
-

CSS Grid: 2 x 2 (auto-fill)

-
-
- - key="grid-a" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(gridA)} - rowCount={gridA.length} - style={{ height: '100%', width: '100%' }} - /> -
-
- - key="grid-b" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(gridB)} - rowCount={gridB.length} - style={{ height: '100%', width: '100%' }} - /> -
-
- - key="grid-c" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(gridC)} - rowCount={gridC.length} - style={{ height: '100%', width: '100%' }} - /> -
-
- - key="grid-d" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(gridD)} - rowCount={gridD.length} - style={{ height: '100%', width: '100%' }} - /> -
-
-
- -
-

Flex: 3 cells (flex: 1 1 0)

-
-
- - key="flex-a" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(flexA)} - rowCount={flexA.length} - style={{ height: '100%', width: '100%' }} - /> -
-
- - key="flex-b" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(flexB)} - rowCount={flexB.length} - style={{ height: '100%', width: '100%' }} - /> -
-
- - key="flex-c" - classes={baseClasses} - className={themeClass} - columns={columns as unknown as ColumnDef[]} - getRows={makeGetRows(flexC)} - rowCount={flexC.length} - style={{ height: '100%', width: '100%' }} - /> -
-
-
-
- ); - }; - - // ------------------------ - // Column Visibility demo - // ------------------------ - const VisibilityDemo: React.FC = () => { - const themeClass = mode === 'dark' ? darkTheme.theme : lightTheme.theme; - const allKeys = React.useMemo(() => columns.map((c) => JSON.stringify(c.path)), []); - const [visibleKeys, setVisibleKeys] = React.useState(allKeys); - - const visibleColumns = React.useMemo(() => { - const set = new Set(visibleKeys); - return (columns as unknown as ColumnDef[]).filter((c) => - set.has(JSON.stringify(c.path)), - ); - }, [visibleKeys]); - const toggleKey = (key: string) => { - setVisibleKeys((prev) => { - const has = prev.includes(key); - if (has) { - // Ensure at least one column remains visible - if (prev.length <= 1) return prev; - return prev.filter((k) => k !== key); - } - return [...prev, key]; - }); - }; - - return ( -
-
-

- Toggle Columns -

-
- {columns.map((c) => { - const key = JSON.stringify(c.path); - const checked = visibleKeys.includes(key); - const disable = checked && visibleKeys.length <= 1; - return ( - - ); - })} -
-
- - key={`visibility:${visibleColumns.length}:${dataVersion}`} - getRows={getRows} - rowCount={data.length} - columns={visibleColumns} - classes={baseClasses} - className={themeClass} - style={{ height: '70vh', width: '100%' }} - /> -
- ); - }; + const { mode, setMode, rowCount, startGeneration, isGenerating } = useDemo(); + const { key, variant, navigate } = useHashRoute('basic'); return (
- {/* Top bar */}
-
-
-
- Rows - - {isGenerating && } -
-
- -
+ GitHub repository + +
- {/* Body: sidebar + content */}
- {/* Sidebar */} -
-
-
-                  
-                
- - + {key === 'basic' && ( + navigate('basic', i)} /> + )} + {key === 'visibility' && } + {key === 'logs' && ( + navigate('logs', i)} /> + )} + {key === 'custom' && ( + navigate('custom', i)} /> + )} + {key === 'sorting' && ( + navigate('sorting', i)} /> + )} + {key === 'reorder' && ( + navigate('reorder', i)} /> + )} + {key === 'resize' && ( + navigate('resize', i)} /> )} + {key === 'grouping' && ( + navigate('grouping', i)} /> + )} + {key === 'all' && ( + navigate('all', i)} /> + )} + {key === 'layout' && } + + ))} + + )} + + + {activeVariant.note && ( +

+ {activeVariant.note} +

+ )} + + + key={`page:${exampleKey}:${variantIndex}:${dataVersion}`} + getRows={activeVariant.props.getRows ?? getRows} + rowCount={activeVariant.props.rowCount ?? data.length} + columns={ + (activeVariant.props.columns as ColumnDef[] | undefined) ?? columns + } + classes={baseClasses} + className={themeClass} + {...activeVariant.props} + style={{ height: '70vh', width: '100%', ...(activeVariant.props.style ?? {}) }} + /> + +
+
+

Usage

+
+ +
+
+
+          
+        
+
+ + ); +}; diff --git a/src/context/DemoContext.tsx b/src/context/DemoContext.tsx new file mode 100644 index 0000000..5d4bc3f --- /dev/null +++ b/src/context/DemoContext.tsx @@ -0,0 +1,467 @@ +import Chance from 'chance'; +import * as React from 'react'; +import { columns as baseColumns } from '../demo/columns'; +import type { GroupHeader, LogRow, Row } from '../demo/types'; +import baseClasses from '../lib/styles/base.module.css'; +import darkTheme from '../lib/styles/dark.module.css'; +import lightTheme from '../lib/styles/light.module.css'; +import type { ColumnDef, GetRowsResult, RowsRequest, Sort } from '../lib/types'; +import { getByPath } from '../lib/utils'; + +const SEED = 1337; +const DEFAULT_ROW_COUNT = 10_000; + +export type DemoContextValue = { + // theme + mode: 'light' | 'dark'; + setMode: (m: 'light' | 'dark') => void; + themeClass: string; + baseClasses: typeof baseClasses; + + // data + rowCount: number; + isGenerating: boolean; + setRowCount: (n: number) => void; + startGeneration: (n: number) => void; + data: Row[]; + dataVersion: number; + columns: ColumnDef[]; + getRows: ( + start: number, + end: number, + req?: RowsRequest, + ) => Promise>; + + // logs example + logsData: LogRow[]; + logsColumns: ColumnDef<(LogRow & Record) | GroupHeader>[]; + getRowsLogs: ( + start: number, + end: number, + req?: RowsRequest>, + ) => Promise>>; + logsExpandedKeys: string[]; + setLogsExpandedKeys: React.Dispatch>; +}; + +const DemoContext = React.createContext(null); + +function buildLogs(): LogRow[] { + const base = Date.now() - 1000 * 60 * 60; + const total = 30; + const traceAidx = [3, 6, 12, 18, 24]; + const traceBidx = [5, 11, 15, 21, 27]; + const levels: LogRow['level'][] = ['DEBUG', 'INFO', 'WARN', 'ERROR']; + const rows: LogRow[] = []; + let id = 1; + for (let i = 0; i < total; i++) { + const ts = base + i * 60_000; + let trace: string | null = null; + if (traceAidx.includes(i)) trace = '1111111'; + else if (traceBidx.includes(i)) trace = '2222222'; + const level = levels[i % levels.length]; + const message = trace + ? `Span ${trace === '1111111' ? traceAidx.indexOf(i) + 1 : traceBidx.indexOf(i) + 1} of 5 for trace ${trace}` + : `Log message ${i + 1}`; + rows.push({ id: id++, ts, level, message, trace_id: trace, index: i + 1 }); + } + return rows; +} + +export const DemoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // theme + const [mode, setMode] = React.useState<'light' | 'dark'>(() => { + try { + const saved = localStorage.getItem('massive-table-mode') as 'light' | 'dark' | null; + if (saved === 'light' || saved === 'dark') return saved; + } catch {} + try { + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false; + return prefersDark ? 'dark' : 'light'; + } catch {} + return 'light'; + }); + React.useEffect(() => { + try { + localStorage.setItem('massive-table-mode', mode); + } catch {} + try { + const root = document.documentElement; + root.setAttribute('data-theme', mode); + document.body?.setAttribute('data-theme', mode); + } catch {} + }, [mode]); + React.useEffect(() => { + let hasSaved = false; + try { + hasSaved = !!localStorage.getItem('massive-table-mode'); + } catch {} + if (hasSaved) return; + const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + if (!mql) return; + const handler = (ev: MediaQueryListEvent) => setMode(ev.matches ? 'dark' : 'light'); + try { + mql.addEventListener('change', handler); + } catch { + mql.addListener?.(handler); + } + return () => { + try { + mql.removeEventListener('change', handler); + } catch { + mql.removeListener?.(handler); + } + }; + }, []); + + // data + const [rowCount, setRowCount] = React.useState(DEFAULT_ROW_COUNT); + const [data, setData] = React.useState([]); + const [dataVersion, setDataVersion] = React.useState(0); + const [isGenerating, setIsGenerating] = React.useState(false); + const workerRef = React.useRef(null); + + const generateRowsSync = React.useCallback((count: number): Row[] => { + const rows: Row[] = new Array(count); + for (let i = 0; i < count; i++) { + const c = new Chance(`${SEED}-${i}`); + rows[i] = { + index: i + 1, + firstName: c.first(), + lastName: c.last(), + category: c.pick(['one', 'two', null]) as Row['category'], + favourites: { + colour: c.color({ format: 'name' }), + number: c.integer({ min: 1, max: 100 }), + }, + }; + } + return rows; + }, []); + + const startGeneration = React.useCallback( + (count: number) => { + setIsGenerating(true); + setRowCount(count); + try { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + } + } catch {} + if (typeof Worker === 'undefined') { + const rows = generateRowsSync(count); + setData(rows); + setDataVersion((v) => v + 1); + setIsGenerating(false); + return; + } + try { + const w = new Worker(new URL('../dataWorker.ts', import.meta.url), { type: 'module' }); + workerRef.current = w; + w.onmessage = (ev: MessageEvent<{ type: string; rows: Row[] }>) => { + if (ev.data?.type === 'generated') { + setData(ev.data.rows); + setDataVersion((v) => v + 1); + setIsGenerating(false); + } + }; + w.postMessage({ type: 'generate', count, seed: SEED }); + } catch { + const rows = generateRowsSync(count); + setData(rows); + setDataVersion((v) => v + 1); + setIsGenerating(false); + } + }, + [generateRowsSync], + ); + + React.useEffect(() => { + startGeneration(DEFAULT_ROW_COUNT); + return () => { + try { + workerRef.current?.terminate(); + } catch {} + }; + }, [startGeneration]); + + const sortedCacheRef = React.useRef>(new Map()); + const groupedCacheRef = React.useRef>(new Map()); + React.useEffect(() => { + sortedCacheRef.current.clear(); + groupedCacheRef.current.clear(); + }, [data]); + + const getRows = React.useCallback( + async ( + start: number, + end: number, + req?: RowsRequest, + ): Promise> => { + const len = Math.max(0, end - start); + const sorts = req?.sorts ?? []; + const groupBy = req?.groupBy ?? []; + const expandedSet = new Set(req?.groupState?.expandedKeys ?? []); + + const sortSig = sorts.map((s) => `${JSON.stringify(s.path)}:${s.dir}`).join('|'); + let sorted = sortedCacheRef.current.get(sortSig); + if (!sorted) { + if (sorts.length === 0) { + sorted = data; + } else { + const cmp = (a: Row, b: Row) => { + for (const s of sorts) { + const va = getByPath(a, s.path); + const vb = getByPath(b, s.path); + if (va == null && vb == null) continue; + if (va == null) return s.dir === 'asc' ? 1 : -1; + if (vb == null) return s.dir === 'asc' ? -1 : 1; + let d = 0; + if (typeof va === 'number' && typeof vb === 'number') { + d = va - vb; + } else { + const sa = String(va).toLocaleString(); + const sb = String(vb).toLocaleString(); + d = sa < sb ? -1 : sa > sb ? 1 : 0; + } + if (d !== 0) return s.dir === 'asc' ? d : -d; + } + return 0; + }; + sorted = data.slice().sort(cmp); + } + sortedCacheRef.current.set(sortSig, sorted); + if (sortedCacheRef.current.size > 8) { + const firstKey = sortedCacheRef.current.keys().next().value as string | undefined; + if (firstKey) sortedCacheRef.current.delete(firstKey); + } + } + + if (groupBy.length === 0) { + return { rows: sorted.slice(start, start + len), total: sorted.length }; + } + + const gbSig = groupBy.map((g) => JSON.stringify(g.path)).join('|'); + const exSig = Array.from(expandedSet).sort().join('|'); + const key = `${sortSig}::${gbSig}::${exSig}`; + let flattened = groupedCacheRef.current.get(key); + if (!flattened) { + const paths = groupBy.map((g) => g.path); + type GroupNode = { + key: string; + depth: number; + value: unknown; + rows: Row[]; + children?: Map; + }; + const root: GroupNode = { key: 'root', depth: 0, value: null, rows: sorted }; + const build = (node: GroupNode, depth: number) => { + if (depth >= paths.length) return; + const path = paths[depth]; + const map = new Map(); + for (const r of node.rows) { + const v = getByPath(r, path); + const k = JSON.stringify([ + ...(JSON.parse(node.key === 'root' ? '[]' : node.key) as unknown[]), + v, + ]); + let child = map.get(k); + if (!child) { + child = { key: k, depth, value: v, rows: [] } as GroupNode; + map.set(k, child); + } + child.rows.push(r); + } + node.children = map; + for (const c of map.values()) build(c, depth + 1); + }; + build(root, 0); + + const out: (Row | GroupHeader)[] = []; + const flatten = (node: GroupNode) => { + if (!node.children) return; + for (const child of node.children.values()) { + const header = { + __group: true, + key: child.key, + depth: child.depth, + value: child.value, + count: child.rows.length, + path: paths[child.depth], + } as GroupHeader; + out.push(header); + const isExpanded = expandedSet.has(child.key); + if (isExpanded) { + if (child.children) { + flatten(child); + } else { + out.push(...child.rows); + } + } + } + }; + flatten(root); + flattened = out; + groupedCacheRef.current.set(key, flattened); + if (groupedCacheRef.current.size > 8) { + const firstKey = groupedCacheRef.current.keys().next().value as string | undefined; + if (firstKey) groupedCacheRef.current.delete(firstKey); + } + } + return { rows: flattened.slice(start, start + len), total: flattened.length }; + }, + [data], + ); + + // logs helpers + const logsData = React.useMemo(() => buildLogs(), []); + const [logsExpandedKeys, setLogsExpandedKeys] = React.useState([]); + const makeComparator = React.useCallback( + (sorts: Sort[]) => + (a: T, b: T) => { + for (const s of sorts) { + const va = getByPath(a as unknown as Record, s.path); + const vb = getByPath(b as unknown as Record, s.path); + if (va == null && vb == null) continue; + if (va == null) return s.dir === 'asc' ? 1 : -1; + if (vb == null) return s.dir === 'asc' ? -1 : 1; + let d = 0; + if (typeof va === 'number' && typeof vb === 'number') d = va - vb; + else { + const sa = String(va).toLocaleString(); + const sb = String(vb).toLocaleString(); + d = sa < sb ? -1 : sa > sb ? 1 : 0; + } + if (d !== 0) return s.dir === 'asc' ? d : -d; + } + return 0; + }, + [], + ); + + type AnyRow = Record & { trace_id?: string | null; ts?: number }; + const getRowsLogs = React.useCallback( + async ( + start: number, + end: number, + req?: RowsRequest, + ): Promise> => { + const sorts = (req?.sorts as Sort[]) ?? []; + const effectiveSorts = + sorts.length > 0 ? sorts : ([{ path: ['index'], dir: 'desc' }] as Sort[]); + const cmp = makeComparator(effectiveSorts); + + const sorted = logsData + .map((r) => r as AnyRow) + .slice() + .sort(cmp); + + const byTrace = new Map(); + for (const r of sorted) { + const tid = r.trace_id as string | null; + if (tid) { + const arr = byTrace.get(tid) ?? []; + arr.push(r); + byTrace.set(tid, arr); + } + } + + type Unit = + | { kind: 'row'; row: AnyRow } + | { kind: 'trace'; id: string; rows: AnyRow[]; anchor: AnyRow }; + const usedInTrace = new Set(); + const traceUnits: Unit[] = []; + for (const [id, rows] of byTrace.entries()) { + const anchor = rows[0]; + traceUnits.push({ kind: 'trace', id, rows, anchor }); + for (const r of rows) usedInTrace.add(r); + } + const rowUnits: Unit[] = sorted + .filter((r) => !usedInTrace.has(r)) + .map((r) => ({ kind: 'row', row: r })); + + const units = [...rowUnits, ...traceUnits]; + const unitCmp = (ua: Unit, ub: Unit) => { + const a = ua.kind === 'trace' ? ua.anchor : ua.row; + const b = ub.kind === 'trace' ? ub.anchor : ub.row; + return cmp(a, b); + }; + units.sort(unitCmp); + + const expandedSet = new Set(req?.groupState?.expandedKeys ?? logsExpandedKeys); + const out: AnyRow[] = []; + for (const u of units) { + if (u.kind === 'row') out.push(u.row); + else { + const key = `trace:${u.id}`; + const isExpanded = expandedSet.has(key); + if (isExpanded) { + for (const r of u.rows) { + out.push({ + ...r, + __inlineGroupKey: key, + __inlineGroupMember: r !== u.anchor, + __inlineGroupAnchor: r === u.anchor, + __inlineGroupExpanded: true, + __inlineGroupSize: u.rows.length, + }); + } + } else { + out.push({ + ...u.anchor, + __inlineGroupKey: key, + __inlineGroupAnchor: true, + __inlineGroupSize: u.rows.length, + }); + } + } + } + + const len = Math.max(0, end - start); + return { rows: out.slice(start, start + len), total: out.length }; + }, + [logsData, logsExpandedKeys, makeComparator], + ); + + const logsColumns = React.useMemo( + () => + [ + { path: ['index'], title: '#', width: 80 }, + { path: ['level'], title: 'Level', width: 200 }, + { path: ['message'], title: 'Message' }, + { path: ['trace_id'], title: 'Trace ID', inlineGroup: true }, + ] as ColumnDef<(LogRow & Record) | GroupHeader>[], + [], + ); + + const themeClass = mode === 'dark' ? darkTheme.theme : lightTheme.theme; + + const value: DemoContextValue = { + mode, + setMode, + themeClass, + baseClasses, + rowCount, + isGenerating, + setRowCount, + startGeneration, + data, + dataVersion, + columns: baseColumns, + getRows, + logsData, + logsColumns, + getRowsLogs, + logsExpandedKeys, + setLogsExpandedKeys, + }; + + return {children}; +}; + +export function useDemo() { + const ctx = React.useContext(DemoContext); + if (!ctx) throw new Error('useDemo must be used within DemoProvider'); + return ctx; +} diff --git a/src/demo/columns.ts b/src/demo/columns.ts new file mode 100644 index 0000000..c71d20a --- /dev/null +++ b/src/demo/columns.ts @@ -0,0 +1,11 @@ +import type { ColumnDef } from '../lib/types'; +import type { GroupHeader, Row } from './types'; + +export const columns: ColumnDef[] = [ + { path: ['index'], title: '#', width: 80, align: 'right' }, + { path: ['category'], title: 'Category', width: 200, align: 'left' }, + { path: ['favourites', 'colour'], title: 'Favourite Colour', width: 200 }, + { path: ['favourites', 'number'], title: 'Favourite Number', width: 140, align: 'right' }, + { path: ['lastName'], title: 'Last Name', width: 220 }, + { path: ['firstName'], title: 'First Name' }, +]; diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 0000000..6288d0a --- /dev/null +++ b/src/demo/types.ts @@ -0,0 +1,26 @@ +export type Row = { + index: number; + firstName: string; + lastName: string; + category: 'one' | 'two' | null; + favourites: { colour: string; number: number }; +}; + +export type GroupHeader = { + __group: true; + key: string; + depth: number; + value: unknown; + count: number; + path: import('../lib/types').ColumnPath; +}; + +export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; +export type LogRow = { + id: number; + ts: number; + level: LogLevel; + message: string; + trace_id: string | null; + index: number; +}; diff --git a/src/lib/MassiveTable.tsx b/src/lib/MassiveTable.tsx index e00055a..aa7949e 100644 --- a/src/lib/MassiveTable.tsx +++ b/src/lib/MassiveTable.tsx @@ -197,6 +197,8 @@ export function MassiveTable(props: MassiveTableProps) { // Capture widths at drag start and only remap widths to follow // columns after a successful drop (to keep preview simple). const dragStartWidthsRef = React.useRef | null>(null); + // Track which column index is currently being dragged (for dimming others) + const [draggingColIndex, setDraggingColIndex] = React.useState(null); // Fetch rows for visible window + overscan const requestId = React.useRef(0); @@ -297,6 +299,18 @@ export function MassiveTable(props: MassiveTableProps) { return hasInlineGroup ? base + inlineColWidth : base; }, [colWidths, hasInlineGroup]); + // Precompute overlay positions (left offsets + widths) for column-dim layer + const overlayMeta = React.useMemo(() => { + const widths = hasInlineGroup ? [inlineColWidth, ...colWidths] : colWidths; + const lefts: number[] = new Array(widths.length); + let acc = 0; + for (let i = 0; i < widths.length; i++) { + lefts[i] = acc; + acc += widths[i]; + } + return { widths, lefts } as const; + }, [colWidths, hasInlineGroup]); + // Drag & drop handlers for columns const dragIndex = React.useRef(null); // Track dragging state via refs; remove unused state variables @@ -306,6 +320,7 @@ export function MassiveTable(props: MassiveTableProps) { const handleHeaderDragStart = (idx: number) => (e: React.DragEvent) => { dragIndex.current = idx; isDraggingRef.current = true; + setDraggingColIndex(idx); endedByDropRef.current = false; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); @@ -345,6 +360,7 @@ export function MassiveTable(props: MassiveTableProps) { if (from != null && from !== idx) { move(from, idx); dragIndex.current = idx; + setDraggingColIndex(idx); } }; const handleHeaderDrop = (idx: number) => (e: React.DragEvent) => { @@ -356,6 +372,7 @@ export function MassiveTable(props: MassiveTableProps) { dragIndex.current = null; isDraggingRef.current = false; endedByDropRef.current = true; + setDraggingColIndex(null); // After drop, remap widths to follow the columns by identity const map = dragStartWidthsRef.current; if (map) { @@ -374,6 +391,7 @@ export function MassiveTable(props: MassiveTableProps) { notifyFinalOrder(); } endedByDropRef.current = false; + setDraggingColIndex(null); suppressClickRef.current = Date.now(); }; @@ -448,6 +466,7 @@ export function MassiveTable(props: MassiveTableProps) { if (dx > 6 && dx > dy) { touchDragRef.current.active = true; suppressClickRef.current = Date.now(); + setDraggingColIndex(touchDragRef.current.idx); } else { return; } @@ -458,6 +477,7 @@ export function MassiveTable(props: MassiveTableProps) { if (Number.isFinite(from) && Number.isFinite(targetIdx) && from !== targetIdx) { move(from, targetIdx); touchDragRef.current.idx = targetIdx; + setDraggingColIndex(targetIdx); } }; const onEnd = () => { @@ -467,6 +487,7 @@ export function MassiveTable(props: MassiveTableProps) { notifyFinalOrder(); suppressClickRef.current = Date.now(); } + setDraggingColIndex(null); touchDragRef.current = null; }; window.addEventListener('touchmove', onMove as EventListener, { passive: false }); @@ -955,6 +976,23 @@ export function MassiveTable(props: MassiveTableProps) { ); })} + {draggingColIndex !== null && ( +
+ {overlayMeta.widths.map((w, j) => { + const isDataCol = hasInlineGroup ? j > 0 : true; + const dataIdx = hasInlineGroup ? j - 1 : j; + const dim = isDataCol && dataIdx !== draggingColIndex; + if (!dim) return null; + return ( +
+ ); + })} +
+ )}
{Array.from({ length: Math.max(0, end - start) }).map((_, i) => { @@ -1165,6 +1203,23 @@ export function MassiveTable(props: MassiveTableProps) { ); })} + {draggingColIndex !== null && ( +
+ {overlayMeta.widths.map((w, j) => { + const isDataCol = hasInlineGroup ? j > 0 : true; + const dataIdx = hasInlineGroup ? j - 1 : j; + const dim = isDataCol && dataIdx !== draggingColIndex; + if (!dim) return null; + return ( +
+ ); + })} +
+ )}
diff --git a/src/lib/styles/base.module.css b/src/lib/styles/base.module.css index 92f2db4..6892764 100644 --- a/src/lib/styles/base.module.css +++ b/src/lib/styles/base.module.css @@ -168,6 +168,30 @@ .headerCellClickable { cursor: pointer; } +.dimColumn { + /* Ensure positioning context for overlay */ + position: relative; +} +.dimColumn::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: var(--massive-table-dim-overlay, rgba(0, 0, 0, 0.1)); + pointer-events: none; /* allow interactions through */ +} +/* Expand overlay to include row vertical padding */ +.row .dimColumn::after { + top: calc(var(--massive-table-cell-py) * -1); + bottom: calc(var(--massive-table-cell-py) * -1); +} +/* Expand overlay to include header vertical padding */ +.header .dimColumn::after { + top: calc(var(--massive-table-header-cell-py) * -1); + bottom: calc(var(--massive-table-header-cell-py) * -1); +} .headerCell:focus-visible { outline: none; box-shadow: var(--massive-table-focus-ring); @@ -197,6 +221,12 @@ padding: 2px 6px; cursor: pointer; } +/* Hide header group toggle on desktop; keep visible on mobile */ +@media (min-width: 901px) { + .headerGroupBtn { + display: none; + } +} .headerHint { margin-left: auto; font-size: 11px; @@ -266,6 +296,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + position: relative; /* allow overlay to cover full cell */ } .groupRow { @@ -285,3 +316,17 @@ width: 22px; height: 22px; } + +/* Column dim overlays */ +.overlayLayer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 4; /* above content */ +} +.overlayCol { + position: absolute; + top: 0; + bottom: 0; + background: var(--massive-table-dim-overlay, rgba(0, 0, 0, 0.1)); +} diff --git a/src/lib/styles/dark.module.css b/src/lib/styles/dark.module.css index 4166cb0..e564b13 100644 --- a/src/lib/styles/dark.module.css +++ b/src/lib/styles/dark.module.css @@ -13,6 +13,7 @@ --massive-table-row-stripe: #0d1526; --massive-table-focus-ring: 0 0 0 2px rgba(96, 165, 250, 0.55); --massive-table-inline-group-bg: rgba(96, 165, 250, 0.12); + --massive-table-dim-overlay: rgba(0, 0, 0, 0.5); /* Optional: theme can set the first row min height for measurement */ --massive-table-first-row-min-h: 32px; } diff --git a/src/lib/styles/light.module.css b/src/lib/styles/light.module.css index ac3b43f..9772e99 100644 --- a/src/lib/styles/light.module.css +++ b/src/lib/styles/light.module.css @@ -13,6 +13,7 @@ --massive-table-row-stripe: #fafbfc; --massive-table-focus-ring: 0 0 0 2px rgba(59, 130, 246, 0.6); --massive-table-inline-group-bg: rgba(59, 130, 246, 0.08); + --massive-table-dim-overlay: rgba(0, 0, 0, 0.1); /* Optional: theme can set the first row min height for measurement */ --massive-table-first-row-min-h: 32px; } diff --git a/src/pages/All.tsx b/src/pages/All.tsx new file mode 100644 index 0000000..75c71cf --- /dev/null +++ b/src/pages/All.tsx @@ -0,0 +1,29 @@ +import { ExamplePage } from '../components/ExamplePage'; + +export default function AllFeaturesPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Basic.tsx b/src/pages/Basic.tsx new file mode 100644 index 0000000..e843ec8 --- /dev/null +++ b/src/pages/Basic.tsx @@ -0,0 +1,21 @@ +import { ExamplePage } from '../components/ExamplePage'; + +export default function BasicPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Custom.tsx b/src/pages/Custom.tsx new file mode 100644 index 0000000..2d01e6e --- /dev/null +++ b/src/pages/Custom.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { ExamplePage } from '../components/ExamplePage'; +import { useDemo } from '../context/DemoContext'; +import type { GroupHeader, Row } from '../demo/types'; +import type { ColumnDef } from '../lib/types'; + +export default function CustomRenderPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + const { columns } = useDemo(); + + const pillColumns: ColumnDef[] = React.useMemo(() => { + const cols = (columns as ColumnDef[]).map((c) => ({ ...c })); + const idx = cols.findIndex( + (c) => JSON.stringify(c.path) === JSON.stringify(['favourites', 'number']), + ); + if (idx >= 0) { + cols[idx] = { + ...cols[idx], + render: (v: unknown, row: Row | GroupHeader, rowIndex: number) => { + const isGroupHeader = (r: Row | GroupHeader): r is GroupHeader => '__group' in r; + if (rowIndex === 10 && row && !isGroupHeader(row) && typeof v === 'number') { + return ( + + + {String(v)} + + + ); + } + return String(v ?? ''); + }, + } as ColumnDef; + } + return cols; + }, [columns]); + + return ( + + ); +} diff --git a/src/pages/Grouping.tsx b/src/pages/Grouping.tsx new file mode 100644 index 0000000..eb1386a --- /dev/null +++ b/src/pages/Grouping.tsx @@ -0,0 +1,33 @@ +import { ExamplePage } from '../components/ExamplePage'; + +export default function GroupingPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Layout.tsx b/src/pages/Layout.tsx new file mode 100644 index 0000000..221c1fe --- /dev/null +++ b/src/pages/Layout.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { useDemo } from '../context/DemoContext'; +import type { Row } from '../demo/types'; +import MassiveTable from '../lib/MassiveTable'; +import type { ColumnDef, GetRowsResult } from '../lib/types'; + +export default function LayoutPage() { + const { data, baseClasses, themeClass, columns } = useDemo(); + + const gridA = React.useMemo(() => data.slice(0, 2500), [data]); + const gridB = React.useMemo(() => data.slice(2500, 5000), [data]); + const gridC = React.useMemo(() => data.slice(5000, 7500), [data]); + const gridD = React.useMemo(() => data.slice(7500, 10000), [data]); + + const flexA = React.useMemo(() => data.filter((r) => r.category === 'one'), [data]); + const flexB = React.useMemo(() => data.filter((r) => r.category === 'two'), [data]); + const flexC = React.useMemo(() => data.filter((r) => r.favourites.number <= 50), [data]); + + const makeGetRows = React.useCallback( + (arr: T[]) => + async (start: number, end: number): Promise> => { + const len = Math.max(0, end - start); + return { rows: arr.slice(start, start + len), total: arr.length }; + }, + [], + ); + + return ( +
+
+

CSS Grid: 2 x 2 (auto-fill)

+
+ {( + [ + ['gridA', gridA], + ['gridB', gridB], + ['gridC', gridC], + ['gridD', gridD], + ] as [string, Row[]][] + ).map(([name, grid]) => ( +
+ + classes={baseClasses} + className={themeClass} + columns={columns as unknown as ColumnDef[]} + getRows={makeGetRows(grid)} + rowCount={grid.length} + style={{ height: '100%', width: '100%' }} + /> +
+ ))} +
+
+ +
+

Flex: 3 cells (flex: 1 1 0)

+
+ {( + [ + ['flexA', flexA], + ['flexB', flexB], + ['flexC', flexC], + ] as [string, Row[]][] + ).map(([name, arr]) => ( +
+ + classes={baseClasses} + className={themeClass} + columns={columns as unknown as ColumnDef[]} + getRows={makeGetRows(arr)} + rowCount={arr.length} + style={{ height: '100%', width: '100%' }} + /> +
+ ))} +
+
+
+ ); +} diff --git a/src/pages/Logs.tsx b/src/pages/Logs.tsx new file mode 100644 index 0000000..4f9bff4 --- /dev/null +++ b/src/pages/Logs.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { ExamplePage } from '../components/ExamplePage'; +import { useDemo } from '../context/DemoContext'; +import type { GroupHeader, Row } from '../demo/types'; +import type { ColumnDef, GetRowsResult, RowsRequest, Sort } from '../lib/types'; + +export default function LogsPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + const { logsColumns, getRowsLogs, logsData, logsExpandedKeys, setLogsExpandedKeys } = useDemo(); + + const variants = React.useMemo( + () => [ + { + name: 'Trace collapse/expand by Trace ID', + props: { + columns: logsColumns as unknown as ColumnDef>[], + getRows: getRowsLogs as unknown as ( + start: number, + end: number, + req?: RowsRequest, + ) => Promise>, + rowCount: logsData.length, + enableSort: true, + defaultSorts: [{ path: ['index'], dir: 'desc' }] as unknown as Sort[], + expandedKeys: logsExpandedKeys, + onExpandedKeysChange: setLogsExpandedKeys, + }, + note: '30 logs; traces inlined under the first occurrence.', + }, + { + name: 'Inline group + Index Asc', + props: { + columns: logsColumns as unknown as ColumnDef[], + getRows: getRowsLogs as unknown as ( + start: number, + end: number, + req?: RowsRequest, + ) => Promise>, + rowCount: logsData.length, + enableSort: true, + defaultSorts: [{ path: ['index'], dir: 'asc' }] as unknown as Sort[], + expandedKeys: logsExpandedKeys, + onExpandedKeysChange: setLogsExpandedKeys, + }, + note: 'Same data but sorted by index ascending by default.', + }, + ], + [getRowsLogs, logsColumns, logsData.length, logsExpandedKeys, setLogsExpandedKeys], + ); + + return ( + + ); +} diff --git a/src/pages/Reorder.tsx b/src/pages/Reorder.tsx new file mode 100644 index 0000000..d0aa661 --- /dev/null +++ b/src/pages/Reorder.tsx @@ -0,0 +1,19 @@ +import { ExamplePage } from '../components/ExamplePage'; + +export default function ReorderPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Resize.tsx b/src/pages/Resize.tsx new file mode 100644 index 0000000..0c40d57 --- /dev/null +++ b/src/pages/Resize.tsx @@ -0,0 +1,19 @@ +import { ExamplePage } from '../components/ExamplePage'; + +export default function ResizePage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Sorting.tsx b/src/pages/Sorting.tsx new file mode 100644 index 0000000..adc663a --- /dev/null +++ b/src/pages/Sorting.tsx @@ -0,0 +1,27 @@ +import { ExamplePage } from '../components/ExamplePage'; +import type { Sort } from '../lib/types'; + +export default function SortingPage({ + variantIndex, + onVariantChange, +}: { + variantIndex: number; + onVariantChange: (i: number) => void; +}) { + return ( + + ); +} diff --git a/src/pages/Visibility.tsx b/src/pages/Visibility.tsx new file mode 100644 index 0000000..8575d4b --- /dev/null +++ b/src/pages/Visibility.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { useDemo } from '../context/DemoContext'; +import type { GroupHeader, Row } from '../demo/types'; +import MassiveTable from '../lib/MassiveTable'; +import type { ColumnDef } from '../lib/types'; + +export default function VisibilityPage() { + const { columns, getRows, data, dataVersion, baseClasses, themeClass } = useDemo(); + const allKeys = React.useMemo(() => columns.map((c) => JSON.stringify(c.path)), [columns]); + const [visibleKeys, setVisibleKeys] = React.useState(allKeys); + + const visibleColumns = React.useMemo(() => { + const set = new Set(visibleKeys); + return (columns as unknown as ColumnDef[]).filter((c) => + set.has(JSON.stringify(c.path)), + ); + }, [columns, visibleKeys]); + + const toggleKey = (key: string) => { + setVisibleKeys((prev) => { + const has = prev.includes(key); + if (has) { + if (prev.length <= 1) return prev; + return prev.filter((k) => k !== key); + } + return [...prev, key]; + }); + }; + + return ( +
+

Column Visibility

+
+
+ {columns.map((c) => { + const key = JSON.stringify(c.path); + const checked = visibleKeys.includes(key); + const disable = checked && visibleKeys.length <= 1; + return ( + + ); + })} +
+
+ + key={`visibility:${visibleColumns.length}:${dataVersion}`} + getRows={getRows} + rowCount={data.length} + columns={visibleColumns} + classes={baseClasses} + className={themeClass} + style={{ height: '70vh', width: '100%' }} + /> +
+ ); +} diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..c38e3f4 --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +export type Route = { + key: string; + title: string; + element: React.ReactNode; +}; + +export function useHashRoute(defaultKey: string) { + const [key, setKey] = React.useState(defaultKey); + const [variant, setVariant] = React.useState(0); + + React.useEffect(() => { + const parse = () => { + const hash = window.location.hash.replace(/^#/, ''); + const parts = hash.split('/').filter(Boolean); + const nextKey = parts[0] || defaultKey; + const idx = parts[1] ? Number(parts[1]) : 0; + setKey(nextKey); + setVariant(Number.isFinite(idx) ? Math.max(0, idx) : 0); + }; + window.addEventListener('hashchange', parse); + parse(); + return () => window.removeEventListener('hashchange', parse); + }, [defaultKey]); + + const navigate = React.useCallback((nextKey: string, nextVariant?: number) => { + const v = typeof nextVariant === 'number' ? `/${nextVariant}` : ''; + const next = `#/${nextKey}${v}`; + if (window.location.hash !== next) window.location.hash = next; + }, []); + + return { key, variant, navigate }; +}