[] = [
+ { 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] |