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..9fccc0aae --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/BasicGrid.vue @@ -0,0 +1,159 @@ + + + + + + + + Toggle pin Name + + + + + + + + + + + + + {{ columns.find(c => c.key === col.key)?.title }} + {{ sortIcon(col.key) }} + + + + + + + )[col.key])" + > + + + {{ (item as Record)[col.key] }} + + + + + + + + + {{ grid.editing.error.value }} + + + + + {{ grid.total.value }} total · double-click email to edit + + + + + Prev + + + + {{ grid.pagination.page.value }} / {{ grid.pagination.pages }} + + + + Next + + + + + 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..9495befea --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/columns.ts @@ -0,0 +1,15 @@ +import type { DataGridColumn } from '@vuetify/v0' +import type { User } from './data' + +export const columns: DataGridColumn[] = [ + { key: 'name', title: 'Name', sortable: true, filterable: true, size: 30 }, + { key: 'email', title: 'Email', sortable: true, filterable: true, size: 40, editable: true }, + { key: 'role', title: 'Role', sortable: true, size: 15 }, + { + key: 'age', + title: 'Age', + sortable: true, + size: 15, + 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..1d6630510 --- /dev/null +++ b/apps/docs/src/examples/composables/create-data-grid/basic/data.ts @@ -0,0 +1,18 @@ +export interface User { + id: number + name: string + email: string + role: string + age: number +} + +export const users: User[] = [ + { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', age: 32 }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'Editor', age: 28 }, + { id: 3, name: 'Carol Davis', email: 'carol@example.com', role: 'Viewer', age: 41 }, + { id: 4, name: 'Dan Wilson', email: 'dan@example.com', role: 'Editor', age: 35 }, + { id: 5, name: 'Eve Martinez', email: 'eve@example.com', role: 'Admin', age: 29 }, + { id: 6, name: 'Frank Lee', email: 'frank@example.com', role: 'Viewer', age: 47 }, + { id: 7, name: 'Grace Kim', email: 'grace@example.com', role: 'Editor', age: 33 }, + { id: 8, name: 'Henry Chen', email: 'henry@example.com', role: 'Viewer', age: 26 }, +] 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..7e0cc1426 --- /dev/null +++ b/apps/docs/src/pages/composables/data/create-data-grid.md @@ -0,0 +1,276 @@ +--- +title: createDataGrid - Composable Data Grid for Vue 3 +meta: +- name: description + content: Headless data grid composable with column layout, cell editing, row ordering, row spanning, and pinned regions. Built on createDataTable. +- name: keywords + content: createDataGrid, data grid, column layout, cell editing, row reordering, row spanning, pinned columns, composable, Vue 3 +features: + category: Composable + label: 'E: createDataGrid' + github: /composables/createDataGrid/ + level: 3 +related: + - /composables/data/create-data-table + - /composables/data/create-virtual + - /composables/data/create-pagination +--- + +# createDataGrid + +Headless data grid built on `createDataTable`. Adds column layout (sizing, pinning, resizing, reordering), cell editing, row ordering, and row spanning. + + + +## Usage + +Pass `items` and `columns` to inherit the full `createDataTable` API plus grid extensions. + +```ts collapse +import { createDataGrid } from '@vuetify/v0' + +const grid = createDataGrid({ + items: users, + columns: [ + { key: 'name', title: 'Name', sortable: true, filterable: true, size: 30 }, + { key: 'email', title: 'Email', filterable: true, size: 40, editable: true }, + { key: 'age', title: 'Age', sortable: true, size: 30, sort: (a, b) => Number(a) - Number(b) }, + ], +}) + +// Inherited from createDataTable +grid.search('alice') +grid.sort.toggle('age') +grid.pagination.next() + +// Grid extensions +grid.layout.pin('name', 'left') +grid.layout.resize('email', 5) // +5% width, neighbor absorbs the inverse +grid.layout.reorder(0, 2) // move first column to index 2 + +grid.editing.edit(1, 'email') +grid.editing.commit('alice@new.com') // fires onEdit callback + +grid.rows.move(0, 3) // manual drag-style row reorder +``` + +::: example +/composables/create-data-grid/basic/BasicGrid.vue +/composables/create-data-grid/basic/columns.ts +/composables/create-data-grid/basic/data.ts + +### Basic Data Grid + +A sortable, filterable, paginated grid with click-to-edit cells and column pinning. + +::: + +## Architecture + +`createDataGrid` is an aggregation orchestrator. It calls `createDataTable` with the supplied options and grafts column layout, cell editing, row ordering, and row spanning onto the returned context. + +```mermaid +graph LR + A[items + columns] --> DT[createDataTable] + DT -->|filter→sort→order→paginate| Visible[items.value] + Visible --> S[spans] + A --> L[layout] + A --> E[editing] + A --> O[rows] + DT --> Out[DataGridContext] + L --> Out + E --> Out + O --> Out + S --> Out +``` + +## Adapters + +Adapters control the data pipeline. Each grid adapter mirrors the corresponding `createDataTable` adapter and inserts row ordering 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 filter/sort/paginate | +| [VirtualGridAdapter](#virtualgridadapter) | filter → sort → order → (no paginate) | Large datasets paired with `createVirtual` | + +### ClientGridAdapter (default) + +Inserted automatically by `createDataGrid`. Reads the row ordering ref so manual reorders survive sort/pagination updates. + +```mermaid +graph LR + A[Raw Items] --> B[Filter] --> C[Sort] --> O[Row Order] --> D[Paginate] --> E[Visible Items] +``` + +```ts +import { createDataGrid, ClientGridAdapter } from '@vuetify/v0' + +// Equivalent to the default — only pass it if you need to customize +const grid = createDataGrid({ + items, + columns, + // adapter is constructed internally with the grid's own row order +}) +``` + +### ServerGridAdapter + +Pass-through adapter for API-driven grids. The server handles all filtering, sorting, and pagination — the client only renders what it receives. Row ordering is *not* applied client-side; emit your own callback to persist order changes server-side. + +| Option | Type | Required | Description | +| - | - | :-: | - | +| `total` | `MaybeRefOrGetter` | Yes | Total item count on the server | +| `loading` | `MaybeRefOrGetter` | No | Loading state from your fetch layer | +| `error` | `MaybeRefOrGetter` | No | Error state from your fetch layer | + +```ts +import { createDataGrid, ServerGridAdapter } from '@vuetify/v0' + +const grid = createDataGrid({ + items: serverItems, + columns, + adapter: new ServerGridAdapter({ total, loading, error }), +}) + +watch( + [grid.query, grid.sort.columns, grid.pagination.page], + () => fetchPage(), +) +``` + +### VirtualGridAdapter + +Client-side filtering, sorting, and ordering — but no pagination slicing. Pair `grid.items` with `createVirtual` at the rendering layer for large datasets. + +```ts +import { createDataGrid, VirtualGridAdapter, createVirtual } from '@vuetify/v0' + +const grid = createDataGrid({ + items: largeDataset, + columns, + adapter: new VirtualGridAdapter(rowOrder, 'id'), +}) + +const virtual = createVirtual(grid.items, { itemHeight: 36 }) +``` + +## Features + +### Column Layout + +Sizes are percentages (0–100) so the layout interoperates with the `Splitter` component. Unsized columns split the remainder evenly. + +```ts +const grid = createDataGrid({ + items, + columns: [ + { key: 'name', size: 30, pinned: 'left' }, + { key: 'email', size: 40, resizable: true }, + { key: 'age', size: 30, minSize: 10, maxSize: 50 }, + ], +}) + +grid.layout.columns.value // resolved columns in display order +grid.layout.pinned.value // { left, scrollable, right } +grid.layout.pin('age', 'right') +grid.layout.resize('name', 5) // delta — neighbor absorbs the inverse +grid.layout.reorder(0, 2) // move first column to index 2 +grid.layout.distribute([25, 50, 25]) +grid.layout.reset() // restore initial sizes/order/pins +``` + +### Cell Editing + +`createDataGrid` does not mutate source data — `commit` fires `onEdit` and the consumer applies the change. + +```ts +const grid = createDataGrid({ + items, + columns: [ + { + key: 'email', + editable: true, + validate: (v) => (typeof v === 'string' && v.includes('@')) || 'Invalid email', + }, + ], + editing: { + onEdit: (rowId, columnKey, value, item) => { + api.patch(`/users/${rowId}`, { [columnKey]: value }) + }, + }, +}) + +grid.editing.edit(1, 'email') // active.value = { row: 1, column: 'email' } +grid.editing.commit('a@b.com') // fires validate, then onEdit, then clears active +grid.editing.cancel() // clears active without committing +grid.editing.error.value // last validation error or null +``` + +`editable` may be a predicate (`(item) => boolean`) for per-row control. + +### Row Ordering + +The default `ClientGridAdapter` applies row order between sort and pagination. Manual order is reset automatically when the sort changes — pass `preserveRowOrder: true` to keep it. + +```ts +grid.rows.order.value // current order (empty until manipulated) +grid.rows.move(0, 3) // move first item to index 3 +grid.rows.reset() // clear manual order +``` + +### Row Spanning + +Provide `rowSpanning(item, column)` to compute per-cell row span. The composable derives a `Map>` you can read while rendering. Spans never cross page boundaries. + +```ts +const grid = createDataGrid({ + items, + columns, + rowSpanning: (item, column) => column === 'department' ? item.deptSize : 1, +}) + +const spans = grid.spans.value +spans.get(1)?.get('department') // { rowSpan: 3, hidden: false } +spans.get(2)?.get('department') // { rowSpan: 1, hidden: true } +``` + +### Nested Headers + +`columns` accepts a recursive `children` tree. The data pipeline still operates on leaves, and `headers.value` exposes a 2D grid with `colspan`/`rowspan` for rendering ``. + +```ts +const grid = createDataGrid({ + items, + columns: [ + { key: 'name', title: 'Name' }, + { + key: 'contact', + title: 'Contact', + children: [ + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Phone' }, + ], + }, + ], +}) + +grid.headers.value // [[Name, Contact], [Email, Phone]] with colspan/rowspan +grid.leaves // [name, email, phone] — drives sort/filter/pagination +``` + +## Reactivity + +`DataGridContext` extends `DataTableContext` — every property documented under [createDataTable's reactivity table](/composables/data/create-data-table#reactivity) is inherited. The grid adds: + +| Property | Reactive | Notes | +| - | :-: | - | +| `layout.columns` | | Resolved columns in display order | +| `layout.pinned` | | `{ left, scrollable, right }` regions | +| `editing.active` | | `{ row, column }` or `null` | +| `editing.error` | | Last validation message or `null` | +| `editing.dirty` | | Per-row, per-cell uncommitted edits | +| `rows.order` | | Manual row order (empty by default) | +| `spans` | | Computed row span map for the current page | + + diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 9be9fee96..cafdb055c 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -301,6 +301,7 @@ Composables for filtering, sorting, paginating, and virtualizing collections. | Name | Description | | - | - | +| [createDataGrid](/composables/data/create-data-grid) | Data grid with column layout, cell editing, row ordering, and row spanning | | [createDataTable](/composables/data/create-data-table) | Composable data table with sort, filter, paginate, select, and expand | | [createFilter](/composables/data/create-filter) | Filter arrays based on search queries | | [createPagination](/composables/data/create-pagination) | Pagination state with navigation methods | diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index e2e120c93..6c9a57f50 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -314,6 +314,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/composables/data/create-data-grid': RouteRecordInfo< + '/composables/data/create-data-grid', + '/composables/data/create-data-grid', + Record, + Record, + | never + >, '/composables/data/create-data-table': RouteRecordInfo< '/composables/data/create-data-table', '/composables/data/create-data-table', @@ -1245,6 +1252,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/composables/data/create-data-grid.md': { + routes: + | '/composables/data/create-data-grid' + views: + | never + } 'src/pages/composables/data/create-data-table.md': { routes: | '/composables/data/create-data-table' diff --git a/packages/0/README.md b/packages/0/README.md index ab98637bf..ab63ee193 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -162,6 +162,7 @@ Base data structures that most other composables build upon: #### Data +- [`createDataGrid`](https://0.vuetifyjs.com/composables/data/create-data-grid) - Data grid built on createDataTable with column layout, cell editing, row ordering, and row spanning - [`createDataTable`](https://0.vuetifyjs.com/composables/data/create-data-table) - Data table with sort, filter, pagination, row selection, grouping, and adapter pattern - [`createFilter`](https://0.vuetifyjs.com/composables/data/create-filter) - Reactive array filtering with multiple modes - [`createPagination`](https://0.vuetifyjs.com/composables/data/create-pagination) - Lightweight page navigation 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..7f45345b9 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/client.ts @@ -0,0 +1,68 @@ +/** + * @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' + +import { applyOrder } from './order' + +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(() => { + return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) + }) + + 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/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/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..c07f52fc8 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/adapters/virtual.ts @@ -0,0 +1,65 @@ +/** + * @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' + +import { applyOrder } from './order' + +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(() => { + return applyOrder(sortedItems.value, this.rowOrder.value, this.itemKey) + }) + + 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), + } + } +} 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..73c26b5cf --- /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, + } +} 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..9fe433d27 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createDataGrid } from './index' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + provide: vi.fn(), + inject: vi.fn(), + } +}) + +const items = [ + { id: 1, name: 'Alice', email: 'alice@test.com', age: 30, dept: 'Eng' }, + { id: 2, name: 'Bob', email: 'bob@test.com', age: 25, dept: 'Eng' }, + { id: 3, name: 'Carol', email: 'carol@test.com', age: 35, dept: 'Sales' }, + { id: 4, name: 'Dave', email: 'dave@test.com', age: 28, dept: 'Sales' }, +] + +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) + }) + }) +}) diff --git a/packages/0/src/composables/createDataGrid/index.ts b/packages/0/src/composables/createDataGrid/index.ts new file mode 100644 index 000000000..42d917989 --- /dev/null +++ b/packages/0/src/composables/createDataGrid/index.ts @@ -0,0 +1,283 @@ +/** + * @module createDataGrid + * + * @see https://0.vuetifyjs.com/composables/data/create-data-grid + * + * @remarks + * Headless data grid built on top of `createDataTable`. Adds: + * + * - Column layout: percentage-based sizing, pinning (left/right), delta-based + * resizing, and reordering. + * - Cell editing: edit/commit/cancel lifecycle with per-column validation and + * dirty tracking. Source data is never mutated; consumers handle persistence + * via `editing.onEdit`. + * - Row ordering: ID-based order applied post-sort, pre-pagination by the + * default `ClientGridAdapter`. Auto-resets when the underlying sort changes + * unless `preserveRowOrder` is set. + * - Row spanning: derived `Map>` from a + * per-cell `rowSpanning` callback. Spans never cross page boundaries. + * + * Composes existing primitives via the spread/aggregation pattern; the entire + * createDataTable API (sort, filter, pagination, selection, expansion, + * grouping) is inherited unchanged. + */ + +// Composables +import { useContext } from '#v0/composables/createContext' +import { createDataTable } from '#v0/composables/createDataTable' +import { extractLeaves } from '#v0/composables/createDataTable/columns' +import { createTrinity } from '#v0/composables/createTrinity' + +// Adapters +import { ClientGridAdapter } from './adapters' + +// Utilities +import { watch } from 'vue' + +// Types +import type { DataTableAdapterInterface, DataTableContext } 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' +import type { ID } from '#v0/types' +import type { CellEditing } from './editing' +import type { ColumnLayout, GridColumnDef } from './layout' +import type { SpanEntry } from './spanning' +import type { MaybeRefOrGetter, Ref, ShallowRef } from 'vue' + +// Grid modules +import { createCellEditing } from './editing' +import { createColumnLayout } from './layout' +import { createRowOrdering } from './ordering' +import { createRowSpanning } from './spanning' + +// Re-exports +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 + /** Cell is editable. May be a predicate per row. */ + readonly editable?: boolean | ((item: T) => boolean) + /** Editor hint for the rendering layer. */ + readonly editor?: 'text' | 'number' | 'boolean' + /** Validate a committed value. Return `true` to accept or a string error message. */ + readonly validate?: (value: unknown, item?: T) => boolean | string + /** Per-cell row span. */ + readonly span?: (item: T) => number + readonly children?: readonly DataGridColumn[] +} + +export interface DataGridOptions> { + items: MaybeRefOrGetter + columns: readonly DataGridColumn[] + /** Property used as row identifier. @default 'id' */ + itemValue?: string + /** Pipeline adapter. @default ClientGridAdapter */ + adapter?: DataTableAdapterInterface + /** Filter options forwarded to createDataTable. */ + filter?: Omit + /** Pagination options forwarded to createDataTable. */ + pagination?: Omit + /** Enable multi-column sort. @default false */ + sortMultiple?: boolean + /** Cell editing configuration. */ + editing?: { + /** Called when an edited cell is committed. */ + onEdit?: (row: ID, column: string, value: unknown, item: T) => void + } + /** Keep manual row order across sort changes. @default false */ + preserveRowOrder?: boolean + /** Per-cell row span function. */ + rowSpanning?: (item: T, column: string) => number +} + +export interface DataGridContext> extends DataTableContext { + /** Column layout: sizing, pinning, resizing, reordering */ + layout: ColumnLayout + /** Row ordering state and mutation methods */ + rows: { + order: Readonly> + move: (fromIndex: number, toIndex: number) => void + reset: () => void + } + /** Cell editing state and lifecycle methods */ + editing: CellEditing + /** Row span map for the current page */ + spans: Readonly>>> +} + +export interface DataGridContextOptions> extends DataGridOptions { + namespace?: string +} + +/** + * Creates a data grid instance with column layout, cell editing, row ordering, + * and row spanning layered on top of a `createDataTable` pipeline. + * + * The default `ClientGridAdapter` inserts row ordering between sort and + * pagination, so manual row order survives sorting (cleared automatically when + * the sort itself changes unless `preserveRowOrder` is set). + * + * @param options Data grid options + * @returns Data grid context with the full DataTable API plus grid extensions + * + * @example + * ```ts + * import { createDataGrid } from '@vuetify/v0' + * + * const grid = createDataGrid({ + * items: users, + * columns: [ + * { key: 'name', title: 'Name', sortable: true, filterable: true, size: 30 }, + * { key: 'email', title: 'Email', filterable: true, size: 40, editable: true }, + * { key: 'age', title: 'Age', sortable: true, size: 30, sort: (a, b) => Number(a) - Number(b) }, + * ], + * }) + * + * grid.search('alice') + * grid.sort.toggle('age') + * grid.layout.pin('name', 'left') + * grid.editing.edit(1, 'email') + * grid.editing.commit('alice@new.com') + * ``` + */ +export function createDataGrid> ( + options: DataGridOptions, +): DataGridContext { + const { + items, + columns, + itemValue = 'id', + adapter: customAdapter, + filter, + pagination, + sortMultiple, + editing: editingOptions, + preserveRowOrder = false, + rowSpanning, + } = options + + const leaves = extractLeaves(columns) + const ordering = createRowOrdering() + const adapter = customAdapter ?? new ClientGridAdapter(ordering.order, itemValue) + + const table = createDataTable({ + items, + columns, + itemValue: itemValue as never, + filter, + pagination, + sortMultiple, + adapter, + }) + + if (!preserveRowOrder) { + watch(table.sort.columns, () => { + ordering.reset() + }) + } + + const layout = createColumnLayout(columns) + + const editableColumns = leaves + .filter(col => col.editable === true || typeof col.editable === 'function') + .map(col => ({ + key: col.key, + editable: col.editable as boolean | ((item: unknown) => boolean), + 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, + }) + + const spans = createRowSpanning({ + items: table.items as Ref, + columns: leaves.map(col => col.key), + itemKey: itemValue, + rowSpanning, + }) + + return { + ...table, + layout, + rows: { + order: ordering.order, + move: ordering.move, + reset: ordering.reset, + }, + editing, + spans, + } +} + +/** + * Creates a data grid context with dependency injection support. + * + * @param options Data grid context options including namespace + * @returns A trinity tuple: `[useDataGrid, provideDataGrid, defaultContext]` + * + * @example + * ```ts + * import { createDataGridContext } from '@vuetify/v0' + * + * const [useUsersGrid, provideUsersGrid] = createDataGridContext({ + * namespace: 'app:users-grid', + * items: users, + * columns, + * }) + * + * provideUsersGrid() + * ``` + */ +export function createDataGridContext> ( + _options: DataGridContextOptions, +): ContextTrinity> { + const { namespace = 'v0:data-grid', ...options } = _options + const context = createDataGrid(options) + + return createTrinity>(namespace, context) +} + +/** + * Returns the current data grid context from dependency injection. + * + * @typeParam T - Must be provided explicitly; cannot be inferred from namespace. + * Prefer the `useX` function from {@link createDataGridContext} for type-safe + * injection. + * + * @param namespace The namespace for the data grid context. @default 'v0:data-grid' + * @returns The current data grid context + * + * @example + * ```vue + * + * ``` + */ +export function useDataGrid> ( + namespace = 'v0:data-grid', +): DataGridContext { + return useContext>(namespace) +} diff --git a/packages/0/src/composables/createDataGrid/layout.test.ts b/packages/0/src/composables/createDataGrid/layout.test.ts new file mode 100644 index 000000000..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..fd97b93ca --- /dev/null +++ b/packages/0/src/composables/createDataGrid/layout.ts @@ -0,0 +1,266 @@ +/** + * @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 { left, scrollable, right } = pinned.value + return [...left, ...scrollable, ...right] + }) + + 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, + } +} 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..e0204046c --- /dev/null +++ b/packages/0/src/composables/createDataGrid/ordering.ts @@ -0,0 +1,81 @@ +/** + * @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 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 { + order, + initialize, + move, + reset, + apply, + } +} 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..f3ece51b0 --- /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 { Ref } from 'vue' + +export interface SpanEntry { + rowSpan: number + hidden: boolean +} + +export interface RowSpanningOptions> { + items: Readonly> + columns: readonly string[] + itemKey?: string + rowSpanning?: (item: T, column: string) => number +} + +export function createRowSpanning> ( + options: RowSpanningOptions, +): Readonly>>> { + 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 + }) +} diff --git a/packages/0/src/composables/createDataTable/adapters/adapter.ts b/packages/0/src/composables/createDataTable/adapters/adapter.ts index da9deffd5..3f614501d 100644 --- a/packages/0/src/composables/createDataTable/adapters/adapter.ts +++ b/packages/0/src/composables/createDataTable/adapters/adapter.ts @@ -15,7 +15,7 @@ import { createFilter } from '#v0/composables/createFilter' // Utilities -import { isArray, isNaN, isNullOrUndefined, isNumber, isObject, isString } from '#v0/utilities' +import { isNaN, isNullOrUndefined, isNumber, isObject, isString } from '#v0/utilities' import { computed, toRef, toValue } from 'vue' // Types @@ -37,7 +37,7 @@ export interface DataTableAdapterContext> { /** Search query ref */ search: ShallowRef /** Column keys eligible for filtering */ - filterableKeys: ReadonlyArray + filterableKeys: readonly string[] /** Current sort state derived from sort controls */ sortBy: Readonly> /** Locale for sorting (reactive, from useLocale or options) */ @@ -120,7 +120,7 @@ export abstract class DataTableAdapter> implem keys: [...context.filterableKeys], customFilter: (query, item) => { if (!isObject(item)) return false - const q = String(isArray(query) ? query[0] : query).toLowerCase() + const q = String(Array.isArray(query) ? query[0] : query).toLowerCase() if (!q) return true const obj = item as Record diff --git a/packages/0/src/composables/createDataTable/columns.test.ts b/packages/0/src/composables/createDataTable/columns.test.ts new file mode 100644 index 000000000..3275c01f0 --- /dev/null +++ b/packages/0/src/composables/createDataTable/columns.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' + +import { computeDepth, extractLeaves, resolveHeaders } from './columns' + +describe('columns', () => { + describe('extractLeaves', () => { + it('returns all columns when flat', () => { + const columns = [ + { key: 'name', title: 'Name' }, + { key: 'email', title: 'Email' }, + ] + expect(extractLeaves(columns)).toEqual(columns) + }) + + it('extracts leaf columns from nested tree', () => { + const columns = [ + { key: 'name', title: 'Name' }, + { + key: 'contact', + title: 'Contact', + children: [ + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Phone' }, + ], + }, + ] + expect(extractLeaves(columns)).toEqual([ + { key: 'name', title: 'Name' }, + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Phone' }, + ]) + }) + + it('handles deeply nested columns', () => { + const columns = [ + { + key: 'group', + children: [ + { + key: 'subgroup', + children: [ + { key: 'leaf', title: 'Leaf' }, + ], + }, + ], + }, + ] + expect(extractLeaves(columns)).toEqual([ + { key: 'leaf', title: 'Leaf' }, + ]) + }) + + it('returns empty array for empty input', () => { + expect(extractLeaves([])).toEqual([]) + }) + }) + + describe('computeDepth', () => { + it('returns 0 for flat columns', () => { + const columns = [ + { key: 'name' }, + { key: 'email' }, + ] + expect(computeDepth(columns)).toBe(0) + }) + + it('returns 1 for one level of nesting', () => { + const columns = [ + { key: 'name' }, + { key: 'contact', children: [ + { key: 'email' }, + { key: 'phone' }, + ] }, + ] + expect(computeDepth(columns)).toBe(1) + }) + + it('returns 2 for two levels of nesting', () => { + const columns = [ + { key: 'group', children: [ + { key: 'sub', children: [ + { key: 'leaf' }, + ] }, + ] }, + ] + expect(computeDepth(columns)).toBe(2) + }) + + it('returns max depth across branches', () => { + const columns = [ + { key: 'shallow', children: [{ key: 'a' }] }, + { key: 'deep', children: [ + { key: 'mid', children: [{ key: 'b' }] }, + ] }, + ] + expect(computeDepth(columns)).toBe(2) + }) + }) + + describe('resolveHeaders', () => { + it('returns single row for flat columns', () => { + const columns = [ + { key: 'name', title: 'Name' }, + { key: 'email', title: 'Email' }, + ] + const headers = resolveHeaders(columns) + expect(headers).toEqual([[ + { key: 'name', title: 'Name', colspan: 1, rowspan: 1, depth: 0 }, + { key: 'email', title: 'Email', colspan: 1, rowspan: 1, depth: 0 }, + ]]) + }) + + it('resolves nested columns into 2D grid', () => { + const columns = [ + { key: 'name', title: 'Name' }, + { + key: 'contact', + title: 'Contact', + children: [ + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Phone' }, + ], + }, + ] + const headers = resolveHeaders(columns) + expect(headers).toEqual([ + [ + { key: 'name', title: 'Name', colspan: 1, rowspan: 2, depth: 0 }, + { key: 'contact', title: 'Contact', colspan: 2, rowspan: 1, depth: 0 }, + ], + [ + { key: 'email', title: 'Email', colspan: 1, rowspan: 1, depth: 1 }, + { key: 'phone', title: 'Phone', colspan: 1, rowspan: 1, depth: 1 }, + ], + ]) + }) + + it('handles deeply nested columns', () => { + const columns = [ + { key: 'a', title: 'A' }, + { + key: 'g1', + title: 'G1', + children: [ + { + key: 'g2', + title: 'G2', + children: [ + { key: 'b', title: 'B' }, + { key: 'c', title: 'C' }, + ], + }, + ], + }, + ] + const headers = resolveHeaders(columns) + expect(headers).toHaveLength(3) + expect(headers[0]).toEqual([ + { key: 'a', title: 'A', colspan: 1, rowspan: 3, depth: 0 }, + { key: 'g1', title: 'G1', colspan: 2, rowspan: 1, depth: 0 }, + ]) + expect(headers[1]).toEqual([ + { key: 'g2', title: 'G2', colspan: 2, rowspan: 1, depth: 1 }, + ]) + expect(headers[2]).toEqual([ + { key: 'b', title: 'B', colspan: 1, rowspan: 1, depth: 2 }, + { key: 'c', title: 'C', colspan: 1, rowspan: 1, depth: 2 }, + ]) + }) + + it('returns empty array for empty input', () => { + expect(resolveHeaders([])).toEqual([]) + }) + }) +}) diff --git a/packages/0/src/composables/createDataTable/columns.ts b/packages/0/src/composables/createDataTable/columns.ts new file mode 100644 index 000000000..66b513b45 --- /dev/null +++ b/packages/0/src/composables/createDataTable/columns.ts @@ -0,0 +1,86 @@ +/** + * @module createDataTable/columns + * + * @remarks + * Utilities for resolving recursive column definitions into flat leaf + * columns and 2D header grids. Used by createDataTable for header + * rendering and by createDataGrid for column layout. + */ + +export interface ColumnNode { + readonly key: string + readonly title?: string + readonly children?: readonly ColumnNode[] +} + +export interface InternalHeader { + key: string + title: string + colspan: number + rowspan: number + depth: number +} + +/** Recursively extract leaf columns (those without children) */ +export function extractLeaves (columns: readonly T[]): T[] { + const leaves: T[] = [] + for (const col of columns) { + if (col.children?.length) { + leaves.push(...extractLeaves(col.children as readonly T[])) + } else { + leaves.push(col) + } + } + return leaves +} + +/** Compute the maximum nesting depth of a column tree (0 = flat) */ +export function computeDepth (columns: readonly ColumnNode[]): number { + let max = 0 + for (const col of columns) { + if (col.children?.length) { + max = Math.max(max, 1 + computeDepth(col.children)) + } + } + return max +} + +/** + * Resolve a recursive column tree into a 2D header grid. + * + * Each cell has colspan (number of leaf descendants) and rowspan + * (how many rows a leaf spans when it doesn't fill all depth levels). + */ +export function resolveHeaders (columns: readonly ColumnNode[]): InternalHeader[][] { + if (columns.length === 0) return [] + + const maxDepth = computeDepth(columns) + const rows: InternalHeader[][] = Array.from({ length: maxDepth + 1 }, () => []) + + function walk (cols: readonly ColumnNode[], depth: number) { + for (const col of cols) { + if (col.children?.length) { + const leaves = extractLeaves(col.children) + rows[depth].push({ + key: col.key, + title: col.title ?? '', + colspan: leaves.length, + rowspan: 1, + depth, + }) + walk(col.children, depth + 1) + } else { + rows[depth].push({ + key: col.key, + title: col.title ?? '', + colspan: 1, + rowspan: maxDepth - depth + 1, + depth, + }) + } + } + } + + walk(columns, 0) + return rows +} diff --git a/packages/0/src/composables/createDataTable/index.ts b/packages/0/src/composables/createDataTable/index.ts index 86e8c4308..254aafeff 100644 --- a/packages/0/src/composables/createDataTable/index.ts +++ b/packages/0/src/composables/createDataTable/index.ts @@ -38,21 +38,29 @@ import type { PaginationContext, PaginationOptions } from '#v0/composables/creat import type { ContextTrinity } from '#v0/composables/createTrinity' import type { ID } from '#v0/types' import type { DataTableAdapterInterface, SortDirection, SortEntry } from './adapters/adapter' +import type { InternalHeader } from './columns' import type { MaybeRefOrGetter, Ref, ShallowRef } from 'vue' +// Column utilities +import { extractLeaves, resolveHeaders } from './columns' + // Re-export adapter types export { DataTableAdapter } from './adapters' export type { DataTableAdapterContext, DataTableAdapterInterface, DataTableAdapterResult, SortDirection, SortEntry } from './adapters' export { ClientAdapter, ServerAdapter, VirtualAdapter } from './adapters' export type { ServerAdapterOptions } from './adapters' +// Re-export column utilities +export { computeDepth, extractLeaves, resolveHeaders } from './columns' +export type { ColumnNode, InternalHeader } from './columns' + /** Extract keys of T whose value type extends V */ type KeysOfType = { [K in keyof T]: T[K] extends V ? K : never }[keyof T] & string export type SelectStrategy = 'single' | 'page' | 'all' export interface DataTableColumn = Record> { - readonly key: keyof T & string + readonly key: string readonly title?: string readonly sortable?: boolean readonly filterable?: boolean @@ -60,6 +68,8 @@ export interface DataTableColumn = Record number /** Custom filter function for this column */ readonly filter?: (value: unknown, query: string) => boolean + /** Child columns for nested header groups. Leaves drive the data pipeline. */ + readonly children?: readonly DataTableColumn[] } export interface DataTableSort { @@ -187,8 +197,12 @@ export interface DataTableContext> { filteredItems: Readonly> /** Items after filtering and sorting */ sortedItems: Readonly> - /** Column definitions */ + /** Column definitions (may be nested via `children`) */ columns: readonly DataTableColumn[] + /** Leaf columns extracted from `columns`. The data pipeline operates on leaves. */ + leaves: readonly DataTableColumn[] + /** Resolved 2D header grid with colspan/rowspan for rendering ``. */ + headers: Readonly> /** Set the search query */ search: (value: string) => void /** Current search query (readonly) */ @@ -291,7 +305,10 @@ export function createDataTable> ( return initialLocale }) - const sortable = columns.filter(col => col.sortable === true) + const leaves = extractLeaves(columns) + const headers = toRef(() => resolveHeaders(columns)) + + const sortable = leaves.filter(col => col.sortable === true) const group = createGroup({ multiple: sortMultiple }) @@ -390,19 +407,19 @@ export function createDataTable> ( reset, } - const filterable = columns + const filterable = leaves .filter(col => col.filterable === true) .map(col => col.key) // Build per-column custom sort comparators - const customSorts: Partial number>> = {} - for (const col of columns) { + const customSorts: Partial number>> = {} + for (const col of leaves) { if (col.sort) customSorts[col.key] = col.sort } // Build per-column custom filter functions - const customColumnFilters: Partial boolean>> = {} - for (const col of columns) { + const customColumnFilters: Partial boolean>> = {} + for (const col of leaves) { if (col.filter) customColumnFilters[col.key] = col.filter } @@ -621,6 +638,8 @@ export function createDataTable> ( filteredItems, sortedItems, columns, + leaves, + headers, search, query: _query as Readonly>, sort, diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index a89dbadd6..30e08f388 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -2,6 +2,7 @@ export * from './createBreadcrumbs' export * from './createCombobox' export * from './createContext' +export * from './createDataGrid' export * from './createDataTable' export * from './createFilter' export * from './createForm' diff --git a/packages/0/src/composables/useDate/index.ts b/packages/0/src/composables/useDate/index.ts index 4a4480869..ff490c32d 100644 --- a/packages/0/src/composables/useDate/index.ts +++ b/packages/0/src/composables/useDate/index.ts @@ -118,8 +118,12 @@ const defaultLocales: Record = { */ function deriveFirstDayOfWeek (locale: string): number { try { - const loc = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number } } - const info = loc.getWeekInfo?.() + const loc = new Intl.Locale(locale) as Intl.Locale & { + getWeekInfo?: () => { firstDay: number } + weekInfo?: { firstDay: number } + } + // Newer Node/ICU exposes getWeekInfo(); older Node exposes the deprecated weekInfo property. + const info = loc.getWeekInfo?.() ?? loc.weekInfo return info ? info.firstDay % 7 : 0 // ISO 1-7 → v0 0-6 } catch { return 0 diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index a731af712..704f4eb80 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -101,6 +101,11 @@ "since": "0.2.0", "category": "forms" }, + "createDataGrid": { + "level": "preview", + "since": "0.2.2", + "category": "data" + }, "createDataTable": { "level": "preview", "since": "0.1.0",