From 7e92f307ecbec191a1ea211ce217bbfc8375f749 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 12:40:48 -0500 Subject: [PATCH 1/6] refactor(createDataTable): drop items option for registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDataTable predated the registry-pattern convention used by every other collection composable in v0 (createRegistry, createModel, createSelection, createSortable, createKanban, …). The items option put row identity outside the composable's registry, forced MaybeRefOrGetter plumbing, and split ownership between the caller's array and the internal state. Drop the items option. createDataTable now owns an internal createRegistry({ events: true, reactive: true }) and spreads its surface into the returned context, so consumers register rows via the inherited register / onboard / unregister / clear / upsert methods. Row identity comes from the ticket id at register time. The itemValue option goes away with it; consumers pass id: value.id when they want a domain-stable identifier (selection, expansion, grouping all key off the ticket id, which equals the caller-supplied id by convention). Adapters are unchanged — they still receive context.items / allItems refs that project ticket.value through the pipeline. Migration shape: // before createDataTable({ items: data, ... }) // after const table = createDataTable({ ... }) table.onboard(data.map(value => ({ id: value.id, value }))) A reactive items source is no longer auto-synced; callers drive register / unregister, or watch and do clear() + onboard() (see the ServerDataTableAdapter prose for the canonical server pattern). Codified in PHILOSOPHY §6.10 "Collection composables: no items option" plus a checklist line in composables.md and new-feature-checklist.md. The PHILOSOPHY entry lists createDataGrid as also compliant in anticipation of the follow-up grid PR that adopts the same surface inherited via spread. Co-authored-by: dev-table Co-authored-by: docs-table Co-authored-by: philosophy --- .claude/rules/composables.md | 6 + .claude/rules/new-feature-checklist.md | 1 + .../create-data-table/basic/BasicTable.vue | 7 +- .../features/FeaturesTable.vue | 11 +- .../create-data-table/server/ServerTable.vue | 36 ++--- .../create-data-table/server/api.ts | 8 +- .../virtual/VirtualTable.vue | 17 +-- .../create-data-table/virtual/data.ts | 2 +- .../composables/data/create-data-table.md | 77 +++++++--- packages/0/PHILOSOPHY.md | 39 ++++- .../createDataTable/index.bench.ts | 20 ++- .../composables/createDataTable/index.test.ts | 134 +++++++++++------- .../src/composables/createDataTable/index.ts | 112 ++++++++++----- 13 files changed, 311 insertions(+), 159 deletions(-) diff --git a/.claude/rules/composables.md b/.claude/rules/composables.md index 52c37e375..8a528ba3f 100644 --- a/.claude/rules/composables.md +++ b/.claude/rules/composables.md @@ -26,6 +26,7 @@ Scope-specific mechanics for `packages/0/src/composables/**`. Covers naming, fac - §6.6 `useProxyModel` - §6.7 `useProxyRegistry` - §6.8 Register / unregister lifecycle contract +- §6.10 Collection composables: no `items` option - §7 Events & lifecycle - §9 Errors & invariants @@ -343,6 +344,10 @@ const table = createDataTable({ - The composable has exactly one correct implementation, and consumers have no reason to swap it. Example: `useHotkey` — the listener semantics are fixed. - You want to switch behavior based on a boolean flag. Use an option (`mode: 'client' | 'server'`) rather than dressing it up as an adapter. Adapters are for swapping *implementations*, not for flipping a known toggle. +### Collection composables: no `items` option + +A composable that owns a collection of values exposes `register` / `onboard` / `unregister`. It never accepts an `items` option in its factory — row identity, order, and per-row state live in the registry. Followed by `createRegistry`, `createModel`, `createSelection`, `createSingle`, `createGroup`, `createStep`, `createNested`, `createSortable`, `createKanban`, `createQueue`, `createTimeline`, `createTokens`, and (after the recent refactor) `createDataTable` and `createDataGrid`. Full rule and migration shape: PHILOSOPHY §6.10. + ### `useProxyModel` and `useProxyRegistry` — cross-link Both composables are covered in PHILOSOPHY §6.6 and §6.7. Repeating the when-to-use summary here for composable authors: @@ -494,3 +499,4 @@ Pure transformers (`toRef`, `toElement`, `toValue`) are fine to call inline — - [ ] No DOM event binding inside the composable - [ ] ID generation through `useId()` - [ ] Trinity return only from `createTrinity` / `createContext` / `createPlugin` +- [ ] Composable that owns a collection of values uses `register` / `onboard`, never an `items` option (PHILOSOPHY §6.10) diff --git a/.claude/rules/new-feature-checklist.md b/.claude/rules/new-feature-checklist.md index 5e822739a..343ed3ec4 100644 --- a/.claude/rules/new-feature-checklist.md +++ b/.claude/rules/new-feature-checklist.md @@ -189,3 +189,4 @@ Prefer extending an existing pattern over creating a new one. - [ ] Feature appears in `apps/docs/build/generated/api-whitelist.ts` after build - [ ] `` renders on the new docs page - [ ] Maturity level matches the promotion criteria table (don't self-promote to `stable` or `mature` — those require a maintainer) +- [ ] Collection composable surface uses `register` / `onboard` (no `items` option) — see PHILOSOPHY §6.10 diff --git a/apps/docs/src/examples/composables/create-data-table/basic/BasicTable.vue b/apps/docs/src/examples/composables/create-data-table/basic/BasicTable.vue index cb0a6563c..e9d3afa2b 100644 --- a/apps/docs/src/examples/composables/create-data-table/basic/BasicTable.vue +++ b/apps/docs/src/examples/composables/create-data-table/basic/BasicTable.vue @@ -4,12 +4,13 @@ import { users } from './data' const table = createDataTable({ - items: users, columns, pagination: { itemsPerPage: 5 }, }) - function sortIcon (key: string) { + table.onboard(users.map(value => ({ id: value.id, value }))) + + function arrow (key: string) { const dir = table.sort.direction(key) if (dir === 'asc') return '↑' if (dir === 'desc') return '↓' @@ -38,7 +39,7 @@ @click="table.sort.toggle(col.key)" > {{ col.title }} - {{ sortIcon(col.key) }} + {{ arrow(col.key) }} diff --git a/apps/docs/src/examples/composables/create-data-table/features/FeaturesTable.vue b/apps/docs/src/examples/composables/create-data-table/features/FeaturesTable.vue index 8274a804d..8f3d2fa18 100644 --- a/apps/docs/src/examples/composables/create-data-table/features/FeaturesTable.vue +++ b/apps/docs/src/examples/composables/create-data-table/features/FeaturesTable.vue @@ -4,7 +4,6 @@ import { employees } from './data' const table = createDataTable({ - items: employees, columns, groupBy: 'department', openAll: true, @@ -15,14 +14,16 @@ pagination: { itemsPerPage: 20 }, }) - function sortIcon (key: string) { + table.onboard(employees.map(value => ({ id: value.id, value }))) + + function arrow (key: string) { const dir = table.sort.direction(key) if (dir === 'asc') return '↑' if (dir === 'desc') return '↓' return '' } - function formatSalary (value: number) { + function format (value: number) { return `$${value.toLocaleString()}` } @@ -74,7 +75,7 @@ @click="table.sort.toggle(col.key)" > {{ col.title }} - {{ sortIcon(col.key) }} + {{ arrow(col.key) }} @@ -111,7 +112,7 @@ {{ item.name }} {{ item.department }} - {{ formatSalary(item.salary) }} + {{ format(item.salary) }} import { createDataTable, ServerDataTableAdapter } from '@vuetify/v0' import { shallowRef, watch } from 'vue' - import { fetchUsers } from './api' + import { fetch } from './api' import { columns } from './columns' - import type { User } from './api' - - const serverItems = shallowRef([]) - const totalCount = shallowRef(0) - const isLoading = shallowRef(false) + const total = shallowRef(0) + const loading = shallowRef(false) const table = createDataTable({ - items: serverItems, columns, pagination: { itemsPerPage: 5 }, - adapter: new ServerDataTableAdapter({ - total: totalCount, - loading: isLoading, - }), + adapter: new ServerDataTableAdapter({ total, loading }), }) - async function loadData () { - isLoading.value = true + async function load () { + loading.value = true - const result = await fetchUsers( + const result = await fetch( table.query.value, table.sort.columns.value, table.pagination.page.value, table.pagination.itemsPerPage, ) - totalCount.value = result.total - serverItems.value = result.items - isLoading.value = false + total.value = result.total + table.clear() + table.onboard(result.items.map(value => ({ id: value.id, value }))) + loading.value = false } watch( [table.query, table.sort.columns, table.pagination.page], - () => loadData(), + () => load(), { immediate: true }, ) - function sortIcon (key: string) { + function arrow (key: string) { const dir = table.sort.direction(key) if (dir === 'asc') return '↑' if (dir === 'desc') return '↓' @@ -82,7 +76,7 @@ @click="table.sort.toggle(col.key)" > {{ col.title }} - {{ sortIcon(col.key) }} + {{ arrow(col.key) }} @@ -109,7 +103,7 @@
- {{ totalCount }} total + {{ total }} total
diff --git a/apps/docs/src/examples/composables/create-data-table/server/api.ts b/apps/docs/src/examples/composables/create-data-table/server/api.ts index 10a408f2b..59b379c5c 100644 --- a/apps/docs/src/examples/composables/create-data-table/server/api.ts +++ b/apps/docs/src/examples/composables/create-data-table/server/api.ts @@ -23,7 +23,7 @@ export interface FetchResult { * Simulates a server API call with filtering, sorting, and pagination. * Returns only the current page of results after a short delay. */ -export function fetchUsers ( +export function fetch ( query: string, sorts: SortEntry[], page: number, @@ -46,9 +46,9 @@ export function fetchUsers ( if (sorts.length > 0) { const { key, direction } = sorts[0] result.sort((a, b) => { - const aVal = String(a[key as keyof User]) - const bVal = String(b[key as keyof User]) - const cmp = aVal.localeCompare(bVal) + const left = String(a[key as keyof User]) + const right = String(b[key as keyof User]) + const cmp = left.localeCompare(right) return direction === 'desc' ? -cmp : cmp }) } diff --git a/apps/docs/src/examples/composables/create-data-table/virtual/VirtualTable.vue b/apps/docs/src/examples/composables/create-data-table/virtual/VirtualTable.vue index 09cf190e4..7c05b5c5e 100644 --- a/apps/docs/src/examples/composables/create-data-table/virtual/VirtualTable.vue +++ b/apps/docs/src/examples/composables/create-data-table/virtual/VirtualTable.vue @@ -2,20 +2,21 @@ import { createDataTable, VirtualDataTableAdapter, createVirtual } from '@vuetify/v0' import { computed } from 'vue' import { columns } from './columns' - import { generateUsers } from './data' + import { generate } from './data' - const items = generateUsers(1000) + const items = generate(1000) const table = createDataTable({ - items, columns, adapter: new VirtualDataTableAdapter(), }) + table.onboard(items.map(value => ({ id: value.id, value }))) + const virtual = createVirtual(table.items, { itemHeight: 40 }) const { element, - items: virtualItems, + items: visible, offset, size, scroll, @@ -24,10 +25,10 @@ const stats = computed(() => ({ total: items.length, filtered: table.items.value.length, - rendered: virtualItems.value.length, + rendered: visible.value.length, })) - function sortIcon (key: string) { + function arrow (key: string) { const dir = table.sort.direction(key) if (dir === 'asc') return '↑' if (dir === 'desc') return '↓' @@ -67,7 +68,7 @@ @click="table.sort.toggle(col.key)" > {{ col.title }} - {{ sortIcon(col.key) }} + {{ arrow(col.key) }} @@ -76,7 +77,7 @@ diff --git a/apps/docs/src/examples/composables/create-data-table/virtual/data.ts b/apps/docs/src/examples/composables/create-data-table/virtual/data.ts index 4995a77ec..23b9c668b 100644 --- a/apps/docs/src/examples/composables/create-data-table/virtual/data.ts +++ b/apps/docs/src/examples/composables/create-data-table/virtual/data.ts @@ -5,7 +5,7 @@ export interface User { score: number } -export function generateUsers (count = 1000): User[] { +export function generate (count = 1000): User[] { return Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, diff --git a/apps/docs/src/pages/composables/data/create-data-table.md b/apps/docs/src/pages/composables/data/create-data-table.md index fb0a0d765..632ff81a6 100644 --- a/apps/docs/src/pages/composables/data/create-data-table.md +++ b/apps/docs/src/pages/composables/data/create-data-table.md @@ -22,15 +22,17 @@ Composable data table built on v0 primitives. Composes sorting, filtering, pagin +> [!TIP] +> Rows are registered through the registry surface, not passed as an `items` option. Call `table.onboard(rows.map(value => ({ id: value.id, value })))` after construction, or register rows one at a time with `table.register({ id, value })`. The ticket id IS the row identifier — selection, expansion, and grouping all key off it. + ## Usage -Pass `items` and `columns` to get a fully reactive data table with search, sort, and pagination ready to use. +Construct the table with `columns`, then register rows via `onboard` (bulk) or `register` (one at a time). Each row becomes a ticket keyed by the `id` you supply — that id is what `selection.toggle`, `expansion.toggle`, and `unregister` accept. ```ts collapse import { createDataTable } from '@vuetify/v0' const table = createDataTable({ - items: users, columns: [ { key: 'name', title: 'Name', sortable: true, filterable: true }, { key: 'email', title: 'Email', sortable: true, filterable: true }, @@ -38,6 +40,8 @@ const table = createDataTable({ ], }) +table.onboard(users.map(value => ({ id: value.id, value }))) + // Search table.search('john') console.log(table.query.value) // 'john' @@ -50,6 +54,12 @@ table.pagination.next() // Select rows table.selection.toggle('user-1') + +// Add / remove rows after setup +const ticket = table.register({ id: 'user-99', value: user }) +ticket.unregister() // remove via returned ticket +table.unregister('user-1') // remove by id +table.clear() // wipe all rows ``` ::: example @@ -70,22 +80,23 @@ Use `createDataTableContext` to share a data table instance across a component t ```ts import { createDataTableContext } from '@vuetify/v0' -const [useUsersTable, provideUsersTable, usersTable] = +const [useUsersTable, provideUsersTable, context] = createDataTableContext({ namespace: 'app:users', - items: users, columns: [ { key: 'name', title: 'Name', sortable: true }, { key: 'email', title: 'Email' }, ], }) +context.onboard(users.map(value => ({ id: value.id, value }))) + // In parent component provideUsersTable() // In child component (e.g., a toolbar or pagination control) const table = useUsersTable() -table.sort('name', 'asc') +table.sort.toggle('name') ``` ## Adapters @@ -117,10 +128,11 @@ import { createDataTable } from '@vuetify/v0' import { ClientDataTableAdapter } from '@vuetify/v0/data-table/adapters/client' const table = createDataTable({ - items: users, columns, adapter: new ClientDataTableAdapter(), // default — not required }) + +table.onboard(users.map(value => ({ id: value.id, value }))) ``` ### ServerDataTableAdapter @@ -146,24 +158,35 @@ graph LR - `allItems`, `filteredItems`, `sortedItems`, and `items` all point to the same source (no client-side processing) - Exposes `loading` and `error` via `table.loading` and `table.error` +Server-backed tables don't hold a long-lived `items` ref — instead, the fetch handler calls `table.clear()` and `table.onboard(...)` whenever a new page of results comes back. The registry becomes the single source of truth for what the table renders, and the adapter's `total` / `loading` / `error` refs drive pagination and UI state. + ```ts import { createDataTable } from '@vuetify/v0' import { ServerDataTableAdapter } from '@vuetify/v0/data-table/adapters/server' +const total = shallowRef(0) +const loading = shallowRef(false) +const error = shallowRef(null) + const table = createDataTable({ - items: serverItems, columns, - adapter: new ServerDataTableAdapter({ - total: totalCount, - loading: isLoading, - error: fetchError, - }), + adapter: new ServerDataTableAdapter({ total, loading, error }), }) +async function load () { + loading.value = true + const result = await fetch(/* query, sorts, page */) + total.value = result.total + table.clear() + table.onboard(result.items.map(value => ({ id: value.id, value }))) + loading.value = false +} + // Watch query/sort/page to trigger API calls watch( [table.query, table.sort.columns, table.pagination.page], - () => fetchData() + () => load(), + { immediate: true }, ) ``` @@ -186,11 +209,12 @@ import { createDataTable, createVirtual } from '@vuetify/v0' import { VirtualDataTableAdapter } from '@vuetify/v0/data-table/adapters/virtual' const table = createDataTable({ - items: largeDataset, columns, adapter: new VirtualDataTableAdapter(), }) +table.onboard(rows.map(value => ({ id: value.id, value }))) + // Wrap table.items with createVirtual for rendering const virtual = createVirtual(table.items, { itemHeight: 40 }) ``` @@ -203,7 +227,6 @@ Toggle sort cycles through directions. Configure with `mandate` and `firstSortOr ```ts const table = createDataTable({ - items, columns: [ { key: 'name', sortable: true }, { key: 'age', sortable: true, sort: (a, b) => Number(a) - Number(b) }, @@ -213,6 +236,8 @@ const table = createDataTable({ sortMultiple: true, // Enable multi-column sort }) +table.onboard(items.map(value => ({ id: value.id, value }))) + table.sort.toggle('name') table.sort.direction('name') // 'asc' | 'desc' | 'none' table.sort.priority('name') // 0-based index, or -1 @@ -227,7 +252,6 @@ Search filters across all `filterable` columns. Use per-column `filter` for cust ```ts const table = createDataTable({ - items, columns: [ { key: 'name', filterable: true }, { key: 'status', filterable: true, filter: (value, query) => { @@ -236,6 +260,8 @@ const table = createDataTable({ ], }) +table.onboard(items.map(value => ({ id: value.id, value }))) + table.search('active') ``` @@ -251,12 +277,13 @@ Control row selection with the `selectStrategy` option. ```ts const table = createDataTable({ - items, columns, selectStrategy: 'page', itemSelectable: 'canSelect', // Disable selection for rows where canSelect is falsy }) +table.onboard(items.map(value => ({ id: value.id, value }))) + table.selection.toggle('row-1') table.selection.isSelected('row-1') // true table.selection.isSelectable('row-1') // true (based on itemSelectable) @@ -271,11 +298,12 @@ Expand rows to reveal detail content. ```ts const table = createDataTable({ - items, columns, expandMultiple: false, // Only one row expanded at a time }) +table.onboard(items.map(value => ({ id: value.id, value }))) + table.expansion.toggle('row-1') table.expansion.isExpanded('row-1') // true table.expansion.expandAll() @@ -288,12 +316,13 @@ Group rows by a column value. ```ts const table = createDataTable({ - items, columns, groupBy: 'department', openAll: true, // Auto-open all groups }) +table.onboard(items.map(value => ({ id: value.id, value }))) + table.grouping.groups.value // [{ key: 'Engineering', value: 'Engineering', items: [...] }] table.grouping.toggle('Engineering') table.grouping.isOpen('Engineering') @@ -303,10 +332,10 @@ table.grouping.closeAll() ## Reactivity -| Property | Reactive | Notes | +| Property / Method | Reactive | Notes | | - | :-: | - | -| `items` | | Computed — final visible items | -| `allItems` | | Computed — raw unprocessed items | +| `items` | | Computed — final visible items (projected from registry tickets) | +| `allItems` | | Computed — every registered row, unfiltered/unsorted | | `filteredItems` | | Computed — items after filtering | | `sortedItems` | | Computed — items after filter + sort | | `query` | | ShallowRef — current search query (readonly) | @@ -321,6 +350,10 @@ table.grouping.closeAll() | `total` | | Computed — total row count | | `loading` | | Computed — adapter loading state | | `error` | | Computed — adapter error state | +| `register(input)` | — | Method — adds a single ticket, mutates the registry (downstream refs recompute) | +| `onboard(inputs)` | — | Method — bulk register, mutates the registry | +| `unregister(id)` | — | Method — removes a ticket by id | +| `clear()` | — | Method — wipes every ticket (useful before re-fetching server data) | ## Examples diff --git a/packages/0/PHILOSOPHY.md b/packages/0/PHILOSOPHY.md index aaea7963a..b5fe9af34 100644 --- a/packages/0/PHILOSOPHY.md +++ b/packages/0/PHILOSOPHY.md @@ -717,6 +717,43 @@ onBeforeUnmount(() => { Used in `Progress*`, `Splitter*`, and any compound whose sub-components need to coexist with consumer-supplied attributes. The outer `Atom` is where these are ultimately bound — sub-components do not forward `attrs` onto their own children, which is why the "never spread `attrs` on a non-renderless child" rule (§3.5) holds. +### 6.10 Collection composables: no `items` option + +**Rule.** A composable that owns a collection of values exposes `register` / `onboard` / `unregister`. It does not accept an `items` option in its factory. Row identity, order, and per-row state live in the registry — never on a parallel array threaded in through options. [intent:351] + +**Why.** A single source of truth: the registry owns the rows. An `items` option splits ownership between the consumer's array and the composable's internal state, which forces every pipeline stage to re-derive identity. Registry-based composables also dodge `MaybeRefOrGetter` plumbing — tickets are reactive by construction, so the composable does not have to `toValue()` an external ref on every recompute. Composition stays uniform: components and composables built on top get the same `register` / `onboard` surface they already use for `createSortable` and the selection chain. Tickets emitted by `unregister` / `offboard` can be moved between registries without re-deriving identity. [intent:352] + +**Before (wrong).** + +```ts +const table = createDataTable({ + items: users, + columns: [...], + itemValue: 'id', +}) +``` + +**After (right).** + +```ts +const table = createDataTable({ columns: [...] }) + +// Bulk register at setup +table.onboard(users.map(value => ({ id: value.id, value }))) + +// Or one at a time +const ticket = table.register({ id: user.id, value: user }) + +// Remove rows +ticket.unregister() +table.unregister(user.id) +table.clear() +``` + +Callers who want domain-stable identity pass `id` explicitly — same pattern as `createSortable`. Omit `id` and the registry auto-generates one via `useId()`. Consumers with a reactive items source watch it themselves and call `clear()` + `onboard()` (or maintain `register` / `unregister` incrementally); the composable does not auto-sync from an external ref. + +**Composables that follow this rule.** `createRegistry`, `createModel`, `createSelection`, `createSingle`, `createGroup`, `createStep`, `createNested`, `createSortable`, `createKanban`, `createQueue`, `createTimeline`, `createTokens`. `createDataTable` and `createDataGrid` predate the convention and have been brought in line — `items` and `itemValue` are gone; consumers `onboard` rows on the returned context. [intent:353] + --- ## 7. Events & lifecycle @@ -1328,7 +1365,7 @@ Reason: §2.8, [intent:151]. Auto-increment breaks SSR hydration. | 3 API shape | returns, args, names, slots, comments | 22 | | 4 Reactivity | primitives, readonly, options, scope | 22 | | 5 Headless | operational definition | 10 | -| 6 Registries | context, tickets, useProxyModel, useProxyRegistry, mergeProps | 14 | +| 6 Registries | context, tickets, useProxyModel, useProxyRegistry, mergeProps, collection composables | 17 | | 7 Events & lifecycle | binding, mounting, cleanup, toggle scope | 7 | | 8 Types | any, readonly-ref, MRG, generics, slot guards | 8 | | 9 Errors | throw/warn/return, logger, SSR | 9 | diff --git a/packages/0/src/composables/createDataTable/index.bench.ts b/packages/0/src/composables/createDataTable/index.bench.ts index f77c0f6a7..fbb0967a2 100644 --- a/packages/0/src/composables/createDataTable/index.bench.ts +++ b/packages/0/src/composables/createDataTable/index.bench.ts @@ -14,7 +14,7 @@ import { bench, describe } from 'vitest' import { createDataTable, ClientDataTableAdapter, VirtualDataTableAdapter } from './index' // Types -import type { DataTableColumn, DataTableOptions } from './index' +import type { DataTableColumn, DataTableOptions, DataTableTicketInput } from './index' // ============================================================================= // FIXTURES - Created once, reused across read-only benchmarks @@ -65,12 +65,20 @@ const COLUMNS_WITH_FILTER: DataTableColumn[] = [ const SEARCH_QUERY_1K = 'User 500' const SEARCH_QUERY_10K = 'User 5000' -function createTable (overrides: Partial> = {}) { - return createDataTable({ - items: overrides.items ?? ROWS_1K, - columns: overrides.columns ?? COLUMNS, - ...overrides, +function toInputs (rows: readonly BenchmarkRow[]): DataTableTicketInput[] { + return rows.map(value => ({ id: value.id, value })) +} + +function createTable ( + options: Partial> & { items?: readonly BenchmarkRow[] } = {}, +) { + const { items: rows = ROWS_1K, columns: cols = COLUMNS, ...rest } = options + const table = createDataTable({ + columns: cols, + ...rest, }) + table.onboard(toInputs(rows)) + return table } // ============================================================================= diff --git a/packages/0/src/composables/createDataTable/index.test.ts b/packages/0/src/composables/createDataTable/index.test.ts index d815c09b1..0875d0421 100644 --- a/packages/0/src/composables/createDataTable/index.test.ts +++ b/packages/0/src/composables/createDataTable/index.test.ts @@ -6,7 +6,7 @@ import { createDataTable, createDataTableContext, useDataTable, ServerDataTableA import { inject, nextTick, provide, ref } from 'vue' // Types -import type { DataTableColumn, DataTableOptions } from './index' +import type { DataTableColumn, DataTableOptions, DataTableTicketInput } from './index' vi.mock('vue', async () => { const actual = await vi.importActual('vue') @@ -45,15 +45,61 @@ const columns: DataTableColumn[] = [ { key: 'active', title: 'Status' }, ] -function createTable (overrides: Partial> = {}) { - return createDataTable({ - items: users, +function toInputs (rows: readonly T[]): DataTableTicketInput>[] { + return rows.map(value => ({ id: value.id, value: value as T & Record })) +} + +function createTable (overrides: Partial> = {}, rows: readonly User[] = users) { + const table = createDataTable({ columns, ...overrides, }) + table.onboard(toInputs(rows)) + return table } describe('createDataTable', () => { + describe('registry', () => { + it('should populate allItems from onboard', () => { + const table = createDataTable({ columns }) + expect(table.allItems.value.length).toBe(0) + + table.onboard(toInputs(users)) + + expect(table.allItems.value.length).toBe(5) + expect(table.size).toBe(5) + }) + + it('should add a single row via register', () => { + const table = createDataTable({ columns }) + const ticket = table.register({ id: 1, value: users[0]! }) + + expect(table.allItems.value.length).toBe(1) + expect(table.allItems.value[0]).toBe(users[0]) + expect(ticket.id).toBe(1) + }) + + it('should remove a row from allItems via unregister', () => { + const table = createTable() + expect(table.allItems.value.length).toBe(5) + + table.unregister(1) + + expect(table.allItems.value.length).toBe(4) + expect(table.allItems.value.find(item => item.id === 1)).toBeUndefined() + }) + + it('should wipe rows via clear', () => { + const table = createTable() + expect(table.allItems.value.length).toBe(5) + + table.clear() + + expect(table.allItems.value.length).toBe(0) + expect(table.size).toBe(0) + }) + }) + describe('search', () => { it('should update query ref', () => { const table = createTable() @@ -178,14 +224,14 @@ describe('createDataTable', () => { }) it('should sort null and undefined values consistently', () => { - const items = [ + const rows = [ { id: 1, name: null, email: '', department: '', salary: 0, active: true }, { id: 2, name: 'Bob', email: '', department: '', salary: 0, active: true }, { id: 3, name: undefined, email: '', department: '', salary: 0, active: true }, { id: 4, name: 'Alice', email: '', department: '', salary: 0, active: true }, ] as unknown as User[] - const table = createTable({ items }) + const table = createTable({}, rows) table.sort.toggle('name') const names = table.sortedItems.value.map(i => (i as User).name) // Non-null values sorted first, null/undefined grouped at end @@ -611,10 +657,9 @@ describe('createDataTable', () => { describe('openAll with async items', () => { it('should auto-open groups when items arrive', async () => { - const items = ref([]) - const table = createTable({ items, groupBy: 'department', openAll: true }) + const table = createDataTable({ columns, groupBy: 'department', openAll: true }) expect(table.grouping.groups.value.length).toBe(0) - items.value = [...users] + table.onboard(toInputs(users)) await nextTick() expect(table.grouping.groups.value.length).toBe(3) expect(table.grouping.isOpen('Engineering')).toBe(true) @@ -622,16 +667,14 @@ describe('createDataTable', () => { }) it('should ignore empty watcher updates before items arrive', async () => { - const items = ref([]) - const table = createTable({ items, groupBy: 'department', openAll: true }) + const table = createDataTable({ columns, groupBy: 'department', openAll: true }) - // Trigger watch with still-empty items (should be no-op via line 581) - items.value = [] + // No rows yet — watcher should remain quiescent await nextTick() expect(table.grouping.groups.value.length).toBe(0) // Now provide actual items - items.value = [...users] + table.onboard(toInputs(users)) await nextTick() expect(table.grouping.groups.value.length).toBe(3) expect(table.grouping.isOpen('Engineering')).toBe(true) @@ -663,11 +706,13 @@ describe('createDataTable', () => { describe('selection with empty selectable scope', () => { it('should handle isAllSelected and isMixed with no selectable items', () => { - const table = createTable({ - selectStrategy: 'all', - items: [{ id: 1, name: 'Alice', email: 'a@t.com', department: 'Eng', salary: 100_000, active: false }], - itemSelectable: 'active', - }) + const table = createTable( + { + selectStrategy: 'all', + itemSelectable: 'active', + }, + [{ id: 1, name: 'Alice', email: 'a@t.com', department: 'Eng', salary: 100_000, active: false }], + ) expect(table.selection.isAllSelected.value).toBe(false) expect(table.selection.isMixed.value).toBe(false) }) @@ -675,11 +720,8 @@ describe('createDataTable', () => { describe('recursive columns', () => { it('should use leaf columns for the data pipeline', () => { - const table = createDataTable({ - items: [ - { id: 1, name: 'Alice', email: 'a@b.com', phone: '555' }, - { id: 2, name: 'Bob', email: 'b@b.com', phone: '666' }, - ], + type Row = { id: number, name: string, email: string, phone: string } + const table = createDataTable({ columns: [ { key: 'name', title: 'Name', sortable: true, filterable: true }, { @@ -692,6 +734,10 @@ describe('createDataTable', () => { }, ], }) + table.onboard([ + { id: 1, value: { id: 1, name: 'Alice', email: 'a@b.com', phone: '555' } }, + { id: 2, value: { id: 2, name: 'Bob', email: 'b@b.com', phone: '666' } }, + ]) expect(table.leaves).toHaveLength(3) expect(table.leaves.map(c => c.key)).toEqual(['name', 'email', 'phone']) @@ -703,7 +749,6 @@ describe('createDataTable', () => { it('should expose resolved 2D headers', () => { const table = createDataTable({ - items: [], columns: [ { key: 'name', title: 'Name' }, { @@ -726,7 +771,6 @@ describe('createDataTable', () => { it('should produce single header row for flat columns', () => { const table = createDataTable({ - items: [], columns: [ { key: 'name', title: 'Name' }, { key: 'email', title: 'Email' }, @@ -739,32 +783,17 @@ describe('createDataTable', () => { }) describe('edge cases', () => { - it('should default itemValue to id', () => { + it('should select rows by registered ticket id', () => { const table = createTable() table.selection.select(1) expect(table.selection.isSelected(1)).toBe(true) }) - it('should throw from rowId on non-string/number itemValue', () => { - type BadItem = { id: number, data: object } - const table = createDataTable({ - items: [{ id: 1, data: { foo: 'bar' } }], - columns: [{ key: 'id', title: 'ID' }], - itemValue: 'data' as never, - selectStrategy: 'all', - }) - - // rowId is called internally when selectAll iterates items - expect(() => table.selection.selectAll()).toThrow('[v0:data-table]') - }) - - it('should update pipeline when reactive items source changes', () => { - const items = ref([...users]) - const table = createTable({ items }) - + it('should update pipeline when rows change', () => { + const table = createTable() expect(table.allItems.value.length).toBe(5) - items.value = [...users, { id: 6, name: 'Frank', email: 'frank@test.com', department: 'Sales', salary: 90_000, active: true }] + table.register({ id: 6, value: { id: 6, name: 'Frank', email: 'frank@test.com', department: 'Sales', salary: 90_000, active: true } }) expect(table.allItems.value.length).toBe(6) }) @@ -800,10 +829,7 @@ describe('createDataTableContext', () => { }) it('should return trinity tuple', () => { - const trinity = createDataTableContext({ - items: users, - columns, - }) + const trinity = createDataTableContext({ columns }) expect(trinity).toHaveLength(3) const [use, prov, ctx] = trinity @@ -812,13 +838,12 @@ describe('createDataTableContext', () => { expect(ctx).toBeDefined() expect(ctx.items).toBeDefined() expect(ctx.sort).toBeDefined() + expect(typeof ctx.register).toBe('function') + expect(typeof ctx.onboard).toBe('function') }) it('should call Vue provide from provideDataTable', () => { - const [, provideDataTable, context] = createDataTableContext({ - items: users, - columns, - }) + const [, provideDataTable, context] = createDataTableContext({ columns }) provideDataTable() expect(mockProvide).toHaveBeenCalledWith('v0:data-table', context) @@ -834,9 +859,8 @@ describe('createDataTableContext', () => { }) it('should support custom namespace', () => { - const [, provideDataTable, context] = createDataTableContext({ + const [, provideDataTable, context] = createDataTableContext({ namespace: 'custom:table', - items: users, columns, }) diff --git a/packages/0/src/composables/createDataTable/index.ts b/packages/0/src/composables/createDataTable/index.ts index 4647a3f89..df326e31b 100644 --- a/packages/0/src/composables/createDataTable/index.ts +++ b/packages/0/src/composables/createDataTable/index.ts @@ -8,6 +8,10 @@ * reimplementing their logic. Uses createGroup's tri-state for sort direction * and delegates the data pipeline (filter, sort, paginate) to an adapter. * + * Rows are managed via an internal registry — register / onboard / unregister. + * Row identity is the ticket id (caller-supplied or auto-generated). The + * factory no longer accepts an `items` option. + * * Key features: * - Adapter pattern for pipeline strategy (client, server, virtual) * - Sort via createGroup tri-state: selected=asc, mixed=desc, unselected=none @@ -23,9 +27,12 @@ * import { createDataTable } from '@vuetify/v0' * * const table = createDataTable({ - * items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], * columns: [{ key: 'name', title: 'Name', sortable: true }], * }) + * table.onboard([ + * { id: 1, value: { id: 1, name: 'Alice' } }, + * { id: 2, value: { id: 2, name: 'Bob' } }, + * ]) * table.sort.toggle('name') * ``` */ @@ -33,6 +40,7 @@ // Composables import { useContext } from '#v0/composables/createContext' import { createGroup } from '#v0/composables/createGroup' +import { createRegistry } from '#v0/composables/createRegistry' import { createTrinity } from '#v0/composables/createTrinity' import { useLocale } from '#v0/composables/useLocale' @@ -43,17 +51,18 @@ import { ClientDataTableAdapter } from './adapters/v0' import { extractLeaves, resolveHeaders } from './columns' // Utilities -import { isNumber, isNullOrUndefined, isString } from '#v0/utilities' +import { isNullOrUndefined } from '#v0/utilities' import { computed, shallowReactive, shallowReadonly, shallowRef, toRef, watch } from 'vue' // Types import type { FilterOptions } from '#v0/composables/createFilter' import type { PaginationContext, PaginationOptions } from '#v0/composables/createPagination' +import type { RegistryContext, RegistryTicket, RegistryTicketInput } from '#v0/composables/createRegistry' import type { ContextTrinity } from '#v0/composables/createTrinity' import type { ID } from '#v0/types' import type { DataTableAdapter, SortDirection, SortEntry } from './adapters/adapter' import type { InternalHeader } from './columns' -import type { MaybeRefOrGetter, Ref, ShallowRef } from 'vue' +import type { Ref, ShallowRef } from 'vue' // Re-export adapter types export { DataTableAdapter } from './adapters' @@ -70,6 +79,26 @@ export type KeysOfType = { [K in keyof T]: T[K] extends V ? K : never }[ke export type SelectStrategy = 'single' | 'page' | 'all' +/** + * Input shape passed to `register` / `onboard` on a data table. + * + * @template T Row value type. + * + * @example + * ```ts + * const table = createDataTable({ columns }) + * table.register({ id: user.id, value: user }) + * ``` + */ +export type DataTableTicketInput> = RegistryTicketInput + +/** + * Output ticket returned by `register` / `onboard` / `get`. + * + * @template T Row value type. + */ +export type DataTableTicket> = RegistryTicket & DataTableTicketInput + export interface DataTableColumn = Record> { readonly key: string readonly title?: string @@ -166,12 +195,8 @@ export interface DataTableExpansion { } export interface DataTableOptions> { - /** Source items */ - items: MaybeRefOrGetter /** Column definitions */ columns: readonly DataTableColumn[] - /** Property used as row identifier. Must resolve to a string or number value. @default 'id' */ - itemValue?: KeysOfType /** Filter options (keys derived from columns) */ filter?: Omit /** Pagination options (size derived from pipeline) */ @@ -198,7 +223,8 @@ export interface DataTableOptions> { adapter?: DataTableAdapter } -export interface DataTableContext> { +export interface DataTableContext> + extends RegistryContext, DataTableTicket> { /** Final paginated items for rendering */ items: Readonly> /** Raw unprocessed items */ @@ -243,18 +269,22 @@ export interface DataTableContextOptions> exte * Creates a data table instance with sort controls, selection, and an * adapter-driven data pipeline. * + * Rows are managed via the embedded registry: `register({ id, value })` for + * a single row, `onboard([...])` for bulk, `unregister(id)` / `clear()` for + * removal. Row identity is the ticket id; pass it explicitly when the caller + * wants to address rows by a domain identifier (e.g. selection toggles). + * * Must be called inside a component `setup()` or a Vue effect scope. * Calling at module scope in SSR environments causes request state leakage. * * @param options Data table options - * @returns Data table context with pipeline stages and controls + * @returns Data table context with pipeline stages, controls, and registry surface * * @example * ```ts * import { createDataTable } from '@vuetify/v0' * - * const table = createDataTable({ - * items: users, + * const table = createDataTable({ * columns: [ * { key: 'name', title: 'Name', sortable: true, filterable: true }, * { key: 'email', title: 'Email', sortable: true, filterable: true }, @@ -262,6 +292,9 @@ export interface DataTableContextOptions> exte * ], * }) * + * // Register rows (id is the row identifier) + * table.onboard(users.map(value => ({ id: value.id, value }))) + * * // Search * table.search('john') * @@ -269,22 +302,19 @@ export interface DataTableContextOptions> exte * table.sort.toggle('name') // asc * table.sort.toggle('name') // desc * table.sort.toggle('name') // none - * console.log(table.sort.columns.value) // [{ key: 'name', direction: 'asc' }] * * // Paginate * table.pagination.next() * - * // Select rows - * table.selection.toggle('user-1') + * // Select rows by ticket id + * table.selection.toggle(1) * ``` */ export function createDataTable> ( options: DataTableOptions, ): DataTableContext { const { - items: _items, columns, - itemValue = 'id' as KeysOfType, filter: filterOptions = {}, pagination: paginationOptions = {}, sortMultiple = false, @@ -299,6 +329,11 @@ export function createDataTable> ( adapter = new ClientDataTableAdapter(), } = options + const registry = createRegistry, DataTableTicket>({ + events: true, + reactive: true, + }) + const _query = shallowRef('') function search (value: string) { @@ -433,6 +468,11 @@ export function createDataTable> ( if (col.filter) filters[col.key] = col.filter } + // Pipeline source: row values projected from the registry's tickets in + // registration order. Adapters still read this as `context.items` and the + // existing pipeline (filter → sort → paginate) is unchanged. + const registryItems = toRef(() => registry.values().map(t => t.value as T)) + const { allItems, filteredItems, @@ -443,7 +483,7 @@ export function createDataTable> ( loading = toRef(() => false) as Readonly>, error = toRef(() => null) as Readonly>, } = adapter.setup({ - items: _items, + items: registryItems, search: _query, filterableKeys: filterable, sortBy, @@ -456,17 +496,21 @@ export function createDataTable> ( const selectedIds = shallowReactive(new Set()) + // Reverse lookup: row → ticket id. Uses the registry's catalog (O(1)) so + // selection/expansion can derive ids from items returned by the pipeline. function rowId (item: T): ID { - const value = item[itemValue] - if (isString(value) || isNumber(value)) return value - throw new Error(`[v0:data-table] itemValue "${itemValue}" must resolve to a string or number`) + const ids = registry.browse(item) + if (isNullOrUndefined(ids) || ids.length === 0) { + throw new Error('[v0:data-table] item is not registered') + } + return ids[0]! } const selectableIds = computed(() => { if (!itemSelectable) return null const ids = new Set() - for (const item of allItems.value) { - if (item[itemSelectable]) ids.add(rowId(item)) + for (const ticket of registry.values()) { + if (ticket.value[itemSelectable]) ids.add(ticket.id) } return ids }) @@ -602,16 +646,14 @@ export function createDataTable> ( opened.add(group.key) } - // Async items may not be available yet — watch for first non-empty groups - if (groups.value.length === 0) { - const stop = watch(groups, newGroups => { - if (newGroups.length === 0) return - for (const group of newGroups) { - opened.add(group.key) - } - stop() - }) - } + // Rows may be registered after construction — watch for new groups and + // auto-open them as they appear. `flush: 'sync'` keeps openAll + // synchronous from the consumer's perspective. + watch(groups, newGroups => { + for (const group of newGroups) { + opened.add(group.key) + } + }, { flush: 'sync' }) } const grouping: DataTableGrouping = { @@ -643,6 +685,7 @@ export function createDataTable> ( } return { + ...registry, items: visible, allItems, filteredItems, @@ -660,6 +703,9 @@ export function createDataTable> ( total, loading, error, + get size () { + return registry.size + }, } } @@ -675,7 +721,6 @@ export function createDataTable> ( * * const [useDataTable, provideDataTable, context] = createDataTableContext({ * namespace: 'app:users', - * items: users, * columns: [ * { key: 'name', title: 'Name', sortable: true }, * ], @@ -683,6 +728,7 @@ export function createDataTable> ( * * // Parent component * provideDataTable() + * context.onboard(users.map(value => ({ id: value.id, value }))) * * // Child component * const table = useDataTable() From db43a3d4f9c87a12c0b6134da1d2cee971c84a4b Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 10:58:12 -0500 Subject: [PATCH 2/6] fix(createDataTable): prune ghost ids, fix dup-value collapse and group reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selection/expansion subscribe to unregister:ticket and clear:registry so stale ids cannot diverge from the registry - replace rowId(item) reverse lookup with ticket-walking computeds so two tickets sharing a row value reference both participate in selectAll / isAllSelected / toggleAll (catalog buckets keyed by object identity used to collapse onto ids[0]) - guard selectableIds against tickets registered without a value - openAll watcher tracks autoOpened keys and only opens new group keys, so registering more rows no longer reopens groups the user explicitly closed - drop stale createDataGrid references from PHILOSOPHY §6.10, composables rule, and columns JSDoc - rename example fetch -> fetchPage to stop shadowing the global --- .claude/rules/composables.md | 2 +- .../create-data-table/server/ServerTable.vue | 4 +- .../create-data-table/server/api.ts | 2 +- .../composables/data/create-data-table.md | 2 +- packages/0/PHILOSOPHY.md | 2 +- .../composables/createDataTable/columns.ts | 2 +- .../composables/createDataTable/index.test.ts | 160 +++++++++++++++++- .../src/composables/createDataTable/index.ts | 90 ++++++---- 8 files changed, 227 insertions(+), 37 deletions(-) diff --git a/.claude/rules/composables.md b/.claude/rules/composables.md index 8a528ba3f..1ba58fd81 100644 --- a/.claude/rules/composables.md +++ b/.claude/rules/composables.md @@ -346,7 +346,7 @@ const table = createDataTable({ ### Collection composables: no `items` option -A composable that owns a collection of values exposes `register` / `onboard` / `unregister`. It never accepts an `items` option in its factory — row identity, order, and per-row state live in the registry. Followed by `createRegistry`, `createModel`, `createSelection`, `createSingle`, `createGroup`, `createStep`, `createNested`, `createSortable`, `createKanban`, `createQueue`, `createTimeline`, `createTokens`, and (after the recent refactor) `createDataTable` and `createDataGrid`. Full rule and migration shape: PHILOSOPHY §6.10. +A composable that owns a collection of values exposes `register` / `onboard` / `unregister`. It never accepts an `items` option in its factory — row identity, order, and per-row state live in the registry. Followed by `createRegistry`, `createModel`, `createSelection`, `createSingle`, `createGroup`, `createStep`, `createNested`, `createSortable`, `createKanban`, `createQueue`, `createTimeline`, `createTokens`, and (after the recent refactor) `createDataTable`. Full rule and migration shape: PHILOSOPHY §6.10. ### `useProxyModel` and `useProxyRegistry` — cross-link diff --git a/apps/docs/src/examples/composables/create-data-table/server/ServerTable.vue b/apps/docs/src/examples/composables/create-data-table/server/ServerTable.vue index 3395dbe3e..887f9a3d6 100644 --- a/apps/docs/src/examples/composables/create-data-table/server/ServerTable.vue +++ b/apps/docs/src/examples/composables/create-data-table/server/ServerTable.vue @@ -1,7 +1,7 @@