diff --git a/.storybook/previewDecorator.tsx b/.storybook/previewDecorator.tsx index 671d85b..748f2f5 100644 --- a/.storybook/previewDecorator.tsx +++ b/.storybook/previewDecorator.tsx @@ -22,6 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => { return ( + {useGlobalStore ? ( diff --git a/src/Griddy/CONTEXT.md b/src/Griddy/CONTEXT.md new file mode 100644 index 0000000..bdb3997 --- /dev/null +++ b/src/Griddy/CONTEXT.md @@ -0,0 +1,102 @@ +# Griddy - Implementation Context + +## What Is This +Griddy is a new data grid component in the Oranguru package (`@warkypublic/oranguru`), replacing Glide Data Grid (used by Gridler) with TanStack Table + TanStack Virtual. + +## Architecture + +### Two TanStack Libraries +- **@tanstack/react-table** (headless table model): owns sorting, filtering, pagination, row selection, column visibility, grouping state +- **@tanstack/react-virtual** (virtualization): renders only visible rows from the table's row model + +### State Management +- **createSyncStore** from `@warkypublic/zustandsyncstore` — same pattern as Gridler's `GridlerStore.tsx` +- `GriddyProvider` wraps children; props auto-sync into the store via `$sync` +- `useGriddyStore((s) => s.fieldName)` to read any prop or UI state +- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility (the sync happens at runtime but TS needs the types) +- UI state (focus, edit mode, search overlay, selection mode) lives in the store +- TanStack Table/Virtual instances stored as `_table`, `_virtualizer` in the store + +### Component Tree +``` + // forwardRef wrapper + // createSyncStore Provider, syncs all props + // sets up useReactTable + useVirtualizer + // Ctrl+F search (Mantine TextInput) +
// scroll container, keyboard target + // renders table.getHeaderGroups() + // maps virtualizer items → TableRow + // focus/selection CSS, click handler + // flexRender or Mantine Checkbox +
+
+
+
+``` + +## Key Files + +| File | Purpose | +|------|---------| +| `core/types.ts` | All interfaces: GriddyColumn, GriddyProps, GriddyRef, GriddyUIState, SelectionConfig, SearchConfig, etc. | +| `core/constants.ts` | CSS class names, defaults (row height 36, overscan 10, page size 50) | +| `core/columnMapper.ts` | Maps GriddyColumn → TanStack ColumnDef. Uses `accessorKey` for strings, `accessorFn` for functions. Auto-prepends checkbox column for selection. | +| `core/GriddyStore.ts` | createSyncStore with GriddyStoreState. Exports `GriddyProvider` and `useGriddyStore`. | +| `core/Griddy.tsx` | Main component. GriddyInner reads props from store, creates useReactTable + useVirtualizer, wires keyboard nav. | +| `rendering/VirtualBody.tsx` | Virtual row rendering. **Important**: all hooks must be before early return (hooks violation fix). | +| `rendering/TableHeader.tsx` | Header with sort indicators, resize handles, select-all checkbox. | +| `rendering/TableRow.tsx` | Row with focus/selection styling, click-to-select. | +| `rendering/TableCell.tsx` | Cell rendering via flexRender, checkbox for selection column. | +| `features/keyboard/useKeyboardNavigation.ts` | Full keyboard handler with ref to latest state. | +| `features/search/SearchOverlay.tsx` | Ctrl+F search overlay with debounced global filter. | +| `styles/griddy.module.css` | CSS Modules with custom properties for theming. | +| `Griddy.stories.tsx` | Storybook stories: Basic, LargeDataset, SingleSelection, MultiSelection, WithSearch, KeyboardNavigation. | + +## Keyboard Bindings +- Arrow Up/Down: move focus +- Page Up/Down: jump by visible page +- Home/End: first/last row +- Space: toggle selection +- Shift+Arrow: extend multi-selection +- Ctrl+A: select all (multi mode) +- Ctrl+F: open search overlay +- Ctrl+E / Enter: enter edit mode +- Ctrl+S: toggle selection mode +- Escape: close search / cancel edit / clear selection + +## Selection Modes +- `'none'`: no selection +- `'single'`: one row at a time (TanStack `enableMultiRowSelection: false`) +- `'multi'`: multiple rows, checkbox column, shift+click range, ctrl+a + +## Gotchas / Bugs Fixed +1. **Hooks violation in VirtualBody**: `useEffect` was after early `return null`. All hooks must run before any conditional return. +2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors (enables auto-detect), `sortingFn: 'auto'` for function accessors. +3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in the store state interface. +4. **useGriddyStore has no .getState()**: It's a context-based hook, not a vanilla zustand store. Use `useRef` to track latest state for imperative access in event handlers. +5. **Keyboard focus must scroll**; When keyboard focus changes off screen the screen must scroll with + +## UI Components +Uses **Mantine** components (not raw HTML): +- `Checkbox` from `@mantine/core` for row/header checkboxes +- `TextInput` from `@mantine/core` for search input + +## Implementation Status +- [x] Phase 1: Core foundation + TanStack Table +- [x] Phase 2: Virtualization + keyboard navigation +- [x] Phase 3: Row selection (single + multi) +- [x] Phase 4: Search (Ctrl+F overlay) +- [x] Sorting (click header) +- [ ] Phase 5: Column filtering UI +- [ ] Phase 6: In-place editing +- [ ] Phase 7: Pagination + remote data adapters +- [ ] Phase 8: Grouping, pinning, column reorder, export +- [ ] Phase 9: Polish, docs, tests + +## Dependencies Added +- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies) + +## Build +- `pnpm run typecheck` — clean +- `pnpm run build` — clean +- `pnpm run storybook` — stories render correctly diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx new file mode 100644 index 0000000..d564cd4 --- /dev/null +++ b/src/Griddy/Griddy.stories.tsx @@ -0,0 +1,229 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { RowSelectionState } from '@tanstack/react-table' + +import { Box } from '@mantine/core' +import { useState } from 'react' + +import type { GriddyColumn, GriddyProps } from './core/types' + +import { Griddy } from './core/Griddy' + +// ─── Sample Data ───────────────────────────────────────────────────────────── + +interface Person { + active: boolean + age: number + department: string + email: string + firstName: string + id: number + lastName: string + salary: number + startDate: string +} + +const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Design', 'Legal', 'Support'] +const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack', 'Karen', 'Leo', 'Mia', 'Nick', 'Olivia', 'Paul', 'Quinn', 'Rose', 'Sam', 'Tina'] +const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Hernandez', 'Moore', 'Martin', 'Jackson', 'Thompson', 'White', 'Lopez', 'Lee'] + +function generateData(count: number): Person[] { + return Array.from({ length: count }, (_, i) => ({ + active: i % 3 !== 0, + age: 22 + (i % 45), + department: departments[i % departments.length], + email: `${firstNames[i % firstNames.length].toLowerCase()}.${lastNames[i % lastNames.length].toLowerCase()}@example.com`, + firstName: firstNames[i % firstNames.length], + id: i + 1, + lastName: lastNames[i % lastNames.length], + salary: 40000 + (i * 1234) % 80000, + startDate: `202${i % 5}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, + })) +} + +const smallData = generateData(20) +const largeData = generateData(10_000) + +// ─── Column Definitions ────────────────────────────────────────────────────── + +const columns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, + { accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 }, + { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 }, + { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 }, + { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, +] + +// ─── Wrapper ───────────────────────────────────────────────────────────────── + +function GriddyWrapper(props: GriddyProps) { + return ( + + + + ) +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── + +const meta = { + args: { + columns, + data: smallData, + getRowId: (row: Person) => String(row.id), + height: 500, + }, + component: GriddyWrapper, + parameters: { + globalStore: false, + layout: 'fullscreen', + }, + tags: ['autodocs'], + title: 'Components/Griddy', +} satisfies Meta + +export default meta +type Story = StoryObj + +// ─── Stories ───────────────────────────────────────────────────────────────── + +/** Basic table with 20 rows, sorting enabled by default */ +export const Basic: Story = {} + +/** 10,000 rows with virtualization */ +export const LargeDataset: Story = { + args: { + data: largeData, + height: 600, + }, +} + +/** Single row selection mode - click or Space to select */ +export const SingleSelection: Story = { + render: () => { + const [selection, setSelection] = useState({}) + + return ( + + + columns={columns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onRowSelectionChange={setSelection} + rowSelection={selection} + selection={{ mode: 'single', selectOnClick: true, showCheckbox: true }} + /> + + Selected: {JSON.stringify(selection)} + + + ) + }, +} + +/** Multi row selection - Shift+Arrow to extend, Ctrl+A to select all, Space to toggle */ +export const MultiSelection: Story = { + render: () => { + const [selection, setSelection] = useState({}) + + return ( + + + columns={columns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onRowSelectionChange={setSelection} + rowSelection={selection} + selection={{ mode: 'multi', selectOnClick: true, showCheckbox: true }} + /> + + Selected ({Object.keys(selection).filter(k => selection[k]).length} rows): {JSON.stringify(selection)} + + + ) + }, +} + +/** Multi-select with 10k rows - test keyboard navigation + selection at scale */ +export const LargeMultiSelection: Story = { + render: () => { + const [selection, setSelection] = useState({}) + + return ( + + + columns={columns} + data={largeData} + getRowId={(row) => String(row.id)} + height={600} + onRowSelectionChange={setSelection} + rowSelection={selection} + selection={{ mode: 'multi', showCheckbox: true }} + /> + + Selected: {Object.keys(selection).filter(k => selection[k]).length} / {largeData.length} rows + + + ) + }, +} + +/** Search enabled - Ctrl+F to open search overlay */ +export const WithSearch: Story = { + args: { + search: { enabled: true, highlightMatches: true, placeholder: 'Search people...' }, + }, +} + +/** Keyboard navigation guide story */ +export const KeyboardNavigation: Story = { + render: () => { + const [selection, setSelection] = useState({}) + + return ( + + + Keyboard Shortcuts (click on the grid first to focus it) + + + {[ + ['Arrow Up/Down', 'Move focus between rows'], + ['Page Up/Down', 'Jump by one page'], + ['Home / End', 'Jump to first / last row'], + ['Space', 'Toggle selection of focused row'], + ['Shift + Arrow Up/Down', 'Extend selection (multi-select)'], + ['Ctrl + A', 'Select all rows'], + ['Ctrl + F', 'Open search overlay'], + ['Ctrl + S', 'Toggle selection mode'], + ['Escape', 'Clear selection / close search'], + ].map(([key, desc]) => ( + + + + + ))} + +
{key}{desc}
+
+ + columns={columns} + data={smallData} + getRowId={(row) => String(row.id)} + height={400} + onRowSelectionChange={setSelection} + rowSelection={selection} + search={{ enabled: true }} + selection={{ mode: 'multi', showCheckbox: true }} + /> + + Selected: {Object.keys(selection).filter(k => selection[k]).length} rows + +
+ ) + }, +} diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx new file mode 100644 index 0000000..3a43ca7 --- /dev/null +++ b/src/Griddy/core/Griddy.tsx @@ -0,0 +1,247 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type ColumnOrderState, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type RowSelectionState, + type SortingState, + useReactTable, + type VisibilityState, +} from '@tanstack/react-table' +import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' + +import type { GriddyProps, GriddyRef } from './types' + +import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation' +import { SearchOverlay } from '../features/search/SearchOverlay' +import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer' +import { TableHeader } from '../rendering/TableHeader' +import { VirtualBody } from '../rendering/VirtualBody' +import styles from '../styles/griddy.module.css' +import { mapColumns } from './columnMapper' +import { CSS, DEFAULTS } from './constants' +import { GriddyProvider, useGriddyStore } from './GriddyStore' + +// ─── Inner Component (lives inside Provider, has store access) ─────────────── + +function _Griddy(props: GriddyProps, ref: Ref>) { + return ( + + + {props.children} + + ) +} + +// ─── Main Component with forwardRef ────────────────────────────────────────── + +function GriddyInner({ tableRef }: { tableRef: Ref> }) { + // Read props from synced store + const data = useGriddyStore((s) => s.data) + const userColumns = useGriddyStore((s) => s.columns) + const getRowId = useGriddyStore((s) => s.getRowId) + const selection = useGriddyStore((s) => s.selection) + const search = useGriddyStore((s) => s.search) + const paginationConfig = useGriddyStore((s) => s.pagination) + const controlledSorting = useGriddyStore((s) => s.sorting) + const onSortingChange = useGriddyStore((s) => s.onSortingChange) + const controlledFilters = useGriddyStore((s) => s.columnFilters) + const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange) + const controlledRowSelection = useGriddyStore((s) => s.rowSelection) + const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange) + const onEditCommit = useGriddyStore((s) => s.onEditCommit) + const rowHeight = useGriddyStore((s) => s.rowHeight) + const overscanProp = useGriddyStore((s) => s.overscan) + const height = useGriddyStore((s) => s.height) + const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation) + const className = useGriddyStore((s) => s.className) + const setTable = useGriddyStore((s) => s.setTable) + const setVirtualizer = useGriddyStore((s) => s.setVirtualizer) + const setScrollRef = useGriddyStore((s) => s.setScrollRef) + const setFocusedRow = useGriddyStore((s) => s.setFocusedRow) + const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn) + const setEditing = useGriddyStore((s) => s.setEditing) + const setTotalRows = useGriddyStore((s) => s.setTotalRows) + const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) + + const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight + const effectiveOverscan = overscanProp ?? DEFAULTS.overscan + const enableKeyboard = keyboardNavigation !== false + + // ─── Column Mapping ─── + const columns = useMemo( + () => mapColumns(userColumns ?? [], selection) as ColumnDef[], + [userColumns, selection], + ) + + // ─── Table State (internal/uncontrolled) ─── + const [internalSorting, setInternalSorting] = useState([]) + const [internalFilters, setInternalFilters] = useState([]) + const [internalRowSelection, setInternalRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState(undefined) + const [columnVisibility, setColumnVisibility] = useState({}) + const [columnOrder, setColumnOrder] = useState([]) + const [internalPagination, setInternalPagination] = useState({ + pageIndex: 0, + pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize, + }) + + // Resolve controlled vs uncontrolled + const sorting = controlledSorting ?? internalSorting + const setSorting = onSortingChange ?? setInternalSorting + const columnFilters = controlledFilters ?? internalFilters + const setColumnFilters = onColumnFiltersChange ?? setInternalFilters + const rowSelectionState = controlledRowSelection ?? internalRowSelection + const setRowSelection = onRowSelectionChange ?? setInternalRowSelection + + // ─── Selection config ─── + const enableRowSelection = selection ? selection.mode !== 'none' : false + const enableMultiRowSelection = selection?.mode === 'multi' + + // ─── TanStack Table Instance ─── + const table = useReactTable({ + columns, + data: (data ?? []) as T[], + enableColumnResizing: true, + enableFilters: true, + enableMultiRowSelection, + enableMultiSort: true, + enableRowSelection, + enableSorting: true, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getRowId: getRowId as any ?? ((_, index) => String(index)), + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters as any, + onColumnOrderChange: setColumnOrder, + onColumnVisibilityChange: setColumnVisibility, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: paginationConfig?.enabled ? setInternalPagination : undefined, + onRowSelectionChange: setRowSelection as any, + onSortingChange: setSorting as any, + state: { + columnFilters, + columnOrder, + columnVisibility, + globalFilter, + rowSelection: rowSelectionState, + sorting, + ...(paginationConfig?.enabled ? { pagination: internalPagination } : {}), + }, + ...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}), + columnResizeMode: 'onChange', + getExpandedRowModel: getExpandedRowModel(), + }) + + // ─── Scroll Container Ref ─── + const scrollRef = useRef(null) + + // ─── TanStack Virtual ─── + const virtualizer = useGridVirtualizer({ + overscan: effectiveOverscan, + rowHeight: effectiveRowHeight, + scrollRef, + table, + }) + + // ─── Sync table + virtualizer + scrollRef into store ─── + useEffect(() => { setTable(table) }, [table, setTable]) + useEffect(() => { setVirtualizer(virtualizer) }, [virtualizer, setVirtualizer]) + useEffect(() => { setScrollRef(scrollRef.current) }, [setScrollRef]) + + // ─── Keyboard Navigation ─── + // Get the full store state for imperative access in keyboard handler + const storeState = useGriddyStore() + + useKeyboardNavigation({ + editingEnabled: !!onEditCommit, + scrollRef, + search, + selection, + storeState, + table, + virtualizer, + }) + + // ─── Set initial focus when data loads ─── + const rowCount = table.getRowModel().rows.length + + useEffect(() => { + setTotalRows(rowCount) + if (rowCount > 0 && focusedRowIndex === null) { + setFocusedRow(0) + } + }, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]) + + // ─── Imperative Ref ─── + useImperativeHandle(tableRef, () => ({ + deselectAll: () => table.resetRowSelection(), + focusRow: (index: number) => { + setFocusedRow(index) + virtualizer.scrollToIndex(index, { align: 'auto' }) + }, + getTable: () => table, + getUIState: () => ({ + focusedColumnId: null, + focusedRowIndex, + isEditing: false, + isSearchOpen: false, + isSelecting: false, + totalRows: rowCount, + } as any), + getVirtualizer: () => virtualizer, + scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }), + selectRow: (id: string) => { + const row = table.getRowModel().rows.find((r) => r.id === id) + row?.toggleSelected(true) + }, + startEditing: (rowId: string, columnId?: string) => { + const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId) + if (rowIndex >= 0) { + setFocusedRow(rowIndex) + if (columnId) setFocusedColumn(columnId) + setEditing(true) + } + }, + }), [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]) + + // ─── Render ─── + const containerStyle: React.CSSProperties = { + height: height ?? '100%', + overflow: 'auto', + position: 'relative', + } + + const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null + const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined + + return ( +
+ {search?.enabled && } +
+ + +
+
+ ) +} + +export const Griddy = forwardRef(_Griddy) as ( + props: GriddyProps & React.RefAttributes> +) => React.ReactElement diff --git a/src/Griddy/core/GriddyStore.ts b/src/Griddy/core/GriddyStore.ts new file mode 100644 index 0000000..f6f5ff1 --- /dev/null +++ b/src/Griddy/core/GriddyStore.ts @@ -0,0 +1,103 @@ +import type { Table } from '@tanstack/react-table' +import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table' +import type { Virtualizer } from '@tanstack/react-virtual' + +import { createSyncStore } from '@warkypublic/zustandsyncstore' + +import type { DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types' + +// ─── Store State ───────────────────────────────────────────────────────────── + +/** + * Full store state: UI state + synced props + internal references. + * Props from GriddyProps are synced automatically via createSyncStore's $sync. + * Fields from GriddyProps must be declared here so TypeScript can see them. + */ +export interface GriddyStoreState extends GriddyUIState { + _scrollRef: HTMLDivElement | null + // ─── Internal refs (set imperatively) ─── + _table: null | Table + _virtualizer: null | Virtualizer + className?: string + columnFilters?: ColumnFiltersState + columns?: GriddyColumn[] + data?: any[] + dataAdapter?: DataAdapter + getRowId?: (row: any, index: number) => string + grouping?: GroupingConfig + height?: number | string + keyboardNavigation?: boolean + onColumnFiltersChange?: (filters: ColumnFiltersState) => void + onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void + onRowSelectionChange?: (selection: RowSelectionState) => void + onSortingChange?: (sorting: SortingState) => void + overscan?: number + pagination?: PaginationConfig + persistenceKey?: string + rowHeight?: number + rowSelection?: RowSelectionState + search?: SearchConfig + + selection?: SelectionConfig + setScrollRef: (el: HTMLDivElement | null) => void + // ─── Internal ref setters ─── + setTable: (table: Table) => void + + setVirtualizer: (virtualizer: Virtualizer) => void + sorting?: SortingState + // ─── Synced from GriddyProps (written by $sync) ─── + uniqueId?: string +} + +// ─── Create Store ──────────────────────────────────────────────────────────── + +export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore< + GriddyStoreState, + GriddyProps +>( + (set, get) => ({ + _scrollRef: null, + // ─── Internal Refs ─── + _table: null, + + _virtualizer: null, + focusedColumnId: null, + // ─── Focus State ─── + focusedRowIndex: null, + + // ─── Mode State ─── + isEditing: false, + + isSearchOpen: false, + isSelecting: false, + moveFocus: (direction, amount) => { + const { focusedRowIndex, totalRows } = get() + const current = focusedRowIndex ?? 0 + const delta = direction === 'down' ? amount : -amount + const next = Math.max(0, Math.min(current + delta, totalRows - 1)) + set({ focusedRowIndex: next }) + }, + + moveFocusToEnd: () => { + const { totalRows } = get() + set({ focusedRowIndex: Math.max(0, totalRows - 1) }) + }, + moveFocusToStart: () => set({ focusedRowIndex: 0 }), + setEditing: (editing) => set({ isEditing: editing }), + setFocusedColumn: (id) => set({ focusedColumnId: id }), + // ─── Actions ─── + setFocusedRow: (index) => set({ focusedRowIndex: index }), + setScrollRef: (el) => set({ _scrollRef: el }), + + setSearchOpen: (open) => set({ isSearchOpen: open }), + + setSelecting: (selecting) => set({ isSelecting: selecting }), + // ─── Internal Ref Setters ─── + setTable: (table) => set({ _table: table }), + + setTotalRows: (count) => set({ totalRows: count }), + setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }), + // ─── Row Count ─── + totalRows: 0, + }), +) diff --git a/src/Griddy/core/columnMapper.ts b/src/Griddy/core/columnMapper.ts new file mode 100644 index 0000000..437b966 --- /dev/null +++ b/src/Griddy/core/columnMapper.ts @@ -0,0 +1,66 @@ +import type { ColumnDef } from '@tanstack/react-table' + +import type { GriddyColumn, SelectionConfig } from './types' + +import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants' + +/** + * Retrieves the original GriddyColumn from a TanStack column's meta. + */ +export function getGriddyColumn(column: { columnDef: ColumnDef }): GriddyColumn | undefined { + return (column.columnDef.meta as { griddy?: GriddyColumn })?.griddy +} + +/** + * Maps Griddy's user-facing GriddyColumn definitions to TanStack Table ColumnDef[]. + * Optionally prepends a selection checkbox column. + */ +export function mapColumns( + columns: GriddyColumn[], + selection?: SelectionConfig, +): ColumnDef[] { + const mapped: ColumnDef[] = columns.map((col) => { + const isStringAccessor = typeof col.accessor !== 'function' + + const def: ColumnDef = { + id: col.id, + // Use accessorKey for string keys (enables TanStack auto-detection of sort/filter), + // accessorFn for function accessors + ...(isStringAccessor + ? { accessorKey: col.accessor as string } + : { accessorFn: col.accessor as (row: T) => unknown }), + enableColumnFilter: col.filterable ?? false, + enableHiding: true, + enableResizing: true, + enableSorting: col.sortable ?? true, + header: () => col.header, + maxSize: col.maxWidth ?? DEFAULTS.maxColumnWidth, + meta: { griddy: col }, + minSize: col.minWidth ?? DEFAULTS.minColumnWidth, + size: col.width, + // For function accessors, TanStack can't auto-detect the sort type, so default to 'auto' + sortingFn: col.sortFn ?? (isStringAccessor ? undefined : 'auto') as any, + } + if (col.filterFn) def.filterFn = col.filterFn + return def + }) + + // Prepend checkbox column if selection is enabled + if (selection && selection.mode !== 'none' && selection.showCheckbox !== false) { + const checkboxCol: ColumnDef = { + cell: 'select-row', // Rendered by TableCell with actual checkbox + enableColumnFilter: false, + enableHiding: false, + enableResizing: false, + enableSorting: false, + header: selection.mode === 'multi' + ? 'select-all' // Rendered by TableHeader with actual checkbox + : '', + id: SELECTION_COLUMN_ID, + size: SELECTION_COLUMN_SIZE, + } + mapped.unshift(checkboxCol) + } + + return mapped +} diff --git a/src/Griddy/core/constants.ts b/src/Griddy/core/constants.ts new file mode 100644 index 0000000..28e5f35 --- /dev/null +++ b/src/Griddy/core/constants.ts @@ -0,0 +1,43 @@ +// ─── CSS Class Names ───────────────────────────────────────────────────────── + +export const CSS = { + cell: 'griddy-cell', + cellEditing: 'griddy-cell--editing', + checkbox: 'griddy-checkbox', + container: 'griddy-container', + headerCell: 'griddy-header-cell', + headerCellSortable: 'griddy-header-cell--sortable', + headerCellSorted: 'griddy-header-cell--sorted', + headerRow: 'griddy-header-row', + resizeHandle: 'griddy-resize-handle', + root: 'griddy', + row: 'griddy-row', + rowEven: 'griddy-row--even', + rowFocused: 'griddy-row--focused', + rowOdd: 'griddy-row--odd', + rowSelected: 'griddy-row--selected', + searchInput: 'griddy-search-input', + searchOverlay: 'griddy-search-overlay', + sortIndicator: 'griddy-sort-indicator', + table: 'griddy-table', + tbody: 'griddy-tbody', + thead: 'griddy-thead', +} as const + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +export const DEFAULTS = { + headerHeight: 36, + maxColumnWidth: 800, + minColumnWidth: 50, + overscan: 10, + pageSize: 50, + pageSizeOptions: [25, 50, 100] as number[], + rowHeight: 36, + searchDebounceMs: 300, +} as const + +// ─── Selection Column ──────────────────────────────────────────────────────── + +export const SELECTION_COLUMN_ID = '_selection' +export const SELECTION_COLUMN_SIZE = 40 diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts new file mode 100644 index 0000000..8c7e8e3 --- /dev/null +++ b/src/Griddy/core/types.ts @@ -0,0 +1,233 @@ +import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, FilterFn, GroupingState, PaginationState, RowSelectionState, SortingFn, SortingState, Table, VisibilityState } from '@tanstack/react-table' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { ReactNode } from 'react' + +// ─── Column Definition ─────────────────────────────────────────────────────── + +export type CellRenderer = (props: RendererProps) => ReactNode + +// ─── Cell Rendering ────────────────────────────────────────────────────────── + +export interface DataAdapter { + delete?: (row: T) => Promise + fetch: (config: FetchConfig) => Promise> + save?: (row: T) => Promise +} + +export type EditorComponent = (props: EditorProps) => ReactNode + +// ─── Editors ───────────────────────────────────────────────────────────────── + +export interface EditorProps { + column: GriddyColumn + onCancel: () => void + onCommit: (newValue: unknown) => void + onMoveNext: () => void + onMovePrev: () => void + row: T + rowIndex: number + value: unknown +} + +export interface FetchConfig { + cursor?: string + filters?: ColumnFiltersState + globalFilter?: string + page?: number + pageSize?: number + sorting?: SortingState +} + +// ─── Selection ─────────────────────────────────────────────────────────────── + +export interface GriddyColumn { + accessor: ((row: T) => unknown) | keyof T + editable?: ((row: T) => boolean) | boolean + editor?: EditorComponent + filterable?: boolean + filterFn?: FilterFn + header: ReactNode | string + headerGroup?: string + hidden?: boolean + id: string + maxWidth?: number + minWidth?: number + pinned?: 'left' | 'right' + renderer?: CellRenderer + searchable?: boolean + sortable?: boolean + sortFn?: SortingFn + width?: number +} + +// ─── Search ────────────────────────────────────────────────────────────────── + +export interface GriddyDataSource { + data: T[] + error?: Error + isLoading?: boolean + pageInfo?: { cursor?: string; hasNextPage: boolean; } + total?: number +} + +// ─── Pagination ────────────────────────────────────────────────────────────── + +export interface GriddyProps { + // ─── Children (adapters, etc.) ─── + children?: ReactNode + // ─── Styling ─── + className?: string + // ─── Filtering ─── + /** Controlled column filters state */ + columnFilters?: ColumnFiltersState + /** Column definitions */ + columns: GriddyColumn[] + + /** Data array */ + data: T[] + + // ─── Data Adapter ─── + dataAdapter?: DataAdapter + /** Stable row identity function */ + getRowId?: (row: T, index: number) => string + // ─── Grouping ─── + grouping?: GroupingConfig + + /** Container height */ + height?: number | string + // ─── Keyboard ─── + /** Enable keyboard navigation. Default: true */ + keyboardNavigation?: boolean + + onColumnFiltersChange?: (filters: ColumnFiltersState) => void + // ─── Editing ─── + onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void + + /** Selection change callback */ + onRowSelectionChange?: (selection: RowSelectionState) => void + + onSortingChange?: (sorting: SortingState) => void + + /** Overscan row count. Default: 10 */ + overscan?: number + + // ─── Pagination ─── + pagination?: PaginationConfig + + // ─── Persistence ─── + /** localStorage key prefix for persisting column layout */ + persistenceKey?: string + // ─── Virtualization ─── + /** Row height in pixels. Default: 36 */ + rowHeight?: number + /** Controlled row selection state */ + rowSelection?: RowSelectionState + + // ─── Search ─── + search?: SearchConfig + + // ─── Selection ─── + /** Selection configuration */ + selection?: SelectionConfig + + // ─── Sorting ─── + /** Controlled sorting state */ + sorting?: SortingState + + /** Unique identifier for persistence */ + uniqueId?: string +} + +// ─── Data Adapter ──────────────────────────────────────────────────────────── + +export interface GriddyRef { + deselectAll: () => void + focusRow: (index: number) => void + getTable: () => Table + getUIState: () => GriddyUIState + getVirtualizer: () => Virtualizer + scrollToRow: (index: number) => void + selectRow: (id: string) => void + startEditing: (rowId: string, columnId?: string) => void +} + +export interface GriddyUIState { + focusedColumnId: null | string + // Focus + focusedRowIndex: null | number + + // Modes + isEditing: boolean + isSearchOpen: boolean + isSelecting: boolean + + moveFocus: (direction: 'down' | 'up', amount: number) => void + + moveFocusToEnd: () => void + moveFocusToStart: () => void + setEditing: (editing: boolean) => void + setFocusedColumn: (id: null | string) => void + // Actions + setFocusedRow: (index: null | number) => void + setSearchOpen: (open: boolean) => void + setSelecting: (selecting: boolean) => void + setTotalRows: (count: number) => void + // Row count (synced from table) + totalRows: number +} + +export interface GroupingConfig { + columns?: string[] + enabled: boolean +} + +// ─── Grouping ──────────────────────────────────────────────────────────────── + +export interface PaginationConfig { + enabled: boolean + onPageChange?: (page: number) => void + onPageSizeChange?: (pageSize: number) => void + pageSize: number + pageSizeOptions?: number[] + type: 'cursor' | 'offset' +} + +// ─── Main Props ────────────────────────────────────────────────────────────── + +export interface RendererProps { + column: GriddyColumn + columnIndex: number + isEditing?: boolean + row: T + rowIndex: number + searchQuery?: string + value: unknown +} + +// ─── UI State (Zustand Store) ──────────────────────────────────────────────── + +export interface SearchConfig { + caseSensitive?: boolean + debounceMs?: number + enabled: boolean + fuzzy?: boolean + highlightMatches?: boolean + placeholder?: string +} + +// ─── Ref API ───────────────────────────────────────────────────────────────── + +export interface SelectionConfig { + /** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */ + mode: 'multi' | 'none' | 'single' + /** Maintain selection across pagination/sorting. Default: true */ + preserveSelection?: boolean + /** Allow clicking row body to toggle selection. Default: true */ + selectOnClick?: boolean + /** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */ + showCheckbox?: boolean +} + +// ─── Re-exports for convenience ────────────────────────────────────────────── + +export type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, GroupingState, PaginationState, RowSelectionState, SortingState, Table, VisibilityState } diff --git a/src/Griddy/features/keyboard/useKeyboardNavigation.ts b/src/Griddy/features/keyboard/useKeyboardNavigation.ts new file mode 100644 index 0000000..26e41d2 --- /dev/null +++ b/src/Griddy/features/keyboard/useKeyboardNavigation.ts @@ -0,0 +1,221 @@ +import type { Table } from '@tanstack/react-table' +import type { Virtualizer } from '@tanstack/react-virtual' + +import { type RefObject, useCallback, useEffect, useRef } from 'react' + +import type { GriddyUIState, SearchConfig, SelectionConfig } from '../../core/types' + +interface UseKeyboardNavigationOptions { + editingEnabled: boolean + scrollRef: RefObject + search?: SearchConfig + selection?: SelectionConfig + storeState: GriddyUIState + table: Table + virtualizer: Virtualizer +} + +export function useKeyboardNavigation({ + editingEnabled, + scrollRef, + search, + selection, + storeState, + table, + virtualizer, +}: UseKeyboardNavigationOptions) { + // Keep a ref to the latest store state so the keydown handler always sees fresh state + const stateRef = useRef(storeState) + stateRef.current = storeState + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + const state = stateRef.current + const { focusedRowIndex, isEditing, isSearchOpen } = state + const rowCount = table.getRowModel().rows.length + const visibleCount = virtualizer.getVirtualItems().length + const selectionMode = selection?.mode ?? 'none' + const multiSelect = selection?.mode === 'multi' + + // ─── Search mode: only Escape exits ─── + if (isSearchOpen) { + if (e.key === 'Escape') { + state.setSearchOpen(false) + e.preventDefault() + } + return + } + + // ─── Edit mode: only Escape exits at grid level ─── + if (isEditing) { + if (e.key === 'Escape') { + state.setEditing(false) + e.preventDefault() + } + return + } + + // ─── Normal mode ─── + const ctrl = e.ctrlKey || e.metaKey + const shift = e.shiftKey + + // Handle shift+arrow before plain arrow + if (shift && !ctrl) { + if (e.key === 'ArrowDown' && multiSelect && focusedRowIndex !== null) { + e.preventDefault() + const nextIdx = Math.min(focusedRowIndex + 1, rowCount - 1) + const row = table.getRowModel().rows[nextIdx] + row?.toggleSelected(true) + state.moveFocus('down', 1) + virtualizer.scrollToIndex(Math.min(focusedRowIndex + 1, rowCount - 1), { align: 'auto' }) + return + } + if (e.key === 'ArrowUp' && multiSelect && focusedRowIndex !== null) { + e.preventDefault() + const prevIdx = Math.max(focusedRowIndex - 1, 0) + const row = table.getRowModel().rows[prevIdx] + row?.toggleSelected(true) + state.moveFocus('up', 1) + virtualizer.scrollToIndex(Math.max(focusedRowIndex - 1, 0), { align: 'auto' }) + return + } + } + + let didNavigate: boolean + + switch (e.key) { + case ' ': { + if (selectionMode !== 'none' && focusedRowIndex !== null) { + e.preventDefault() + const row = table.getRowModel().rows[focusedRowIndex] + if (row) { + if (selectionMode === 'single') { + table.resetRowSelection() + row.toggleSelected(true) + } else { + row.toggleSelected() + } + } + } + return + } + + case 'a': { + if (ctrl && multiSelect) { + e.preventDefault() + table.toggleAllRowsSelected() + } + return + } + + case 'ArrowDown': { + e.preventDefault() + state.moveFocus('down', 1) + didNavigate = true + break + } + + case 'ArrowUp': { + e.preventDefault() + state.moveFocus('up', 1) + didNavigate = true + break + } + + case 'e': { + if (ctrl && editingEnabled && focusedRowIndex !== null) { + e.preventDefault() + state.setEditing(true) + } + return + } + + case 'End': { + e.preventDefault() + state.moveFocusToEnd() + didNavigate = true + break + } + + case 'Enter': { + if (editingEnabled && focusedRowIndex !== null && !ctrl) { + e.preventDefault() + state.setEditing(true) + } + return + } + + case 'Escape': { + if (state.isSelecting) { + state.setSelecting(false) + e.preventDefault() + } else if (selectionMode !== 'none') { + table.resetRowSelection() + e.preventDefault() + } + return + } + + case 'f': { + if (ctrl && search?.enabled) { + e.preventDefault() + state.setSearchOpen(true) + } + return + } + + case 'Home': { + e.preventDefault() + state.moveFocusToStart() + didNavigate = true + break + } + + case 'PageDown': { + e.preventDefault() + state.moveFocus('down', visibleCount) + didNavigate = true + break + } + + case 'PageUp': { + e.preventDefault() + state.moveFocus('up', visibleCount) + didNavigate = true + break + } + + case 's': { + if (ctrl && selectionMode !== 'none') { + e.preventDefault() + state.setSelecting(!state.isSelecting) + } + return + } + + default: + return + } + + // Auto-scroll after navigation keys + if (didNavigate && focusedRowIndex !== null) { + // Estimate the new position based on the action + const newIndex = Math.max(0, Math.min( + e.key === 'Home' ? 0 : + e.key === 'End' ? rowCount - 1 : + e.key === 'PageDown' ? focusedRowIndex + visibleCount : + e.key === 'PageUp' ? focusedRowIndex - visibleCount : + e.key === 'ArrowDown' ? focusedRowIndex + 1 : + focusedRowIndex - 1, + rowCount - 1, + )) + virtualizer.scrollToIndex(newIndex, { align: 'auto' }) + } + }, [table, virtualizer, selection, search, editingEnabled]) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + el.addEventListener('keydown', handleKeyDown) + return () => el.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown, scrollRef]) +} diff --git a/src/Griddy/features/search/SearchOverlay.tsx b/src/Griddy/features/search/SearchOverlay.tsx new file mode 100644 index 0000000..336c476 --- /dev/null +++ b/src/Griddy/features/search/SearchOverlay.tsx @@ -0,0 +1,62 @@ +import { TextInput } from '@mantine/core' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { CSS, DEFAULTS } from '../../core/constants' +import { useGriddyStore } from '../../core/GriddyStore' +import styles from '../../styles/griddy.module.css' + +export function SearchOverlay() { + const table = useGriddyStore((s) => s._table) + const isSearchOpen = useGriddyStore((s) => s.isSearchOpen) + const setSearchOpen = useGriddyStore((s) => s.setSearchOpen) + const search = useGriddyStore((s) => s.search) + + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const timerRef = useRef>(null) + + const debounceMs = search?.debounceMs ?? DEFAULTS.searchDebounceMs + const placeholder = search?.placeholder ?? 'Search...' + + useEffect(() => { + if (isSearchOpen) { + inputRef.current?.focus() + } else { + setQuery('') + table?.setGlobalFilter(undefined) + } + }, [isSearchOpen, table]) + + const handleChange = useCallback((value: string) => { + setQuery(value) + + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + table?.setGlobalFilter(value || undefined) + }, debounceMs) + }, [table, debounceMs]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setSearchOpen(false) + } + }, [setSearchOpen]) + + if (!isSearchOpen) return null + + return ( +
+ handleChange(e.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + ref={inputRef} + size="xs" + value={query} + /> +
+ ) +} diff --git a/src/Griddy/index.ts b/src/Griddy/index.ts new file mode 100644 index 0000000..3272bd7 --- /dev/null +++ b/src/Griddy/index.ts @@ -0,0 +1,22 @@ +export { getGriddyColumn, mapColumns } from './core/columnMapper' +export { CSS, DEFAULTS, SELECTION_COLUMN_ID } from './core/constants' +export { Griddy } from './core/Griddy' +export { GriddyProvider, useGriddyStore } from './core/GriddyStore' +export type { GriddyStoreState } from './core/GriddyStore' +export type { + CellRenderer, + DataAdapter, + EditorComponent, + EditorProps, + FetchConfig, + GriddyColumn, + GriddyDataSource, + GriddyProps, + GriddyRef, + GriddyUIState, + GroupingConfig, + PaginationConfig, + RendererProps, + SearchConfig, + SelectionConfig, +} from './core/types' diff --git a/src/Griddy/plan.md b/src/Griddy/plan.md index d19f451..76a8a18 100644 --- a/src/Griddy/plan.md +++ b/src/Griddy/plan.md @@ -850,28 +850,32 @@ Rendering uses `table.getLeftHeaderGroups()`, `table.getCenterHeaderGroups()`, ` ## Architectural Patterns from Gridler to Adopt -### 1. Zustand Store Pattern -For UI state not managed by TanStack Table: +### 1. createSyncStore Pattern (from @warkypublic/zustandsyncstore) +Uses `createSyncStore` which provides a Provider that auto-syncs parent props into the Zustand store, plus a context-scoped `useStore` hook with selector support. `GriddyStoreState` includes both UI state AND synced prop fields (so TypeScript sees them): ```typescript -const { Provider, useGriddyStore } = createSyncStore( +const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore< + GriddyStoreState, // UI state + prop fields + internal refs + GriddyProps // Props synced from parent +>( (set, get) => ({ + // UI state focusedRowIndex: null, isEditing: false, isSearchOpen: false, isSelecting: false, + // Internal refs + _table: null, + _virtualizer: null, + // Actions setFocusedRow: (index) => set({ focusedRowIndex: index }), - setEditing: (editing) => set({ isEditing: editing }), - setSearchOpen: (open) => set({ isSearchOpen: open }), - setSelecting: (selecting) => set({ isSelecting: selecting }), - moveFocus: (direction, amount) => set(state => { - const current = state.focusedRowIndex ?? 0 - const delta = direction === 'down' ? amount : -amount - return { focusedRowIndex: Math.max(0, Math.min(current + delta, get().totalRows - 1)) } - }), - moveFocusToStart: () => set({ focusedRowIndex: 0 }), - moveFocusToEnd: () => set(state => ({ focusedRowIndex: get().totalRows - 1 })), + moveFocus: (direction, amount) => { ... }, + setTable: (table) => set({ _table: table }), + ... }) ) + +// Usage: +// All props (data, columns, selection, etc.) are available via useGriddyStore((s) => s.data) ``` ### 2. Data Adapter Pattern diff --git a/src/Griddy/rendering/TableCell.tsx b/src/Griddy/rendering/TableCell.tsx new file mode 100644 index 0000000..2a771df --- /dev/null +++ b/src/Griddy/rendering/TableCell.tsx @@ -0,0 +1,48 @@ +import { Checkbox } from '@mantine/core' +import { type Cell, flexRender } from '@tanstack/react-table' + +import { CSS, SELECTION_COLUMN_ID } from '../core/constants' +import styles from '../styles/griddy.module.css' + +interface TableCellProps { + cell: Cell +} + +export function TableCell({ cell }: TableCellProps) { + const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID + + if (isSelectionCol) { + return + } + + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ) +} + +function RowCheckbox({ cell }: TableCellProps) { + const row = cell.row + + return ( +
+ e.stopPropagation()} + size="xs" + /> +
+ ) +} diff --git a/src/Griddy/rendering/TableHeader.tsx b/src/Griddy/rendering/TableHeader.tsx new file mode 100644 index 0000000..32df056 --- /dev/null +++ b/src/Griddy/rendering/TableHeader.tsx @@ -0,0 +1,80 @@ +import { Checkbox } from '@mantine/core' +import { flexRender } from '@tanstack/react-table' + +import { CSS, SELECTION_COLUMN_ID } from '../core/constants' +import { useGriddyStore } from '../core/GriddyStore' +import styles from '../styles/griddy.module.css' + +export function TableHeader() { + const table = useGriddyStore((s) => s._table) + if (!table) return null + + const headerGroups = table.getHeaderGroups() + + return ( +
+ {headerGroups.map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => { + const isSortable = header.column.getCanSort() + const sortDir = header.column.getIsSorted() + const isSelectionCol = header.column.id === SELECTION_COLUMN_ID + + return ( +
+ {isSelectionCol ? ( + + ) : header.isPlaceholder ? null : ( + <> + {flexRender(header.column.columnDef.header, header.getContext())} + {sortDir && ( + + {sortDir === 'asc' ? ' \u2191' : ' \u2193'} + + )} + + )} + {header.column.getCanResize() && ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + /> + )} +
+ ) + })} +
+ ))} +
+ ) +} + +function SelectAllCheckbox() { + const table = useGriddyStore((s) => s._table) + const selection = useGriddyStore((s) => s.selection) + + if (!table || !selection || selection.mode !== 'multi') return null + + return ( + + ) +} diff --git a/src/Griddy/rendering/TableRow.tsx b/src/Griddy/rendering/TableRow.tsx new file mode 100644 index 0000000..40399e3 --- /dev/null +++ b/src/Griddy/rendering/TableRow.tsx @@ -0,0 +1,68 @@ +import type { Row } from '@tanstack/react-table' + +import { useCallback } from 'react' + +import { CSS } from '../core/constants' +import { useGriddyStore } from '../core/GriddyStore' +import styles from '../styles/griddy.module.css' +import { TableCell } from './TableCell' + +interface TableRowProps { + row: Row + size: number + start: number +} + +export function TableRow({ row, size, start }: TableRowProps) { + const selection = useGriddyStore((s) => s.selection) + const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) + const setFocusedRow = useGriddyStore((s) => s.setFocusedRow) + + const isFocused = focusedRowIndex === row.index + const isSelected = row.getIsSelected() + const isEven = row.index % 2 === 0 + + const handleClick = useCallback(() => { + setFocusedRow(row.index) + + if (selection && selection.mode !== 'none' && selection.selectOnClick !== false) { + if (selection.mode === 'single') { + row.toggleSelected(true) + } else { + row.toggleSelected() + } + } + }, [row, selection, setFocusedRow]) + + const classNames = [ + styles[CSS.row], + isFocused ? styles[CSS.rowFocused] : '', + isSelected ? styles[CSS.rowSelected] : '', + isEven ? styles[CSS.rowEven] : '', + !isEven ? styles[CSS.rowOdd] : '', + ].filter(Boolean).join(' ') + + return ( +
+ {row.getVisibleCells().map((cell) => ( + + ))} +
+ ) +} diff --git a/src/Griddy/rendering/VirtualBody.tsx b/src/Griddy/rendering/VirtualBody.tsx new file mode 100644 index 0000000..0fbe5de --- /dev/null +++ b/src/Griddy/rendering/VirtualBody.tsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react' + +import { CSS } from '../core/constants' +import { useGriddyStore } from '../core/GriddyStore' +import styles from '../styles/griddy.module.css' +import { TableRow } from './TableRow' + +export function VirtualBody() { + const table = useGriddyStore((s) => s._table) + const virtualizer = useGriddyStore((s) => s._virtualizer) + const setTotalRows = useGriddyStore((s) => s.setTotalRows) + + const rows = table?.getRowModel().rows + const virtualRows = virtualizer?.getVirtualItems() + const totalSize = virtualizer?.getTotalSize() ?? 0 + + // Sync row count to store for keyboard navigation bounds + useEffect(() => { + if (rows) { + setTotalRows(rows.length) + } + }, [rows?.length, setTotalRows]) + + if (!table || !virtualizer || !rows || !virtualRows) return null + + return ( +
+ {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + if (!row) return null + + return ( + + ) + })} +
+ ) +} diff --git a/src/Griddy/rendering/hooks/useGridVirtualizer.ts b/src/Griddy/rendering/hooks/useGridVirtualizer.ts new file mode 100644 index 0000000..d46e27b --- /dev/null +++ b/src/Griddy/rendering/hooks/useGridVirtualizer.ts @@ -0,0 +1,29 @@ +import type { Table } from '@tanstack/react-table' +import type { RefObject } from 'react' + +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' + +import { DEFAULTS } from '../../core/constants' + +interface UseGridVirtualizerOptions { + overscan?: number + rowHeight?: number + scrollRef: RefObject + table: Table +} + +export function useGridVirtualizer({ + overscan = DEFAULTS.overscan, + rowHeight = DEFAULTS.rowHeight, + scrollRef, + table, +}: UseGridVirtualizerOptions): Virtualizer { + const rowCount = table.getRowModel().rows.length + + return useVirtualizer({ + count: rowCount, + estimateSize: () => rowHeight, + getScrollElement: () => scrollRef.current, + overscan, + }) +} diff --git a/src/Griddy/styles/griddy.module.css b/src/Griddy/styles/griddy.module.css new file mode 100644 index 0000000..02ce7f2 --- /dev/null +++ b/src/Griddy/styles/griddy.module.css @@ -0,0 +1,212 @@ +/* ─── Root ──────────────────────────────────────────────────────────────── */ + +.griddy { + --griddy-font-family: inherit; + --griddy-font-size: 14px; + --griddy-border-color: #e0e0e0; + --griddy-header-bg: #f8f9fa; + --griddy-header-color: #212529; + --griddy-row-bg: #ffffff; + --griddy-row-hover-bg: #f1f3f5; + --griddy-row-even-bg: #f8f9fa; + --griddy-focus-color: #228be6; + --griddy-selection-bg: rgba(34, 139, 230, 0.1); + --griddy-cell-padding: 0 8px; + --griddy-search-bg: #ffffff; + --griddy-search-border: #dee2e6; + + font-family: var(--griddy-font-family); + font-size: var(--griddy-font-size); + position: relative; + width: 100%; + border: 1px solid var(--griddy-border-color); + border-radius: 4px; + overflow: hidden; +} + +/* ─── Container (scroll area) ──────────────────────────────────────────── */ + +.griddy-container { + outline: none; +} + +.griddy-container:focus-visible { + box-shadow: inset 0 0 0 2px var(--griddy-focus-color); +} + +/* ─── Header ───────────────────────────────────────────────────────────── */ + +.griddy-thead { + position: sticky; + top: 0; + z-index: 2; + background: var(--griddy-header-bg); + border-bottom: 2px solid var(--griddy-border-color); +} + +.griddy-header-row { + display: flex; + width: 100%; +} + +.griddy-header-cell { + display: flex; + align-items: center; + padding: var(--griddy-cell-padding); + height: 36px; + font-weight: 600; + color: var(--griddy-header-color); + border-right: 1px solid var(--griddy-border-color); + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + flex-shrink: 0; +} + +.griddy-header-cell:last-child { + border-right: none; + flex: 1; +} + +.griddy-header-cell--sortable { + cursor: pointer; +} + +.griddy-header-cell--sortable:hover { + background: rgba(0, 0, 0, 0.04); +} + +.griddy-header-cell--sorted { + color: var(--griddy-focus-color); +} + +/* ─── Sort Indicator ───────────────────────────────────────────────────── */ + +.griddy-sort-indicator { + margin-left: 4px; + font-size: 12px; +} + +/* ─── Resize Handle ────────────────────────────────────────────────────── */ + +.griddy-resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 4px; + cursor: col-resize; + background: transparent; +} + +.griddy-resize-handle:hover { + background: var(--griddy-focus-color); +} + +/* ─── Body ─────────────────────────────────────────────────────────────── */ + +.griddy-tbody { + width: 100%; +} + +/* ─── Row ──────────────────────────────────────────────────────────────── */ + +.griddy-row { + display: flex; + width: 100%; + border-bottom: 1px solid var(--griddy-border-color); + background: var(--griddy-row-bg); + cursor: default; + box-sizing: border-box; +} + +.griddy-row:hover { + background: var(--griddy-row-hover-bg); +} + +.griddy-row--even { + background: var(--griddy-row-even-bg); +} + +.griddy-row--even:hover { + background: var(--griddy-row-hover-bg); +} + +.griddy-row--focused { + outline: 2px solid var(--griddy-focus-color); + outline-offset: -2px; + z-index: 1; +} + +.griddy-row--selected { + background-color: var(--griddy-selection-bg); +} + +.griddy-row--selected:hover { + background-color: rgba(34, 139, 230, 0.15); +} + +.griddy-row--focused.griddy-row--selected { + outline: 2px solid var(--griddy-focus-color); + background-color: var(--griddy-selection-bg); +} + +/* ─── Cell ─────────────────────────────────────────────────────────────── */ + +.griddy-cell { + display: flex; + align-items: center; + padding: var(--griddy-cell-padding); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-right: 1px solid var(--griddy-border-color); + flex-shrink: 0; +} + +.griddy-cell:last-child { + border-right: none; + flex: 1; +} + +.griddy-cell--editing { + padding: 0; +} + +/* ─── Checkbox ─────────────────────────────────────────────────────────── */ + +.griddy-checkbox { + cursor: pointer; + margin: 0 auto; + display: block; +} + +/* ─── Search Overlay ───────────────────────────────────────────────────── */ + +.griddy-search-overlay { + position: absolute; + top: 0; + right: 0; + z-index: 10; + padding: 8px; + background: var(--griddy-search-bg); + border: 1px solid var(--griddy-search-border); + border-radius: 0 0 0 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.griddy-search-input { + font-size: var(--griddy-font-size); + padding: 4px 8px; + border: 1px solid var(--griddy-search-border); + border-radius: 4px; + outline: none; + width: 240px; +} + +.griddy-search-input:focus { + border-color: var(--griddy-focus-color); + box-shadow: 0 0 0 2px rgba(34, 139, 230, 0.2); +} diff --git a/src/lib.ts b/src/lib.ts index ff1eed0..6a06a67 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -3,6 +3,7 @@ export * from './ErrorBoundary'; export * from './Former'; export * from './FormerControllers'; export * from './GlobalStateStore'; +export * from './Griddy'; export * from './Gridler'; export {