A Griddy AI prototype
This commit is contained in:
@@ -22,6 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
|
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
{useGlobalStore ? (
|
{useGlobalStore ? (
|
||||||
<GlobalStateStoreProvider fetchOnMount={false}>
|
<GlobalStateStoreProvider fetchOnMount={false}>
|
||||||
|
|||||||
102
src/Griddy/CONTEXT.md
Normal file
102
src/Griddy/CONTEXT.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
<Griddy props> // forwardRef wrapper
|
||||||
|
<GriddyProvider {...props}> // createSyncStore Provider, syncs all props
|
||||||
|
<GriddyInner> // sets up useReactTable + useVirtualizer
|
||||||
|
<SearchOverlay /> // Ctrl+F search (Mantine TextInput)
|
||||||
|
<div tabIndex={0}> // scroll container, keyboard target
|
||||||
|
<TableHeader /> // renders table.getHeaderGroups()
|
||||||
|
<VirtualBody /> // maps virtualizer items → TableRow
|
||||||
|
<TableRow /> // focus/selection CSS, click handler
|
||||||
|
<TableCell /> // flexRender or Mantine Checkbox
|
||||||
|
</div>
|
||||||
|
</GriddyInner>
|
||||||
|
</GriddyProvider>
|
||||||
|
</Griddy>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
229
src/Griddy/Griddy.stories.tsx
Normal file
229
src/Griddy/Griddy.stories.tsx
Normal file
@@ -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<Person>[] = [
|
||||||
|
{ 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<Person>) {
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy {...props} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<typeof GriddyWrapper>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// ─── 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<RowSelectionState>({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columns={columns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onRowSelectionChange={setSelection}
|
||||||
|
rowSelection={selection}
|
||||||
|
selection={{ mode: 'single', selectOnClick: true, showCheckbox: true }}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
Selected: {JSON.stringify(selection)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi row selection - Shift+Arrow to extend, Ctrl+A to select all, Space to toggle */
|
||||||
|
export const MultiSelection: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columns={columns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onRowSelectionChange={setSelection}
|
||||||
|
rowSelection={selection}
|
||||||
|
selection={{ mode: 'multi', selectOnClick: true, showCheckbox: true }}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
Selected ({Object.keys(selection).filter(k => selection[k]).length} rows): {JSON.stringify(selection)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-select with 10k rows - test keyboard navigation + selection at scale */
|
||||||
|
export const LargeMultiSelection: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="600px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columns={columns}
|
||||||
|
data={largeData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={600}
|
||||||
|
onRowSelectionChange={setSelection}
|
||||||
|
rowSelection={selection}
|
||||||
|
selection={{ mode: 'multi', showCheckbox: true }}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
Selected: {Object.keys(selection).filter(k => selection[k]).length} / {largeData.length} rows
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<RowSelectionState>({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="600px" w="100%">
|
||||||
|
<Box mb="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontSize: 13 }}>
|
||||||
|
<strong>Keyboard Shortcuts</strong> (click on the grid first to focus it)
|
||||||
|
<table style={{ borderCollapse: 'collapse', marginTop: 8, width: '100%' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontWeight: 600, padding: '2px 12px 2px 0', whiteSpace: 'nowrap' }}>{key}</td>
|
||||||
|
<td style={{ padding: '2px 0' }}>{desc}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
<Griddy<Person>
|
||||||
|
columns={columns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={400}
|
||||||
|
onRowSelectionChange={setSelection}
|
||||||
|
rowSelection={selection}
|
||||||
|
search={{ enabled: true }}
|
||||||
|
selection={{ mode: 'multi', showCheckbox: true }}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
Selected: {Object.keys(selection).filter(k => selection[k]).length} rows
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
247
src/Griddy/core/Griddy.tsx
Normal file
247
src/Griddy/core/Griddy.tsx
Normal file
@@ -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<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
|
||||||
|
return (
|
||||||
|
<GriddyProvider {...props}>
|
||||||
|
<GriddyInner tableRef={ref} />
|
||||||
|
{props.children}
|
||||||
|
</GriddyProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component with forwardRef ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||||
|
// 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<T, any>[],
|
||||||
|
[userColumns, selection],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Table State (internal/uncontrolled) ───
|
||||||
|
const [internalSorting, setInternalSorting] = useState<SortingState>([])
|
||||||
|
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([])
|
||||||
|
const [internalPagination, setInternalPagination] = useState<PaginationState>({
|
||||||
|
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<T>({
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
aria-activedescendant={focusedRowId}
|
||||||
|
aria-label="Data grid"
|
||||||
|
aria-rowcount={(data ?? []).length}
|
||||||
|
className={[styles[CSS.root], className].filter(Boolean).join(' ')}
|
||||||
|
role="grid"
|
||||||
|
>
|
||||||
|
{search?.enabled && <SearchOverlay />}
|
||||||
|
<div
|
||||||
|
className={styles[CSS.container]}
|
||||||
|
ref={scrollRef}
|
||||||
|
style={containerStyle}
|
||||||
|
tabIndex={enableKeyboard ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<TableHeader />
|
||||||
|
<VirtualBody />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Griddy = forwardRef(_Griddy) as <T>(
|
||||||
|
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
|
||||||
|
) => React.ReactElement
|
||||||
103
src/Griddy/core/GriddyStore.ts
Normal file
103
src/Griddy/core/GriddyStore.ts
Normal file
@@ -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<any>
|
||||||
|
_virtualizer: null | Virtualizer<HTMLDivElement, Element>
|
||||||
|
className?: string
|
||||||
|
columnFilters?: ColumnFiltersState
|
||||||
|
columns?: GriddyColumn<any>[]
|
||||||
|
data?: any[]
|
||||||
|
dataAdapter?: DataAdapter<any>
|
||||||
|
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> | 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<any>) => void
|
||||||
|
|
||||||
|
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void
|
||||||
|
sorting?: SortingState
|
||||||
|
// ─── Synced from GriddyProps (written by $sync) ───
|
||||||
|
uniqueId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create Store ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
|
||||||
|
GriddyStoreState,
|
||||||
|
GriddyProps<any>
|
||||||
|
>(
|
||||||
|
(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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
66
src/Griddy/core/columnMapper.ts
Normal file
66
src/Griddy/core/columnMapper.ts
Normal file
@@ -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<T>(column: { columnDef: ColumnDef<T> }): GriddyColumn<T> | undefined {
|
||||||
|
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Griddy's user-facing GriddyColumn<T> definitions to TanStack Table ColumnDef<T>[].
|
||||||
|
* Optionally prepends a selection checkbox column.
|
||||||
|
*/
|
||||||
|
export function mapColumns<T>(
|
||||||
|
columns: GriddyColumn<T>[],
|
||||||
|
selection?: SelectionConfig,
|
||||||
|
): ColumnDef<T>[] {
|
||||||
|
const mapped: ColumnDef<T>[] = columns.map((col) => {
|
||||||
|
const isStringAccessor = typeof col.accessor !== 'function'
|
||||||
|
|
||||||
|
const def: ColumnDef<T> = {
|
||||||
|
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<T> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
43
src/Griddy/core/constants.ts
Normal file
43
src/Griddy/core/constants.ts
Normal file
@@ -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
|
||||||
233
src/Griddy/core/types.ts
Normal file
233
src/Griddy/core/types.ts
Normal file
@@ -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<T> = (props: RendererProps<T>) => ReactNode
|
||||||
|
|
||||||
|
// ─── Cell Rendering ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DataAdapter<T> {
|
||||||
|
delete?: (row: T) => Promise<void>
|
||||||
|
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>
|
||||||
|
save?: (row: T) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode
|
||||||
|
|
||||||
|
// ─── Editors ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EditorProps<T> {
|
||||||
|
column: GriddyColumn<T>
|
||||||
|
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<T> {
|
||||||
|
accessor: ((row: T) => unknown) | keyof T
|
||||||
|
editable?: ((row: T) => boolean) | boolean
|
||||||
|
editor?: EditorComponent<T>
|
||||||
|
filterable?: boolean
|
||||||
|
filterFn?: FilterFn<T>
|
||||||
|
header: ReactNode | string
|
||||||
|
headerGroup?: string
|
||||||
|
hidden?: boolean
|
||||||
|
id: string
|
||||||
|
maxWidth?: number
|
||||||
|
minWidth?: number
|
||||||
|
pinned?: 'left' | 'right'
|
||||||
|
renderer?: CellRenderer<T>
|
||||||
|
searchable?: boolean
|
||||||
|
sortable?: boolean
|
||||||
|
sortFn?: SortingFn<T>
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GriddyDataSource<T> {
|
||||||
|
data: T[]
|
||||||
|
error?: Error
|
||||||
|
isLoading?: boolean
|
||||||
|
pageInfo?: { cursor?: string; hasNextPage: boolean; }
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pagination ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GriddyProps<T> {
|
||||||
|
// ─── Children (adapters, etc.) ───
|
||||||
|
children?: ReactNode
|
||||||
|
// ─── Styling ───
|
||||||
|
className?: string
|
||||||
|
// ─── Filtering ───
|
||||||
|
/** Controlled column filters state */
|
||||||
|
columnFilters?: ColumnFiltersState
|
||||||
|
/** Column definitions */
|
||||||
|
columns: GriddyColumn<T>[]
|
||||||
|
|
||||||
|
/** Data array */
|
||||||
|
data: T[]
|
||||||
|
|
||||||
|
// ─── Data Adapter ───
|
||||||
|
dataAdapter?: DataAdapter<T>
|
||||||
|
/** 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> | 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<T = unknown> {
|
||||||
|
deselectAll: () => void
|
||||||
|
focusRow: (index: number) => void
|
||||||
|
getTable: () => Table<T>
|
||||||
|
getUIState: () => GriddyUIState
|
||||||
|
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>
|
||||||
|
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<T> {
|
||||||
|
column: GriddyColumn<T>
|
||||||
|
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 }
|
||||||
221
src/Griddy/features/keyboard/useKeyboardNavigation.ts
Normal file
221
src/Griddy/features/keyboard/useKeyboardNavigation.ts
Normal file
@@ -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<TData = unknown> {
|
||||||
|
editingEnabled: boolean
|
||||||
|
scrollRef: RefObject<HTMLDivElement | null>
|
||||||
|
search?: SearchConfig
|
||||||
|
selection?: SelectionConfig
|
||||||
|
storeState: GriddyUIState
|
||||||
|
table: Table<TData>
|
||||||
|
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardNavigation<TData = unknown>({
|
||||||
|
editingEnabled,
|
||||||
|
scrollRef,
|
||||||
|
search,
|
||||||
|
selection,
|
||||||
|
storeState,
|
||||||
|
table,
|
||||||
|
virtualizer,
|
||||||
|
}: UseKeyboardNavigationOptions<TData>) {
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
62
src/Griddy/features/search/SearchOverlay.tsx
Normal file
62
src/Griddy/features/search/SearchOverlay.tsx
Normal file
@@ -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<HTMLInputElement>(null)
|
||||||
|
const timerRef = useRef<null | ReturnType<typeof setTimeout>>(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 (
|
||||||
|
<div className={styles[CSS.searchOverlay]}>
|
||||||
|
<TextInput
|
||||||
|
aria-label="Search grid"
|
||||||
|
onChange={(e) => handleChange(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
size="xs"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/Griddy/index.ts
Normal file
22
src/Griddy/index.ts
Normal file
@@ -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'
|
||||||
@@ -850,28 +850,32 @@ Rendering uses `table.getLeftHeaderGroups()`, `table.getCenterHeaderGroups()`, `
|
|||||||
|
|
||||||
## Architectural Patterns from Gridler to Adopt
|
## Architectural Patterns from Gridler to Adopt
|
||||||
|
|
||||||
### 1. Zustand Store Pattern
|
### 1. createSyncStore Pattern (from @warkypublic/zustandsyncstore)
|
||||||
For UI state not managed by TanStack Table:
|
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
|
```typescript
|
||||||
const { Provider, useGriddyStore } = createSyncStore<GriddyUIState, GriddyStoreProps>(
|
const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
|
||||||
|
GriddyStoreState, // UI state + prop fields + internal refs
|
||||||
|
GriddyProps<any> // Props synced from parent
|
||||||
|
>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
// UI state
|
||||||
focusedRowIndex: null,
|
focusedRowIndex: null,
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
isSearchOpen: false,
|
isSearchOpen: false,
|
||||||
isSelecting: false,
|
isSelecting: false,
|
||||||
|
// Internal refs
|
||||||
|
_table: null,
|
||||||
|
_virtualizer: null,
|
||||||
|
// Actions
|
||||||
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
||||||
setEditing: (editing) => set({ isEditing: editing }),
|
moveFocus: (direction, amount) => { ... },
|
||||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
setTable: (table) => set({ _table: table }),
|
||||||
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 })),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Usage: <GriddyProvider {...props}><GriddyInner /></GriddyProvider>
|
||||||
|
// All props (data, columns, selection, etc.) are available via useGriddyStore((s) => s.data)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Data Adapter Pattern
|
### 2. Data Adapter Pattern
|
||||||
|
|||||||
48
src/Griddy/rendering/TableCell.tsx
Normal file
48
src/Griddy/rendering/TableCell.tsx
Normal file
@@ -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<T> {
|
||||||
|
cell: Cell<T, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell<T>({ cell }: TableCellProps<T>) {
|
||||||
|
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID
|
||||||
|
|
||||||
|
if (isSelectionCol) {
|
||||||
|
return <RowCheckbox cell={cell} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles[CSS.cell]}
|
||||||
|
role="gridcell"
|
||||||
|
style={{ width: cell.column.getSize() }}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowCheckbox<T>({ cell }: TableCellProps<T>) {
|
||||||
|
const row = cell.row
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles[CSS.cell]}
|
||||||
|
role="gridcell"
|
||||||
|
style={{ width: cell.column.getSize() }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`Select row ${row.index + 1}`}
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
disabled={!row.getCanSelect()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/Griddy/rendering/TableHeader.tsx
Normal file
80
src/Griddy/rendering/TableHeader.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles[CSS.thead]} role="rowgroup">
|
||||||
|
{headerGroups.map((headerGroup) => (
|
||||||
|
<div className={styles[CSS.headerRow]} key={headerGroup.id} role="row">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const isSortable = header.column.getCanSort()
|
||||||
|
const sortDir = header.column.getIsSorted()
|
||||||
|
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
|
||||||
|
className={[
|
||||||
|
styles[CSS.headerCell],
|
||||||
|
isSortable ? styles[CSS.headerCellSortable] : '',
|
||||||
|
sortDir ? styles[CSS.headerCellSorted] : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
key={header.id}
|
||||||
|
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
role="columnheader"
|
||||||
|
style={{ width: header.getSize() }}
|
||||||
|
>
|
||||||
|
{isSelectionCol ? (
|
||||||
|
<SelectAllCheckbox />
|
||||||
|
) : header.isPlaceholder ? null : (
|
||||||
|
<>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{sortDir && (
|
||||||
|
<span className={styles[CSS.sortIndicator]}>
|
||||||
|
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<div
|
||||||
|
className={styles[CSS.resizeHandle]}
|
||||||
|
onDoubleClick={() => header.column.resetSize()}
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectAllCheckbox() {
|
||||||
|
const table = useGriddyStore((s) => s._table)
|
||||||
|
const selection = useGriddyStore((s) => s.selection)
|
||||||
|
|
||||||
|
if (!table || !selection || selection.mode !== 'multi') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all rows"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomeRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/Griddy/rendering/TableRow.tsx
Normal file
68
src/Griddy/rendering/TableRow.tsx
Normal file
@@ -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<T> {
|
||||||
|
row: Row<T>
|
||||||
|
size: number
|
||||||
|
start: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRow<T>({ row, size, start }: TableRowProps<T>) {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
aria-rowindex={row.index + 1}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={classNames}
|
||||||
|
id={`griddy-row-${row.id}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
role="row"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: size,
|
||||||
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
transform: `translateY(${start}px)`,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell cell={cell} key={cell.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/Griddy/rendering/VirtualBody.tsx
Normal file
51
src/Griddy/rendering/VirtualBody.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={styles[CSS.tbody]}
|
||||||
|
role="rowgroup"
|
||||||
|
style={{
|
||||||
|
height: totalSize,
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
size={virtualRow.size}
|
||||||
|
start={virtualRow.start}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/Griddy/rendering/hooks/useGridVirtualizer.ts
Normal file
29
src/Griddy/rendering/hooks/useGridVirtualizer.ts
Normal file
@@ -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<HTMLDivElement | null>
|
||||||
|
table: Table<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGridVirtualizer({
|
||||||
|
overscan = DEFAULTS.overscan,
|
||||||
|
rowHeight = DEFAULTS.rowHeight,
|
||||||
|
scrollRef,
|
||||||
|
table,
|
||||||
|
}: UseGridVirtualizerOptions): Virtualizer<HTMLDivElement, Element> {
|
||||||
|
const rowCount = table.getRowModel().rows.length
|
||||||
|
|
||||||
|
return useVirtualizer({
|
||||||
|
count: rowCount,
|
||||||
|
estimateSize: () => rowHeight,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
overscan,
|
||||||
|
})
|
||||||
|
}
|
||||||
212
src/Griddy/styles/griddy.module.css
Normal file
212
src/Griddy/styles/griddy.module.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from './ErrorBoundary';
|
|||||||
export * from './Former';
|
export * from './Former';
|
||||||
export * from './FormerControllers';
|
export * from './FormerControllers';
|
||||||
export * from './GlobalStateStore';
|
export * from './GlobalStateStore';
|
||||||
|
export * from './Griddy';
|
||||||
export * from './Gridler';
|
export * from './Gridler';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user