From d69f18f48a76601b3e645e9f7b723aaa0fded9cd Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:09:49 -0500 Subject: [PATCH 01/38] feat(createDataGrid): add row ordering state management --- .../createDataGrid/ordering.test.ts | 48 +++++++++++ .../composables/createDataGrid/ordering.ts | 80 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/0/src/composables/createDataGrid/ordering.test.ts create mode 100644 packages/0/src/composables/createDataGrid/ordering.ts diff --git a/packages/0/src/composables/createDataGrid/ordering.test.ts b/packages/0/src/composables/createDataGrid/ordering.test.ts new file mode 100644 index 000000000..d454c0977 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/ordering.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { createRowOrdering } from './ordering' + +describe('createRowOrdering', () => { + it('starts with empty order', () => { + const ordering = createRowOrdering() + expect(ordering.order.value).toEqual([]) + }) + + it('move sets order', () => { + const ordering = createRowOrdering() + ordering.initialize([1, 2, 3, 4]) + ordering.move(0, 2) + expect(ordering.order.value).toEqual([2, 3, 1, 4]) + }) + + it('reset clears order', () => { + const ordering = createRowOrdering() + ordering.initialize([1, 2, 3]) + ordering.move(0, 2) + ordering.reset() + expect(ordering.order.value).toEqual([]) + }) + + it('apply reorders items according to order', () => { + const ordering = createRowOrdering() + const items = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + { id: 3, name: 'C' }, + ] + ordering.initialize([1, 2, 3]) + ordering.move(0, 2) // [2, 3, 1] + + const result = ordering.apply(items, 'id') + expect(result.map(i => i.id)).toEqual([2, 3, 1]) + }) + + it('apply returns original items when order is empty', () => { + const ordering = createRowOrdering() + const items = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ] + expect(ordering.apply(items, 'id')).toEqual(items) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/ordering.ts b/packages/0/src/composables/createDataGrid/ordering.ts new file mode 100644 index 000000000..2203ed542 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/ordering.ts @@ -0,0 +1,80 @@ +/** + * @module createDataGrid/ordering + * + * @remarks + * Row ordering state. Maintains an ID-based order that can be applied + * as a post-sort transform. The component layer handles drag interaction; + * this module only manages ordering state. + */ + +// Utilities +import { shallowRef } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { ShallowRef } from 'vue' + +export interface RowOrdering { + order: Readonly> + initialize: (ids: ID[]) => void + move: (fromIndex: number, toIndex: number) => void + reset: () => void + apply: >(items: readonly T[], itemKey: string) => readonly T[] +} + +export function createRowOrdering (): RowOrdering { + const order = shallowRef([]) + + function initialize (ids: ID[]) { + order.value = [...ids] + } + + function move (fromIndex: number, toIndex: number) { + const arr = [...order.value] + if (fromIndex < 0 || fromIndex >= arr.length) return + if (toIndex < 0 || toIndex >= arr.length) return + + const [moved] = arr.splice(fromIndex, 1) + arr.splice(toIndex, 0, moved) + order.value = arr + } + + function reset () { + order.value = [] + } + + function apply> ( + items: readonly T[], + itemKey: string, + ): readonly T[] { + if (order.value.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.value) { + const item = map.get(id) + if (item) result.push(item) + } + + // Append any items not in the order (new items added after reorder) + for (const item of items) { + if (!order.value.includes(item[itemKey] as ID)) { + result.push(item) + } + } + + return result + } + + return { + order, + initialize, + move, + reset, + apply, + } +} From 30285d8a3a2158de213729c54cf611353a851228 Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:10:00 -0500 Subject: [PATCH 02/38] feat(createDataGrid): add cell editing with validation and dirty tracking --- .../createDataGrid/editing.test.ts | 83 ++++++++++++++ .../src/composables/createDataGrid/editing.ts | 106 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 packages/0/src/composables/createDataGrid/editing.test.ts create mode 100644 packages/0/src/composables/createDataGrid/editing.ts 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..c28cee6df --- /dev/null +++ b/packages/0/src/composables/createDataGrid/editing.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCellEditing } from './editing' + +describe('createCellEditing', () => { + const columns = [ + { key: 'name', editable: true }, + { key: 'email', editable: true, validate: (v: unknown) => typeof v === 'string' && v.includes('@') || 'Invalid email' }, + { key: 'id', editable: false }, + ] + + it('starts with no active cell', () => { + const editing = createCellEditing({ columns }) + expect(editing.active.value).toBeNull() + }) + + it('edit sets active cell', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + expect(editing.active.value).toEqual({ row: 1, column: 'name' }) + }) + + it('edit rejects non-editable columns', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'id') + expect(editing.active.value).toBeNull() + }) + + it('cancel clears active cell', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + editing.cancel() + expect(editing.active.value).toBeNull() + }) + + it('commit calls onEdit and clears active', () => { + 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('commit rejects invalid value and sets error', () => { + 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('commit accepts 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('tracks dirty cells', () => { + const editing = createCellEditing({ columns }) + editing.edit(1, 'name') + editing.dirty.value.get(1)?.set('name', 'pending') + expect(editing.dirty.value.get(1)?.get('name')).toBe('pending') + }) + + it('cancel clears error', () => { + 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() + }) +}) diff --git a/packages/0/src/composables/createDataGrid/editing.ts b/packages/0/src/composables/createDataGrid/editing.ts new file mode 100644 index 000000000..e33b750c2 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/editing.ts @@ -0,0 +1,106 @@ +/** + * @module createDataGrid/editing + * + * @remarks + * Cell editing state management. Tracks active cell, validation errors, + * and dirty (uncommitted) edits. Does not mutate source data — commit + * fires a callback for the consumer to handle. + */ + +// Utilities +import { isString } from '#v0/utilities' +import { ref, shallowRef } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { Ref, ShallowRef } from 'vue' + +export interface EditableColumn { + readonly key: string + readonly editable?: boolean | ((item: unknown) => boolean) + readonly validate?: (value: unknown, item?: unknown) => boolean | string +} + +export interface CellEditingOptions { + columns: readonly EditableColumn[] + onEdit?: (row: ID, column: string, value: unknown) => void +} + +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> + dirty: Readonly>>> +} + +export function createCellEditing (options: CellEditingOptions): CellEditing { + const { columns, onEdit } = options + + const columnMap = new Map() + for (const col of columns) { + columnMap.set(col.key, col) + } + + const active = shallowRef(null) + const error = shallowRef(null) + const dirty = ref(new Map>()) + + function edit (row: ID, column: string) { + const col = columnMap.get(column) + if (!col || col.editable === false || col.editable === undefined) { + return + } + error.value = null + active.value = { row, column } + if (!dirty.value.has(row)) { + dirty.value.set(row, new Map()) + } + } + + function commit (value: unknown) { + const cell = active.value + if (!cell) return + + const col = columnMap.get(cell.column) + if (col?.validate) { + const result = col.validate(value) + if (isString(result)) { + error.value = result + return + } + } + + onEdit?.(cell.row, cell.column, value) + + // Clear dirty entry for this cell + const rowDirty = dirty.value.get(cell.row) + if (rowDirty) { + rowDirty.delete(cell.column) + if (rowDirty.size === 0) dirty.value.delete(cell.row) + } + + error.value = null + active.value = null + } + + function cancel () { + error.value = null + active.value = null + } + + return { + active, + edit, + commit, + cancel, + error, + dirty, + } +} From 829f0bcb77298323843c3465a75570cab9e9277b Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:10:01 -0500 Subject: [PATCH 03/38] feat(createDataGrid): add row spanning computation --- .../createDataGrid/spanning.test.ts | 58 +++++++++++++++ .../composables/createDataGrid/spanning.ts | 71 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 packages/0/src/composables/createDataGrid/spanning.test.ts create mode 100644 packages/0/src/composables/createDataGrid/spanning.ts 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..bf150869c --- /dev/null +++ b/packages/0/src/composables/createDataGrid/spanning.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +// Utilities +import { computed } from 'vue' + +import { createRowSpanning } from './spanning' + +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..30d12d025 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/spanning.ts @@ -0,0 +1,71 @@ +/** + * @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 } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { ComputedRef, Ref } from 'vue' + +export interface SpanEntry { + rowSpan: number + hidden: boolean +} + +export interface RowSpanningOptions> { + items: Ref | ComputedRef + columns: readonly string[] + itemKey?: string + rowSpanning?: (item: T, column: string) => number +} + +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 + + // 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: columns.length }).fill(0) + + for (let row = 0; row < list.length; row++) { + const item = list[row] + const id = item[itemKey] as ID + const cellMap = new Map() + + for (const [col, column] of columns.entries()) { + if (covered[col] > 0) { + cellMap.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 + ) + cellMap.set(column, { rowSpan: span, hidden: false }) + if (span > 1) { + covered[col] = span - 1 + } + } + } + + result.set(id, cellMap) + } + + return result + }) +} From d7b005a0fe96b584af156ef4a4e0311174a0f22e Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:11:01 -0500 Subject: [PATCH 04/38] feat(createDataGrid): add column layout with sizing, pinning, resizing, reordering --- .../composables/createDataGrid/layout.test.ts | 349 ++++++++++++++++++ .../src/composables/createDataGrid/layout.ts | 276 ++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 packages/0/src/composables/createDataGrid/layout.test.ts create mode 100644 packages/0/src/composables/createDataGrid/layout.ts 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..9876bcc67 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/layout.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from 'vitest' + +import { createColumnLayout } from './layout' + +describe('createColumnLayout', () => { + describe('auto-distribute sizes', () => { + it('gives 4 equal columns 25% each', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + { key: 'c' }, + { key: 'd' }, + ]) + + const cols = layout.columns.value + for (const col of cols) { + expect(col.size).toBe(25) + } + }) + + it('splits remainder evenly among unsized columns', () => { + // 'a' takes 40, remaining 60 split between b and c + const layout = createColumnLayout([ + { key: 'a', size: 40 }, + { key: 'b' }, + { key: 'c' }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(40) + expect(cols.find(c => c.key === 'b')!.size).toBe(30) + expect(cols.find(c => c.key === 'c')!.size).toBe(30) + }) + + it('keeps explicit sizes when all specified', () => { + const layout = createColumnLayout([ + { key: 'a', size: 60 }, + { key: 'b', size: 40 }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(60) + expect(cols.find(c => c.key === 'b')!.size).toBe(40) + }) + }) + + describe('offset computation', () => { + it('computes cumulative offsets within scrollable region', () => { + const layout = createColumnLayout([ + { key: 'a', size: 30 }, + { key: 'b', size: 40 }, + { key: 'c', size: 30 }, + ]) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.offset).toBe(0) + expect(cols.find(c => c.key === 'b')!.offset).toBe(30) + expect(cols.find(c => c.key === 'c')!.offset).toBe(70) + }) + }) + + describe('leaf extraction from nested columns', () => { + it('extracts leaves from nested defs', () => { + const layout = createColumnLayout([ + { key: 'name', size: 30 }, + { + key: 'contact', + children: [ + { key: 'email', size: 35 }, + { key: 'phone', size: 35 }, + ], + }, + ]) + + const cols = layout.columns.value + expect(cols).toHaveLength(3) + expect(cols.map(c => c.key)).toEqual(['name', 'email', 'phone']) + }) + + it('auto-distributes remainder across nested leaves', () => { + const layout = createColumnLayout([ + { key: 'name' }, + { + key: 'contact', + children: [ + { key: 'email' }, + { key: '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('splits columns into left/scrollable/right regions from options', () => { + const layout = createColumnLayout([ + { key: 'a', size: 20, pinned: 'left' }, + { key: 'b', size: 60 }, + { key: 'c', size: 20, pinned: 'right' }, + ]) + + const { left, scrollable, right } = layout.pinned.value + expect(left.map(c => c.key)).toEqual(['a']) + expect(scrollable.map(c => c.key)).toEqual(['b']) + expect(right.map(c => c.key)).toEqual(['c']) + }) + + it('pin mutation moves a column to the specified region', () => { + const layout = createColumnLayout([ + { key: 'a', size: 30 }, + { key: 'b', size: 40 }, + { key: 'c', size: 30 }, + ]) + + layout.pin('a', 'left') + + const { left, scrollable } = layout.pinned.value + expect(left.map(c => c.key)).toEqual(['a']) + expect(scrollable.map(c => c.key)).toEqual(['b', 'c']) + }) + + it('unpin moves column back to scrollable', () => { + const layout = createColumnLayout([ + { key: 'a', size: 30, pinned: 'left' }, + { key: 'b', size: 40 }, + { key: 'c', size: 30 }, + ]) + + layout.pin('a', false) + + const { left, scrollable } = layout.pinned.value + expect(left).toHaveLength(0) + expect(scrollable.map(c => c.key)).toEqual(['a', 'b', 'c']) + }) + + it('computes offsets independently per region', () => { + const layout = createColumnLayout([ + { key: 'a', size: 20, pinned: 'left' }, + { key: 'b', size: 20, pinned: 'left' }, + { key: 'c', size: 30 }, + { key: 'd', size: 30 }, + ]) + + const { left, scrollable } = layout.pinned.value + + // Left region offsets start at 0 + expect(left.find(c => c.key === 'a')!.offset).toBe(0) + expect(left.find(c => c.key === 'b')!.offset).toBe(20) + + // Scrollable region offsets start at 0 independently + expect(scrollable.find(c => c.key === 'c')!.offset).toBe(0) + expect(scrollable.find(c => c.key === 'd')!.offset).toBe(30) + }) + }) + + describe('resize', () => { + it('adjusts target and neighbor by delta', () => { + const layout = createColumnLayout([ + { key: 'a', size: 50 }, + { key: 'b', size: 50 }, + ]) + + layout.resize('a', 10) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(60) + expect(cols.find(c => c.key === 'b')!.size).toBe(40) + }) + + it('clamps at minSize', () => { + const layout = createColumnLayout([ + { key: 'a', size: 50, minSize: 20 }, + { key: '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.key === 'a')!.size).toBe(20) + expect(cols.find(c => c.key === 'b')!.size).toBe(80) + }) + + it('clamps at maxSize', () => { + const layout = createColumnLayout([ + { key: 'a', size: 50, maxSize: 60 }, + { key: 'b', size: 50, minSize: 20 }, + ]) + + layout.resize('a', 30) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(60) + expect(cols.find(c => c.key === 'b')!.size).toBe(40) + }) + + it('no-op on last column in its region', () => { + const layout = createColumnLayout([ + { key: 'a', size: 50 }, + { key: 'b', size: 50 }, + ]) + + layout.resize('b', 10) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(50) + expect(cols.find(c => c.key === 'b')!.size).toBe(50) + }) + + it('resizes within pin region only', () => { + const layout = createColumnLayout([ + { key: 'a', size: 20, pinned: 'left' }, + { key: 'b', size: 20, pinned: 'left' }, + { key: 'c', size: 30 }, + { key: 'd', size: 30 }, + ]) + + layout.resize('a', 5) + + const cols = layout.columns.value + // a grows, b shrinks (left region) + expect(cols.find(c => c.key === 'a')!.size).toBe(25) + expect(cols.find(c => c.key === 'b')!.size).toBe(15) + // scrollable region unchanged + expect(cols.find(c => c.key === 'c')!.size).toBe(30) + expect(cols.find(c => c.key === 'd')!.size).toBe(30) + }) + }) + + describe('reorder', () => { + it('moves a column from one position to another', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + { key: 'c' }, + ]) + + // Move 'a' (index 0) to index 2 + layout.reorder(0, 2) + + expect(layout.columns.value.map(c => c.key)).toEqual(['b', 'c', 'a']) + }) + + it('no-op for out-of-bounds from index', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + ]) + + layout.reorder(5, 0) + + expect(layout.columns.value.map(c => c.key)).toEqual(['a', 'b']) + }) + }) + + describe('reset', () => { + it('restores initial sizes', () => { + const layout = createColumnLayout([ + { key: 'a', size: 60 }, + { key: 'b', size: 40 }, + ]) + + layout.resize('a', -20) + layout.reset() + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(60) + expect(cols.find(c => c.key === 'b')!.size).toBe(40) + }) + + it('restores initial order', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + { key: 'c' }, + ]) + + layout.reorder(0, 2) + layout.reset() + + expect(layout.columns.value.map(c => c.key)).toEqual(['a', 'b', 'c']) + }) + + it('restores initial pins', () => { + const layout = createColumnLayout([ + { key: 'a', size: 30, pinned: 'left' }, + { key: 'b', size: 40 }, + { key: 'c', size: 30 }, + ]) + + layout.pin('a', false) + layout.reset() + + const { left } = layout.pinned.value + expect(left.map(c => c.key)).toEqual(['a']) + }) + }) + + describe('distribute', () => { + it('sets sizes from array and normalizes to 100', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + { key: 'c' }, + ]) + + layout.distribute([50, 30, 20]) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(50) + expect(cols.find(c => c.key === 'b')!.size).toBe(30) + expect(cols.find(c => c.key === 'c')!.size).toBe(20) + const total = cols.reduce((sum, c) => sum + c.size, 0) + expect(total).toBeCloseTo(100) + }) + + it('no-op when array length mismatches', () => { + const layout = createColumnLayout([ + { key: 'a', size: 50 }, + { key: 'b', size: 50 }, + ]) + + layout.distribute([100]) + + const cols = layout.columns.value + expect(cols.find(c => c.key === 'a')!.size).toBe(50) + expect(cols.find(c => c.key === 'b')!.size).toBe(50) + }) + + it('normalizes values that do not sum to 100', () => { + const layout = createColumnLayout([ + { key: 'a' }, + { key: 'b' }, + ]) + + layout.distribute([30, 30]) + + const cols = layout.columns.value + const total = cols.reduce((sum, c) => sum + c.size, 0) + expect(total).toBeCloseTo(100) + }) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/layout.ts b/packages/0/src/composables/createDataGrid/layout.ts new file mode 100644 index 000000000..e0dfda8c1 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -0,0 +1,276 @@ +/** + * @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. + * + * 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' + +// Utilities +import { clamp } from '#v0/utilities' +import { reactive, ref, shallowReactive, toRef } from 'vue' + +// Types +import type { ColumnNode } from '#v0/composables/createDataTable/columns' +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 { + key: string + index: number + /** Current size as a percentage */ + size: number + /** Cumulative offset within the column's pin region */ + 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: (key: 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: (key: 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 +} + +function distributeEven (leaves: GridColumnDef[]): Map { + const map = new Map() + const explicit = leaves.filter(c => c.size !== undefined) + const implicit = leaves.filter(c => c.size === undefined) + + const usedTotal = explicit.reduce((sum, c) => sum + c.size!, 0) + const remainder = Math.max(0, 100 - usedTotal) + const share = implicit.length > 0 ? remainder / implicit.length : 0 + + for (const col of leaves) { + map.set(col.key, col.size === undefined ? share : col.size) + } + + return map +} + +function computeOffsets (cols: ResolvedColumn[]): void { + let offset = 0 + for (const col of cols) { + col.offset = offset + offset += col.size + } +} + +function splitRegions (keys: string[], resolved: Map): PinnedRegion { + const left: ResolvedColumn[] = [] + const scrollable: ResolvedColumn[] = [] + const right: ResolvedColumn[] = [] + + for (const key of keys) { + const col = resolved.get(key) + if (!col) continue + if (col.pinned === 'left') left.push(col) + else if (col.pinned === 'right') right.push(col) + else scrollable.push(col) + } + + computeOffsets(left) + computeOffsets(scrollable) + computeOffsets(right) + + return { left, scrollable, right } +} + +/** + * Creates a column layout manager for a data grid. + * + * @param defs Column definitions (may be nested; leaves are extracted) + * @returns Column layout state and mutation methods + */ +export function createColumnLayout (defs: readonly GridColumnDef[]): ColumnLayout { + const leaves = extractLeaves(defs) + const initial = distributeEven(leaves) + + // Initial snapshots for reset + const initialSizes = new Map(initial) + const initialOrder = leaves.map(c => c.key) + const initialPins = new Map( + leaves.map(c => [c.key, c.pinned ?? false]), + ) + + const sizes = shallowReactive(new Map(initial)) + const order = ref([...initialOrder]) + const pins = reactive(new Map(initialPins)) + + const defMap = new Map(leaves.map(c => [c.key, c])) + + function resolved (): Map { + const map = new Map() + let index = 0 + for (const key of order.value) { + const def = defMap.get(key)! + map.set(key, { + key, + index: index++, + size: sizes.get(key) ?? 0, + offset: 0, + pinned: pins.get(key) ?? false, + resizable: def.resizable ?? true, + reorderable: def.reorderable ?? true, + minSize: def.minSize ?? 2, + maxSize: def.maxSize ?? 100, + }) + } + return map + } + + const pinned = toRef((): PinnedRegion => { + return splitRegions(order.value, resolved()) + }) + + const columns = toRef((): ResolvedColumn[] => { + const r = resolved() + const region = pinned.value + // Return all columns in display order with offsets already set + return order.value.map(key => r.get(key)!).map(col => { + // Offsets are set by splitRegions — return updated col + const regions = [region.left, region.scrollable, region.right] + for (const reg of regions) { + const found = reg.find(c => c.key === col.key) + if (found) return found + } + return col + }) + }) + + function pin (key: string, position: PinPosition) { + if (!defMap.has(key)) return + pins.set(key, position) + } + + function resize (key: string, delta: number) { + const r = resolved() + const col = r.get(key) + if (!col || !col.resizable) return + + // Find the region this column belongs to + const region = pinned.value + let group: ResolvedColumn[] + if (col.pinned === 'left') group = region.left + else if (col.pinned === 'right') group = region.right + else group = region.scrollable + + const regionIndex = group.findIndex(c => c.key === key) + if (regionIndex === -1 || regionIndex === group.length - 1) return + + const target = group[regionIndex]! + const neighbor = group[regionIndex + 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 newSize = clamp(target.size + delta, lower, upper) + sizes.set(key, newSize) + sizes.set(neighbor.key, total - newSize) + } + + function reorder (from: number, to: number) { + const arr = [...order.value] + const [item] = arr.splice(from, 1) + if (item === undefined) return + arr.splice(to, 0, item) + order.value = arr + } + + function distribute (incoming: number[]) { + const keys = order.value + if (incoming.length !== keys.length) return + + // Apply raw values first, clamped to min/max + for (const [i, key_] of keys.entries()) { + const key = key_! + const def = defMap.get(key)! + const min = def.minSize ?? 2 + const max = def.maxSize ?? 100 + sizes.set(key, clamp(incoming[i]!, min, max)) + } + + // Normalize so total sums to 100 + let remainder = 100 - keys.reduce((sum, k) => sum + (sizes.get(k) ?? 0), 0) + for (const key of keys) { + if (remainder === 0) break + const def = defMap.get(key)! + const min = def.minSize ?? 2 + const max = def.maxSize ?? 100 + const current = sizes.get(key) ?? 0 + const room = remainder > 0 ? max - current : current - min + const adjust = remainder > 0 ? Math.min(remainder, room) : Math.max(remainder, -room) + sizes.set(key, current + adjust) + remainder -= adjust + } + } + + function reset () { + for (const [key, size] of initialSizes) { + sizes.set(key, size) + } + order.value = [...initialOrder] + for (const [key, position] of initialPins) { + pins.set(key, position) + } + } + + return { + pinned, + columns, + pin, + resize, + reorder, + distribute, + reset, + } +} From dcf2eb18fba955f2af964bee301a6af53f91818a Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:13:29 -0500 Subject: [PATCH 05/38] feat(createDataGrid): add grid adapters (client, server, virtual) --- .../createDataGrid/adapters/adapter.ts | 15 ++++ .../createDataGrid/adapters/client.ts | 86 +++++++++++++++++++ .../createDataGrid/adapters/index.ts | 5 ++ .../createDataGrid/adapters/server.ts | 11 +++ .../createDataGrid/adapters/virtual.ts | 83 ++++++++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 packages/0/src/composables/createDataGrid/adapters/adapter.ts create mode 100644 packages/0/src/composables/createDataGrid/adapters/client.ts create mode 100644 packages/0/src/composables/createDataGrid/adapters/index.ts create mode 100644 packages/0/src/composables/createDataGrid/adapters/server.ts create mode 100644 packages/0/src/composables/createDataGrid/adapters/virtual.ts 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..14199fecc --- /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 { + DataTableAdapterContext, + DataTableAdapterInterface, + DataTableAdapterResult, + SortDirection, + SortEntry, +} from '../../createDataTable/adapters/adapter' diff --git a/packages/0/src/composables/createDataGrid/adapters/client.ts b/packages/0/src/composables/createDataGrid/adapters/client.ts new file mode 100644 index 000000000..0896de545 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/client.ts @@ -0,0 +1,86 @@ +/** + * @module createDataGrid/adapters/client + * + * @remarks + * Client-side grid adapter. Extends DataTableAdapter with row ordering + * inserted between sort and pagination stages. + */ + +// Composables +import { createPagination } from '#v0/composables/createPagination' + +// Adapters +import { DataTableAdapter } from '../../createDataTable/adapters/adapter' + +// Utilities +import { computed, toRef, watch } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' +import type { ShallowRef } from 'vue' + +export class ClientGridAdapter> extends DataTableAdapter { + private rowOrder: ShallowRef + private itemKey: string + + constructor (rowOrder: ShallowRef, itemKey: string) { + super() + this.rowOrder = rowOrder + this.itemKey = itemKey + } + + setup (context: DataTableAdapterContext): DataTableAdapterResult { + const { search, sortBy, locale, paginationOptions, customSorts } = context + + const { allItems, filteredItems } = this.filter(context) + const sortedItems = this.sort(filteredItems, sortBy, locale, customSorts) + + // Row ordering: applied post-sort, pre-pagination + const orderedItems = computed(() => { + const order = this.rowOrder.value + if (order.length === 0) return sortedItems.value + + const map = new Map() + for (const item of sortedItems.value) { + map.set(item[this.itemKey] as ID, item) + } + + const result: T[] = [] + for (const id of order) { + const item = map.get(id) + if (item) result.push(item) + } + + for (const item of sortedItems.value) { + if (!order.includes(item[this.itemKey] as ID)) { + result.push(item) + } + } + + return result + }) + + const pagination = createPagination({ + ...paginationOptions, + size: toRef(() => orderedItems.value.length), + }) + + const items = computed(() => { + return orderedItems.value.slice(pagination.pageStart.value, pagination.pageStop.value) + }) + + watch([search, sortBy], () => { + pagination.first() + }) + + return { + allItems, + filteredItems, + sortedItems: orderedItems, + items, + pagination, + total: toRef(() => orderedItems.value.length), + } + } +} 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..335039d8e --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/index.ts @@ -0,0 +1,5 @@ +export type { DataTableAdapterContext, DataTableAdapterInterface, DataTableAdapterResult, SortDirection, SortEntry } from './adapter' +export { ClientGridAdapter } from './client' +export { ServerGridAdapter } from './server' +export type { ServerGridAdapterOptions } from './server' +export { VirtualGridAdapter } from './virtual' 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..07959321f --- /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 { ServerAdapter as ServerGridAdapter, type ServerAdapterOptions as ServerGridAdapterOptions } from '../../createDataTable/adapters/server' diff --git a/packages/0/src/composables/createDataGrid/adapters/virtual.ts b/packages/0/src/composables/createDataGrid/adapters/virtual.ts new file mode 100644 index 000000000..344f64f70 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/virtual.ts @@ -0,0 +1,83 @@ +/** + * @module createDataGrid/adapters/virtual + * + * @remarks + * Virtual scrolling grid adapter. Client-side filter/sort with row + * ordering, feeding all sorted items to createVirtual. + */ + +// Composables +import { createPagination } from '#v0/composables/createPagination' + +// Adapters +import { DataTableAdapter } from '../../createDataTable/adapters/adapter' + +// Utilities +import { computed, toRef, watch } from 'vue' + +// Types +import type { ID } from '#v0/types' +import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' +import type { ShallowRef } from 'vue' + +export class VirtualGridAdapter> extends DataTableAdapter { + private rowOrder: ShallowRef + private itemKey: string + + constructor (rowOrder: ShallowRef, itemKey: string) { + super() + this.rowOrder = rowOrder + this.itemKey = itemKey + } + + setup (context: DataTableAdapterContext): DataTableAdapterResult { + const { search, sortBy, locale, customSorts } = context + + const { allItems, filteredItems } = this.filter(context) + const sortedItems = this.sort(filteredItems, sortBy, locale, customSorts) + + const orderedItems = computed(() => { + const order = this.rowOrder.value + if (order.length === 0) return sortedItems.value + + const map = new Map() + for (const item of sortedItems.value) { + map.set(item[this.itemKey] as ID, item) + } + + const result: T[] = [] + for (const id of order) { + const item = map.get(id) + if (item) result.push(item) + } + + for (const item of sortedItems.value) { + if (!order.includes(item[this.itemKey] as ID)) { + result.push(item) + } + } + + return result + }) + + const size = toRef(() => orderedItems.value.length) + + const pagination = createPagination({ + size, + itemsPerPage: size, + }) + + watch([search, sortBy], () => { + pagination.first() + }) + + return { + allItems, + filteredItems, + sortedItems: orderedItems, + items: orderedItems, + pagination, + total: toRef(() => orderedItems.value.length), + } + } +} From 778f7ed470c07aa93e967577728a7a5060fe4f06 Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:17:00 -0500 Subject: [PATCH 06/38] feat(createDataGrid): add main factory with trinity pattern Wires together column layout, cell editing, row ordering, and row spanning on top of createDataTable with a ClientGridAdapter. Exports createDataGrid, createDataGridContext, and useDataGrid following the trinity pattern. Also fixes a pre-existing TypeScript error in spanning.ts (Array.from unknown type). --- .../0/src/composables/createDataGrid/index.ts | 240 ++++++++++++++++++ .../composables/createDataGrid/spanning.ts | 2 +- packages/0/src/composables/index.ts | 1 + 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/0/src/composables/createDataGrid/index.ts diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts new file mode 100644 index 000000000..452571eab --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -0,0 +1,240 @@ +/** + * @module createDataGrid + * + * @remarks + * Main factory that wires together column layout, cell editing, row ordering, + * and row spanning on top of a createDataTable pipeline. Uses a ClientGridAdapter + * so row ordering is applied post-sort, pre-pagination. + * + * Follows the trinity pattern for dependency injection. + */ + +// Composables +import { createContext, useContext } from '#v0/composables/createContext' +import { createDataTable } from '#v0/composables/createDataTable' +import { extractLeaves, resolveHeaders } from '#v0/composables/createDataTable/columns' +import { createTrinity } from '#v0/composables/createTrinity' + +// Adapters +import { ClientGridAdapter } from './adapters' + +// Utilities +import { toRef, watch } from 'vue' + +// Types +import type { DataTableAdapterInterface, DataTableContext } from '#v0/composables/createDataTable' +import type { InternalHeader } from '#v0/composables/createDataTable/columns' +import type { FilterOptions } from '#v0/composables/createFilter' +import type { PaginationOptions } from '#v0/composables/createPagination' +import type { ContextTrinity } from '#v0/composables/createTrinity' +import type { VirtualOptions } from '#v0/composables/createVirtual' +import type { ID } from '#v0/types' +import type { CellEditing } from './editing' +import type { ColumnLayout, GridColumnDef } from './layout' +import type { RowSpanningOptions, SpanEntry } from './spanning' +import type { App, ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue' + +// Grid modules +import { createCellEditing } from './editing' +import { createColumnLayout } from './layout' +import { createRowOrdering } from './ordering' +import { createRowSpanning } from './spanning' + +export type { ColumnLayout, GridColumnDef, PinnedRegion, PinPosition, ResolvedColumn } from './layout' +export type { ActiveCell, CellEditing, CellEditingOptions, EditableColumn } from './editing' +export type { RowOrdering } from './ordering' +export type { RowSpanningOptions, SpanEntry } from './spanning' +export { ClientGridAdapter, ServerGridAdapter, VirtualGridAdapter } from './adapters' +export type { ServerGridAdapterOptions } from './adapters' + +export interface DataGridColumn = Record> extends GridColumnDef { + readonly key: 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) => boolean | string + readonly span?: (item: T) => number + readonly children?: readonly DataGridColumn[] +} + +export interface DataGridOptions> { + items: MaybeRefOrGetter + columns: readonly DataGridColumn[] + itemValue?: string + adapter?: DataTableAdapterInterface + 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: (fromIndex: number, toIndex: number) => void + reset: () => void + } + editing: CellEditing + headers: Readonly> + 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. + * + * @param options Data grid options + * @returns Data grid context + */ +export function createDataGrid> ( + options: DataGridOptions, +): DataGridContext { + const { + items, + columns, + itemValue = 'id', + adapter: customAdapter, + filter, + pagination, + sortMultiple, + editing: editingOptions, + preserveRowOrder = false, + rowSpanning, + } = options + + // 1. Extract leaves from possibly nested column definitions + const leaves = extractLeaves(columns) + + // 2. Create row ordering state + const ordering = createRowOrdering() + + // 3. Create adapter: use ClientGridAdapter (closes over ordering) unless custom provided + const adapter = customAdapter ?? new ClientGridAdapter(ordering.order, itemValue) + + // 4. Create the data table with the grid adapter + const table = createDataTable({ + items, + columns, + itemValue: itemValue as never, + filter, + pagination, + sortMultiple, + adapter, + }) + + // 5. Watch sort changes to reset row order (unless preserveRowOrder) + if (!preserveRowOrder) { + watch(table.sort.columns, () => { + ordering.reset() + }) + } + + // 6. Create column layout + const layout = createColumnLayout(columns) + + // 7. Resolve headers (toRef wrapping resolveHeaders) + const headers = toRef(() => resolveHeaders(columns)) + + // 8. Create cell editing + const editableColumns = leaves + .filter(col => col.editable !== undefined || col.validate !== undefined) + .map(col => ({ + key: col.key, + editable: col.editable as boolean | ((item: unknown) => boolean) | undefined, + validate: col.validate as ((value: unknown, item?: unknown) => boolean | string) | undefined, + })) + + const editing = createCellEditing({ + columns: editableColumns, + onEdit: editingOptions?.onEdit + ? (row, column, value) => { + const item = table.allItems.value.find( + i => (i[itemValue] as ID) === row, + ) as T | undefined + editingOptions.onEdit!(row, column, value, item as T) + } + : undefined, + }) + + // 9. Create row spanning + const columnKeys = leaves.map(col => col.key) + + const spanOptions: RowSpanningOptions = { + items: table.items as Ref, + columns: columnKeys, + itemKey: itemValue, + rowSpanning, + } + + const spans = createRowSpanning(spanOptions) + + // 10. Return merged context (table + grid features) + return { + ...table, + layout, + rows: { + order: ordering.order, + move: ordering.move, + reset: ordering.reset, + }, + editing, + headers, + spans, + virtual: null, + } +} + +/** + * Creates a data grid context with dependency injection support. + * + * @param options Data grid context options including namespace + * @returns A trinity tuple: [useDataGrid, provideDataGrid, defaultContext] + */ +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 + */ +export function useDataGrid> ( + namespace = 'v0:data-grid', +): DataGridContext { + return useContext>(namespace) +} diff --git a/packages/0/src/composables/createDataGrid/spanning.ts b/packages/0/src/composables/createDataGrid/spanning.ts index 30d12d025..a64bbbcbe 100644 --- a/packages/0/src/composables/createDataGrid/spanning.ts +++ b/packages/0/src/composables/createDataGrid/spanning.ts @@ -40,7 +40,7 @@ export function createRowSpanning> ( // 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: columns.length }).fill(0) + const covered = Array.from({ length: columns.length }).fill(0) for (let row = 0; row < list.length; row++) { const item = list[row] diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 0f1363371..8fbf0c986 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' From 1ecb870fe11f87904200390cdc97094ef48b6300 Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 14:20:26 -0500 Subject: [PATCH 07/38] feat(createDataGrid): add integration tests --- .../createDataGrid/editing.test.ts | 2 +- .../composables/createDataGrid/index.test.ts | 172 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/0/src/composables/createDataGrid/index.test.ts diff --git a/packages/0/src/composables/createDataGrid/editing.test.ts b/packages/0/src/composables/createDataGrid/editing.test.ts index c28cee6df..73c26b5cf 100644 --- a/packages/0/src/composables/createDataGrid/editing.test.ts +++ b/packages/0/src/composables/createDataGrid/editing.test.ts @@ -5,7 +5,7 @@ import { createCellEditing } from './editing' describe('createCellEditing', () => { const columns = [ { key: 'name', editable: true }, - { key: 'email', editable: true, validate: (v: unknown) => typeof v === 'string' && v.includes('@') || 'Invalid email' }, + { key: 'email', editable: true, validate: (v: unknown) => (typeof v === 'string' && v.includes('@')) || 'Invalid email' }, { key: 'id', editable: false }, ] 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..a6f4bb33e --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest' + +// Utilities +import { inject, provide } from 'vue' + +import { createDataGrid } from './index' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + provide: vi.fn(), + inject: vi.fn(), + } +}) + +vi.mocked(provide) +vi.mocked(inject) + +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' }, +] + +describe('createDataGrid', () => { + it('creates a grid with data table pipeline', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', title: 'Name', sortable: true, filterable: true, size: 30 }, + { key: 'email', title: 'Email', filterable: true, size: 40 }, + { key: 'age', title: 'Age', sortable: true, size: 30 }, + ], + }) + + expect(grid.items.value).toHaveLength(4) + expect(grid.layout.columns.value).toHaveLength(3) + }) + + it('search filters items', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', filterable: true, size: 50 }, + { key: 'email', filterable: true, size: 50 }, + ], + }) + + grid.search('alice') + expect(grid.items.value).toHaveLength(1) + expect(grid.items.value[0].name).toBe('Alice') + }) + + it('sort works through the table pipeline', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', sortable: true, size: 50 }, + { key: 'age', sortable: true, size: 50 }, + ], + }) + + 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('column layout', () => { + it('initializes with correct sizes', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', size: 40 }, + { key: 'email', size: 60 }, + ], + }) + + expect(grid.layout.columns.value[0].size).toBe(40) + expect(grid.layout.columns.value[1].size).toBe(60) + }) + + it('supports nested columns', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', title: 'Name', size: 30 }, + { + key: 'contact', + title: 'Contact', + children: [ + { key: 'email', title: 'Email', size: 40 }, + { key: 'age', title: 'Age', size: 30 }, + ], + }, + ], + }) + + // 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('edit and commit lifecycle', () => { + const onEdit = vi.fn() + const grid = createDataGrid({ + items, + columns: [ + { key: 'name', size: 50, editable: true }, + { key: 'email', size: 50 }, + ], + editing: { onEdit }, + }) + + 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('validation rejects bad values', () => { + const grid = createDataGrid({ + items, + columns: [ + { + key: 'email', + size: 100, + editable: true, + validate: v => (typeof v === 'string' && v.includes('@')) || 'Invalid email', + }, + ], + editing: {}, + }) + + 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 spanning', () => { + it('computes span map', () => { + const grid = createDataGrid({ + items, + columns: [ + { key: 'dept', size: 50 }, + { key: 'name', size: 50 }, + ], + rowSpanning: (item, column) => { + if (column === 'dept' && (item.dept === 'Eng' || item.dept === 'Sales')) return 2 + return 1 + }, + }) + + 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) + }) + }) +}) From 2c5ba0509fe2310cbf265b05f0fcafab33a8ab89 Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 30 Mar 2026 18:49:58 -0500 Subject: [PATCH 08/38] refactor(createDataGrid): simplify adapters, layout, and factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared applyOrder helper, eliminating duplicate ordering logic between ClientGridAdapter and VirtualGridAdapter - Fix O(n*m) order.includes() → Set-based O(n) lookup in adapters and ordering.apply() - Simplify layout.ts columns computed to reuse pinned regions instead of double-computing resolved() - Fix editable column filter to match editing.ts guard - Remove unused RowSpanningOptions import and numbered comments - Clean up test mock imports --- dev/src/components.d.ts | 60 +----- dev/src/composables.d.ts | 179 +++++++----------- .../createDataGrid/adapters/client.ts | 24 +-- .../createDataGrid/adapters/order.ts | 31 +++ .../createDataGrid/adapters/virtual.ts | 24 +-- .../composables/createDataGrid/index.test.ts | 6 - .../0/src/composables/createDataGrid/index.ts | 30 +-- .../src/composables/createDataGrid/layout.ts | 14 +- .../composables/createDataGrid/ordering.ts | 5 +- 9 files changed, 117 insertions(+), 256 deletions(-) create mode 100644 packages/0/src/composables/createDataGrid/adapters/order.ts diff --git a/dev/src/components.d.ts b/dev/src/components.d.ts index 5b3969f93..d9565b966 100644 --- a/dev/src/components.d.ts +++ b/dev/src/components.d.ts @@ -11,15 +11,6 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { - AlertDialogAction: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogAction.vue')['default'] - AlertDialogActivator: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogActivator.vue')['default'] - AlertDialogCancel: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogCancel.vue')['default'] - AlertDialogClose: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogClose.vue')['default'] - AlertDialogContent: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogContent.vue')['default'] - AlertDialogDescription: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogDescription.vue')['default'] - AlertDialogRoot: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogRoot.vue')['default'] - AlertDialogTitle: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogTitle.vue')['default'] - AspectRatio: typeof import('./../../packages/0/src/components/AspectRatio/AspectRatio.vue')['default'] Atom: typeof import('./../../packages/0/src/components/Atom/Atom.vue')['default'] AvatarFallback: typeof import('./../../packages/0/src/components/Avatar/AvatarFallback.vue')['default'] AvatarImage: typeof import('./../../packages/0/src/components/Avatar/AvatarImage.vue')['default'] @@ -37,33 +28,11 @@ declare module 'vue' { ButtonIcon: typeof import('./../../packages/0/src/components/Button/ButtonIcon.vue')['default'] ButtonLoading: typeof import('./../../packages/0/src/components/Button/ButtonLoading.vue')['default'] ButtonRoot: typeof import('./../../packages/0/src/components/Button/ButtonRoot.vue')['default'] - CarouselIndicator: typeof import('./../../packages/0/src/components/Carousel/CarouselIndicator.vue')['default'] - CarouselItem: typeof import('./../../packages/0/src/components/Carousel/CarouselItem.vue')['default'] - CarouselLiveRegion: typeof import('./../../packages/0/src/components/Carousel/CarouselLiveRegion.vue')['default'] - CarouselNext: typeof import('./../../packages/0/src/components/Carousel/CarouselNext.vue')['default'] - CarouselPrevious: typeof import('./../../packages/0/src/components/Carousel/CarouselPrevious.vue')['default'] - CarouselProgress: typeof import('./../../packages/0/src/components/Carousel/CarouselProgress.vue')['default'] - CarouselRoot: typeof import('./../../packages/0/src/components/Carousel/CarouselRoot.vue')['default'] - CarouselViewport: typeof import('./../../packages/0/src/components/Carousel/CarouselViewport.vue')['default'] CheckboxGroup: typeof import('./../../packages/0/src/components/Checkbox/CheckboxGroup.vue')['default'] CheckboxHiddenInput: typeof import('./../../packages/0/src/components/Checkbox/CheckboxHiddenInput.vue')['default'] CheckboxIndicator: typeof import('./../../packages/0/src/components/Checkbox/CheckboxIndicator.vue')['default'] CheckboxRoot: typeof import('./../../packages/0/src/components/Checkbox/CheckboxRoot.vue')['default'] CheckboxSelectAll: typeof import('./../../packages/0/src/components/Checkbox/CheckboxSelectAll.vue')['default'] - CollapsibleActivator: typeof import('./../../packages/0/src/components/Collapsible/CollapsibleActivator.vue')['default'] - CollapsibleContent: typeof import('./../../packages/0/src/components/Collapsible/CollapsibleContent.vue')['default'] - CollapsibleCue: typeof import('./../../packages/0/src/components/Collapsible/CollapsibleCue.vue')['default'] - CollapsibleRoot: typeof import('./../../packages/0/src/components/Collapsible/CollapsibleRoot.vue')['default'] - ComboboxActivator: typeof import('./../../packages/0/src/components/Combobox/ComboboxActivator.vue')['default'] - ComboboxContent: typeof import('./../../packages/0/src/components/Combobox/ComboboxContent.vue')['default'] - ComboboxControl: typeof import('./../../packages/0/src/components/Combobox/ComboboxControl.vue')['default'] - ComboboxCue: typeof import('./../../packages/0/src/components/Combobox/ComboboxCue.vue')['default'] - ComboboxDescription: typeof import('./../../packages/0/src/components/Combobox/ComboboxDescription.vue')['default'] - ComboboxEmpty: typeof import('./../../packages/0/src/components/Combobox/ComboboxEmpty.vue')['default'] - ComboboxError: typeof import('./../../packages/0/src/components/Combobox/ComboboxError.vue')['default'] - ComboboxHiddenInput: typeof import('./../../packages/0/src/components/Combobox/ComboboxHiddenInput.vue')['default'] - ComboboxItem: typeof import('./../../packages/0/src/components/Combobox/ComboboxItem.vue')['default'] - ComboboxRoot: typeof import('./../../packages/0/src/components/Combobox/ComboboxRoot.vue')['default'] DialogActivator: typeof import('./../../packages/0/src/components/Dialog/DialogActivator.vue')['default'] DialogClose: typeof import('./../../packages/0/src/components/Dialog/DialogClose.vue')['default'] DialogContent: typeof import('./../../packages/0/src/components/Dialog/DialogContent.vue')['default'] @@ -72,29 +41,17 @@ declare module 'vue' { DialogTitle: typeof import('./../../packages/0/src/components/Dialog/DialogTitle.vue')['default'] ExpansionPanelActivator: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelActivator.vue')['default'] ExpansionPanelContent: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelContent.vue')['default'] - ExpansionPanelCue: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelCue.vue')['default'] - ExpansionPanelGroup: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelGroup.vue')['default'] ExpansionPanelHeader: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelHeader.vue')['default'] + ExpansionPanelItem: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelItem.vue')['default'] ExpansionPanelRoot: typeof import('./../../packages/0/src/components/ExpansionPanel/ExpansionPanelRoot.vue')['default'] Form: typeof import('./../../packages/0/src/components/Form/Form.vue')['default'] GroupItem: typeof import('./../../packages/0/src/components/Group/GroupItem.vue')['default'] GroupRoot: typeof import('./../../packages/0/src/components/Group/GroupRoot.vue')['default'] - ImageFallback: typeof import('./../../packages/0/src/components/Image/ImageFallback.vue')['default'] - ImageImg: typeof import('./../../packages/0/src/components/Image/ImageImg.vue')['default'] - ImagePlaceholder: typeof import('./../../packages/0/src/components/Image/ImagePlaceholder.vue')['default'] - ImageRoot: typeof import('./../../packages/0/src/components/Image/ImageRoot.vue')['default'] InputControl: typeof import('./../../packages/0/src/components/Input/InputControl.vue')['default'] InputDescription: typeof import('./../../packages/0/src/components/Input/InputDescription.vue')['default'] InputError: typeof import('./../../packages/0/src/components/Input/InputError.vue')['default'] InputRoot: typeof import('./../../packages/0/src/components/Input/InputRoot.vue')['default'] Locale: typeof import('./../../packages/0/src/components/Locale/Locale.vue')['default'] - NumberFieldControl: typeof import('./../../packages/0/src/components/NumberField/NumberFieldControl.vue')['default'] - NumberFieldDecrement: typeof import('./../../packages/0/src/components/NumberField/NumberFieldDecrement.vue')['default'] - NumberFieldDescription: typeof import('./../../packages/0/src/components/NumberField/NumberFieldDescription.vue')['default'] - NumberFieldError: typeof import('./../../packages/0/src/components/NumberField/NumberFieldError.vue')['default'] - NumberFieldIncrement: typeof import('./../../packages/0/src/components/NumberField/NumberFieldIncrement.vue')['default'] - NumberFieldRoot: typeof import('./../../packages/0/src/components/NumberField/NumberFieldRoot.vue')['default'] - NumberFieldScrub: typeof import('./../../packages/0/src/components/NumberField/NumberFieldScrub.vue')['default'] PaginationEllipsis: typeof import('./../../packages/0/src/components/Pagination/PaginationEllipsis.vue')['default'] PaginationFirst: typeof import('./../../packages/0/src/components/Pagination/PaginationFirst.vue')['default'] PaginationItem: typeof import('./../../packages/0/src/components/Pagination/PaginationItem.vue')['default'] @@ -106,22 +63,10 @@ declare module 'vue' { PopoverActivator: typeof import('./../../packages/0/src/components/Popover/PopoverActivator.vue')['default'] PopoverContent: typeof import('./../../packages/0/src/components/Popover/PopoverContent.vue')['default'] PopoverRoot: typeof import('./../../packages/0/src/components/Popover/PopoverRoot.vue')['default'] - Portal: typeof import('./../../packages/0/src/components/Portal/Portal.vue')['default'] - Presence: typeof import('./../../packages/0/src/components/Presence/Presence.vue')['default'] - ProgressBuffer: typeof import('./../../packages/0/src/components/Progress/ProgressBuffer.vue')['default'] - ProgressFill: typeof import('./../../packages/0/src/components/Progress/ProgressFill.vue')['default'] - ProgressHiddenInput: typeof import('./../../packages/0/src/components/Progress/ProgressHiddenInput.vue')['default'] - ProgressLabel: typeof import('./../../packages/0/src/components/Progress/ProgressLabel.vue')['default'] - ProgressRoot: typeof import('./../../packages/0/src/components/Progress/ProgressRoot.vue')['default'] - ProgressTrack: typeof import('./../../packages/0/src/components/Progress/ProgressTrack.vue')['default'] - ProgressValue: typeof import('./../../packages/0/src/components/Progress/ProgressValue.vue')['default'] RadioGroup: typeof import('./../../packages/0/src/components/Radio/RadioGroup.vue')['default'] RadioHiddenInput: typeof import('./../../packages/0/src/components/Radio/RadioHiddenInput.vue')['default'] RadioIndicator: typeof import('./../../packages/0/src/components/Radio/RadioIndicator.vue')['default'] RadioRoot: typeof import('./../../packages/0/src/components/Radio/RadioRoot.vue')['default'] - RatingHiddenInput: typeof import('./../../packages/0/src/components/Rating/RatingHiddenInput.vue')['default'] - RatingItem: typeof import('./../../packages/0/src/components/Rating/RatingItem.vue')['default'] - RatingRoot: typeof import('./../../packages/0/src/components/Rating/RatingRoot.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Scrim: typeof import('./../../packages/0/src/components/Scrim/Scrim.vue')['default'] @@ -164,9 +109,6 @@ declare module 'vue' { TabsPanel: typeof import('./../../packages/0/src/components/Tabs/TabsPanel.vue')['default'] TabsRoot: typeof import('./../../packages/0/src/components/Tabs/TabsRoot.vue')['default'] Theme: typeof import('./../../packages/0/src/components/Theme/Theme.vue')['default'] - ToggleGroup: typeof import('./../../packages/0/src/components/Toggle/ToggleGroup.vue')['default'] - ToggleIndicator: typeof import('./../../packages/0/src/components/Toggle/ToggleIndicator.vue')['default'] - ToggleRoot: typeof import('./../../packages/0/src/components/Toggle/ToggleRoot.vue')['default'] TreeviewActivator: typeof import('./../../packages/0/src/components/Treeview/TreeviewActivator.vue')['default'] TreeviewCheckbox: typeof import('./../../packages/0/src/components/Treeview/TreeviewCheckbox.vue')['default'] TreeviewContent: typeof import('./../../packages/0/src/components/Treeview/TreeviewContent.vue')['default'] diff --git a/dev/src/composables.d.ts b/dev/src/composables.d.ts index 674e2484a..07b36e167 100644 --- a/dev/src/composables.d.ts +++ b/dev/src/composables.d.ts @@ -8,8 +8,7 @@ export {} declare global { const COMMON_ELEMENTS: typeof import('../../packages/0/src/constants/htmlElements').COMMON_ELEMENTS const ClientAdapter: typeof import('../../packages/0/src/composables/createDataTable/index').ClientAdapter - const ComboboxClientAdapter: typeof import('../../packages/0/src/composables/createCombobox/index').ComboboxClientAdapter - const ComboboxServerAdapter: typeof import('../../packages/0/src/composables/createCombobox/index').ComboboxServerAdapter + const ClientGridAdapter: typeof import('../../packages/0/src/composables/createDataGrid/index').ClientGridAdapter const ConsolaLoggerAdapter: typeof import('../../packages/0/src/composables/useLogger/index').ConsolaLoggerAdapter const DEFAULT_DARK: typeof import('../../packages/paper/src/composables/useTheme/index').DEFAULT_DARK const DEFAULT_LIGHT: typeof import('../../packages/paper/src/composables/useTheme/index').DEFAULT_LIGHT @@ -30,11 +29,13 @@ declare global { const SUPPORTS_OBSERVER: typeof import('../../packages/0/src/constants/globals').SUPPORTS_OBSERVER const SUPPORTS_TOUCH: typeof import('../../packages/0/src/constants/globals').SUPPORTS_TOUCH const ServerAdapter: typeof import('../../packages/0/src/composables/createDataTable/index').ServerAdapter + const ServerGridAdapter: typeof import('../../packages/0/src/composables/createDataGrid/index').ServerGridAdapter const TemporalDateAdapter: typeof import('../../packages/0/src/composables/useDate/index').TemporalDateAdapter const V0StyleSheetThemeAdapter: typeof import('../../packages/0/src/composables/useTheme/index').V0StyleSheetThemeAdapter const V0UnheadThemeAdapter: typeof import('../../packages/0/src/composables/useTheme/index').V0UnheadThemeAdapter const V0_ELEVATION_KEY: typeof import('../../packages/paper/src/composables/useElevation/index').V0_ELEVATION_KEY const VirtualAdapter: typeof import('../../packages/0/src/composables/createDataTable/index').VirtualAdapter + const VirtualGridAdapter: typeof import('../../packages/0/src/composables/createDataGrid/index').VirtualGridAdapter const Vuetify0DateAdapter: typeof import('../../packages/0/src/composables/useDate/index').Vuetify0DateAdapter const Vuetify0LocaleAdapter: typeof import('../../packages/0/src/composables/useLocale/index').Vuetify0LocaleAdapter const Vuetify0LoggerAdapter: typeof import('../../packages/0/src/composables/useLogger/index').Vuetify0LoggerAdapter @@ -51,9 +52,9 @@ declare global { const createBreakpoints: typeof import('../../packages/0/src/composables/useBreakpoints/index').createBreakpoints const createBreakpointsContext: typeof import('../../packages/0/src/composables/useBreakpoints/index').createBreakpointsContext const createBreakpointsPlugin: typeof import('../../packages/0/src/composables/useBreakpoints/index').createBreakpointsPlugin - const createCombobox: typeof import('../../packages/0/src/composables/createCombobox/index').createCombobox - const createComboboxContext: typeof import('../../packages/0/src/composables/createCombobox/index').createComboboxContext const createContext: typeof import('../../packages/0/src/composables/createContext/index').createContext + const createDataGrid: typeof import('../../packages/0/src/composables/createDataGrid/index').createDataGrid + const createDataGridContext: typeof import('../../packages/0/src/composables/createDataGrid/index').createDataGridContext const createDataTable: typeof import('../../packages/0/src/composables/createDataTable/index').createDataTable const createDataTableContext: typeof import('../../packages/0/src/composables/createDataTable/index').createDataTableContext const createDate: typeof import('../../packages/0/src/composables/useDate/index').createDate @@ -74,7 +75,6 @@ declare global { const createHydration: typeof import('../../packages/0/src/composables/useHydration/index').createHydration const createHydrationContext: typeof import('../../packages/0/src/composables/useHydration/index').createHydrationContext const createHydrationPlugin: typeof import('../../packages/0/src/composables/useHydration/index').createHydrationPlugin - const createInput: typeof import('../../packages/0/src/composables/createInput/index').createInput const createLocale: typeof import('../../packages/0/src/composables/useLocale/index').createLocale const createLocaleContext: typeof import('../../packages/0/src/composables/useLocale/index').createLocaleContext const createLocaleFallback: typeof import('../../packages/0/src/composables/useLocale/index').createLocaleFallback @@ -88,8 +88,6 @@ declare global { const createNotifications: typeof import('../../packages/0/src/composables/useNotifications/index').createNotifications const createNotificationsContext: typeof import('../../packages/0/src/composables/useNotifications/index').createNotificationsContext const createNotificationsPlugin: typeof import('../../packages/0/src/composables/useNotifications/index').createNotificationsPlugin - const createNumberField: typeof import('../../packages/0/src/composables/createNumberField/index').createNumberField - const createNumeric: typeof import('../../packages/0/src/composables/createNumeric/index').createNumeric const createOverflow: typeof import('../../packages/0/src/composables/createOverflow/index').createOverflow const createOverflowContext: typeof import('../../packages/0/src/composables/createOverflow/index').createOverflowContext const createPagination: typeof import('../../packages/0/src/composables/createPagination/index').createPagination @@ -99,12 +97,8 @@ declare global { const createPermissionsPlugin: typeof import('../../packages/0/src/composables/usePermissions/index').createPermissionsPlugin const createPlugin: typeof import('../../packages/0/src/composables/createPlugin/index').createPlugin const createPluginContext: typeof import('../../packages/0/src/composables/createPlugin/index').createPluginContext - const createProgress: typeof import('../../packages/0/src/composables/createProgress/index').createProgress - const createProgressContext: typeof import('../../packages/0/src/composables/createProgress/index').createProgressContext const createQueue: typeof import('../../packages/0/src/composables/createQueue/index').createQueue const createQueueContext: typeof import('../../packages/0/src/composables/createQueue/index').createQueueContext - const createRating: typeof import('../../packages/0/src/composables/createRating/index').createRating - const createRatingContext: typeof import('../../packages/0/src/composables/createRating/index').createRatingContext const createRegistry: typeof import('../../packages/0/src/composables/createRegistry/index').createRegistry const createRegistryContext: typeof import('../../packages/0/src/composables/createRegistry/index').createRegistryContext const createRtl: typeof import('../../packages/0/src/composables/useRtl/index').createRtl @@ -207,8 +201,6 @@ declare global { const ref: typeof import('vue').ref const resolveComponent: typeof import('vue').resolveComponent const resolveHeaders: typeof import('../../packages/0/src/composables/createDataTable/index').resolveHeaders - const resolveIds: typeof import('../../packages/0/src/utilities/helpers').resolveIds - const resolveIndexes: typeof import('../../packages/0/src/utilities/helpers').resolveIndexes const rgbToHex: typeof import('../../packages/0/src/utilities/color').rgbToHex const rgbToRgba: typeof import('../../packages/paper/src/composables/useColor/index').rgbToRgba const rgbaToHexa: typeof import('../../packages/paper/src/composables/useColor/index').rgbaToHexa @@ -233,11 +225,11 @@ declare global { const useBreakpoints: typeof import('../../packages/0/src/composables/useBreakpoints/index').useBreakpoints const useClickOutside: typeof import('../../packages/0/src/composables/useClickOutside/index').useClickOutside const useColor: typeof import('../../packages/paper/src/composables/useColor/index').useColor - const useCombobox: typeof import('../../packages/0/src/composables/createCombobox/index').useCombobox const useContext: typeof import('../../packages/0/src/composables/createContext/index').useContext const useContrast: typeof import('../../packages/paper/src/composables/useContrast/index').useContrast const useCssModule: typeof import('vue').useCssModule const useCssVars: typeof import('vue').useCssVars + const useDataGrid: typeof import('../../packages/0/src/composables/createDataGrid/index').useDataGrid const useDataTable: typeof import('../../packages/0/src/composables/createDataTable/index').useDataTable const useDate: typeof import('../../packages/0/src/composables/useDate/index').useDate const useDimensions: typeof import('../../packages/paper/src/composables/useDimensions/index').useDimensions @@ -254,7 +246,6 @@ declare global { const useHotkey: typeof import('../../packages/0/src/composables/useHotkey/index').useHotkey const useHydration: typeof import('../../packages/0/src/composables/useHydration/index').useHydration const useId: typeof import('../../packages/0/src/utilities/helpers').useId - const useImage: typeof import('../../packages/0/src/composables/useImage/index').useImage const useIntersectionObserver: typeof import('../../packages/0/src/composables/useIntersectionObserver/index').useIntersectionObserver const useLazy: typeof import('../../packages/0/src/composables/useLazy/index').useLazy const useLocale: typeof import('../../packages/0/src/composables/useLocale/index').useLocale @@ -271,13 +262,10 @@ declare global { const usePrefersContrast: typeof import('../../packages/0/src/composables/useMediaQuery/index').usePrefersContrast const usePrefersDark: typeof import('../../packages/0/src/composables/useMediaQuery/index').usePrefersDark const usePrefersReducedMotion: typeof import('../../packages/0/src/composables/useMediaQuery/index').usePrefersReducedMotion - const usePresence: typeof import('../../packages/0/src/composables/usePresence/index').usePresence - const useProgress: typeof import('../../packages/0/src/composables/createProgress/index').useProgress const useProxyModel: typeof import('../../packages/0/src/composables/useProxyModel/index').useProxyModel const useProxyRegistry: typeof import('../../packages/0/src/composables/useProxyRegistry/index').useProxyRegistry const useQueue: typeof import('../../packages/0/src/composables/createQueue/index').useQueue const useRaf: typeof import('../../packages/0/src/composables/useRaf/index').useRaf - const useRating: typeof import('../../packages/0/src/composables/createRating/index').useRating const useRegistry: typeof import('../../packages/0/src/composables/createRegistry/index').useRegistry const useResizeObserver: typeof import('../../packages/0/src/composables/useResizeObserver/index').useResizeObserver const useRounded: typeof import('../../packages/paper/src/composables/useRounded/index').useRounded @@ -336,84 +324,21 @@ declare global { export type { BreadcrumbTicketInput, BreadcrumbTicket, BreadcrumbsContext, BreadcrumbsOptions, BreadcrumbsContextOptions } from '../../packages/0/src/composables/createBreadcrumbs/index' import('../../packages/0/src/composables/createBreadcrumbs/index') // @ts-ignore - export type { ComboboxOptions, ComboboxContext, ComboboxAdapterContext, ComboboxAdapterInterface, ComboboxAdapterResult, ComboboxClientAdapterOptions } from '../../packages/0/src/composables/createCombobox/index' - import('../../packages/0/src/composables/createCombobox/index') - // @ts-ignore export type { ContextKey, CreateContextOptions } from '../../packages/0/src/composables/createContext/index' import('../../packages/0/src/composables/createContext/index') // @ts-ignore - export type { KeysOfType, SelectStrategy, DataTableColumn, DataTableSort, DataTableSelection, DataTableGroup, DataTableGrouping, DataTableExpansion, DataTableOptions, DataTableContext, DataTableContextOptions, DataTableAdapterContext, DataTableAdapterInterface, DataTableAdapterResult, SortDirection, SortEntry, ServerAdapterOptions, ColumnNode, InternalHeader } from '../../packages/0/src/composables/createDataTable/index' - import('../../packages/0/src/composables/createDataTable/index') - // @ts-ignore - export type { Primitive, FilterQuery, FilterItem, FilterMode, FilterFunction, FilterOptions, FilterResult, FilterContext, FilterContextOptions } from '../../packages/0/src/composables/createFilter/index' - import('../../packages/0/src/composables/createFilter/index') - // @ts-ignore - export type { FormValidationResult, FormValue, FormTicketInput, FormTicket, FormContext, FormOptions, FormContextOptions } from '../../packages/0/src/composables/createForm/index' - import('../../packages/0/src/composables/createForm/index') - // @ts-ignore - export type { GroupTicketInput, GroupTicket, GroupContext, GroupOptions, GroupContextOptions } from '../../packages/0/src/composables/createGroup/index' - import('../../packages/0/src/composables/createGroup/index') + export type { DataGridColumn, DataGridOptions, DataGridContext, DataGridContextOptions, ColumnLayout, GridColumnDef, PinnedRegion, PinPosition, ResolvedColumn, ActiveCell, CellEditing, CellEditingOptions, EditableColumn, RowOrdering, RowSpanningOptions, SpanEntry, ServerGridAdapterOptions } from '../../packages/0/src/composables/createDataGrid/index' + import('../../packages/0/src/composables/createDataGrid/index') // @ts-ignore - export type { InputState, InputOptions, InputContext } from '../../packages/0/src/composables/createInput/index' - import('../../packages/0/src/composables/createInput/index') - // @ts-ignore - export type { ModelTicketInput, ModelTicket, ModelContext, ModelOptions } from '../../packages/0/src/composables/createModel/index' - import('../../packages/0/src/composables/createModel/index') - // @ts-ignore - export type { NumberFieldOptions, NumberFieldContext } from '../../packages/0/src/composables/createNumberField/index' - import('../../packages/0/src/composables/createNumberField/index') - // @ts-ignore - export type { NumericOptions, NumericContext } from '../../packages/0/src/composables/createNumeric/index' - import('../../packages/0/src/composables/createNumeric/index') - // @ts-ignore - export type { OverflowOptions, OverflowContext, OverflowContextOptions } from '../../packages/0/src/composables/createOverflow/index' - import('../../packages/0/src/composables/createOverflow/index') - // @ts-ignore - export type { PaginationTicket, PaginationContext, PaginationOptions, PaginationContextOptions } from '../../packages/0/src/composables/createPagination/index' - import('../../packages/0/src/composables/createPagination/index') + export type { SelectStrategy, DataTableColumn, DataTableSort, DataTableSelection, DataTableGroup, DataTableGrouping, DataTableExpansion, DataTableOptions, DataTableContext, DataTableContextOptions, DataTableAdapterContext, DataTableAdapterInterface, DataTableAdapterResult, SortDirection, SortEntry, ServerAdapterOptions, ColumnNode, InternalHeader } from '../../packages/0/src/composables/createDataTable/index' + import('../../packages/0/src/composables/createDataTable/index') // @ts-ignore export type { PluginOptions, Plugin, PluginContextConfig } from '../../packages/0/src/composables/createPlugin/index' import('../../packages/0/src/composables/createPlugin/index') // @ts-ignore - export type { ProgressTicketInput, ProgressTicket, ProgressOptions, ProgressContext, ProgressContextOptions } from '../../packages/0/src/composables/createProgress/index' - import('../../packages/0/src/composables/createProgress/index') - // @ts-ignore - export type { QueueTicketInput, QueueTicket, QueueContext, QueueOptions, QueueContextOptions } from '../../packages/0/src/composables/createQueue/index' - import('../../packages/0/src/composables/createQueue/index') - // @ts-ignore - export type { RatingItemState, RatingItemDescriptor, RatingContext, RatingOptions, RatingContextOptions } from '../../packages/0/src/composables/createRating/index' - import('../../packages/0/src/composables/createRating/index') - // @ts-ignore - export type { RegistryTicketInput, RegistryTicket, RegistryEventName, RegistryEventMap, RegistryEventCallback, RegistryContext, RegistryOptions, RegistryContextOptions } from '../../packages/0/src/composables/createRegistry/index' - import('../../packages/0/src/composables/createRegistry/index') - // @ts-ignore - export type { SelectionTicketInput, SelectionTicket, SelectionContext, SelectionOptions, SelectionContextOptions } from '../../packages/0/src/composables/createSelection/index' - import('../../packages/0/src/composables/createSelection/index') - // @ts-ignore - export type { SingleTicketInput, SingleTicket, SingleContext, SingleOptions, SingleContextOptions } from '../../packages/0/src/composables/createSingle/index' - import('../../packages/0/src/composables/createSingle/index') - // @ts-ignore - export type { SliderTicketInput, SliderOptions, SliderContext } from '../../packages/0/src/composables/createSlider/index' - import('../../packages/0/src/composables/createSlider/index') - // @ts-ignore - export type { StepTicketInput, StepTicket, StepContext, StepOptions, StepContextOptions } from '../../packages/0/src/composables/createStep/index' - import('../../packages/0/src/composables/createStep/index') - // @ts-ignore - export type { TimelineContext, TimelineTicket, TimelineOptions, TimelineContextOptions } from '../../packages/0/src/composables/createTimeline/index' - import('../../packages/0/src/composables/createTimeline/index') - // @ts-ignore - export type { TokenAlias, TokenPrimitive, TokenValue, TokenCollection, FlatTokenCollection, TokenTicket, TokenContext, TokenOptions, TokenContextOptions } from '../../packages/0/src/composables/createTokens/index' - import('../../packages/0/src/composables/createTokens/index') - // @ts-ignore export type { ContextTrinity } from '../../packages/0/src/composables/createTrinity/index' import('../../packages/0/src/composables/createTrinity/index') // @ts-ignore - export type { ValidationTicketInput, ValidationTicket, ValidationContext, ValidationOptions } from '../../packages/0/src/composables/createValidation/index' - import('../../packages/0/src/composables/createValidation/index') - // @ts-ignore - export type { VirtualDirection, VirtualState, VirtualAnchor, ScrollToOptions, VirtualOptions, VirtualItem, VirtualContext, VirtualContextOptions } from '../../packages/0/src/composables/createVirtual/index' - import('../../packages/0/src/composables/createVirtual/index') - // @ts-ignore export type { MaybeElementRef } from '../../packages/0/src/composables/toElement/index' import('../../packages/0/src/composables/toElement/index') // @ts-ignore @@ -432,15 +357,24 @@ declare global { export type { FeatureTicketInput, FeatureTicket, FeatureContext, FeatureOptions, FeatureContextOptions, FeaturePluginOptions, FeaturesAdapterFlags, FeaturesAdapterInterface, FeaturesAdapterValue } from '../../packages/0/src/composables/useFeatures/index' import('../../packages/0/src/composables/useFeatures/index') // @ts-ignore + export type { Primitive, FilterQuery, FilterItem, FilterMode, FilterFunction, FilterOptions, FilterResult, FilterContext, FilterContextOptions } from '../../packages/0/src/composables/createFilter/index' + import('../../packages/0/src/composables/createFilter/index') + // @ts-ignore + export type { FormValidationResult, FormValue, FormTicketInput, FormTicket, FormContext, FormOptions, FormContextOptions } from '../../packages/0/src/composables/createForm/index' + import('../../packages/0/src/composables/createForm/index') + // @ts-ignore + export type { GroupTicketInput, GroupTicket, GroupContext, GroupOptions, GroupContextOptions } from '../../packages/0/src/composables/createGroup/index' + import('../../packages/0/src/composables/createGroup/index') + // @ts-ignore + export type { ModelTicketInput, ModelTicket, ModelContext, ModelOptions } from '../../packages/0/src/composables/createModel/index' + import('../../packages/0/src/composables/createModel/index') + // @ts-ignore export type { UseHotkeyOptions, UseHotkeyReturn, PlatformContext } from '../../packages/0/src/composables/useHotkey/index' import('../../packages/0/src/composables/useHotkey/index') // @ts-ignore export type { HydrationContext, HydrationOptions, HydrationContextOptions, HydrationPluginOptions } from '../../packages/0/src/composables/useHydration/index' import('../../packages/0/src/composables/useHydration/index') // @ts-ignore - export type { ImageStatus, UseImageOptions, UseImageReturn } from '../../packages/0/src/composables/useImage/index' - import('../../packages/0/src/composables/useImage/index') - // @ts-ignore export type { IntersectionObserverEntry, IntersectionObserverOptions, UseIntersectionObserverReturn, UseElementIntersectionReturn } from '../../packages/0/src/composables/useIntersectionObserver/index' import('../../packages/0/src/composables/useIntersectionObserver/index') // @ts-ignore @@ -462,15 +396,18 @@ declare global { export type { NotificationSeverity, NotificationInput, NotificationTicket, NotificationsAdapterContext, NotificationsAdapterInterface, NotificationsOptions, NotificationsContext, NotificationsPluginOptions } from '../../packages/0/src/composables/useNotifications/index' import('../../packages/0/src/composables/useNotifications/index') // @ts-ignore + export type { OverflowOptions, OverflowContext, OverflowContextOptions } from '../../packages/0/src/composables/createOverflow/index' + import('../../packages/0/src/composables/createOverflow/index') + // @ts-ignore + export type { PaginationTicket, PaginationContext, PaginationOptions, PaginationContextOptions } from '../../packages/0/src/composables/createPagination/index' + import('../../packages/0/src/composables/createPagination/index') + // @ts-ignore export type { PermissionTicket, PermissionContext, PermissionOptions, PermissionContextOptions, PermissionPluginOptions, PermissionAdapterInterface } from '../../packages/0/src/composables/usePermissions/index' import('../../packages/0/src/composables/usePermissions/index') // @ts-ignore export type { PopoverOptions, PopoverReturn } from '../../packages/0/src/composables/usePopover/index' import('../../packages/0/src/composables/usePopover/index') // @ts-ignore - export type { PresenceState, UsePresenceOptions, UsePresenceReturn } from '../../packages/0/src/composables/usePresence/index' - import('../../packages/0/src/composables/usePresence/index') - // @ts-ignore export type { ProxyModelOptions, ProxyModelTarget } from '../../packages/0/src/composables/useProxyModel/index' import('../../packages/0/src/composables/useProxyModel/index') // @ts-ignore @@ -480,6 +417,15 @@ declare global { export type { UseRafReturn } from '../../packages/0/src/composables/useRaf/index' import('../../packages/0/src/composables/useRaf/index') // @ts-ignore + export type { QueueTicketInput, QueueTicket, QueueContext, QueueOptions, QueueContextOptions } from '../../packages/0/src/composables/createQueue/index' + import('../../packages/0/src/composables/createQueue/index') + // @ts-ignore + export type { RegistryTicketInput, RegistryTicket, RegistryEventName, RegistryEventMap, RegistryEventCallback, RegistryContext, RegistryOptions, RegistryContextOptions } from '../../packages/0/src/composables/createRegistry/index' + import('../../packages/0/src/composables/createRegistry/index') + // @ts-ignore + export type { RuleAlias, RuleInput, RuleAliases, RulesContext, RulesOptions, RulesContextOptions, StandardSchemaV1 } from '../../packages/0/src/composables/useRules/index' + import('../../packages/0/src/composables/useRules/index') + // @ts-ignore export type { ResizeObserverEntry, ResizeObserverOptions, UseResizeObserverReturn, UseElementSizeReturn } from '../../packages/0/src/composables/useResizeObserver/index' import('../../packages/0/src/composables/useResizeObserver/index') // @ts-ignore @@ -489,12 +435,21 @@ declare global { export type { RtlContext, RtlOptions, RtlContextOptions, RtlPluginOptions, RtlAdapter, RtlAdapterSetupContext } from '../../packages/0/src/composables/useRtl/index' import('../../packages/0/src/composables/useRtl/index') // @ts-ignore - export type { RuleAlias, RuleInput, RuleAliases, RulesContext, RulesOptions, RulesContextOptions, FormValidationRule, StandardSchemaV1 } from '../../packages/0/src/composables/useRules/index' - import('../../packages/0/src/composables/useRules/index') + export type { SelectionTicketInput, SelectionTicket, SelectionContext, SelectionOptions, SelectionContextOptions } from '../../packages/0/src/composables/createSelection/index' + import('../../packages/0/src/composables/createSelection/index') + // @ts-ignore + export type { SliderTicketInput, SliderOptions, SliderContext } from '../../packages/0/src/composables/createSlider/index' + import('../../packages/0/src/composables/createSlider/index') + // @ts-ignore + export type { SingleTicketInput, SingleTicket, SingleContext, SingleOptions, SingleContextOptions } from '../../packages/0/src/composables/createSingle/index' + import('../../packages/0/src/composables/createSingle/index') // @ts-ignore export type { StackTicketInput, StackTicket, StackContext, StackOptions, StackContextOptions, StackPluginOptions } from '../../packages/0/src/composables/useStack/index' import('../../packages/0/src/composables/useStack/index') // @ts-ignore + export type { StepTicketInput, StepTicket, StepContext, StepOptions, StepContextOptions } from '../../packages/0/src/composables/createStep/index' + import('../../packages/0/src/composables/createStep/index') + // @ts-ignore export type { StorageContext, StorageOptions, StorageContextOptions, StoragePluginOptions, StorageAdapter, StorageType } from '../../packages/0/src/composables/useStorage/index' import('../../packages/0/src/composables/useStorage/index') // @ts-ignore @@ -504,9 +459,21 @@ declare global { export type { TimerOptions, TimerContext } from '../../packages/0/src/composables/useTimer/index' import('../../packages/0/src/composables/useTimer/index') // @ts-ignore + export type { TimelineContext, TimelineTicket, TimelineOptions, TimelineContextOptions } from '../../packages/0/src/composables/createTimeline/index' + import('../../packages/0/src/composables/createTimeline/index') + // @ts-ignore + export type { ValidationTicketInput, ValidationTicket, ValidationContext, ValidationOptions, FormValidationRule } from '../../packages/0/src/composables/createValidation/index' + import('../../packages/0/src/composables/createValidation/index') + // @ts-ignore export type { ToggleScopeControls } from '../../packages/0/src/composables/useToggleScope/index' import('../../packages/0/src/composables/useToggleScope/index') // @ts-ignore + export type { TokenAlias, TokenPrimitive, TokenValue, TokenCollection, FlatTokenCollection, TokenTicket, TokenContext, TokenOptions, TokenContextOptions } from '../../packages/0/src/composables/createTokens/index' + import('../../packages/0/src/composables/createTokens/index') + // @ts-ignore + export type { VirtualDirection, VirtualState, VirtualAnchor, ScrollToOptions, VirtualOptions, VirtualItem, VirtualContext, VirtualContextOptions } from '../../packages/0/src/composables/createVirtual/index' + import('../../packages/0/src/composables/createVirtual/index') + // @ts-ignore export type { VirtualFocusItem, VirtualFocusOptions, VirtualFocusReturn } from '../../packages/0/src/composables/useVirtualFocus/index' import('../../packages/0/src/composables/useVirtualFocus/index') // @ts-ignore @@ -524,8 +491,7 @@ declare module 'vue' { interface ComponentCustomProperties { readonly COMMON_ELEMENTS: UnwrapRef readonly ClientAdapter: UnwrapRef - readonly ComboboxClientAdapter: UnwrapRef - readonly ComboboxServerAdapter: UnwrapRef + readonly ClientGridAdapter: UnwrapRef readonly ConsolaLoggerAdapter: UnwrapRef readonly DEFAULT_DARK: UnwrapRef readonly DEFAULT_LIGHT: UnwrapRef @@ -543,10 +509,12 @@ declare module 'vue' { readonly SUPPORTS_OBSERVER: UnwrapRef readonly SUPPORTS_TOUCH: UnwrapRef readonly ServerAdapter: UnwrapRef + readonly ServerGridAdapter: UnwrapRef readonly V0StyleSheetThemeAdapter: UnwrapRef readonly V0UnheadThemeAdapter: UnwrapRef readonly V0_ELEVATION_KEY: UnwrapRef readonly VirtualAdapter: UnwrapRef + readonly VirtualGridAdapter: UnwrapRef readonly Vuetify0LocaleAdapter: UnwrapRef readonly Vuetify0LoggerAdapter: UnwrapRef readonly Vuetify0RtlAdapter: UnwrapRef @@ -561,9 +529,9 @@ declare module 'vue' { readonly createBreakpoints: UnwrapRef readonly createBreakpointsContext: UnwrapRef readonly createBreakpointsPlugin: UnwrapRef - readonly createCombobox: UnwrapRef - readonly createComboboxContext: UnwrapRef readonly createContext: UnwrapRef + readonly createDataGrid: UnwrapRef + readonly createDataGridContext: UnwrapRef readonly createDataTable: UnwrapRef readonly createDataTableContext: UnwrapRef readonly createDate: UnwrapRef @@ -583,7 +551,6 @@ declare module 'vue' { readonly createHydration: UnwrapRef readonly createHydrationContext: UnwrapRef readonly createHydrationPlugin: UnwrapRef - readonly createInput: UnwrapRef readonly createLocale: UnwrapRef readonly createLocaleContext: UnwrapRef readonly createLocaleFallback: UnwrapRef @@ -597,8 +564,6 @@ declare module 'vue' { readonly createNotifications: UnwrapRef readonly createNotificationsContext: UnwrapRef readonly createNotificationsPlugin: UnwrapRef - readonly createNumberField: UnwrapRef - readonly createNumeric: UnwrapRef readonly createOverflow: UnwrapRef readonly createOverflowContext: UnwrapRef readonly createPagination: UnwrapRef @@ -608,12 +573,8 @@ declare module 'vue' { readonly createPermissionsPlugin: UnwrapRef readonly createPlugin: UnwrapRef readonly createPluginContext: UnwrapRef - readonly createProgress: UnwrapRef - readonly createProgressContext: UnwrapRef readonly createQueue: UnwrapRef readonly createQueueContext: UnwrapRef - readonly createRating: UnwrapRef - readonly createRatingContext: UnwrapRef readonly createRegistry: UnwrapRef readonly createRegistryContext: UnwrapRef readonly createRtl: UnwrapRef @@ -689,6 +650,7 @@ declare module 'vue' { readonly isUndefined: UnwrapRef readonly markRaw: UnwrapRef readonly mergeDeep: UnwrapRef + readonly multipleOpenStrategy: UnwrapRef readonly nextTick: UnwrapRef readonly onActivated: UnwrapRef readonly onBeforeMount: UnwrapRef @@ -713,14 +675,13 @@ declare module 'vue' { readonly ref: UnwrapRef readonly resolveComponent: UnwrapRef readonly resolveHeaders: UnwrapRef - readonly resolveIds: UnwrapRef - readonly resolveIndexes: UnwrapRef readonly rgbToHex: UnwrapRef readonly rgbToRgba: UnwrapRef readonly rgbaToHexa: UnwrapRef readonly shallowReactive: UnwrapRef readonly shallowReadonly: UnwrapRef readonly shallowRef: UnwrapRef + readonly singleOpenStrategy: UnwrapRef readonly toArray: UnwrapRef readonly toCamelCase: UnwrapRef readonly toElement: UnwrapRef @@ -738,11 +699,11 @@ declare module 'vue' { readonly useBreakpoints: UnwrapRef readonly useClickOutside: UnwrapRef readonly useColor: UnwrapRef - readonly useCombobox: UnwrapRef readonly useContext: UnwrapRef readonly useContrast: UnwrapRef readonly useCssModule: UnwrapRef readonly useCssVars: UnwrapRef + readonly useDataGrid: UnwrapRef readonly useDataTable: UnwrapRef readonly useDate: UnwrapRef readonly useDimensions: UnwrapRef @@ -758,7 +719,6 @@ declare module 'vue' { readonly useHotkey: UnwrapRef readonly useHydration: UnwrapRef readonly useId: UnwrapRef - readonly useImage: UnwrapRef readonly useIntersectionObserver: UnwrapRef readonly useLazy: UnwrapRef readonly useLocale: UnwrapRef @@ -775,13 +735,10 @@ declare module 'vue' { readonly usePrefersContrast: UnwrapRef readonly usePrefersDark: UnwrapRef readonly usePrefersReducedMotion: UnwrapRef - readonly usePresence: UnwrapRef - readonly useProgress: UnwrapRef readonly useProxyModel: UnwrapRef readonly useProxyRegistry: UnwrapRef readonly useQueue: UnwrapRef readonly useRaf: UnwrapRef - readonly useRating: UnwrapRef readonly useRegistry: UnwrapRef readonly useResizeObserver: UnwrapRef readonly useRounded: UnwrapRef diff --git a/packages/0/src/composables/createDataGrid/adapters/client.ts b/packages/0/src/composables/createDataGrid/adapters/client.ts index 0896de545..7f45345b9 100644 --- a/packages/0/src/composables/createDataGrid/adapters/client.ts +++ b/packages/0/src/composables/createDataGrid/adapters/client.ts @@ -20,6 +20,8 @@ import type { ID } from '#v0/types' import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' import type { ShallowRef } from 'vue' +import { applyOrder } from './order' + export class ClientGridAdapter> extends DataTableAdapter { private rowOrder: ShallowRef private itemKey: string @@ -38,27 +40,7 @@ export class ClientGridAdapter> extends DataTa // Row ordering: applied post-sort, pre-pagination const orderedItems = computed(() => { - const order = this.rowOrder.value - if (order.length === 0) return sortedItems.value - - const map = new Map() - for (const item of sortedItems.value) { - map.set(item[this.itemKey] as ID, item) - } - - const result: T[] = [] - for (const id of order) { - const item = map.get(id) - if (item) result.push(item) - } - - for (const item of sortedItems.value) { - if (!order.includes(item[this.itemKey] as ID)) { - result.push(item) - } - } - - return result + return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) }) const pagination = createPagination({ diff --git a/packages/0/src/composables/createDataGrid/adapters/order.ts b/packages/0/src/composables/createDataGrid/adapters/order.ts new file mode 100644 index 000000000..10be35348 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/order.ts @@ -0,0 +1,31 @@ +// Types +import type { ID } from '#v0/types' + +/** Reorder items by an ID-based order, appending unmatched items at the end. */ +export 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 +} diff --git a/packages/0/src/composables/createDataGrid/adapters/virtual.ts b/packages/0/src/composables/createDataGrid/adapters/virtual.ts index 344f64f70..c07f52fc8 100644 --- a/packages/0/src/composables/createDataGrid/adapters/virtual.ts +++ b/packages/0/src/composables/createDataGrid/adapters/virtual.ts @@ -20,6 +20,8 @@ import type { ID } from '#v0/types' import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' import type { ShallowRef } from 'vue' +import { applyOrder } from './order' + export class VirtualGridAdapter> extends DataTableAdapter { private rowOrder: ShallowRef private itemKey: string @@ -37,27 +39,7 @@ export class VirtualGridAdapter> extends DataT const sortedItems = this.sort(filteredItems, sortBy, locale, customSorts) const orderedItems = computed(() => { - const order = this.rowOrder.value - if (order.length === 0) return sortedItems.value - - const map = new Map() - for (const item of sortedItems.value) { - map.set(item[this.itemKey] as ID, item) - } - - const result: T[] = [] - for (const id of order) { - const item = map.get(id) - if (item) result.push(item) - } - - for (const item of sortedItems.value) { - if (!order.includes(item[this.itemKey] as ID)) { - result.push(item) - } - } - - return result + return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) }) const size = toRef(() => orderedItems.value.length) diff --git a/packages/0/src/composables/createDataGrid/index.test.ts b/packages/0/src/composables/createDataGrid/index.test.ts index a6f4bb33e..9fe433d27 100644 --- a/packages/0/src/composables/createDataGrid/index.test.ts +++ b/packages/0/src/composables/createDataGrid/index.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -// Utilities -import { inject, provide } from 'vue' - import { createDataGrid } from './index' vi.mock('vue', async () => { @@ -14,9 +11,6 @@ vi.mock('vue', async () => { } }) -vi.mocked(provide) -vi.mocked(inject) - 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' }, diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts index 452571eab..b11243cc7 100644 --- a/packages/0/src/composables/createDataGrid/index.ts +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -31,7 +31,7 @@ import type { VirtualOptions } from '#v0/composables/createVirtual' import type { ID } from '#v0/types' import type { CellEditing } from './editing' import type { ColumnLayout, GridColumnDef } from './layout' -import type { RowSpanningOptions, SpanEntry } from './spanning' +import type { SpanEntry } from './spanning' import type { App, ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue' // Grid modules @@ -122,16 +122,10 @@ export function createDataGrid> ( rowSpanning, } = options - // 1. Extract leaves from possibly nested column definitions const leaves = extractLeaves(columns) - - // 2. Create row ordering state const ordering = createRowOrdering() - - // 3. Create adapter: use ClientGridAdapter (closes over ordering) unless custom provided const adapter = customAdapter ?? new ClientGridAdapter(ordering.order, itemValue) - // 4. Create the data table with the grid adapter const table = createDataTable({ items, columns, @@ -142,25 +136,20 @@ export function createDataGrid> ( adapter, }) - // 5. Watch sort changes to reset row order (unless preserveRowOrder) if (!preserveRowOrder) { watch(table.sort.columns, () => { ordering.reset() }) } - // 6. Create column layout const layout = createColumnLayout(columns) - - // 7. Resolve headers (toRef wrapping resolveHeaders) const headers = toRef(() => resolveHeaders(columns)) - // 8. Create cell editing const editableColumns = leaves - .filter(col => col.editable !== undefined || col.validate !== undefined) + .filter(col => col.editable === true || typeof col.editable === 'function') .map(col => ({ key: col.key, - editable: col.editable as boolean | ((item: unknown) => boolean) | undefined, + editable: col.editable as boolean | ((item: unknown) => boolean), validate: col.validate as ((value: unknown, item?: unknown) => boolean | string) | undefined, })) @@ -176,19 +165,12 @@ export function createDataGrid> ( : undefined, }) - // 9. Create row spanning - const columnKeys = leaves.map(col => col.key) - - const spanOptions: RowSpanningOptions = { + const spans = createRowSpanning({ items: table.items as Ref, - columns: columnKeys, + columns: leaves.map(col => col.key), itemKey: itemValue, rowSpanning, - } - - const spans = createRowSpanning(spanOptions) - - // 10. Return merged context (table + grid features) + }) return { ...table, layout, diff --git a/packages/0/src/composables/createDataGrid/layout.ts b/packages/0/src/composables/createDataGrid/layout.ts index e0dfda8c1..fd97b93ca 100644 --- a/packages/0/src/composables/createDataGrid/layout.ts +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -172,18 +172,8 @@ export function createColumnLayout (defs: readonly GridColumnDef[]): ColumnLayou }) const columns = toRef((): ResolvedColumn[] => { - const r = resolved() - const region = pinned.value - // Return all columns in display order with offsets already set - return order.value.map(key => r.get(key)!).map(col => { - // Offsets are set by splitRegions — return updated col - const regions = [region.left, region.scrollable, region.right] - for (const reg of regions) { - const found = reg.find(c => c.key === col.key) - if (found) return found - } - return col - }) + const { left, scrollable, right } = pinned.value + return [...left, ...scrollable, ...right] }) function pin (key: string, position: PinPosition) { diff --git a/packages/0/src/composables/createDataGrid/ordering.ts b/packages/0/src/composables/createDataGrid/ordering.ts index 2203ed542..e0204046c 100644 --- a/packages/0/src/composables/createDataGrid/ordering.ts +++ b/packages/0/src/composables/createDataGrid/ordering.ts @@ -60,9 +60,10 @@ export function createRowOrdering (): RowOrdering { if (item) result.push(item) } - // Append any items not in the order (new items added after reorder) + // Append items not in the order (new items added after reorder) + const ordered = new Set(order.value) for (const item of items) { - if (!order.value.includes(item[itemKey] as ID)) { + if (!ordered.has(item[itemKey] as ID)) { result.push(item) } } From a17112e15788e4f4bad6a846c6ef4f29fd19a413 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 11 Apr 2026 22:38:36 -0500 Subject: [PATCH 09/38] refactor(createDataGrid): fix validation, editing, and pattern compliance - Fix validate(false) silently passing as valid in cell editing - Evaluate editable functions with item context via itemLookup - Pass item to validate for context-aware validation - Type-safe itemValue using KeysOfType instead of string - Guard onEdit callback against undefined item - Delegate ordering.apply() to applyOrder to eliminate duplication - Add same-index and bounds guards to move/reorder - Remove redundant headers from DataGridContext - Use #v0/ path aliases in grid adapters - Use isFunction type guard instead of typeof - Add missing JSDoc to exported functions and barrel files --- .../createDataGrid/adapters/adapter.ts | 2 +- .../createDataGrid/adapters/client.ts | 6 ++-- .../createDataGrid/adapters/index.ts | 9 ++++++ .../createDataGrid/adapters/order.ts | 18 ++++++++++- .../createDataGrid/adapters/server.ts | 2 +- .../createDataGrid/adapters/virtual.ts | 6 ++-- .../src/composables/createDataGrid/editing.ts | 25 +++++++++++---- .../0/src/composables/createDataGrid/index.ts | 32 ++++++++++--------- .../src/composables/createDataGrid/layout.ts | 4 ++- .../composables/createDataGrid/ordering.ts | 32 ++++++------------- .../composables/createDataGrid/spanning.ts | 6 ++++ 11 files changed, 87 insertions(+), 55 deletions(-) diff --git a/packages/0/src/composables/createDataGrid/adapters/adapter.ts b/packages/0/src/composables/createDataGrid/adapters/adapter.ts index 14199fecc..3e9603729 100644 --- a/packages/0/src/composables/createDataGrid/adapters/adapter.ts +++ b/packages/0/src/composables/createDataGrid/adapters/adapter.ts @@ -12,4 +12,4 @@ export type { DataTableAdapterResult, SortDirection, SortEntry, -} from '../../createDataTable/adapters/adapter' +} from '#v0/composables/createDataTable' diff --git a/packages/0/src/composables/createDataGrid/adapters/client.ts b/packages/0/src/composables/createDataGrid/adapters/client.ts index 7f45345b9..bb2c0a2a9 100644 --- a/packages/0/src/composables/createDataGrid/adapters/client.ts +++ b/packages/0/src/composables/createDataGrid/adapters/client.ts @@ -7,17 +7,15 @@ */ // Composables +import { DataTableAdapter } from '#v0/composables/createDataTable' import { createPagination } from '#v0/composables/createPagination' -// Adapters -import { DataTableAdapter } from '../../createDataTable/adapters/adapter' - // Utilities import { computed, toRef, watch } from 'vue' // Types +import type { DataTableAdapterContext, DataTableAdapterResult } from '#v0/composables/createDataTable' import type { ID } from '#v0/types' -import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' import type { ShallowRef } from 'vue' import { applyOrder } from './order' diff --git a/packages/0/src/composables/createDataGrid/adapters/index.ts b/packages/0/src/composables/createDataGrid/adapters/index.ts index 335039d8e..f2cab323b 100644 --- a/packages/0/src/composables/createDataGrid/adapters/index.ts +++ b/packages/0/src/composables/createDataGrid/adapters/index.ts @@ -1,3 +1,12 @@ +/** + * @module createDataGrid/adapters + * + * @remarks + * Barrel for grid adapter exports. Each grid adapter extends the + * corresponding DataTable adapter to insert row ordering between + * sort and pagination. + */ + export type { DataTableAdapterContext, DataTableAdapterInterface, DataTableAdapterResult, SortDirection, SortEntry } from './adapter' export { ClientGridAdapter } from './client' export { ServerGridAdapter } from './server' diff --git a/packages/0/src/composables/createDataGrid/adapters/order.ts b/packages/0/src/composables/createDataGrid/adapters/order.ts index 10be35348..f105452ef 100644 --- a/packages/0/src/composables/createDataGrid/adapters/order.ts +++ b/packages/0/src/composables/createDataGrid/adapters/order.ts @@ -1,7 +1,23 @@ +/** + * @module createDataGrid/adapters/order + * + * @remarks + * Utility for applying an ID-based row ordering to a list of items. + * Used by both ClientGridAdapter and VirtualGridAdapter to insert + * row ordering between sort and pagination stages. + */ + // Types import type { ID } from '#v0/types' -/** Reorder items by an ID-based order, appending unmatched items at the end. */ +/** + * Reorder items by an ID-based order, appending unmatched items at the end. + * + * @param items Source items to reorder + * @param order ID sequence defining the desired order + * @param itemKey Property name used to extract item IDs + * @returns Reordered items array + */ export function applyOrder> ( items: readonly T[], order: readonly ID[], diff --git a/packages/0/src/composables/createDataGrid/adapters/server.ts b/packages/0/src/composables/createDataGrid/adapters/server.ts index 07959321f..a9a838ddf 100644 --- a/packages/0/src/composables/createDataGrid/adapters/server.ts +++ b/packages/0/src/composables/createDataGrid/adapters/server.ts @@ -8,4 +8,4 @@ // Types -export { ServerAdapter as ServerGridAdapter, type ServerAdapterOptions as ServerGridAdapterOptions } from '../../createDataTable/adapters/server' +export { ServerAdapter as ServerGridAdapter, type ServerAdapterOptions as ServerGridAdapterOptions } from '#v0/composables/createDataTable' diff --git a/packages/0/src/composables/createDataGrid/adapters/virtual.ts b/packages/0/src/composables/createDataGrid/adapters/virtual.ts index c07f52fc8..ab2544ea8 100644 --- a/packages/0/src/composables/createDataGrid/adapters/virtual.ts +++ b/packages/0/src/composables/createDataGrid/adapters/virtual.ts @@ -7,17 +7,15 @@ */ // Composables +import { DataTableAdapter } from '#v0/composables/createDataTable' import { createPagination } from '#v0/composables/createPagination' -// Adapters -import { DataTableAdapter } from '../../createDataTable/adapters/adapter' - // Utilities import { computed, toRef, watch } from 'vue' // Types +import type { DataTableAdapterContext, DataTableAdapterResult } from '#v0/composables/createDataTable' import type { ID } from '#v0/types' -import type { DataTableAdapterContext, DataTableAdapterResult } from '../../createDataTable/adapters/adapter' import type { ShallowRef } from 'vue' import { applyOrder } from './order' diff --git a/packages/0/src/composables/createDataGrid/editing.ts b/packages/0/src/composables/createDataGrid/editing.ts index e33b750c2..1a9ff2d07 100644 --- a/packages/0/src/composables/createDataGrid/editing.ts +++ b/packages/0/src/composables/createDataGrid/editing.ts @@ -8,7 +8,7 @@ */ // Utilities -import { isString } from '#v0/utilities' +import { isFunction, isString } from '#v0/utilities' import { ref, shallowRef } from 'vue' // Types @@ -24,6 +24,7 @@ export interface EditableColumn { export interface CellEditingOptions { columns: readonly EditableColumn[] onEdit?: (row: ID, column: string, value: unknown) => void + itemLookup?: (row: ID) => unknown } export interface ActiveCell { @@ -40,8 +41,14 @@ export interface CellEditing { 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 + */ export function createCellEditing (options: CellEditingOptions): CellEditing { - const { columns, onEdit } = options + const { columns, onEdit, itemLookup } = options const columnMap = new Map() for (const col of columns) { @@ -54,7 +61,12 @@ export function createCellEditing (options: CellEditingOptions): CellEditing { function edit (row: ID, column: string) { const col = columnMap.get(column) - if (!col || col.editable === false || col.editable === undefined) { + if (!col) return + + if (isFunction(col.editable)) { + const item = itemLookup?.(row) + if (!col.editable(item)) return + } else if (col.editable !== true) { return } error.value = null @@ -70,9 +82,10 @@ export function createCellEditing (options: CellEditingOptions): CellEditing { const col = columnMap.get(cell.column) if (col?.validate) { - const result = col.validate(value) - if (isString(result)) { - error.value = result + const item = itemLookup?.(cell.row) + const result = col.validate(value, item) + if (result !== true) { + error.value = isString(result) ? result : 'Invalid value' return } } diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts index b11243cc7..728aff67f 100644 --- a/packages/0/src/composables/createDataGrid/index.ts +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -12,18 +12,18 @@ // Composables import { createContext, useContext } from '#v0/composables/createContext' import { createDataTable } from '#v0/composables/createDataTable' -import { extractLeaves, resolveHeaders } from '#v0/composables/createDataTable/columns' +import { extractLeaves } from '#v0/composables/createDataTable/columns' import { createTrinity } from '#v0/composables/createTrinity' // Adapters import { ClientGridAdapter } from './adapters' // Utilities -import { toRef, watch } from 'vue' +import { isFunction } from '#v0/utilities' +import { watch } from 'vue' // Types -import type { DataTableAdapterInterface, DataTableContext } from '#v0/composables/createDataTable' -import type { InternalHeader } from '#v0/composables/createDataTable/columns' +import type { DataTableAdapterInterface, DataTableContext, KeysOfType } from '#v0/composables/createDataTable' import type { FilterOptions } from '#v0/composables/createFilter' import type { PaginationOptions } from '#v0/composables/createPagination' import type { ContextTrinity } from '#v0/composables/createTrinity' @@ -64,7 +64,7 @@ export interface DataGridColumn = Record> { items: MaybeRefOrGetter columns: readonly DataGridColumn[] - itemValue?: string + itemValue?: KeysOfType adapter?: DataTableAdapterInterface filter?: Omit pagination?: Omit @@ -90,7 +90,6 @@ export interface DataGridContext> extends Data reset: () => void } editing: CellEditing - headers: Readonly> spans: ComputedRef>> virtual: null } @@ -112,7 +111,7 @@ export function createDataGrid> ( const { items, columns, - itemValue = 'id', + itemValue = 'id' as KeysOfType, adapter: customAdapter, filter, pagination, @@ -129,7 +128,7 @@ export function createDataGrid> ( const table = createDataTable({ items, columns, - itemValue: itemValue as never, + itemValue, filter, pagination, sortMultiple, @@ -143,24 +142,28 @@ export function createDataGrid> ( } const layout = createColumnLayout(columns) - const headers = toRef(() => resolveHeaders(columns)) const editableColumns = leaves - .filter(col => col.editable === true || typeof col.editable === 'function') + .filter(col => col.editable === true || isFunction(col.editable)) .map(col => ({ key: col.key, editable: col.editable as boolean | ((item: unknown) => boolean), validate: col.validate as ((value: unknown, item?: unknown) => boolean | string) | undefined, })) + function itemLookup (row: ID): T | undefined { + return table.allItems.value.find( + i => (i[itemValue] as ID) === row, + ) + } + const editing = createCellEditing({ columns: editableColumns, + itemLookup, onEdit: editingOptions?.onEdit ? (row, column, value) => { - const item = table.allItems.value.find( - i => (i[itemValue] as ID) === row, - ) as T | undefined - editingOptions.onEdit!(row, column, value, item as T) + const item = itemLookup(row) + if (item) editingOptions.onEdit!(row, column, value, item) } : undefined, }) @@ -180,7 +183,6 @@ export function createDataGrid> ( reset: ordering.reset, }, editing, - headers, spans, virtual: null, } diff --git a/packages/0/src/composables/createDataGrid/layout.ts b/packages/0/src/composables/createDataGrid/layout.ts index fd97b93ca..41617a870 100644 --- a/packages/0/src/composables/createDataGrid/layout.ts +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -209,9 +209,11 @@ export function createColumnLayout (defs: readonly GridColumnDef[]): ColumnLayou } function reorder (from: number, to: number) { + if (from === to) return const arr = [...order.value] + if (from < 0 || from >= arr.length) return + if (to < 0 || to >= arr.length) return const [item] = arr.splice(from, 1) - if (item === undefined) return arr.splice(to, 0, item) order.value = arr } diff --git a/packages/0/src/composables/createDataGrid/ordering.ts b/packages/0/src/composables/createDataGrid/ordering.ts index e0204046c..e199987ec 100644 --- a/packages/0/src/composables/createDataGrid/ordering.ts +++ b/packages/0/src/composables/createDataGrid/ordering.ts @@ -7,6 +7,9 @@ * this module only manages ordering state. */ +// Adapters +import { applyOrder } from './adapters/order' + // Utilities import { shallowRef } from 'vue' @@ -22,6 +25,11 @@ export interface RowOrdering { apply: >(items: readonly T[], itemKey: string) => readonly T[] } +/** + * Creates row ordering state for a data grid. + * + * @returns Row ordering state and mutation methods + */ export function createRowOrdering (): RowOrdering { const order = shallowRef([]) @@ -30,6 +38,7 @@ export function createRowOrdering (): RowOrdering { } function move (fromIndex: number, toIndex: number) { + if (fromIndex === toIndex) return const arr = [...order.value] if (fromIndex < 0 || fromIndex >= arr.length) return if (toIndex < 0 || toIndex >= arr.length) return @@ -47,28 +56,7 @@ export function createRowOrdering (): RowOrdering { items: readonly T[], itemKey: string, ): readonly T[] { - if (order.value.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.value) { - const item = map.get(id) - if (item) result.push(item) - } - - // Append items not in the order (new items added after reorder) - const ordered = new Set(order.value) - for (const item of items) { - if (!ordered.has(item[itemKey] as ID)) { - result.push(item) - } - } - - return result + return applyOrder(items, order.value, itemKey) } return { diff --git a/packages/0/src/composables/createDataGrid/spanning.ts b/packages/0/src/composables/createDataGrid/spanning.ts index a64bbbcbe..0a415769f 100644 --- a/packages/0/src/composables/createDataGrid/spanning.ts +++ b/packages/0/src/composables/createDataGrid/spanning.ts @@ -26,6 +26,12 @@ export interface RowSpanningOptions> { 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>> { From 8a0672c281d609dd3560ed0bbdc63598a4fa62b5 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 11 Apr 2026 22:41:54 -0500 Subject: [PATCH 10/38] fix(createDataGrid): use isUndefined guard for item check --- packages/0/src/composables/createDataGrid/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts index 728aff67f..c61f1759a 100644 --- a/packages/0/src/composables/createDataGrid/index.ts +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -19,7 +19,7 @@ import { createTrinity } from '#v0/composables/createTrinity' import { ClientGridAdapter } from './adapters' // Utilities -import { isFunction } from '#v0/utilities' +import { isFunction, isUndefined } from '#v0/utilities' import { watch } from 'vue' // Types @@ -163,7 +163,7 @@ export function createDataGrid> ( onEdit: editingOptions?.onEdit ? (row, column, value) => { const item = itemLookup(row) - if (item) editingOptions.onEdit!(row, column, value, item) + if (!isUndefined(item)) editingOptions.onEdit!(row, column, value, item) } : undefined, }) From baca64333489f4b68e0cb6a592bcaad4e8dc1d96 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 11 Apr 2026 22:51:14 -0500 Subject: [PATCH 11/38] refactor(createDataGrid): shorten multi-word variable names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefer single-word names per style convention: - customSorts → sorts, customColumnFilters → filters - openGroupKeys → opened - editableColumns → editable - itemLookup → lookup - rowDirty → entry, cellMap → cells - fromIndex/toIndex → from/to - orderedItems → ordered - splitRegions → split, regionIndex → index --- .../createDataGrid/adapters/client.ts | 10 +++++----- .../createDataGrid/adapters/virtual.ts | 10 +++++----- .../0/src/composables/createDataGrid/editing.ts | 16 ++++++++-------- .../0/src/composables/createDataGrid/index.ts | 10 +++++----- .../0/src/composables/createDataGrid/layout.ts | 12 ++++++------ .../0/src/composables/createDataGrid/ordering.ts | 14 +++++++------- .../0/src/composables/createDataGrid/spanning.ts | 8 ++++---- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/0/src/composables/createDataGrid/adapters/client.ts b/packages/0/src/composables/createDataGrid/adapters/client.ts index bb2c0a2a9..c1ba00ed4 100644 --- a/packages/0/src/composables/createDataGrid/adapters/client.ts +++ b/packages/0/src/composables/createDataGrid/adapters/client.ts @@ -37,17 +37,17 @@ export class ClientGridAdapter> extends DataTa const sortedItems = this.sort(filteredItems, sortBy, locale, customSorts) // Row ordering: applied post-sort, pre-pagination - const orderedItems = computed(() => { + const ordered = computed(() => { return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) }) const pagination = createPagination({ ...paginationOptions, - size: toRef(() => orderedItems.value.length), + size: toRef(() => ordered.value.length), }) const items = computed(() => { - return orderedItems.value.slice(pagination.pageStart.value, pagination.pageStop.value) + return ordered.value.slice(pagination.pageStart.value, pagination.pageStop.value) }) watch([search, sortBy], () => { @@ -57,10 +57,10 @@ export class ClientGridAdapter> extends DataTa return { allItems, filteredItems, - sortedItems: orderedItems, + sortedItems: ordered, items, pagination, - total: toRef(() => orderedItems.value.length), + total: toRef(() => ordered.value.length), } } } diff --git a/packages/0/src/composables/createDataGrid/adapters/virtual.ts b/packages/0/src/composables/createDataGrid/adapters/virtual.ts index ab2544ea8..cc80bb5ac 100644 --- a/packages/0/src/composables/createDataGrid/adapters/virtual.ts +++ b/packages/0/src/composables/createDataGrid/adapters/virtual.ts @@ -36,11 +36,11 @@ export class VirtualGridAdapter> extends DataT const { allItems, filteredItems } = this.filter(context) const sortedItems = this.sort(filteredItems, sortBy, locale, customSorts) - const orderedItems = computed(() => { + const ordered = computed(() => { return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) }) - const size = toRef(() => orderedItems.value.length) + const size = toRef(() => ordered.value.length) const pagination = createPagination({ size, @@ -54,10 +54,10 @@ export class VirtualGridAdapter> extends DataT return { allItems, filteredItems, - sortedItems: orderedItems, - items: orderedItems, + sortedItems: ordered, + items: ordered, pagination, - total: toRef(() => orderedItems.value.length), + total: toRef(() => ordered.value.length), } } } diff --git a/packages/0/src/composables/createDataGrid/editing.ts b/packages/0/src/composables/createDataGrid/editing.ts index 1a9ff2d07..9ffbd6ca4 100644 --- a/packages/0/src/composables/createDataGrid/editing.ts +++ b/packages/0/src/composables/createDataGrid/editing.ts @@ -24,7 +24,7 @@ export interface EditableColumn { export interface CellEditingOptions { columns: readonly EditableColumn[] onEdit?: (row: ID, column: string, value: unknown) => void - itemLookup?: (row: ID) => unknown + lookup?: (row: ID) => unknown } export interface ActiveCell { @@ -48,7 +48,7 @@ export interface CellEditing { * @returns Cell editing state and controls */ export function createCellEditing (options: CellEditingOptions): CellEditing { - const { columns, onEdit, itemLookup } = options + const { columns, onEdit, lookup } = options const columnMap = new Map() for (const col of columns) { @@ -64,7 +64,7 @@ export function createCellEditing (options: CellEditingOptions): CellEditing { if (!col) return if (isFunction(col.editable)) { - const item = itemLookup?.(row) + const item = lookup?.(row) if (!col.editable(item)) return } else if (col.editable !== true) { return @@ -82,7 +82,7 @@ export function createCellEditing (options: CellEditingOptions): CellEditing { const col = columnMap.get(cell.column) if (col?.validate) { - const item = itemLookup?.(cell.row) + const item = lookup?.(cell.row) const result = col.validate(value, item) if (result !== true) { error.value = isString(result) ? result : 'Invalid value' @@ -93,10 +93,10 @@ export function createCellEditing (options: CellEditingOptions): CellEditing { onEdit?.(cell.row, cell.column, value) // Clear dirty entry for this cell - const rowDirty = dirty.value.get(cell.row) - if (rowDirty) { - rowDirty.delete(cell.column) - if (rowDirty.size === 0) dirty.value.delete(cell.row) + const entry = dirty.value.get(cell.row) + if (entry) { + entry.delete(cell.column) + if (entry.size === 0) dirty.value.delete(cell.row) } error.value = null diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts index c61f1759a..c3dc0343c 100644 --- a/packages/0/src/composables/createDataGrid/index.ts +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -143,7 +143,7 @@ export function createDataGrid> ( const layout = createColumnLayout(columns) - const editableColumns = leaves + const editable = leaves .filter(col => col.editable === true || isFunction(col.editable)) .map(col => ({ key: col.key, @@ -151,18 +151,18 @@ export function createDataGrid> ( validate: col.validate as ((value: unknown, item?: unknown) => boolean | string) | undefined, })) - function itemLookup (row: ID): T | undefined { + function lookup (row: ID): T | undefined { return table.allItems.value.find( i => (i[itemValue] as ID) === row, ) } const editing = createCellEditing({ - columns: editableColumns, - itemLookup, + columns: editable, + lookup, onEdit: editingOptions?.onEdit ? (row, column, value) => { - const item = itemLookup(row) + const item = lookup(row) if (!isUndefined(item)) editingOptions.onEdit!(row, column, value, item) } : undefined, diff --git a/packages/0/src/composables/createDataGrid/layout.ts b/packages/0/src/composables/createDataGrid/layout.ts index 41617a870..40d19c3fa 100644 --- a/packages/0/src/composables/createDataGrid/layout.ts +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -104,7 +104,7 @@ function computeOffsets (cols: ResolvedColumn[]): void { } } -function splitRegions (keys: string[], resolved: Map): PinnedRegion { +function split (keys: string[], resolved: Map): PinnedRegion { const left: ResolvedColumn[] = [] const scrollable: ResolvedColumn[] = [] const right: ResolvedColumn[] = [] @@ -168,7 +168,7 @@ export function createColumnLayout (defs: readonly GridColumnDef[]): ColumnLayou } const pinned = toRef((): PinnedRegion => { - return splitRegions(order.value, resolved()) + return split(order.value, resolved()) }) const columns = toRef((): ResolvedColumn[] => { @@ -193,11 +193,11 @@ export function createColumnLayout (defs: readonly GridColumnDef[]): ColumnLayou else if (col.pinned === 'right') group = region.right else group = region.scrollable - const regionIndex = group.findIndex(c => c.key === key) - if (regionIndex === -1 || regionIndex === group.length - 1) return + const index = group.findIndex(c => c.key === key) + if (index === -1 || index === group.length - 1) return - const target = group[regionIndex]! - const neighbor = group[regionIndex + 1]! + const target = group[index]! + const neighbor = group[index + 1]! const total = target.size + neighbor.size const lower = Math.max(target.minSize, total - neighbor.maxSize) diff --git a/packages/0/src/composables/createDataGrid/ordering.ts b/packages/0/src/composables/createDataGrid/ordering.ts index e199987ec..95c867bd3 100644 --- a/packages/0/src/composables/createDataGrid/ordering.ts +++ b/packages/0/src/composables/createDataGrid/ordering.ts @@ -20,7 +20,7 @@ import type { ShallowRef } from 'vue' export interface RowOrdering { order: Readonly> initialize: (ids: ID[]) => void - move: (fromIndex: number, toIndex: number) => void + move: (from: number, to: number) => void reset: () => void apply: >(items: readonly T[], itemKey: string) => readonly T[] } @@ -37,14 +37,14 @@ export function createRowOrdering (): RowOrdering { order.value = [...ids] } - function move (fromIndex: number, toIndex: number) { - if (fromIndex === toIndex) return + function move (from: number, to: number) { + if (from === to) return const arr = [...order.value] - if (fromIndex < 0 || fromIndex >= arr.length) return - if (toIndex < 0 || toIndex >= arr.length) return + if (from < 0 || from >= arr.length) return + if (to < 0 || to >= arr.length) return - const [moved] = arr.splice(fromIndex, 1) - arr.splice(toIndex, 0, moved) + const [moved] = arr.splice(from, 1) + arr.splice(to, 0, moved) order.value = arr } diff --git a/packages/0/src/composables/createDataGrid/spanning.ts b/packages/0/src/composables/createDataGrid/spanning.ts index 0a415769f..ba776bd46 100644 --- a/packages/0/src/composables/createDataGrid/spanning.ts +++ b/packages/0/src/composables/createDataGrid/spanning.ts @@ -51,25 +51,25 @@ export function createRowSpanning> ( for (let row = 0; row < list.length; row++) { const item = list[row] const id = item[itemKey] as ID - const cellMap = new Map() + const cells = new Map() for (const [col, column] of columns.entries()) { if (covered[col] > 0) { - cellMap.set(column, { rowSpan: 1, hidden: true }) + 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 ) - cellMap.set(column, { rowSpan: span, hidden: false }) + cells.set(column, { rowSpan: span, hidden: false }) if (span > 1) { covered[col] = span - 1 } } } - result.set(id, cellMap) + result.set(id, cells) } return result From 99d657b5eae102d52d5247e455d6fe08d6a0dee8 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 11 Apr 2026 23:06:20 -0500 Subject: [PATCH 12/38] docs(createDataGrid): add documentation page with examples - Usage section with basic grid example (search, sort, pagination, column sizing) - Adapters section covering ClientGridAdapter, ServerGridAdapter, VirtualGridAdapter - Features: column layout, cell editing, row ordering, row spanning, nested columns - Reactivity table - Pinned grid example with resize handles and pin/unpin controls - Editable grid example with inline validation and edit log - Spanning grid example with merged department cells --- .../create-data-grid/basic/BasicGrid.vue | 95 +++++ .../create-data-grid/basic/columns.ts | 10 + .../create-data-grid/basic/data.ts | 21 ++ .../create-data-grid/editing/EditableGrid.vue | 128 +++++++ .../create-data-grid/editing/columns.ts | 10 + .../create-data-grid/editing/data.ts | 1 + .../create-data-grid/pinned/PinnedGrid.vue | 142 +++++++ .../create-data-grid/pinned/columns.ts | 11 + .../create-data-grid/pinned/data.ts | 1 + .../spanning/SpanningGrid.vue | 60 +++ .../create-data-grid/spanning/columns.ts | 8 + .../create-data-grid/spanning/data.ts | 17 + .../composables/data/create-data-grid.md | 350 ++++++++++++++++++ 13 files changed, 854 insertions(+) create mode 100644 apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue create mode 100644 apps/docs/src/examples/composables/create-data-grid/basic/columns.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/basic/data.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/editing/EditableGrid.vue create mode 100644 apps/docs/src/examples/composables/create-data-grid/editing/columns.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/editing/data.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/pinned/PinnedGrid.vue create mode 100644 apps/docs/src/examples/composables/create-data-grid/pinned/columns.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/pinned/data.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/spanning/SpanningGrid.vue create mode 100644 apps/docs/src/examples/composables/create-data-grid/spanning/columns.ts create mode 100644 apps/docs/src/examples/composables/create-data-grid/spanning/data.ts create mode 100644 apps/docs/src/pages/composables/data/create-data-grid.md diff --git a/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue b/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue new file mode 100644 index 000000000..07225b7ea --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue @@ -0,0 +1,95 @@ + + + diff --git a/apps/docs/src/examples/composables/create-data-grid/basic/columns.ts b/apps/docs/src/examples/composables/create-data-grid/basic/columns.ts new file mode 100644 index 000000000..acd52f502 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/columns.ts @@ -0,0 +1,10 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Employee } from './data' + +export const columns: DataGridColumn[] = [ + { key: 'name', title: 'Name', sortable: true, filterable: true, size: 25 }, + { key: 'email', title: 'Email', sortable: true, filterable: true, size: 30 }, + { key: 'department', title: 'Department', sortable: true, size: 20 }, + { key: 'role', title: 'Role', sortable: true, size: 15 }, + { key: 'salary', title: 'Salary', sortable: true, size: 10, sort: (a, b) => Number(a) - Number(b) }, +] diff --git a/apps/docs/src/examples/composables/create-data-grid/basic/data.ts b/apps/docs/src/examples/composables/create-data-grid/basic/data.ts new file mode 100644 index 000000000..4df281164 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/data.ts @@ -0,0 +1,21 @@ +export type Employee = { + id: number + name: string + email: string + department: string + role: string + salary: number +} + +export const employees: Employee[] = [ + { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering', role: 'Lead', salary: 145_000 }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Engineering', role: 'Senior', salary: 130_000 }, + { id: 3, name: 'Carol Davis', email: 'carol@example.com', department: 'Design', role: 'Lead', salary: 125_000 }, + { id: 4, name: 'Dan Wilson', email: 'dan@example.com', department: 'Design', role: 'Senior', salary: 115_000 }, + { id: 5, name: 'Eve Martinez', email: 'eve@example.com', department: 'Marketing', role: 'Director', salary: 140_000 }, + { id: 6, name: 'Frank Lee', email: 'frank@example.com', department: 'Engineering', role: 'Junior', salary: 95_000 }, + { id: 7, name: 'Grace Kim', email: 'grace@example.com', department: 'Marketing', role: 'Senior', salary: 110_000 }, + { id: 8, name: 'Henry Chen', email: 'henry@example.com', department: 'Design', role: 'Junior', salary: 90_000 }, + { id: 9, name: 'Iris Patel', email: 'iris@example.com', department: 'Engineering', role: 'Senior', salary: 135_000 }, + { id: 10, name: 'Jack Brown', email: 'jack@example.com', department: 'Marketing', role: 'Junior', salary: 85_000 }, +] 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..9fee057e2 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/editing/EditableGrid.vue @@ -0,0 +1,128 @@ + + + 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..80ef936ce --- /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 { Employee } from './data' + +export const columns: DataGridColumn[] = [ + { key: 'name', title: 'Name', size: 25, editable: true, validate: v => (typeof v === 'string' && v.length > 0) || 'Name is required' }, + { key: 'email', title: 'Email', size: 30, editable: true, validate: v => (typeof v === 'string' && v.includes('@')) || 'Invalid email' }, + { key: 'department', title: 'Dept', size: 20 }, + { key: 'role', title: 'Role', size: 15 }, + { key: 'salary', title: 'Salary', size: 10 }, +] 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..3f64f262e --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/editing/data.ts @@ -0,0 +1 @@ +export { type Employee, employees } from '../basic/data' 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..4a09e8e95 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/PinnedGrid.vue @@ -0,0 +1,142 @@ + + + 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..35e2de6b0 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/columns.ts @@ -0,0 +1,11 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Employee } from './data' + +export const columns: DataGridColumn[] = [ + { key: 'name', title: 'Name', sortable: true, size: 20, pinned: 'left' }, + { key: 'email', title: 'Email', sortable: true, size: 25 }, + { key: 'department', title: 'Dept', sortable: true, size: 15 }, + { key: 'role', title: 'Role', sortable: true, size: 15 }, + { key: 'salary', title: 'Salary', sortable: true, size: 15, sort: (a, b) => Number(a) - Number(b) }, + { key: 'id', title: 'ID', size: 10, 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..3f64f262e --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/pinned/data.ts @@ -0,0 +1 @@ +export { type Employee, employees } from '../basic/data' 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..d51bd2f75 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/SpanningGrid.vue @@ -0,0 +1,60 @@ + + + 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..de4808d60 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/columns.ts @@ -0,0 +1,8 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { Employee } from './data' + +export const columns: DataGridColumn[] = [ + { key: 'department', title: 'Department', size: 30 }, + { key: 'name', title: 'Name', size: 40 }, + { key: 'role', title: 'Role', size: 30 }, +] 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..c137403c6 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/spanning/data.ts @@ -0,0 +1,17 @@ +export type Employee = { + id: number + name: string + department: string + role: string +} + +export const employees: Employee[] = [ + { id: 1, name: 'Alice Johnson', department: 'Engineering', role: 'Lead' }, + { id: 2, name: 'Bob Smith', department: 'Engineering', role: 'Senior' }, + { id: 3, name: 'Frank Lee', department: 'Engineering', role: 'Junior' }, + { id: 4, name: 'Carol Davis', department: 'Design', role: 'Lead' }, + { id: 5, name: 'Dan Wilson', department: 'Design', role: 'Senior' }, + { id: 6, name: 'Henry Chen', department: 'Design', role: 'Junior' }, + { id: 7, name: 'Eve Martinez', department: 'Marketing', role: 'Director' }, + { id: 8, name: 'Grace Kim', department: 'Marketing', role: 'Senior' }, +] 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..a10614e77 --- /dev/null +++ b/apps/docs/src/pages/composables/data/create-data-grid.md @@ -0,0 +1,350 @@ +--- +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 data grid composable that layers column layout, cell editing, row ordering, and row spanning on top of the [createDataTable](/composables/data/create-data-table) pipeline. + + + +## Usage + +Pass `items` and `columns` with `size` percentages to get a grid with column layout management, search, sort, and pagination. + +```ts collapse +import { createDataGrid } from '@vuetify/v0' + +const grid = createDataGrid({ + items: employees, + columns: [ + { key: 'name', title: 'Name', sortable: true, filterable: true, size: 25 }, + { key: 'email', title: 'Email', sortable: true, size: 35 }, + { key: 'department', title: 'Dept', sortable: true, size: 20 }, + { key: 'salary', title: 'Salary', sortable: true, size: 20 }, + ], +}) + +// 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 +``` + +::: example +/composables/create-data-grid/basic/BasicGrid.vue +/composables/create-data-grid/basic/columns.ts +/composables/create-data-grid/basic/data.ts +::: + +## Adapters + +Grid adapters extend the data table adapters with row ordering inserted between sort and pagination. + +| Adapter | Pipeline | Use Case | +| - | - | - | +| [ClientGridAdapter](#clientgridadapter-default) | filter → sort → order → paginate | Default. All processing client-side | +| [ServerGridAdapter](#servergridadapter) | pass-through | API-driven. Server handles everything | +| [VirtualGridAdapter](#virtualgridadapter) | filter → sort → order → (no paginate) | Large lists with createVirtual | + +### ClientGridAdapter (default) + +Extends the client adapter with row ordering applied post-sort, pre-pagination. + +```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({ + items: employees, + columns, + // ClientGridAdapter is the default — not required +}) + +// Row ordering +grid.rows.move(0, 3) // move row 0 to position 3 +grid.rows.reset() // clear custom ordering +``` + +### ServerGridAdapter + +Pass-through adapter for API-driven grids. Re-exports the data table's `ServerAdapter`. + +```ts +import { createDataGrid, ServerGridAdapter } from '@vuetify/v0' + +const grid = createDataGrid({ + items: serverItems, + columns, + adapter: new ServerGridAdapter({ total: totalCount, loading: isLoading }), +}) +``` + +### VirtualGridAdapter + +Client-side filter/sort/order without pagination slicing. All items are passed to `createVirtual`. + +```ts +import { createDataGrid, VirtualGridAdapter } from '@vuetify/v0' + +const grid = createDataGrid({ + items: largeDataset, + columns, + adapter: new VirtualGridAdapter(grid.rows.order, 'id'), +}) +``` + +## Features + +### Column Layout + +Columns are sized as percentages (0–100) and can be pinned, resized, and reordered. + +```ts +const grid = createDataGrid({ + items, + columns: [ + { key: 'name', size: 30, pinned: 'left', minSize: 15, maxSize: 50 }, + { key: 'email', size: 40 }, + { key: 'status', size: 30, pinned: 'right' }, + ], +}) + +// 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({ + items, + columns: [ + { + key: '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.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. + +```ts +const grid = createDataGrid({ items, columns }) + +grid.rows.move(0, 3) // Move row from index 0 to 3 +grid.rows.order.value // Current ID-based order +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({ + items, + columns, + rowSpanning: (item, column) => { + if (column === 'department') return 3 // span 3 rows + return 1 + }, +}) + +// Span map: item ID → column key → { 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({ + items, + columns: [ + { key: 'name', title: 'Name', size: 30 }, + { + key: 'contact', + title: 'Contact', + children: [ + { key: 'email', title: 'Email', size: 40 }, + { key: 'phone', title: 'Phone', size: 30 }, + ], + }, + ], +}) + +// headers: 2D array with colspan/rowspan for rendering +grid.headers.value +// [[{ key: 'name', rowspan: 2 }, { key: 'contact', colspan: 2 }], +// [{ key: 'email' }, { key: 'phone' }]] +``` + +## Reactivity + +| Property | Reactive | Notes | +| - | :-: | - | +| `items` | | Final visible items (paginated) | +| `allItems` | | Raw unprocessed items | +| `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 | + +## 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 grid with pinned columns and drag-to-resize. The name column is pinned left, ID pinned right, and the center columns scroll independently. + +**File breakdown:** + +| File | Role | +|------|------| +| `PinnedGrid.vue` | Three-region grid with resize handles and pin/unpin controls | +| `columns.ts` | Column definitions with initial pin positions and size constraints | +| `data.ts` | Shared employee dataset | + +**Key patterns:** + +- `layout.pinned` splits columns into `left`, `scrollable`, and `right` regions with independent offsets +- `layout.resize(key, delta)` adjusts a column and its neighbor to maintain total width +- `layout.pin(key, 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 + +Click-to-edit grid with inline validation. Name and email columns are editable — invalid values show an error and block commit. + +**File breakdown:** + +| File | Role | +|------|------| +| `EditableGrid.vue` | Click-to-edit cells with Enter/Escape keyboard handling and error display | +| `columns.ts` | Columns with `editable: true` and `validate` functions | +| `data.ts` | Shared employee dataset | + +**Key patterns:** + +- `editing.edit(row, column)` activates a cell for editing +- `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 receives the full item for context-aware updates + +::: + +::: 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 grid with merged department cells. Consecutive rows with the same department are merged into a single cell using `rowSpanning`. + +**File breakdown:** + +| File | Role | +|------|------| +| `SpanningGrid.vue` | Table with `rowspan` attributes driven by the span map | +| `columns.ts` | Simple column definitions | +| `data.ts` | Dataset sorted by department for natural grouping | + +**Key patterns:** + +- `rowSpanning(item, column)` returns the number of rows a cell should span +- `spans.value` provides a Map of `rowID → column → { rowSpan, hidden }` +- 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 + +::: + + From 0a3ba690ceb977f315b77bd9eb51d7e15495d5fb Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 11 Apr 2026 23:17:35 -0500 Subject: [PATCH 13/38] docs(createDataGrid): redesign examples with distinct use cases - Basic: project tracker with status pills, priority colors, progress bars - Pinned: financial spreadsheet with 10 columns, sticky freeze lines, resize handles - Editing: inventory editor with focus ring, inline validation, edit history - Spanning: team schedule with department spanning and availability status - Sort indicators use MDI icons instead of unicode arrows - Each example has a distinct real-world purpose that justifies grid over table --- .../create-data-grid/basic/BasicGrid.vue | 120 ++++++++++---- .../create-data-grid/basic/columns.ts | 16 +- .../create-data-grid/basic/data.ts | 34 ++-- .../create-data-grid/editing/EditableGrid.vue | 98 ++++++++--- .../create-data-grid/editing/columns.ts | 14 +- .../create-data-grid/editing/data.ts | 20 ++- .../create-data-grid/pinned/PinnedGrid.vue | 152 ++++++++++++------ .../create-data-grid/pinned/columns.ts | 20 ++- .../create-data-grid/pinned/data.ts | 29 +++- .../spanning/SpanningGrid.vue | 42 ++++- .../create-data-grid/spanning/columns.ts | 14 +- .../create-data-grid/spanning/data.ts | 30 ++-- .../composables/data/create-data-grid.md | 35 ++-- 13 files changed, 437 insertions(+), 187 deletions(-) diff --git a/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue b/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue index 07225b7ea..1d7c1da0d 100644 --- a/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue +++ b/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue @@ -1,89 +1,153 @@