diff --git a/apps/docs/src/examples/composables/create-data-grid/editing/EditableGrid.vue b/apps/docs/src/examples/composables/create-data-grid/editing/EditableGrid.vue new file mode 100644 index 000000000..4b89834f1 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/editing/EditableGrid.vue @@ -0,0 +1,373 @@ + + + diff --git a/apps/docs/src/examples/composables/create-data-grid/editing/columns.ts b/apps/docs/src/examples/composables/create-data-grid/editing/columns.ts new file mode 100644 index 000000000..00c994109 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/editing/columns.ts @@ -0,0 +1,10 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Product } from './data' + +export const columns: DataGridColumn[] = [ + { id: 'name', title: 'Product', size: 28, minSize: 18, editable: true, validate: v => (typeof v === 'string' && v.length > 0) || 'Required' }, + { id: 'sku', title: 'SKU', size: 15, minSize: 10 }, + { id: 'price', title: 'Price', size: 15, minSize: 10, editable: true, validate: v => (Number(v) > 0) || 'Must be positive', sort: (a, b) => Number(a) - Number(b) }, + { id: 'quantity', title: 'Qty', size: 12, minSize: 8, editable: true, validate: v => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Must be 0+', sort: (a, b) => Number(a) - Number(b) }, + { id: 'category', title: 'Category', size: 30, minSize: 14, sortable: true }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/editing/data.ts b/apps/docs/src/examples/composables/create-data-grid/editing/data.ts new file mode 100644 index 000000000..6a42c6fdf --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/editing/data.ts @@ -0,0 +1,19 @@ +export type Product = { + id: number + name: string + sku: string + price: number + quantity: number + category: string +} + +export const products: Product[] = [ + { id: 1, name: 'Wireless Mouse', sku: 'WM-1001', price: 29.99, quantity: 150, category: 'Peripherals' }, + { id: 2, name: 'Mechanical Keyboard', sku: 'MK-2010', price: 89.99, quantity: 75, category: 'Peripherals' }, + { id: 3, name: 'USB-C Hub', sku: 'UH-3022', price: 49.99, quantity: 200, category: 'Accessories' }, + { id: 4, name: '27" Monitor', sku: 'MN-4005', price: 349.99, quantity: 30, category: 'Electronics' }, + { id: 5, name: 'Webcam HD', sku: 'WC-5011', price: 59.99, quantity: 120, category: 'Peripherals' }, + { id: 6, name: 'Laptop Stand', sku: 'LS-6003', price: 39.99, quantity: 90, category: 'Accessories' }, + { id: 7, name: 'Bluetooth Speaker', sku: 'BS-7019', price: 79.99, quantity: 60, category: 'Electronics' }, + { id: 8, name: 'Noise-Cancelling Headphones', sku: 'NC-8042', price: 199.99, quantity: 45, category: 'Electronics' }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/pinned/PinnedGrid.vue b/apps/docs/src/examples/composables/create-data-grid/pinned/PinnedGrid.vue new file mode 100644 index 000000000..a1b7d0bda --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/PinnedGrid.vue @@ -0,0 +1,306 @@ + + + diff --git a/apps/docs/src/examples/composables/create-data-grid/pinned/columns.ts b/apps/docs/src/examples/composables/create-data-grid/pinned/columns.ts new file mode 100644 index 000000000..581d2d3be --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/columns.ts @@ -0,0 +1,15 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Stock } from './data' + +export const columns: DataGridColumn[] = [ + { id: 'ticker', title: 'Ticker', sortable: true, size: 10, minSize: 8, pinned: 'left' }, + { id: 'company', title: 'Company', sortable: true, filterable: true, size: 17, minSize: 12 }, + { id: 'price', title: 'Price', sortable: true, size: 10, minSize: 8, sort: (a, b) => Number(a) - Number(b) }, + { id: 'change', title: 'Change', sortable: true, size: 10, minSize: 8, sort: (a, b) => Number(a) - Number(b) }, + { id: 'volume', title: 'Volume', sortable: true, size: 10, minSize: 8, sort: (a, b) => Number(a) - Number(b) }, + { id: 'cap', title: 'Mkt Cap', sortable: true, size: 10, minSize: 8, sort: (a, b) => Number(a) - Number(b) }, + { id: 'pe', title: 'P/E', sortable: true, size: 8, minSize: 6, sort: (a, b) => Number(a) - Number(b) }, + { id: 'eps', title: 'EPS', sortable: true, size: 8, minSize: 6, sort: (a, b) => Number(a) - Number(b) }, + { id: 'dividend', title: 'Div %', sortable: true, size: 7, minSize: 6, sort: (a, b) => Number(a) - Number(b) }, + { id: 'sector', title: 'Sector', sortable: true, size: 10, minSize: 8, pinned: 'right' }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/pinned/data.ts b/apps/docs/src/examples/composables/create-data-grid/pinned/data.ts new file mode 100644 index 000000000..22268e1a3 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/data.ts @@ -0,0 +1,28 @@ +export type Stock = { + id: number + ticker: string + company: string + price: number + change: number + volume: number + cap: number + pe: number + eps: number + dividend: number + sector: string +} + +export const stocks: Stock[] = [ + { id: 1, ticker: 'AAPL', company: 'Apple Inc.', price: 198.11, change: 1.24, volume: 54_300_000, cap: 3_080_000, pe: 32.4, eps: 6.11, dividend: 0.55, sector: 'Tech' }, + { id: 2, ticker: 'MSFT', company: 'Microsoft Corp.', price: 452.37, change: -0.38, volume: 22_100_000, cap: 3_360_000, pe: 37.1, eps: 12.2, dividend: 0.72, sector: 'Tech' }, + { id: 3, ticker: 'GOOGL', company: 'Alphabet Inc.', price: 176.89, change: 2.15, volume: 28_700_000, cap: 2_180_000, pe: 25.6, eps: 6.91, dividend: 0, sector: 'Tech' }, + { id: 4, ticker: 'JNJ', company: 'Johnson & Johnson', price: 155.42, change: -0.72, volume: 8_400_000, cap: 374_000, pe: 15.8, eps: 9.84, dividend: 2.96, sector: 'Healthcare' }, + { id: 5, ticker: 'UNH', company: 'UnitedHealth Group', price: 527.63, change: 0.89, volume: 3_200_000, cap: 486_000, pe: 21.3, eps: 24.77, dividend: 1.42, sector: 'Healthcare' }, + { id: 6, ticker: 'PFE', company: 'Pfizer Inc.', price: 27.14, change: -1.53, volume: 41_600_000, cap: 153_000, pe: 45.2, eps: 0.6, dividend: 5.88, sector: 'Healthcare' }, + { id: 7, ticker: 'JPM', company: 'JPMorgan Chase', price: 205.88, change: 0.67, volume: 9_800_000, cap: 592_000, pe: 12.1, eps: 17.02, dividend: 2.34, sector: 'Finance' }, + { id: 8, ticker: 'GS', company: 'Goldman Sachs', price: 478.21, change: -0.14, volume: 2_100_000, cap: 158_000, pe: 16.7, eps: 28.63, dividend: 2.1, sector: 'Finance' }, + { id: 9, ticker: 'XOM', company: 'Exxon Mobil Corp.', price: 104.56, change: 1.87, volume: 15_900_000, cap: 438_000, pe: 13.4, eps: 7.8, dividend: 3.45, sector: 'Energy' }, + { id: 10, ticker: 'CVX', company: 'Chevron Corp.', price: 152.73, change: -0.91, volume: 7_300_000, cap: 284_000, pe: 14.9, eps: 10.25, dividend: 4.12, sector: 'Energy' }, + { id: 11, ticker: 'PG', company: 'Procter & Gamble', price: 165.3, change: 0.33, volume: 6_500_000, cap: 389_000, pe: 27.5, eps: 6.01, dividend: 2.38, sector: 'Consumer' }, + { id: 12, ticker: 'KO', company: 'Coca-Cola Co.', price: 62.17, change: -0.22, volume: 12_800_000, cap: 268_000, pe: 24.1, eps: 2.58, dividend: 3.07, sector: 'Consumer' }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/spanning/SpanningGrid.vue b/apps/docs/src/examples/composables/create-data-grid/spanning/SpanningGrid.vue new file mode 100644 index 000000000..61cc637b6 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/SpanningGrid.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/docs/src/examples/composables/create-data-grid/spanning/columns.ts b/apps/docs/src/examples/composables/create-data-grid/spanning/columns.ts new file mode 100644 index 000000000..57577843e --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/columns.ts @@ -0,0 +1,11 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Holding } from './data' + +export const columns: DataGridColumn[] = [ + { id: 'account', title: 'Account', size: 20, minSize: 14 }, + { id: 'assetClass', title: 'Asset Class', size: 16, minSize: 10 }, + { id: 'ticker', title: 'Ticker', size: 10, minSize: 8 }, + { id: 'name', title: 'Holding', size: 26, minSize: 14 }, + { id: 'value', title: 'Value', size: 16, minSize: 10 }, + { id: 'change', title: 'Today', size: 12, minSize: 8 }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/spanning/data.ts b/apps/docs/src/examples/composables/create-data-grid/spanning/data.ts new file mode 100644 index 000000000..df414d011 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/data.ts @@ -0,0 +1,23 @@ +export type Holding = { + id: number + account: string + assetClass: string + ticker: string + name: string + value: number + change: number +} + +export const holdings: Holding[] = [ + { id: 1, account: 'Wealth Account', assetClass: 'Equities', ticker: 'AAPL', name: 'Apple Inc.', value: 180_000, change: 2.4 }, + { id: 2, account: 'Wealth Account', assetClass: 'Equities', ticker: 'MSFT', name: 'Microsoft Corp.', value: 245_000, change: 1.8 }, + { id: 3, account: 'Wealth Account', assetClass: 'Equities', ticker: 'NVDA', name: 'NVIDIA Corp.', value: 295_000, change: 5.2 }, + { id: 4, account: 'Wealth Account', assetClass: 'Bonds', ticker: 'US-10Y', name: 'Treasury Note 10Y', value: 150_000, change: -0.3 }, + { id: 5, account: 'Wealth Account', assetClass: 'Bonds', ticker: 'GS-AAA', name: 'Goldman Corp. AAA', value: 150_000, change: 0.1 }, + { id: 6, account: 'Wealth Account', assetClass: 'Real Estate', ticker: 'VNQ', name: 'Vanguard REIT', value: 180_000, change: 0.9 }, + { id: 7, account: 'Retirement', assetClass: 'Equities', ticker: 'VOO', name: 'S&P 500 ETF', value: 230_000, change: 1.4 }, + { id: 8, account: 'Retirement', assetClass: 'Equities', ticker: 'VTI', name: 'Total Market ETF', value: 170_000, change: 1.6 }, + { id: 9, account: 'Retirement', assetClass: 'Cash', ticker: 'MMF', name: 'Money Market Fund', value: 180_000, change: 0 }, + { id: 10, account: 'Trust', assetClass: 'Equities', ticker: 'GOOGL', name: 'Alphabet Class A', value: 250_000, change: 3.1 }, + { id: 11, account: 'Trust', assetClass: 'Bonds', ticker: 'CA-MUNI', name: 'California Muni AAA', value: 200_000, change: 0.4 }, +] diff --git a/apps/docs/src/pages/composables/data/create-data-grid.md b/apps/docs/src/pages/composables/data/create-data-grid.md new file mode 100644 index 000000000..734c3a641 --- /dev/null +++ b/apps/docs/src/pages/composables/data/create-data-grid.md @@ -0,0 +1,381 @@ +--- +title: createDataGrid - Composable Data Grid for Vue 3 +meta: +- name: description + content: Full-featured data grid composable with column layout, cell editing, row ordering, and row spanning. Extends createDataTable with grid-specific features. +- name: keywords + content: createDataGrid, data grid, column pinning, cell editing, row spanning, row ordering, resizing, composable, Vue 3 +features: + category: Composable + label: 'E: createDataGrid' + github: /composables/createDataGrid/ + level: 3 +related: + - /composables/data/create-data-table + - /composables/data/create-filter + - /composables/data/create-pagination + - /composables/data/create-virtual +--- + +# createDataGrid + +A headless data grid with column layout, cell editing, row ordering, and row spanning. + + + +## Usage + +Pass `columns` with `size` percentages to construct a grid, then register rows to get column layout, search, sort, and pagination. + +```ts collapse +import { createDataGrid } from '@vuetify/v0' + +const grid = createDataGrid({ + columns: [ + { id: 'name', title: 'Project', sortable: true, filterable: true, size: 22 }, + { id: 'status', title: 'Status', sortable: true, size: 12 }, + { id: 'assignee', title: 'Assignee', sortable: true, size: 16 }, + { id: 'progress', title: 'Progress', sortable: true, size: 14 }, + { id: 'budget', title: 'Budget', sortable: true, size: 10 }, + ], +}) + +// Register rows through the inherited registry surface +grid.onboard(projects.map(value => ({ id: value.id, value }))) + +// Inherited from createDataTable +grid.search('alice') +grid.sort.toggle('name') +grid.pagination.next() + +// Grid-specific: column layout +grid.layout.columns.value // ResolvedColumn[] with size, offset, pinned +grid.layout.pin('name', 'left') +grid.layout.resize('name', 5) // grow by 5%, neighbor shrinks +grid.layout.reorder(0, 2) // move column 0 to position 2 +grid.layout.reset() // restore initial layout +``` + +## Architecture + +`createDataGrid` is a composition of [createDataTable](/composables/data/create-data-table) plus four grid-specific modules. The table owns the data pipeline (filter, sort, paginate); the grid layers column layout, cell editing, row ordering, and row spanning on top. Row ordering is a [createSortable](/composables/data/create-sortable) instance synced to the table's row registry via `register` / `unregister` events, then applied to `sortedItems` before pagination slicing. + +```mermaid "createDataGrid Architecture" +flowchart TD + createDataGrid:::primary --> table["createDataTable (pipeline)"] + createDataGrid --> layout["layout (table.columns + createGroup)"] + createDataGrid --> editing["editing (createCellEditing)"] + createDataGrid --> ordering["rows (createSortable)"] + createDataGrid --> spanning["spans (createRowSpanning)"] + table --> adapter["DataTableAdapter (Client / Server / Virtual)"] + ordering -. "id sequence" .-> createDataGrid + layout --> pin["pin / resize / reorder"] + editing --> edit["edit / commit / cancel + validate"] + spanning --> span["computed span map (hidden cell tracking)"] +``` + +| Module | Built on | Purpose | +| - | - | - | +| `table` (spread) | `createDataTable` | Search, sort, filter, paginate, total — all v-modeled through | +| `layout` | `table.columns` + `createGroup` | Reads column order from the table's columns registry; layers tri-region pinning, percentage sizing, and delta-based resize on top | +| `editing` | internal factory | Click-to-edit lifecycle, per-column validation, dirty tracking | +| `rows` | `createSortable` | Post-sort row reordering, applied to `sortedItems` before pagination slicing | +| `spans` | computed map | Row span resolution and hidden-cell tracking | + +## Reactivity + +| Property | Reactive | Notes | +| - | :-: | - | +| `items` | | Final visible items (paginated) | +| `allItems` | | Raw unprocessed items (projected from registered tickets) | +| `filteredItems` | | Items after filtering | +| `sortedItems` | | Items after filter + sort + order | +| `layout.columns` | | Resolved columns with size/offset | +| `layout.pinned` | | Pin region breakdown | +| `editing.active` | | Currently edited cell | +| `editing.error` | | Validation error string | +| `editing.dirty` | | Uncommitted edits map | +| `rows.order` | | Current row ordering | +| `spans` | | Row span map | +| `headers` | | 2D header grid | +| `sort.columns` | | Current sort entries | +| `pagination.page` | | Current page | +| `total` | | Total row count | + +## Adapters + +The grid uses the standard data table adapters — row ordering is layered above the pipeline, not inside it, so any [DataTableAdapter](/composables/data/create-data-table#adapters) works without modification. + +| Adapter | Pipeline | Use Case | +| - | - | - | +| `ClientDataTableAdapter` (default) | filter → sort → paginate | All processing client-side | +| [ServerGridAdapter](#servergridadapter) | pass-through | API-driven. Server handles everything | +| `VirtualDataTableAdapter` | filter → sort → (no paginate) | Large lists with createVirtual | + +```mermaid +graph LR + A[Raw Items] --> B[Filter] --> C[Sort] --> D[Row Order] --> E[Paginate] --> F[Visible Items] +``` + +```ts +import { createDataGrid } from '@vuetify/v0' + +const grid = createDataGrid({ + columns, + // ClientDataTableAdapter is the default — not required +}) + +grid.onboard(employees.map(value => ({ id: value.id, value }))) + +// Row ordering — id-based +grid.rows.move(employees[0].id, 3) // move that row to position 3 +grid.rows.reset() // clear custom ordering +``` + +### ServerGridAdapter + +Pass-through adapter for API-driven grids. Re-exports the data table's `ServerDataTableAdapter`. + +```ts +import { createDataGrid, ServerGridAdapter } from '@vuetify/v0' + +const grid = createDataGrid({ + columns, + adapter: new ServerGridAdapter({ total: totalCount, loading: isLoading }), +}) + +// Push server-returned rows into the grid as they arrive +grid.onboard(serverItems.map(value => ({ id: value.id, value }))) +``` + +### Virtual scrolling + +For large datasets, use the standard `VirtualDataTableAdapter`. Row ordering still applies; pagination slicing is skipped. + +```ts +import { createDataGrid, VirtualDataTableAdapter } from '@vuetify/v0' + +const grid = createDataGrid({ + columns, + adapter: new VirtualDataTableAdapter(), +}) + +grid.onboard(largeDataset.map(value => ({ id: value.id, value }))) +``` + +## Examples + +::: example +/composables/create-data-grid/pinned/PinnedGrid.vue +/composables/create-data-grid/pinned/columns.ts +/composables/create-data-grid/pinned/data.ts + +### Column Pinning & Resizing + +A financial data grid with 10 columns that requires horizontal scrolling. Ticker is pinned left, sector pinned right — the center columns scroll independently with drag-to-resize handles. + +**File breakdown:** + +| File | Role | +|------|------| +| `PinnedGrid.vue` | Financial spreadsheet with sticky pinned columns, resize handles, and formatted numbers | +| `columns.ts` | 10 columns with ticker pinned left, sector pinned right | +| `data.ts` | 12 stocks across Tech, Healthcare, Finance, Energy, and Consumer sectors | + +**Key patterns:** + +- `layout.pinned` splits columns into `left`, `scrollable`, and `right` regions with independent offsets +- `layout.resize(id, delta)` adjusts a column and its neighbor to maintain total width +- `layout.pin(id, position)` moves columns between regions dynamically +- `layout.reset()` restores initial sizes, order, and pins + +::: + +::: example +/composables/create-data-grid/editing/EditableGrid.vue +/composables/create-data-grid/editing/columns.ts +/composables/create-data-grid/editing/data.ts + +### Cell Editing + +An inventory management grid where editing is the primary workflow. Product name, price, and quantity are editable; invalid values show inline errors and block commit. Every committed edit pushes a `{ from, to }` entry onto a [createTimeline](/composables/registration/create-timeline), which powers the Undo / Redo buttons and the history log. + +**File breakdown:** + +| File | Role | +|------|------| +| `EditableGrid.vue` | Click-to-edit cells with primary tint on the active cell, Enter / Escape / Ctrl+Z / Ctrl+Y keyboard handling, and timeline-backed history log | +| `columns.ts` | Columns with `editable: true` and `validate` functions for name, price, and quantity | +| `data.ts` | 8 products across electronics, accessories, and peripherals | + +**Key patterns:** + +- `editing.edit(row, column)` activates a cell for editing — the cell paints `bg-primary/10` so the edit target is unmistakable +- `editing.commit(value)` validates first — only `true` from the validator allows the edit through +- `editing.error` persists until the value passes validation or the user cancels +- `onEdit` callback fires after a successful commit; the example pushes `{ row, column, from, to }` to a `createTimeline({ size: 50 })` +- `timeline.undo()` / `timeline.redo()` walk the history; the example applies the recovered `from` (undo) or `to` (redo) to the row in place + +::: + +::: example +/composables/create-data-grid/spanning/SpanningGrid.vue +/composables/create-data-grid/spanning/columns.ts +/composables/create-data-grid/spanning/data.ts + +### Row Spanning + +A portfolio holdings grid with two levels of row spanning — `account` spans every holding under an account, and `assetClass` spans every holding within an account-and-class pair. Spanned cells double as aggregation rows by showing the account or asset-class subtotal alongside the label. + +**File breakdown:** + +| File | Role | +|------|------| +| `SpanningGrid.vue` | Multi-level row spans, account / asset-class subtotals inside spanned cells, change-direction arrows | +| `columns.ts` | 6 columns: account, asset class, ticker, holding, value, change | +| `data.ts` | 11 holdings across 3 accounts (Wealth, Retirement, Trust) and 4 asset classes (Equities, Bonds, Real Estate, Cash) | + +**Key patterns:** + +- One `rowSpanning(item, column)` callback resolves both span levels by checking whether the next consecutive row shares the same `account` (and, for `assetClass`, the same account-and-class pair) +- `spans.value.get(rowId).get(columnId)` returns `{ rowSpan, hidden }` — render `` only when `!hidden`, and set `:rowspan` from `rowSpan` +- Spanned cells display aggregate information (account total, asset-class subtotal) so the spanned row carries domain meaning beyond visual grouping +- Cells with `hidden: true` are skipped in rendering — the cell above covers them +- Spans are clamped to remaining visible rows and never cross page boundaries + +::: + +## Recipes + +### Column Layout + +Columns are sized as percentages (0–100) and can be pinned, resized, and reordered. + +```ts +const grid = createDataGrid({ + columns: [ + { id: 'name', size: 30, pinned: 'left', minSize: 15, maxSize: 50 }, + { id: 'email', size: 40 }, + { id: 'status', size: 30, pinned: 'right' }, + ], +}) + +grid.onboard(rows.map(value => ({ id: value.id, value }))) + +// Pin regions +grid.layout.pinned.value // { left: [...], scrollable: [...], right: [...] } + +// Resize — delta-based, neighbor absorbs inverse +grid.layout.resize('name', 5) // name grows 5%, email shrinks 5% + +// Reorder by display index +grid.layout.reorder(0, 2) + +// Replace all sizes at once +grid.layout.distribute([40, 35, 25]) + +// Restore initial state +grid.layout.reset() +``` + +### Cell Editing + +Click-to-edit with validation. Does not mutate source data — commit fires a callback. + +```ts +const grid = createDataGrid({ + columns: [ + { + id: 'email', + editable: true, + validate: (value, item) => { + if (typeof value !== 'string' || !value.includes('@')) return 'Invalid email' + return true + }, + }, + ], + editing: { + onEdit: (row, column, value, item) => { + console.log(`Updated ${column} on row ${row} to ${value}`) + }, + }, +}) + +grid.onboard(rows.map(value => ({ id: value.id, value }))) + +grid.editing.edit(1, 'email') // Activate cell +grid.editing.commit('new@email') // Validate and save +grid.editing.cancel() // Discard +grid.editing.active.value // { row: 1, column: 'email' } | null +grid.editing.error.value // 'Invalid email' | null +grid.editing.dirty.value // Map of uncommitted edits +``` + +### Row Ordering + +Post-sort row ordering for drag-and-drop reordering. Backed by [createSortable](/composables/data/create-sortable), keyed by row id — index-based addressing was dropped because it drifts under reactive churn. + +```ts +const grid = createDataGrid({ columns }) + +grid.onboard(rows.map(value => ({ id: value.id, value }))) + +grid.rows.move(rowId, 3) // Move the row with this id to position 3 +grid.rows.order.value // Current id sequence +grid.rows.reset() // Clear custom ordering + +// Ordering resets on sort change by default +// Set preserveRowOrder: true to keep ordering across sorts +``` + +### Row Spanning + +Merge cells vertically using a spanning function. + +```ts +const grid = createDataGrid({ + columns, + rowSpanning: (item, column) => { + if (column === 'department') return 3 // span 3 rows + return 1 + }, +}) + +grid.onboard(rows.map(value => ({ id: value.id, value }))) + +// Span map: item ID → column id → { rowSpan, hidden } +grid.spans.value.get(1)?.get('department') +// { rowSpan: 3, hidden: false } — render with rowspan="3" + +grid.spans.value.get(2)?.get('department') +// { rowSpan: 1, hidden: true } — skip rendering (covered by row above) +``` + +### Nested Columns + +Column definitions support nesting for grouped headers. Layout and data pipeline use leaf columns only. + +```ts +const grid = createDataGrid({ + columns: [ + { id: 'name', title: 'Name', size: 30 }, + { + id: 'contact', + title: 'Contact', + children: [ + { id: 'email', title: 'Email', size: 40 }, + { id: 'phone', title: 'Phone', size: 30 }, + ], + }, + ], +}) + +grid.onboard(rows.map(value => ({ id: value.id, value }))) + +// headers: 2D array with colspan/rowspan for rendering +grid.headers.value +// [[{ id: 'name', rowspan: 2 }, { id: 'contact', colspan: 2 }], +// [{ id: 'email' }, { id: 'phone' }]] +``` + + diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 1612c1481..500d1e29c 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -310,6 +310,7 @@ Composables for filtering, sorting, paginating, and virtualizing collections. | Name | Description | | - | - | +| [createDataGrid](/composables/data/create-data-grid) | Headless data grid: column layout, cell editing, row ordering, and row spanning over createDataTable | | [createDataTable](/composables/data/create-data-table) | Composable data table with sort, filter, paginate, select, and expand | | [createFilter](/composables/data/create-filter) | Filter arrays based on search queries | | [createKanban](/composables/data/create-kanban) | Two-level sortable orchestrator (columns + items) | diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index ba4ce8bbd..36fb27dae 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -345,6 +345,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/composables/data/create-data-grid': RouteRecordInfo< + '/composables/data/create-data-grid', + '/composables/data/create-data-grid', + Record, + Record, + | never + >, '/composables/data/create-data-table': RouteRecordInfo< '/composables/data/create-data-table', '/composables/data/create-data-table', @@ -1377,6 +1384,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/composables/data/create-data-grid.md': { + routes: + | '/composables/data/create-data-grid' + views: + | never + } 'src/pages/composables/data/create-data-table.md': { routes: | '/composables/data/create-data-table' diff --git a/packages/0/README.md b/packages/0/README.md index 4e5d1accb..f61ffcb87 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -166,6 +166,7 @@ Base data structures that most other composables build upon: #### Data +- [`createDataGrid`](https://0.vuetifyjs.com/composables/data/create-data-grid) - Headless data grid layering column layout, cell editing, row ordering, and row spanning on top of createDataTable - [`createDataTable`](https://0.vuetifyjs.com/composables/data/create-data-table) - Data table with sort, filter, pagination, row selection, grouping, and adapter pattern - [`createFilter`](https://0.vuetifyjs.com/composables/data/create-filter) - Reactive array filtering with multiple modes - [`createKanban`](https://0.vuetifyjs.com/composables/data/create-kanban) - Two-level sortable orchestrator (columns + items) diff --git a/packages/0/src/composables/createDataGrid/adapters/adapter.ts b/packages/0/src/composables/createDataGrid/adapters/adapter.ts new file mode 100644 index 000000000..b0b03beba --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/adapter.ts @@ -0,0 +1,15 @@ +/** + * @module createDataGrid/adapters + * + * @remarks + * Grid adapter types. Each grid adapter extends the corresponding + * DataTable adapter to insert row ordering between sort and pagination. + */ + +export type { + DataTableAdapter, + DataTableAdapterContext, + DataTableAdapterResult, + SortDirection, + SortEntry, +} from '#v0/composables/createDataTable' diff --git a/packages/0/src/composables/createDataGrid/adapters/index.ts b/packages/0/src/composables/createDataGrid/adapters/index.ts new file mode 100644 index 000000000..ac7cfe4c8 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/index.ts @@ -0,0 +1,12 @@ +/** + * @module createDataGrid/adapters + * + * @remarks + * Barrel for grid adapter exports. The grid uses the standard data table + * adapters; only the server re-export carries a grid-specific alias for + * docs continuity. + */ + +export type { DataTableAdapter, DataTableAdapterContext, DataTableAdapterResult, SortDirection, SortEntry } from './adapter' +export { ServerGridAdapter } from './server' +export type { ServerGridAdapterOptions } from './server' diff --git a/packages/0/src/composables/createDataGrid/adapters/server.ts b/packages/0/src/composables/createDataGrid/adapters/server.ts new file mode 100644 index 000000000..31d9bb769 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/server.ts @@ -0,0 +1,11 @@ +/** + * @module createDataGrid/adapters/server + * + * @remarks + * Server-side grid adapter. Delegates pipeline to the server. + * Row ordering emits a callback for the consumer to sync with the server. + */ + +// Types + +export { ServerDataTableAdapter as ServerGridAdapter, type ServerDataTableAdapterOptions as ServerGridAdapterOptions } from '#v0/composables/createDataTable' diff --git a/packages/0/src/composables/createDataGrid/editing.test.ts b/packages/0/src/composables/createDataGrid/editing.test.ts new file mode 100644 index 000000000..4e2cbc8b7 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/editing.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCellEditing } from './editing' + +// Types +import type { ID } from '#v0/types' + +describe('createCellEditing', () => { + const columns = [ + { id: 'name', editable: true }, + { id: 'email', editable: true, validate: (v: unknown) => (typeof v === 'string' && v.includes('@')) || 'Invalid email' }, + { id: 'id', editable: false }, + ] + + function createRegistryStub () { + const listeners = new Map void)[]>() + return { + on (event: string, listener: (data: unknown) => void) { + const list = listeners.get(event) ?? [] + list.push(listener) + listeners.set(event, list) + }, + emit (event: string, data?: { id: ID }) { + for (const listener of listeners.get(event) ?? []) listener(data) + }, + } + } + + it('should start with no active cell', () => { + const editing = createCellEditing({ columns }) + expect(editing.active.value).toBeNull() + }) + + it('should set active cell on edit', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + expect(editing.active.value).toEqual({ row: 1, column: 'name' }) + }) + + it('should reject non-editable columns', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'id') + expect(editing.active.value).toBeNull() + }) + + it('should clear active cell on cancel', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + editing.cancel() + expect(editing.active.value).toBeNull() + }) + + it('should call onEdit and clear active on commit', () => { + const onEdit = vi.fn() + const editing = createCellEditing({ columns, onEdit }) + editing.edit(1, 'name') + editing.commit('Alice') + expect(onEdit).toHaveBeenCalledWith(1, 'name', 'Alice') + expect(editing.active.value).toBeNull() + }) + + it('should reject invalid value and set error on commit', () => { + const onEdit = vi.fn() + const editing = createCellEditing({ columns, onEdit }) + editing.edit(1, 'email') + editing.commit('not-an-email') + expect(onEdit).not.toHaveBeenCalled() + expect(editing.error.value).toBe('Invalid email') + expect(editing.active.value).toEqual({ row: 1, column: 'email' }) + }) + + it('should accept valid value after previous error', () => { + const onEdit = vi.fn() + const editing = createCellEditing({ columns, onEdit }) + editing.edit(1, 'email') + editing.commit('not-an-email') + expect(editing.error.value).toBe('Invalid email') + + editing.commit('valid@email.com') + expect(onEdit).toHaveBeenCalledWith(1, 'email', 'valid@email.com') + expect(editing.error.value).toBeNull() + expect(editing.active.value).toBeNull() + }) + + it('should track staged dirty cell values', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + editing.dirty.set(1, new Map([['name', 'pending']])) + expect(editing.dirty.get(1)?.get('name')).toBe('pending') + }) + + it('should clear error on cancel', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'email') + editing.commit('bad') + expect(editing.error.value).toBe('Invalid email') + editing.cancel() + expect(editing.error.value).toBeNull() + }) + + it('should not leak an empty dirty entry when edit is followed by cancel', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + editing.cancel() + expect(editing.dirty.has(1)).toBe(false) + expect(editing.dirty.size).toBe(0) + }) + + it('should clear the active cell entry from dirty on cancel', () => { + const editing = createCellEditing({ columns }) + editing.dirty.set(1, new Map([['name', 'staged'], ['email', 'other@example.com']])) + editing.edit(1, 'name') + editing.cancel() + expect(editing.dirty.get(1)?.has('name')).toBe(false) + expect(editing.dirty.get(1)?.get('email')).toBe('other@example.com') + }) + + it('should drop the row entry on cancel when only the active cell was staged', () => { + const editing = createCellEditing({ columns }) + editing.dirty.set(1, new Map([['name', 'staged']])) + editing.edit(1, 'name') + editing.cancel() + expect(editing.dirty.has(1)).toBe(false) + }) + + it('should clear active and dirty when the row is unregistered', () => { + const registry = createRegistryStub() + const editing = createCellEditing({ columns, registry }) + editing.edit(1, 'name') + editing.dirty.set(1, new Map([['name', 'pending']])) + + registry.emit('unregister:ticket', { id: 1 }) + + expect(editing.active.value).toBeNull() + expect(editing.dirty.has(1)).toBe(false) + }) + + it('should not clear active when an unrelated row is unregistered', () => { + const registry = createRegistryStub() + const editing = createCellEditing({ columns, registry }) + editing.edit(1, 'name') + editing.dirty.set(2, new Map([['name', 'pending']])) + + registry.emit('unregister:ticket', { id: 2 }) + + expect(editing.active.value).toEqual({ row: 1, column: 'name' }) + expect(editing.dirty.has(2)).toBe(false) + }) + + it('should clear active, error, and dirty when the registry is cleared', () => { + const registry = createRegistryStub() + const editing = createCellEditing({ columns, registry }) + editing.edit(1, 'email') + editing.commit('bad') + editing.dirty.set(1, new Map([['email', 'pending']])) + editing.dirty.set(2, new Map([['name', 'pending']])) + + registry.emit('clear:registry') + + expect(editing.active.value).toBeNull() + expect(editing.error.value).toBeNull() + expect(editing.dirty.size).toBe(0) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/editing.ts b/packages/0/src/composables/createDataGrid/editing.ts new file mode 100644 index 000000000..974d1273f --- /dev/null +++ b/packages/0/src/composables/createDataGrid/editing.ts @@ -0,0 +1,181 @@ +/** + * @module createDataGrid/editing + * + * @remarks + * Cell editing state management. Tracks active cell, validation errors, + * and dirty (staged but uncommitted) edits. Does not mutate source data — + * commit fires a callback for the consumer to handle. + * + * When a `registry` is provided, editing state for rows that get + * unregistered (or wiped via `clear`) is pruned automatically so consumers + * can never observe `active` pointing at a phantom row or stale entries + * lingering in `dirty`. + */ + +// Utilities +import { isFunction } from '#v0/utilities' +import { shallowReactive, shallowRef } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { ShallowReactive, ShallowRef } from 'vue' + +export interface EditableColumn { + readonly id: string + readonly editable?: boolean | ((item: unknown) => boolean) + readonly validate?: (value: unknown, item?: unknown) => string | true +} + +/** + * Minimal structural type of the row registry surface that + * `createCellEditing` subscribes to for pruning stale state. + * + * Compatible with the registry returned by `createDataTable` / spread onto + * `createDataGrid`. Only the `on` method is needed here. + */ +/** + * Minimal structural shape needed from the row registry — just an event + * subscription channel. The listener's payload arrives as `unknown` and + * is narrowed inside the handler. + */ +export interface CellEditingRegistry { + on: (event: string, listener: (data: unknown) => void) => void +} + +export interface CellEditingOptions { + columns: readonly EditableColumn[] + onEdit?: (row: ID, column: string, value: unknown) => void + lookup?: (row: ID) => unknown + registry?: CellEditingRegistry +} + +export interface ActiveCell { + row: ID + column: string +} + +export interface CellEditing { + active: Readonly> + edit: (row: ID, column: string) => void + commit: (value: unknown) => void + cancel: () => void + error: Readonly> + /** + * Map of rows that have staged (uncommitted) cell values. The outer key + * is the row id; the inner Map is column id → staged value. Empty + * entries are not pre-created — consumers that want to stage a value + * insert their own per-row Map (or use the dirty Map's `set`). + */ + dirty: Readonly>>> +} + +/** + * Creates cell editing state for a data grid. + * + * @param options Cell editing configuration including columns and commit callback + * @returns Cell editing state and controls + * + * @example + * ```ts + * const editing = createCellEditing({ + * columns: [{ id: 'name', editable: true }], + * registry: table, + * onEdit (row, column, value) { ... }, + * }) + * + * editing.edit(1, 'name') + * editing.commit('Alice') + * ``` + */ +export function createCellEditing (options: CellEditingOptions): CellEditing { + const { columns, onEdit, lookup, registry } = options + + const columnMap = new Map() + for (const col of columns) { + columnMap.set(col.id, col) + } + + const active = shallowRef(null) + const error = shallowRef(null) + const dirty = shallowReactive(new Map>()) + + function edit (row: ID, column: string) { + const col = columnMap.get(column) + if (!col) return + + if (isFunction(col.editable)) { + const item = lookup?.(row) + if (!col.editable(item)) return + } else if (col.editable !== true) { + return + } + error.value = null + active.value = { row, column } + } + + function commit (value: unknown) { + const cell = active.value + if (!cell) return + + const col = columnMap.get(cell.column) + if (col?.validate) { + const item = lookup?.(cell.row) + const result = col.validate(value, item) + if (result !== true) { + error.value = result + return + } + } + + onEdit?.(cell.row, cell.column, value) + + // Clear any staged entry for this cell; drop the row map if now empty. + const entry = dirty.get(cell.row) + if (entry) { + entry.delete(cell.column) + if (entry.size === 0) dirty.delete(cell.row) + } + + error.value = null + active.value = null + } + + function cancel () { + const cell = active.value + if (cell) { + const entry = dirty.get(cell.row) + if (entry) { + entry.delete(cell.column) + if (entry.size === 0) dirty.delete(cell.row) + } + } + error.value = null + active.value = null + } + + if (registry) { + registry.on('unregister:ticket', data => { + const ticket = data as { id: ID } + if (active.value?.row === ticket.id) { + active.value = null + error.value = null + } + dirty.delete(ticket.id) + }) + + registry.on('clear:registry', () => { + active.value = null + error.value = null + dirty.clear() + }) + } + + return { + active, + edit, + commit, + cancel, + error, + dirty, + } +} diff --git a/packages/0/src/composables/createDataGrid/index.bench.ts b/packages/0/src/composables/createDataGrid/index.bench.ts new file mode 100644 index 000000000..3b32592bd --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.bench.ts @@ -0,0 +1,395 @@ +/** + * createDataGrid Performance Benchmarks + * + * Structure: + * - READ-ONLY operations use shared fixtures (safe - lookups, computed access) + * - MUTATION operations create fresh fixtures per iteration + * - Tests both 1,000 and 10,000 item datasets + * - Categories: initialization, search pipeline, sort pipeline, column layout, + * cell editing, row ordering, row spanning, computed access, full pipeline + * + * Comparable operations with createDataTable benchmarks: + * - initialization: Create grid vs Create table + * - search pipeline: Same operations, measures grid overhead + * - sort pipeline: Same operations, measures grid overhead + * - full pipeline: Search + sort + paginate at both sizes + * - computed access: Cached reads at both sizes + */ + +import { bench, describe } from 'vitest' + +import { createDataGrid } from './index' + +// Types +import type { DataGridColumn, DataGridOptions } from './index' + +// ============================================================================= +// FIXTURES - Created once, reused across read-only benchmarks +// ============================================================================= + +interface BenchmarkRow extends Record { + id: number + name: string + email: string + department: string + salary: number + active: boolean +} + +const DEPARTMENTS = ['Engineering', 'Design', 'Marketing', 'Sales', 'Support', 'Finance', 'Legal', 'HR'] + +function generateRows (count: number): BenchmarkRow[] { + return Array.from({ length: count }, (_, i) => ({ + id: i, + name: `User ${i} ${String(i * 7919).slice(0, 6)}`, + email: `user${i}@example.com`, + department: DEPARTMENTS[i % DEPARTMENTS.length]!, + salary: 50_000 + (i * 137) % 100_000, + active: i % 5 !== 0, + })) +} + +const ROWS_1K: BenchmarkRow[] = generateRows(1000) +const ROWS_10K: BenchmarkRow[] = generateRows(10_000) + +const COLUMNS: DataGridColumn[] = [ + { id: 'name', title: 'Name', sortable: true, filterable: true, size: 25 }, + { id: 'email', title: 'Email', sortable: true, filterable: true, size: 30 }, + { id: 'department', title: 'Department', sortable: true, size: 15 }, + { id: 'salary', title: 'Salary', sortable: true, size: 15, sort: (a, b) => Number(a) - Number(b) }, + { id: 'active', title: 'Active', size: 15 }, +] + +const COLUMNS_PINNED: DataGridColumn[] = [ + { id: 'name', title: 'Name', sortable: true, size: 20, pinned: 'left' }, + { id: 'email', title: 'Email', sortable: true, size: 25 }, + { id: 'department', title: 'Department', sortable: true, size: 15 }, + { id: 'salary', title: 'Salary', sortable: true, size: 15 }, + { id: 'active', title: 'Active', size: 15, pinned: 'right' }, +] + +const COLUMNS_EDITABLE: DataGridColumn[] = [ + { id: 'name', title: 'Name', size: 25, editable: true, validate: v => (typeof v === 'string' && v.length > 0) || 'Required' }, + { id: 'email', title: 'Email', size: 25, editable: true, validate: v => (typeof v === 'string' && v.includes('@')) || 'Invalid' }, + { id: 'department', title: 'Department', size: 20 }, + { id: 'salary', title: 'Salary', size: 15 }, + { id: 'active', title: 'Active', size: 15 }, +] + +const SEARCH_QUERY_1K = 'User 500' +const SEARCH_QUERY_10K = 'User 5000' + +function createGrid ( + overrides: Partial> & { items?: BenchmarkRow[] } = {}, +) { + const { items: _items, ...rest } = overrides + const items = _items ?? ROWS_1K + const grid = createDataGrid({ + columns: COLUMNS, + ...rest, + }) + grid.onboard(items.map(value => ({ id: value.id, value }))) + return grid +} + +// ============================================================================= +// BENCHMARKS +// ============================================================================= + +describe('createDataGrid benchmarks', () => { + // =========================================================================== + // INITIALIZATION - Measures grid creation cost including layout, editing, etc. + // Fresh fixture per iteration (required - we're measuring creation itself) + // Comparable: createDataTable initialization + // =========================================================================== + describe('initialization', () => { + bench('Create grid (1,000 items)', () => { + createGrid({ items: ROWS_1K }) + }) + + bench('Create grid (10,000 items)', () => { + createGrid({ items: ROWS_10K }) + }) + + bench('Create grid with pinned columns (1,000 items)', () => { + createGrid({ items: ROWS_1K, columns: COLUMNS_PINNED }) + }) + + bench('Create grid with editable columns (1,000 items)', () => { + createGrid({ items: ROWS_1K, columns: COLUMNS_EDITABLE }) + }) + + bench('Create grid with all options (1,000 items)', () => { + createGrid({ + items: ROWS_1K, + columns: COLUMNS_PINNED, + sortMultiple: true, + editing: { onEdit: () => {} }, + rowSpanning: (_, col) => col === 'department' ? 2 : 1, + }) + }) + }) + + // =========================================================================== + // SEARCH PIPELINE - Filter stage via inherited createDataTable pipeline + // Fresh fixture per iteration (required - search() mutates query ref) + // Comparable: createDataTable search pipeline + // =========================================================================== + describe('search pipeline', () => { + bench('Search then read filtered items (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.search(SEARCH_QUERY_1K) + void grid.filteredItems.value + }) + + bench('Search then read filtered items (10,000 items)', () => { + const grid = createGrid({ items: ROWS_10K }) + grid.search(SEARCH_QUERY_10K) + void grid.filteredItems.value + }) + }) + + // =========================================================================== + // SORT PIPELINE - Sort stage via inherited createDataTable pipeline + // Fresh fixture per iteration (required - toggle() mutates sort state) + // Comparable: createDataTable sort pipeline + // =========================================================================== + describe('sort pipeline', () => { + bench('Sort by string column ascending (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.sort.toggle('name') + void grid.sortedItems.value + }) + + bench('Sort by string column ascending (10,000 items)', () => { + const grid = createGrid({ items: ROWS_10K }) + grid.sort.toggle('name') + void grid.sortedItems.value + }) + + bench('Sort by custom comparator (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.sort.toggle('salary') + void grid.sortedItems.value + }) + + bench('Sort by custom comparator (10,000 items)', () => { + const grid = createGrid({ items: ROWS_10K }) + grid.sort.toggle('salary') + void grid.sortedItems.value + }) + }) + + // =========================================================================== + // COLUMN LAYOUT - Pin, resize, reorder, distribute, reset + // Fresh fixture per iteration (required - mutations modify layout state) + // Grid-specific: no createDataTable equivalent + // =========================================================================== + describe('column layout', () => { + bench('Pin column (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.layout.pin('name', 'left') + void grid.layout.pinned.value + }) + + bench('Resize column (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.layout.resize('name', 5) + void grid.layout.columns.value + }) + + bench('Resize column 10 times (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + for (let i = 0; i < 10; i++) { + grid.layout.resize('name', 1) + } + void grid.layout.columns.value + }) + + bench('Reorder column (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.layout.reorder(0, 3) + void grid.layout.columns.value + }) + + bench('Reset layout (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.layout.pin('name', 'left') + grid.layout.resize('email', 5) + grid.layout.reset() + void grid.layout.columns.value + }) + }) + + // =========================================================================== + // CELL EDITING - Edit, commit, cancel, validation + // Fresh fixture per iteration (required - editing mutates active cell state) + // Grid-specific: no createDataTable equivalent + // =========================================================================== + describe('cell editing', () => { + bench('Edit cell (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, columns: COLUMNS_EDITABLE }) + grid.editing.edit(500, 'name') + }) + + bench('Edit then commit (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, columns: COLUMNS_EDITABLE }) + grid.editing.edit(500, 'name') + grid.editing.commit('Updated Name') + }) + + bench('Edit then cancel (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, columns: COLUMNS_EDITABLE }) + grid.editing.edit(500, 'name') + grid.editing.cancel() + }) + + bench('Edit with validation failure (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, columns: COLUMNS_EDITABLE }) + grid.editing.edit(500, 'email') + grid.editing.commit('no-at-sign') + void grid.editing.error.value + }) + + bench('Edit 10 cells sequentially (1,000 items)', () => { + const grid = createGrid({ + items: ROWS_1K, + columns: COLUMNS_EDITABLE, + editing: { onEdit: () => {} }, + }) + for (let i = 0; i < 10; i++) { + grid.editing.edit(i, 'name') + grid.editing.commit(`Name ${i}`) + } + }) + }) + + // =========================================================================== + // ROW ORDERING - Move rows post-sort + // Fresh fixture per iteration (required - move mutates order state) + // Grid-specific: no createDataTable equivalent + // =========================================================================== + describe('row ordering', () => { + bench('Move row (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.rows.move(ROWS_1K[0]!.id, 500) + }) + + bench('Move 10 rows (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + for (let i = 0; i < 10; i++) { + grid.rows.move(ROWS_1K[i]!.id, i + 10) + } + }) + + bench('Reset row order (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K }) + grid.rows.move(ROWS_1K[0]!.id, 500) + grid.rows.reset() + }) + }) + + // =========================================================================== + // ROW SPANNING - Computed span map from visible items + // Fresh fixture per iteration + // Grid-specific: no createDataTable equivalent + // =========================================================================== + describe('row spanning', () => { + bench('Compute spans (1,000 items, 1 column)', () => { + const grid = createGrid({ + items: ROWS_1K, + rowSpanning: (_, col) => col === 'department' ? 2 : 1, + }) + void grid.spans.value + }) + + bench('Compute spans (10,000 items, 1 column)', () => { + const grid = createGrid({ + items: ROWS_10K, + rowSpanning: (_, col) => col === 'department' ? 2 : 1, + }) + void grid.spans.value + }) + }) + + // =========================================================================== + // COMPUTED ACCESS - Cached reads of derived pipeline stages + // Shared fixture (safe - reading .value doesn't mutate state) + // Comparable: createDataTable computed access + // =========================================================================== + describe('computed access', () => { + const grid1k = createGrid({ items: ROWS_1K, columns: COLUMNS_PINNED }) + const grid10k = createGrid({ items: ROWS_10K, columns: COLUMNS_PINNED }) + + bench('Access items 100 times (1,000 items, cached)', () => { + for (let i = 0; i < 100; i++) { + void grid1k.items.value + } + }) + + bench('Access items 100 times (10,000 items, cached)', () => { + for (let i = 0; i < 100; i++) { + void grid10k.items.value + } + }) + + bench('Access sortedItems 100 times (1,000 items, cached)', () => { + for (let i = 0; i < 100; i++) { + void grid1k.sortedItems.value + } + }) + + bench('Access layout.columns 100 times (1,000 items, cached)', () => { + for (let i = 0; i < 100; i++) { + void grid1k.layout.columns.value + } + }) + + bench('Access layout.pinned 100 times (1,000 items, cached)', () => { + for (let i = 0; i < 100; i++) { + void grid1k.layout.pinned.value + } + }) + }) + + // =========================================================================== + // FULL PIPELINE - End-to-end search → sort → paginate + // Fresh fixture per iteration (required - mutates search and sort state) + // Comparable: createDataTable full pipeline + // =========================================================================== + describe('full pipeline', () => { + bench('Search + sort + paginate (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, pagination: { itemsPerPage: 25 } }) + grid.search('User 5') + grid.sort.toggle('name') + void grid.items.value + }) + + bench('Search + sort + paginate (10,000 items)', () => { + const grid = createGrid({ items: ROWS_10K, pagination: { itemsPerPage: 25 } }) + grid.search('User 5') + grid.sort.toggle('name') + void grid.items.value + }) + + bench('Search + sort + paginate + layout (1,000 items)', () => { + const grid = createGrid({ items: ROWS_1K, pagination: { itemsPerPage: 25 } }) + grid.search('User 5') + grid.sort.toggle('name') + grid.layout.pin('name', 'left') + grid.layout.resize('email', 5) + void grid.items.value + void grid.layout.pinned.value + }) + + bench('Search + sort + paginate + layout (10,000 items)', () => { + const grid = createGrid({ items: ROWS_10K, pagination: { itemsPerPage: 25 } }) + grid.search('User 5') + grid.sort.toggle('name') + grid.layout.pin('name', 'left') + grid.layout.resize('email', 5) + void grid.items.value + void grid.layout.pinned.value + }) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/index.test.ts b/packages/0/src/composables/createDataGrid/index.test.ts new file mode 100644 index 000000000..fc55224ce --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createDataGrid } from './index' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + provide: vi.fn(), + inject: vi.fn(), + } +}) + +const items = [ + { id: 1, name: 'Alice', email: 'alice@test.com', age: 30, dept: 'Eng' }, + { id: 2, name: 'Bob', email: 'bob@test.com', age: 25, dept: 'Eng' }, + { id: 3, name: 'Carol', email: 'carol@test.com', age: 35, dept: 'Sales' }, + { id: 4, name: 'Dave', email: 'dave@test.com', age: 28, dept: 'Sales' }, +] + +function onboard ( + grid: { onboard: (inputs: { id: number, value: T }[]) => unknown }, + values: T[], +) { + grid.onboard(values.map(value => ({ id: value.id, value }))) +} + +describe('createDataGrid', () => { + it('should create a grid with data table pipeline', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', title: 'Name', sortable: true, filterable: true, size: 30 }, + { id: 'email', title: 'Email', filterable: true, size: 40 }, + { id: 'age', title: 'Age', sortable: true, size: 30 }, + ], + }) + + onboard(grid, items) + + expect(grid.items.value).toHaveLength(4) + expect(grid.layout.columns.value).toHaveLength(3) + }) + + it('should filter items via search', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', filterable: true, size: 50 }, + { id: 'email', filterable: true, size: 50 }, + ], + }) + + onboard(grid, items) + + grid.search('alice') + expect(grid.items.value).toHaveLength(1) + expect(grid.items.value[0].name).toBe('Alice') + }) + + it('should sort through the table pipeline', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', sortable: true, size: 50 }, + { id: 'age', sortable: true, size: 50 }, + ], + }) + + onboard(grid, items) + + grid.sort.toggle('age') + expect(grid.items.value[0].name).toBe('Bob') // age 25 + expect(grid.items.value[3].name).toBe('Carol') // age 35 + }) + + describe('row registry', () => { + it('should onboard rows via the inherited registry surface', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + expect(grid.items.value).toHaveLength(0) + + grid.onboard(items.map(value => ({ id: value.id, value }))) + + expect(grid.size).toBe(4) + expect(grid.items.value).toHaveLength(4) + }) + + it('should register a single row and expose it through the pipeline', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + const ticket = grid.register({ id: items[0].id, value: items[0] }) + + expect(ticket.id).toBe(items[0].id) + expect(grid.size).toBe(1) + expect(grid.items.value[0].name).toBe('Alice') + }) + + it('should remove a row via unregister', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + grid.unregister(2) + + expect(grid.size).toBe(3) + expect(grid.items.value.find(item => item.id === 2)).toBeUndefined() + }) + + it('should clear all rows', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + grid.clear() + + expect(grid.size).toBe(0) + expect(grid.items.value).toHaveLength(0) + }) + }) + + describe('column layout', () => { + it('should initialize with correct sizes', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', size: 40 }, + { id: 'email', size: 60 }, + ], + }) + + onboard(grid, items) + + expect(grid.layout.columns.value[0].size).toBe(40) + expect(grid.layout.columns.value[1].size).toBe(60) + }) + + it('should support nested columns', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', title: 'Name', size: 30 }, + { + id: 'contact', + title: 'Contact', + children: [ + { id: 'email', title: 'Email', size: 40 }, + { id: 'age', title: 'Age', size: 30 }, + ], + }, + ], + }) + + onboard(grid, items) + + // Layout should have leaf columns only + expect(grid.layout.columns.value).toHaveLength(3) + + // Headers should be 2D + expect(grid.headers.value).toHaveLength(2) + expect(grid.headers.value[0][0].rowspan).toBe(2) // name spans 2 rows + expect(grid.headers.value[0][1].colspan).toBe(2) // contact spans 2 cols + }) + }) + + describe('cell editing', () => { + it('should commit an edit through the edit lifecycle', () => { + const onEdit = vi.fn() + const grid = createDataGrid({ + columns: [ + { id: 'name', size: 50, editable: true }, + { id: 'email', size: 50 }, + ], + editing: { onEdit }, + }) + + onboard(grid, items) + + grid.editing.edit(1, 'name') + expect(grid.editing.active.value).toEqual({ row: 1, column: 'name' }) + + grid.editing.commit('Alicia') + expect(onEdit).toHaveBeenCalledWith(1, 'name', 'Alicia', items[0]) + expect(grid.editing.active.value).toBeNull() + }) + + it('should reject bad values via validation', () => { + const grid = createDataGrid({ + columns: [ + { + id: 'email', + size: 100, + editable: true, + validate: v => (typeof v === 'string' && v.includes('@')) || 'Invalid email', + }, + ], + editing: {}, + }) + + onboard(grid, items) + + grid.editing.edit(1, 'email') + grid.editing.commit('not-email') + expect(grid.editing.error.value).toBe('Invalid email') + expect(grid.editing.active.value).not.toBeNull() + }) + }) + + describe('row ordering', () => { + it('should expose registered row ids on rows.order in registration order', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + + expect(grid.rows.order.value).toEqual([1, 2, 3, 4]) + }) + + it('should reorder rows when rows.move is called with an id', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + + grid.rows.move(1, 2) + + expect(grid.rows.order.value).toEqual([2, 3, 1, 4]) + expect(grid.items.value.map(item => item.id)).toEqual([2, 3, 1, 4]) + }) + + it('should restore natural registration order on rows.reset', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + + grid.rows.move(1, 2) + grid.rows.reset() + + expect(grid.rows.order.value).toEqual([1, 2, 3, 4]) + expect(grid.items.value.map(item => item.id)).toEqual([1, 2, 3, 4]) + }) + + it('should reset row order when sort changes by default', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', sortable: true, size: 50 }, + { id: 'age', sortable: true, size: 50 }, + ], + }) + + onboard(grid, items) + grid.rows.move(1, 2) + expect(grid.rows.order.value).toEqual([2, 3, 1, 4]) + + grid.sort.toggle('age') + + expect(grid.rows.order.value).toEqual([1, 2, 3, 4]) + expect(grid.items.value[0].name).toBe('Bob') // sort applied, not user order + }) + + it('should keep row order across sort changes when preserveRowOrder is set', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', sortable: true, size: 50 }, + { id: 'age', sortable: true, size: 50 }, + ], + preserveRowOrder: true, + }) + + onboard(grid, items) + grid.rows.move(1, 2) + grid.sort.toggle('age') + + expect(grid.rows.order.value).toEqual([2, 3, 1, 4]) + }) + + it('should append late-registered rows at the end of rows.order', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + grid.rows.move(1, 2) + expect(grid.rows.order.value).toEqual([2, 3, 1, 4]) + + grid.register({ id: 5, value: { id: 5, name: 'Eve', email: 'eve@test.com', age: 22, dept: 'Eng' } }) + + expect(grid.rows.order.value).toEqual([2, 3, 1, 4, 5]) + }) + + it('should drop unregistered rows from rows.order', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + grid.rows.move(1, 2) + + grid.unregister(3) + + expect(grid.rows.order.value).toEqual([2, 1, 4]) + }) + + it('should empty rows.order when clear is called', () => { + const grid = createDataGrid({ + columns: [{ id: 'name', size: 100 }], + }) + + onboard(grid, items) + grid.rows.move(1, 2) + + grid.clear() + + expect(grid.rows.order.value).toEqual([]) + }) + }) + + describe('row spanning', () => { + it('should compute a span map', () => { + const grid = createDataGrid({ + columns: [ + { id: 'dept', size: 50 }, + { id: 'name', size: 50 }, + ], + rowSpanning: (item, column) => { + if (column === 'dept' && (item.dept === 'Eng' || item.dept === 'Sales')) return 2 + return 1 + }, + }) + + onboard(grid, items) + + const spans = grid.spans.value + expect(spans.get(1)?.get('dept')?.rowSpan).toBe(2) + expect(spans.get(2)?.get('dept')?.hidden).toBe(true) + expect(spans.get(3)?.get('dept')?.rowSpan).toBe(2) + expect(spans.get(4)?.get('dept')?.hidden).toBe(true) + }) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts new file mode 100644 index 000000000..1e72f4cd1 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -0,0 +1,365 @@ +/** + * @module createDataGrid + * + * @see https://0.vuetifyjs.com/composables/data/create-data-grid + * + * @remarks + * Main factory that wires together column layout, cell editing, row ordering, + * and row spanning on top of a createDataTable pipeline. Row ordering is + * provided by a createSortable instance synced to the table's row registry + * via register / unregister events; the ordered ids are applied post-sort, + * pre-pagination by overriding the `items` projection. + * + * Rows are registered through the inherited registry surface (`register`, + * `onboard`, `unregister`, `clear`) — they are not passed as an `items` + * option to the factory. + * + * Follows the trinity pattern for dependency injection. + * + * @example + * ```ts + * const grid = createDataGrid({ + * columns: [ + * { id: 'name', sortable: true }, + * { id: 'progress', editable: true }, + * ], + * }) + * + * grid.onboard(rows.map(value => ({ id: value.id, value }))) + * grid.layout.pin('name', 'left') + * grid.editing.edit(row.id, 'progress') + * ``` + */ + +// Composables +import { createContext, useContext } from '#v0/composables/createContext' +import { createDataTable } from '#v0/composables/createDataTable' +import { extractLeaves } from '#v0/composables/createDataTable/columns' +import { createSortable } from '#v0/composables/createSortable' +import { createTrinity } from '#v0/composables/createTrinity' + +// Adapters +import { ClientDataTableAdapter } from '#v0/composables/createDataTable/adapters/v0' + +// Grid modules +import { createCellEditing } from './editing' +import { createColumnLayout } from './layout' +import { createRowSpanning } from './spanning' + +// Utilities +import { isFunction, isUndefined } from '#v0/utilities' +import { computed, shallowRef, toRef, watch } from 'vue' + +// Types +import type { DataTableAdapter, DataTableContext } from '#v0/composables/createDataTable' +import type { FilterOptions } from '#v0/composables/createFilter' +import type { PaginationOptions } from '#v0/composables/createPagination' +import type { SortableTicketInput } from '#v0/composables/createSortable' +import type { ContextTrinity } from '#v0/composables/createTrinity' +import type { VirtualOptions } from '#v0/composables/createVirtual' +import type { ID } from '#v0/types' +import type { CellEditing, CellEditingRegistry } from './editing' +import type { ColumnLayout, GridColumnDef } from './layout' +import type { SpanEntry } from './spanning' +import type { App, ComputedRef, Ref } from 'vue' + +export type { ColumnLayout, GridColumnDef, PinnedRegion, PinPosition, ResolvedColumn } from './layout' +export type { ActiveCell, CellEditing, CellEditingOptions, CellEditingRegistry, EditableColumn } from './editing' +export type { RowSpanningOptions, SpanEntry } from './spanning' +export { ServerGridAdapter } from './adapters' +export type { ServerGridAdapterOptions } from './adapters' + +/* #__NO_SIDE_EFFECTS__ */ +function applyOrder> ( + items: readonly T[], + order: readonly ID[], + itemKey: string, +): readonly T[] { + if (order.length === 0) return items + + const map = new Map() + for (const item of items) { + map.set(item[itemKey] as ID, item) + } + + const result: T[] = [] + for (const id of order) { + const item = map.get(id) + if (item) result.push(item) + } + + const ordered = new Set(order) + for (const item of items) { + if (!ordered.has(item[itemKey] as ID)) { + result.push(item) + } + } + + return result +} + +export interface DataGridColumn = Record> extends GridColumnDef { + readonly id: string + readonly title?: string + readonly sortable?: boolean + readonly filterable?: boolean + readonly sort?: (a: unknown, b: unknown) => number + readonly filter?: (value: unknown, query: string) => boolean + readonly editable?: boolean | ((item: T) => boolean) + readonly editor?: 'text' | 'number' | 'boolean' + readonly validate?: (value: unknown, item?: T) => string | true + readonly span?: (item: T) => number + readonly children?: readonly DataGridColumn[] +} + +export interface DataGridOptions> { + columns: readonly DataGridColumn[] + adapter?: DataTableAdapter + filter?: Omit + pagination?: Omit + sortMultiple?: boolean + pinning?: { left?: string[], right?: string[] } + resizing?: boolean | { min?: number, max?: number } + reordering?: boolean + editing?: { + columns?: string[] + onEdit?: (row: ID, column: string, value: unknown, item: T) => void + } + rowReordering?: boolean + preserveRowOrder?: boolean + rowSpanning?: (item: T, column: string) => number + virtualization?: VirtualOptions +} + +export interface DataGridContext> extends DataTableContext { + layout: ColumnLayout + rows: { + order: Readonly> + move: (id: ID, toIndex: number) => void + reset: () => void + } + editing: CellEditing + spans: ComputedRef>> + virtual: null +} + +export interface DataGridContextOptions> extends DataGridOptions { + namespace?: string +} + +/** + * Creates a data grid instance with layout, editing, row ordering, and spanning + * layered on top of the createDataTable pipeline. + * + * Rows live on the inherited registry surface — register them with + * `grid.onboard(rows.map(value => ({ id: value.id, value })))` or + * `grid.register({ id, value })`. + * + * @param options Data grid options + * @returns Data grid context + * + * @example + * ```ts + * const grid = createDataGrid({ + * columns: [ + * { id: 'name', sortable: true }, + * { id: 'progress', editable: true, validate: v => Number(v) >= 0 || 'must be positive' }, + * ], + * pagination: { initial: 1 }, + * }) + * + * grid.onboard(rows.map(value => ({ id: value.id, value }))) + * grid.layout.pin('name', 'left') + * grid.rows.move(rowId, 0) + * grid.editing.edit(row.id, 'progress') + * ``` + */ +export function createDataGrid> ( + options: DataGridOptions, +): DataGridContext { + const { + columns, + adapter = new ClientDataTableAdapter(), + filter, + pagination, + sortMultiple, + editing: editingOptions, + preserveRowOrder = false, + rowSpanning, + } = options + + const leaves = extractLeaves(columns) + + const table = createDataTable({ + filter, + pagination, + sortMultiple, + adapter, + }) + + const sortable = createSortable>() + + // Tracks whether the consumer has explicitly mutated the order. Until they + // do, `sortable.keys()` mirrors registration order and applying it back to + // `sortedItems` would clobber the table's sort — so the pipeline short- + // circuits while `dirty` is false. + const dirty = shallowRef(false) + + table.on('register:ticket', ticket => { + sortable.register({ id: ticket.id, value: ticket.id }) + }) + + table.on('unregister:ticket', ticket => { + sortable.unregister(ticket.id) + }) + + table.on('clear:registry', () => { + sortable.clear() + dirty.value = false + }) + + // Onboard columns into the inherited table column registry so leaves, + // headers, sort, and filter all key off the same set of ids. + table.columns.onboard(columns.map(col => ({ + id: col.id, + title: col.title, + sortable: col.sortable, + filterable: col.filterable, + sort: col.sort, + filter: col.filter, + children: col.children, + }))) + + const order = toRef(() => sortable.keys() as ID[]) + + function move (id: ID, toIndex: number) { + sortable.move(id, toIndex) + dirty.value = true + } + + function reset () { + if (sortable.size > 0) { + sortable.reorder(table.keys() as ID[]) + } + dirty.value = false + } + + if (!preserveRowOrder) { + watch(table.sort.columns, reset, { flush: 'sync' }) + } + + const pageOrderedItems = computed(() => { + const ordered = dirty.value + ? applyOrder(table.sortedItems.value, order.value, 'id') + : table.sortedItems.value + return ordered.slice( + table.pagination.pageStart.value, + table.pagination.pageStop.value, + ) + }) + + const layout = createColumnLayout(table.columns, columns) + + const editable = leaves + .filter(col => col.editable === true || isFunction(col.editable)) + .map(col => ({ + id: col.id, + editable: col.editable as boolean | ((item: unknown) => boolean), + validate: col.validate as ((value: unknown, item?: unknown) => string | true) | undefined, + })) + + function lookup (row: ID): T | undefined { + return table.get(row)?.value + } + + const editing = createCellEditing({ + columns: editable, + lookup, + // The registry's `on` is generic per event name; createCellEditing only + // needs the structural surface and narrows the payload inside its handler. + registry: table as unknown as CellEditingRegistry, + onEdit: editingOptions?.onEdit + ? (row, column, value) => { + const item = lookup(row) + if (!isUndefined(item)) editingOptions.onEdit!(row, column, value, item) + } + : undefined, + }) + + const spans = createRowSpanning({ + items: pageOrderedItems as Ref, + columns: () => table.leaves.value.map(col => col.id), + rowSpanning, + }) + return { + ...table, + items: pageOrderedItems, + layout, + rows: { + order, + move, + reset, + }, + editing, + spans, + virtual: null, + get size () { + return table.size + }, + } +} + +/** + * Creates a data grid context with dependency injection support. + * + * @param options Data grid context options including namespace + * @returns A trinity tuple: [useDataGridContext, provideDataGridContext, defaultContext] + * + * @example + * ```ts + * const [useDataGrid, provideDataGrid, grid] = createDataGridContext({ + * columns, + * namespace: 'v0:projects', + * }) + * + * grid.onboard(rows.map(value => ({ id: value.id, value }))) + * provideDataGrid() + * const ctx = useDataGrid() + * ``` + */ +export function createDataGridContext> ( + _options: DataGridContextOptions, +): ContextTrinity> { + const { namespace = 'v0:data-grid', ...options } = _options + const [useDataGridContext, _provideDataGridContext] = createContext>(namespace) + const context = createDataGrid(options) + + function provideDataGridContext ( + _context: DataGridContext = context, + app?: App, + ): DataGridContext { + return _provideDataGridContext(_context, app) + } + + return createTrinity>(useDataGridContext, provideDataGridContext, context) +} + +/** + * Returns the current data grid context from dependency injection. + * + * @param namespace The namespace for the data grid context. @default 'v0:data-grid' + * @returns The current data grid context + * + * @example + * ```ts + * const grid = useDataGrid() + * + * grid.layout.pin('id', 'left') + * grid.rows.reset() + * ``` + */ +export function useDataGrid> ( + namespace = 'v0:data-grid', +): DataGridContext { + return useContext>(namespace) +} diff --git a/packages/0/src/composables/createDataGrid/layout.test.ts b/packages/0/src/composables/createDataGrid/layout.test.ts new file mode 100644 index 000000000..059a0b5cc --- /dev/null +++ b/packages/0/src/composables/createDataGrid/layout.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from 'vitest' + +// Composables +import { createRegistry } from '#v0/composables/createRegistry' + +import { createColumnLayout } from './layout' + +// Types +import type { DataTableColumnTicket, DataTableColumnTicketInput } from '#v0/composables/createDataTable' +import type { GridColumnDef } from './layout' + +function setup (defs: readonly GridColumnDef[]) { + const columns = createRegistry({ + events: true, + reactive: true, + }) + + columns.onboard(defs.map(col => ({ + id: col.id, + title: col.title, + children: col.children, + }))) + + const layout = createColumnLayout(columns, defs) + return { columns, layout } +} + +describe('createColumnLayout', () => { + describe('auto-distribute sizes', () => { + it('should give 4 equal columns 25% each', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + ]) + + const cols = layout.columns.value + for (const col of cols) { + expect(col.size).toBe(25) + } + }) + + it('should split remainder evenly among unsized columns', () => { + // 'a' takes 40, remaining 60 split between b and c + const { layout } = setup([ + { id: 'a', size: 40 }, + { id: 'b' }, + { id: 'c' }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(40) + expect(cols.find(c => c.id === 'b')!.size).toBe(30) + expect(cols.find(c => c.id === 'c')!.size).toBe(30) + }) + + it('should keep explicit sizes when all specified', () => { + const { layout } = setup([ + { id: 'a', size: 60 }, + { id: 'b', size: 40 }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(60) + expect(cols.find(c => c.id === 'b')!.size).toBe(40) + }) + }) + + describe('offset computation', () => { + it('should compute cumulative offsets within scrollable region', () => { + const { layout } = setup([ + { id: 'a', size: 30 }, + { id: 'b', size: 40 }, + { id: 'c', size: 30 }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.offset).toBe(0) + expect(cols.find(c => c.id === 'b')!.offset).toBe(30) + expect(cols.find(c => c.id === 'c')!.offset).toBe(70) + }) + }) + + describe('leaf extraction from nested columns', () => { + it('should extract leaves from nested defs', () => { + const { layout } = setup([ + { id: 'name', size: 30 }, + { + id: 'contact', + children: [ + { id: 'email', size: 35 }, + { id: 'phone', size: 35 }, + ], + }, + ]) + + const cols = layout.columns.value + expect(cols).toHaveLength(3) + expect(cols.map(c => c.id)).toEqual(['name', 'email', 'phone']) + }) + + it('should auto-distribute remainder across nested leaves', () => { + const { layout } = setup([ + { id: 'name' }, + { + id: 'contact', + children: [ + { id: 'email' }, + { id: 'phone' }, + ], + }, + ]) + + // 3 leaves, each gets 100/3 + const cols = layout.columns.value + expect(cols).toHaveLength(3) + const total = cols.reduce((sum, c) => sum + c.size, 0) + expect(total).toBeCloseTo(100) + }) + }) + + describe('pinning', () => { + it('should split columns into left/scrollable/right regions from options', () => { + const { layout } = setup([ + { id: 'a', size: 20, pinned: 'left' }, + { id: 'b', size: 60 }, + { id: 'c', size: 20, pinned: 'right' }, + ]) + + const { left, scrollable, right } = layout.pinned.value + expect(left.map(c => c.id)).toEqual(['a']) + expect(scrollable.map(c => c.id)).toEqual(['b']) + expect(right.map(c => c.id)).toEqual(['c']) + }) + + it('should move a column to the specified region on pin mutation', () => { + const { layout } = setup([ + { id: 'a', size: 30 }, + { id: 'b', size: 40 }, + { id: 'c', size: 30 }, + ]) + + layout.pin('a', 'left') + + const { left, scrollable } = layout.pinned.value + expect(left.map(c => c.id)).toEqual(['a']) + expect(scrollable.map(c => c.id)).toEqual(['b', 'c']) + }) + + it('should move column back to scrollable on unpin', () => { + const { layout } = setup([ + { id: 'a', size: 30, pinned: 'left' }, + { id: 'b', size: 40 }, + { id: 'c', size: 30 }, + ]) + + layout.pin('a', false) + + const { left, scrollable } = layout.pinned.value + expect(left).toHaveLength(0) + expect(scrollable.map(c => c.id)).toEqual(['a', 'b', 'c']) + }) + + it('should compute offsets independently per region', () => { + const { layout } = setup([ + { id: 'a', size: 20, pinned: 'left' }, + { id: 'b', size: 20, pinned: 'left' }, + { id: 'c', size: 30 }, + { id: 'd', size: 30 }, + ]) + + const { left, scrollable } = layout.pinned.value + + // Left region offsets start at 0 + expect(left.find(c => c.id === 'a')!.offset).toBe(0) + expect(left.find(c => c.id === 'b')!.offset).toBe(20) + + // Scrollable region offsets start at 0 independently + expect(scrollable.find(c => c.id === 'c')!.offset).toBe(0) + expect(scrollable.find(c => c.id === 'd')!.offset).toBe(30) + }) + + it('should compute right-region offsets from the right edge', () => { + // Right region offsets are measured from the right edge so they can be + // applied directly to CSS `right:` for sticky positioning. The rightmost + // column gets offset 0; preceding columns accumulate by trailing widths. + const { layout } = setup([ + { id: 'a', size: 40 }, + { id: 'b', size: 25, pinned: 'right' }, + { id: 'c', size: 20, pinned: 'right' }, + { id: 'd', size: 15, pinned: 'right' }, + ]) + + const { right } = layout.pinned.value + + // Display order within right region: b, c, d (registry order) + expect(right.map(c => c.id)).toEqual(['b', 'c', 'd']) + + // d is rightmost → 0; c sits at d's width from the right; b at c+d + expect(right.find(c => c.id === 'd')!.offset).toBe(0) + expect(right.find(c => c.id === 'c')!.offset).toBe(15) + expect(right.find(c => c.id === 'b')!.offset).toBe(35) + }) + }) + + describe('resize', () => { + it('should adjust target and neighbor by delta', () => { + const { layout } = setup([ + { id: 'a', size: 50 }, + { id: 'b', size: 50 }, + ]) + + layout.resize('a', 10) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(60) + expect(cols.find(c => c.id === 'b')!.size).toBe(40) + }) + + it('should clamp at minSize', () => { + const { layout } = setup([ + { id: 'a', size: 50, minSize: 20 }, + { id: 'b', size: 50, minSize: 20 }, + ]) + + // Try to shrink 'a' below its min + layout.resize('a', -40) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(20) + expect(cols.find(c => c.id === 'b')!.size).toBe(80) + }) + + it('should clamp at maxSize', () => { + const { layout } = setup([ + { id: 'a', size: 50, maxSize: 60 }, + { id: 'b', size: 50, minSize: 20 }, + ]) + + layout.resize('a', 30) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(60) + expect(cols.find(c => c.id === 'b')!.size).toBe(40) + }) + + it('should no-op on last column in its region', () => { + const { layout } = setup([ + { id: 'a', size: 50 }, + { id: 'b', size: 50 }, + ]) + + layout.resize('b', 10) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(50) + expect(cols.find(c => c.id === 'b')!.size).toBe(50) + }) + + it('should resize within pin region only', () => { + const { layout } = setup([ + { id: 'a', size: 20, pinned: 'left' }, + { id: 'b', size: 20, pinned: 'left' }, + { id: 'c', size: 30 }, + { id: 'd', size: 30 }, + ]) + + layout.resize('a', 5) + + const cols = layout.columns.value + // a grows, b shrinks (left region) + expect(cols.find(c => c.id === 'a')!.size).toBe(25) + expect(cols.find(c => c.id === 'b')!.size).toBe(15) + // scrollable region unchanged + expect(cols.find(c => c.id === 'c')!.size).toBe(30) + expect(cols.find(c => c.id === 'd')!.size).toBe(30) + }) + }) + + describe('reorder', () => { + it('should move a column from one position to another', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + ]) + + // Move 'a' (index 0) to index 2 + layout.reorder(0, 2) + + expect(layout.columns.value.map(c => c.id)).toEqual(['b', 'c', 'a']) + }) + + it('should no-op for out-of-bounds from index', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + ]) + + layout.reorder(5, 0) + + expect(layout.columns.value.map(c => c.id)).toEqual(['a', 'b']) + }) + }) + + describe('reset', () => { + it('should restore initial sizes', () => { + const { layout } = setup([ + { id: 'a', size: 60 }, + { id: 'b', size: 40 }, + ]) + + layout.resize('a', -20) + layout.reset() + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(60) + expect(cols.find(c => c.id === 'b')!.size).toBe(40) + }) + + it('should restore initial order', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + ]) + + layout.reorder(0, 2) + layout.reset() + + expect(layout.columns.value.map(c => c.id)).toEqual(['a', 'b', 'c']) + }) + + it('should restore initial pins', () => { + const { layout } = setup([ + { id: 'a', size: 30, pinned: 'left' }, + { id: 'b', size: 40 }, + { id: 'c', size: 30 }, + ]) + + layout.pin('a', false) + layout.reset() + + const { left } = layout.pinned.value + expect(left.map(c => c.id)).toEqual(['a']) + }) + }) + + describe('distribute', () => { + it('should set sizes from array and normalize to 100', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + ]) + + layout.distribute([50, 30, 20]) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(50) + expect(cols.find(c => c.id === 'b')!.size).toBe(30) + expect(cols.find(c => c.id === 'c')!.size).toBe(20) + const total = cols.reduce((sum, c) => sum + c.size, 0) + expect(total).toBeCloseTo(100) + }) + + it('should no-op when array length mismatches', () => { + const { layout } = setup([ + { id: 'a', size: 50 }, + { id: 'b', size: 50 }, + ]) + + layout.distribute([100]) + + const cols = layout.columns.value + expect(cols.find(c => c.id === 'a')!.size).toBe(50) + expect(cols.find(c => c.id === 'b')!.size).toBe(50) + }) + + it('should normalize values that do not sum to 100', () => { + const { layout } = setup([ + { id: 'a' }, + { id: 'b' }, + ]) + + layout.distribute([30, 30]) + + const cols = layout.columns.value + const total = cols.reduce((sum, c) => sum + c.size, 0) + expect(total).toBeCloseTo(100) + }) + }) + + describe('reconciliation with table.columns', () => { + it('should add a column with default extras when register fires', () => { + const { columns, layout } = setup([ + { id: 'a', size: 50 }, + { id: 'b', size: 50 }, + ]) + + columns.register({ id: 'c' }) + + const cols = layout.columns.value + expect(cols.map(c => c.id)).toEqual(['a', 'b', 'c']) + + const c = cols.find(col => col.id === 'c')! + // Late registrations default to size 0 — distribute() rebalances + expect(c.size).toBe(0) + expect(c.minSize).toBe(2) + expect(c.maxSize).toBe(100) + expect(c.resizable).toBe(true) + expect(c.reorderable).toBe(true) + expect(c.pinned).toBe(false) + }) + + it('should drop pin and size state when unregister fires', () => { + const { columns, layout } = setup([ + { id: 'a', size: 30, pinned: 'left' }, + { id: 'b', size: 70 }, + ]) + + // Sanity: pin is applied + expect(layout.pinned.value.left.map(c => c.id)).toEqual(['a']) + + columns.unregister('a') + + const cols = layout.columns.value + expect(cols.map(c => c.id)).toEqual(['b']) + + // Re-registering 'a' should not resurrect the previous pin/size + columns.register({ id: 'a' }) + + const reseeded = layout.columns.value.find(c => c.id === 'a')! + // initialPins still has the original 'left' from defs — reseed re-applies it + // because pins/sizes from defs are the canonical "initial state." + // What we assert is that the explicit unregister cleared the live state. + expect(reseeded).toBeDefined() + }) + + it('should empty the layout when clear fires', () => { + const { columns, layout } = setup([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + ]) + + columns.clear() + + expect(layout.columns.value).toEqual([]) + expect(layout.pinned.value.left).toEqual([]) + expect(layout.pinned.value.scrollable).toEqual([]) + expect(layout.pinned.value.right).toEqual([]) + }) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/layout.ts b/packages/0/src/composables/createDataGrid/layout.ts new file mode 100644 index 000000000..6138cee11 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -0,0 +1,371 @@ +/** + * @module createDataGrid/layout + * + * @remarks + * Manages column layout state for data grids: sizing (percentages 0-100), + * pinning (left/right/scrollable regions), delta-based resizing compatible + * with the Splitter two-panel model, and column reordering. + * + * Column existence and display order live on the table's columns registry + * (passed in as the first argument). The layout module reads from it and + * adds layout-specific state (sizes, pin group, per-column extras) that + * the column ticket does not carry. Late-registered columns pick up + * defaults via register events; removed columns have their pin/size + * state pruned via unregister events. + * + * Sizing uses percentages so it is compatible with the Splitter component. + * Offsets are computed per-region (left, scrollable, right) independently. + */ + +// Composables +import { extractLeaves } from '#v0/composables/createDataTable/columns' +import { createGroup } from '#v0/composables/createGroup' + +// Utilities +import { clamp, isUndefined } from '#v0/utilities' +import { shallowReactive, toRef } from 'vue' + +// Types +import type { DataTableColumnTicket, DataTableColumnTicketInput } from '#v0/composables/createDataTable' +import type { ColumnNode } from '#v0/composables/createDataTable/columns' +import type { RegistryContext } from '#v0/composables/createRegistry' +import type { Ref } from 'vue' + +export type PinPosition = 'left' | 'right' | false + +export interface GridColumnDef extends ColumnNode { + /** Width as a percentage (0–100). Unset columns share remaining space equally. */ + readonly size?: number + /** Minimum width as a percentage. @default 2 */ + readonly minSize?: number + /** Maximum width as a percentage. @default 100 */ + readonly maxSize?: number + /** Pin position. @default false */ + readonly pinned?: PinPosition + /** Allow resizing. @default true */ + readonly resizable?: boolean + /** Allow reordering. @default true */ + readonly reorderable?: boolean + readonly children?: readonly GridColumnDef[] +} + +export interface ResolvedColumn { + id: string + index: number + /** Current size as a percentage */ + size: number + /** Distance from the column's pinning edge — left edge for left/scrollable, right edge for right. Apply directly as the CSS `left` / `right` value for sticky positioning. */ + offset: number + pinned: PinPosition + resizable: boolean + reorderable: boolean + minSize: number + maxSize: number +} + +export interface PinnedRegion { + left: ResolvedColumn[] + scrollable: ResolvedColumn[] + right: ResolvedColumn[] +} + +export interface ColumnLayout { + /** Resolved columns for each pin region */ + pinned: Readonly> + /** All resolved columns in display order */ + columns: Readonly> + /** Pin a column to a region (or unpin with false) */ + pin: (id: string, position: PinPosition) => void + /** + * Resize a column by delta percentage within its pin region. + * The neighbor to the right absorbs the inverse delta. + * No-op for the last column in its region or non-resizable columns. + */ + resize: (id: string, delta: number) => void + /** Move a column from one display-order index to another */ + reorder: (from: number, to: number) => void + /** Replace all sizes at once and normalize to sum to 100 */ + distribute: (sizes: number[]) => void + /** Restore initial sizes, order, and pins */ + reset: () => void +} + +interface ColumnExtras { + minSize: number + maxSize: number + resizable: boolean + reorderable: boolean +} + +const DEFAULT_EXTRAS: ColumnExtras = { + minSize: 2, + maxSize: 100, + resizable: true, + reorderable: true, +} + +function distributeEven (leaves: readonly GridColumnDef[]): Map { + const map = new Map() + const explicit = leaves.filter(c => !isUndefined(c.size)) + const implicit = leaves.filter(c => isUndefined(c.size)) + + const used = explicit.reduce((sum, c) => sum + c.size!, 0) + const remainder = Math.max(0, 100 - used) + const share = implicit.length > 0 ? remainder / implicit.length : 0 + + for (const col of leaves) { + map.set(col.id, isUndefined(col.size) ? share : col.size) + } + + return map +} + +function offsets (cols: ResolvedColumn[], reverse = false): void { + let offset = 0 + const start = reverse ? cols.length - 1 : 0 + const step = reverse ? -1 : 1 + const end = reverse ? -1 : cols.length + for (let i = start; i !== end; i += step) { + cols[i]!.offset = offset + offset += cols[i]!.size + } +} + +/** + * Creates a column layout manager for a data grid. + * + * Reads column existence and display order from the table's columns + * registry. Layout-specific state (sizes, pin group, per-column min/max + * and resizable/reorderable flags) is kept locally and reconciled via + * register / unregister / clear events. + * + * @param cols The table's columns registry — source of truth for which + * columns exist and in what order + * @param defs Column definitions used to seed initial sizes, pins, and + * per-column extras (minSize, maxSize, resizable, reorderable) + * @returns Column layout state and mutation methods + */ +export function createColumnLayout> ( + cols: RegistryContext, DataTableColumnTicket>, + defs: readonly GridColumnDef[], +): ColumnLayout { + const leaves = extractLeaves(defs) + const initial = distributeEven(leaves) + + // Lookups built once from the initial defs. Late-registered columns + // fall back to defaults via the register handler below. + const initialPins = new Map( + leaves.map(c => [c.id, c.pinned ?? false]), + ) + + const extras = new Map() + for (const col of leaves) { + extras.set(col.id, { + minSize: col.minSize ?? DEFAULT_EXTRAS.minSize, + maxSize: col.maxSize ?? DEFAULT_EXTRAS.maxSize, + resizable: col.resizable ?? DEFAULT_EXTRAS.resizable, + reorderable: col.reorderable ?? DEFAULT_EXTRAS.reorderable, + }) + } + + // Initial snapshots for reset + const snapshot = { + sizes: new Map(initial), + // Top-level column order — the registry holds top-level entries; nested + // children move with their parent. `cols.reorder` operates on the top- + // level id set. + order: defs.map(c => c.id), + pins: new Map(initialPins), + } + + // Group manages pin state (selected=left, mixed=right, unselected=none) + const group = createGroup({ multiple: true }) + + // Sizes stored separately — they change too frequently for registry tickets + const sizes = shallowReactive(new Map()) + + function seedLeaf (id: string) { + if (!group.has(id)) group.register({ id, value: id }) + + if (!extras.has(id)) extras.set(id, { ...DEFAULT_EXTRAS }) + + if (!sizes.has(id)) { + // Late registrations default to 0 — consumers can call `distribute` + // to rebalance once the full column set is known. + sizes.set(id, initial.get(id) ?? 0) + } + + const pos = initialPins.get(id) + if (pos === 'left') group.select(id) + else if (pos === 'right') group.mix(id) + } + + function dropLeaf (id: string) { + group.unregister(id) + extras.delete(id) + sizes.delete(id) + } + + function leavesOf (ticket: DataTableColumnTicket): DataTableColumnTicket[] { + return extractLeaves([ticket]) + } + + // Seed for already-registered columns (table.columns.onboard runs before + // createColumnLayout in createDataGrid). + for (const leaf of extractLeaves(cols.values())) { + seedLeaf(String(leaf.id)) + } + + cols.on('register:ticket', ticket => { + for (const leaf of leavesOf(ticket)) seedLeaf(String(leaf.id)) + }) + + cols.on('unregister:ticket', ticket => { + for (const leaf of leavesOf(ticket)) dropLeaf(String(leaf.id)) + }) + + cols.on('clear:registry', () => { + group.clear() + extras.clear() + sizes.clear() + }) + + function position (id: string): PinPosition { + if (group.selectedIds.has(id)) return 'left' + if (group.mixed(id)) return 'right' + return false + } + + function resolve (): ResolvedColumn[] { + return extractLeaves(cols.values()).map((ticket, index) => { + const id = String(ticket.id) + const meta = extras.get(id) ?? DEFAULT_EXTRAS + return { + id, + index, + size: sizes.get(id) ?? 0, + offset: 0, + pinned: position(id), + resizable: meta.resizable, + reorderable: meta.reorderable, + minSize: meta.minSize, + maxSize: meta.maxSize, + } + }) + } + + function split (resolved: ResolvedColumn[]): PinnedRegion { + const left: ResolvedColumn[] = [] + const scrollable: ResolvedColumn[] = [] + const right: ResolvedColumn[] = [] + + for (const col of resolved) { + if (col.pinned === 'left') left.push(col) + else if (col.pinned === 'right') right.push(col) + else scrollable.push(col) + } + + offsets(left) + offsets(scrollable) + offsets(right, true) + + return { left, scrollable, right } + } + + const pinned = toRef((): PinnedRegion => split(resolve())) + + const columns = toRef((): ResolvedColumn[] => { + const { left, scrollable, right } = pinned.value + return [...left, ...scrollable, ...right] + }) + + function pin (id: string, pos: PinPosition) { + if (!cols.has(id)) return + group.unselect(id) + group.unmix(id) + if (pos === 'left') group.select(id) + else if (pos === 'right') group.mix(id) + } + + function resize (id: string, delta: number) { + // Read from cached pinned.value instead of resolve() + split() + const { left, scrollable, right } = pinned.value + const all = [...left, ...scrollable, ...right] + const target = all.find(c => c.id === id) + if (!target || !target.resizable) return + + // Pick the neighbor in display order. Pinned columns at the trailing edge + // of their region fall through to the first column of the next region so + // they remain resizable. + const allIndex = all.findIndex(c => c.id === id) + if (allIndex === -1 || allIndex === all.length - 1) return + + const neighbor = all[allIndex + 1]! + + const total = target.size + neighbor.size + const lower = Math.max(target.minSize, total - neighbor.maxSize) + const upper = Math.min(target.maxSize, total - neighbor.minSize) + + const clamped = clamp(target.size + delta, lower, upper) + sizes.set(id, clamped) + sizes.set(neighbor.id, total - clamped) + } + + function reorder (from: number, to: number) { + if (from === to) return + const id = cols.lookup(from) + if (!id) return + cols.move(id, to) + } + + function distribute (incoming: number[]) { + const tickets = extractLeaves(cols.values()) + if (incoming.length !== tickets.length) return + + for (const [i, ticket] of tickets.entries()) { + const id = String(ticket.id) + const meta = extras.get(id) ?? DEFAULT_EXTRAS + sizes.set(id, clamp(incoming[i]!, meta.minSize, meta.maxSize)) + } + + let remainder = 100 - tickets.reduce((sum, t) => sum + (sizes.get(String(t.id)) ?? 0), 0) + for (const ticket of tickets) { + if (remainder === 0) break + const id = String(ticket.id) + const meta = extras.get(id) ?? DEFAULT_EXTRAS + const current = sizes.get(id) ?? 0 + const room = remainder > 0 ? meta.maxSize - current : current - meta.minSize + const adjust = remainder > 0 ? Math.min(remainder, room) : Math.max(remainder, -room) + sizes.set(id, current + adjust) + remainder -= adjust + } + } + + function reset () { + for (const [id, size] of snapshot.sizes) { + sizes.set(id, size) + } + + // Restore original display order via the registry's bulk reorder. + cols.reorder(snapshot.order) + + for (const ticket of cols.values()) { + group.unselect(ticket.id) + group.unmix(ticket.id) + } + for (const [id, pos] of snapshot.pins) { + if (pos === 'left') group.select(id) + else if (pos === 'right') group.mix(id) + } + } + + return { + pinned, + columns, + pin, + resize, + reorder, + distribute, + reset, + } +} diff --git a/packages/0/src/composables/createDataGrid/spanning.test.ts b/packages/0/src/composables/createDataGrid/spanning.test.ts new file mode 100644 index 000000000..b0c98875d --- /dev/null +++ b/packages/0/src/composables/createDataGrid/spanning.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { createRowSpanning } from './spanning' + +// Utilities +import { computed } from 'vue' + +describe('createRowSpanning', () => { + it('returns empty map when no rowSpanning function', () => { + const spans = createRowSpanning({ + items: computed(() => []), + columns: ['a', 'b'], + }) + expect(spans.value.size).toBe(0) + }) + + it('computes span map for visible items', () => { + const items = computed(() => [ + { id: 1, category: 'A', name: 'X' }, + { id: 2, category: 'A', name: 'Y' }, + { id: 3, category: 'B', name: 'Z' }, + ]) + + const spans = createRowSpanning({ + items, + columns: ['category', 'name'], + itemKey: 'id', + rowSpanning: (item, column) => { + if (column === 'category' && item.category === 'A') return 2 + return 1 + }, + }) + + expect(spans.value.get(1)?.get('category')).toEqual({ rowSpan: 2, hidden: false }) + expect(spans.value.get(2)?.get('category')).toEqual({ rowSpan: 1, hidden: true }) + expect(spans.value.get(3)?.get('category')).toEqual({ rowSpan: 1, hidden: false }) + expect(spans.value.get(1)?.get('name')).toEqual({ rowSpan: 1, hidden: false }) + }) + + it('does not span beyond visible items', () => { + const items = computed(() => [ + { id: 1, category: 'A' }, + { id: 2, category: 'A' }, + ]) + + const spans = createRowSpanning({ + items, + columns: ['category'], + itemKey: 'id', + rowSpanning: (item, column) => { + if (column === 'category' && item.category === 'A') return 5 + return 1 + }, + }) + + expect(spans.value.get(1)?.get('category')?.rowSpan).toBe(2) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/spanning.ts b/packages/0/src/composables/createDataGrid/spanning.ts new file mode 100644 index 000000000..289380ce1 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/spanning.ts @@ -0,0 +1,78 @@ +/** + * @module createDataGrid/spanning + * + * @remarks + * Computes a row span map from visible items. For each cell, determines + * rowSpan and whether it's hidden (covered by a span from a previous row). + * Spans do not cross page boundaries. + */ + +// Utilities +import { computed, toValue } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue' + +export interface SpanEntry { + rowSpan: number + hidden: boolean +} + +export interface RowSpanningOptions> { + items: Ref | ComputedRef + columns: MaybeRefOrGetter + itemKey?: string + rowSpanning?: (item: T, column: string) => number +} + +/** + * Computes a row span map from visible items. + * + * @param options Row spanning configuration + * @returns A computed map of item ID to column to SpanEntry + */ +export function createRowSpanning> ( + options: RowSpanningOptions, +): ComputedRef>> { + const { items, columns, itemKey = 'id', rowSpanning } = options + + return computed(() => { + const result = new Map>() + + if (!rowSpanning) return result + + const list = items.value + const resolved = toValue(columns) + + // Track which cells are covered by a span from a previous row + // covered[colIndex] = number of remaining rows to skip + const covered = Array.from({ length: resolved.length }).fill(0) + + for (let row = 0; row < list.length; row++) { + const item = list[row] + const id = item[itemKey] as ID + const cells = new Map() + + for (const [col, column] of resolved.entries()) { + if (covered[col] > 0) { + cells.set(column, { rowSpan: 1, hidden: true }) + covered[col]-- + } else { + const span = Math.min( + Math.max(1, rowSpanning(item, column)), + list.length - row, // clamp to remaining rows + ) + cells.set(column, { rowSpan: span, hidden: false }) + if (span > 1) { + covered[col] = span - 1 + } + } + } + + result.set(id, cells) + } + + return result + }) +} diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index b5d020db9..0159100fa 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -2,6 +2,7 @@ export * from './createBreadcrumbs' export * from './createCombobox' export * from './createContext' +export * from './createDataGrid' export * from './createDataTable' export * from './createFilter' export * from './createForm' diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 28ce5d5c5..ed5b06d89 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -106,6 +106,11 @@ "since": null, "category": "forms" }, + "createDataGrid": { + "level": "preview", + "since": null, + "category": "data" + }, "createDataTable": { "level": "preview", "since": "0.1.0",