Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/rules/composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`. 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:
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions .claude/rules/new-feature-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [ ] `<DocsApi />` 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
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import { users } from './data'

const table = createDataTable({
items: users,
columns,
pagination: { itemsPerPage: 5 },
})

function sortIcon (key: string) {
const dir = table.sort.direction(key)
table.columns.onboard(columns)
table.onboard(users.map(value => ({ id: value.id, value })))

function arrow (id: string) {
const dir = table.sort.direction(id)
if (dir === 'asc') return '↑'
if (dir === 'desc') return '↓'
return ''
Expand All @@ -32,13 +33,13 @@
<thead>
<tr class="border-b border-divider bg-surface-tint">
<th
v-for="col in columns"
:key="col.key"
v-for="col in table.columns.values()"
:key="col.id"
class="px-4 py-3 text-left font-medium cursor-pointer select-none hover:text-primary transition-colors"
@click="table.sort.toggle(col.key)"
@click="table.sort.toggle(col.id)"
>
{{ col.title }}
<span class="ml-1 text-xs opacity-50">{{ sortIcon(col.key) }}</span>
<span class="ml-1 text-xs opacity-50">{{ arrow(col.id) }}</span>
</th>
</tr>
</thead>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { DataTableColumn } from '@vuetify/v0'
import type { DataTableColumnTicketInput } from '@vuetify/v0'
import type { User } from './data'

export const columns: DataTableColumn<User>[] = [
{ key: 'name', title: 'Name', sortable: true, filterable: true },
{ key: 'email', title: 'Email', sortable: true, filterable: true },
{ key: 'role', title: 'Role', sortable: true },
export const columns: DataTableColumnTicketInput<User>[] = [
{ id: 'name', title: 'Name', sortable: true, filterable: true },
{ id: 'email', title: 'Email', sortable: true, filterable: true },
{ id: 'role', title: 'Role', sortable: true },
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import { employees } from './data'

const table = createDataTable({
items: employees,
columns,
groupBy: 'department',
openAll: true,
mandate: true,
Expand All @@ -15,14 +13,17 @@
pagination: { itemsPerPage: 20 },
})

function sortIcon (key: string) {
const dir = table.sort.direction(key)
table.columns.onboard(columns)
table.onboard(employees.map(value => ({ id: value.id, value })))

function arrow (id: string) {
const dir = table.sort.direction(id)
if (dir === 'asc') return '↑'
if (dir === 'desc') return '↓'
return ''
}

function formatSalary (value: number) {
function format (value: number) {
return `$${value.toLocaleString()}`
}
</script>
Expand Down Expand Up @@ -68,13 +69,13 @@
</th>

<th
v-for="col in columns"
:key="col.key"
v-for="col in table.columns.values()"
:key="col.id"
class="px-4 py-3 text-left font-medium cursor-pointer select-none hover:text-primary transition-colors"
@click="table.sort.toggle(col.key)"
@click="table.sort.toggle(col.id)"
>
{{ col.title }}
<span class="ml-1 text-xs opacity-50">{{ sortIcon(col.key) }}</span>
<span class="ml-1 text-xs opacity-50">{{ arrow(col.id) }}</span>
</th>
</tr>
</thead>
Expand All @@ -85,7 +86,7 @@
class="bg-surface-tint cursor-pointer hover:bg-surface-variant transition-colors"
@click="table.grouping.toggle(group.key)"
>
<td class="px-4 py-2 font-medium" :colspan="columns.length + 1">
<td class="px-4 py-2 font-medium" :colspan="table.columns.size + 1">
<span class="mr-2 text-xs">{{ table.grouping.isOpen(group.key) ? '−' : '+' }}</span>
{{ group.key }}
<span class="ml-2 text-xs opacity-50">({{ group.items.length }})</span>
Expand All @@ -111,7 +112,7 @@

<td class="px-4 py-3">{{ item.name }}</td>
<td class="px-4 py-3">{{ item.department }}</td>
<td class="px-4 py-3 font-mono">{{ formatSalary(item.salary) }}</td>
<td class="px-4 py-3 font-mono">{{ format(item.salary) }}</td>

<td class="px-4 py-3">
<span
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { DataTableColumn } from '@vuetify/v0'
import type { DataTableColumnTicketInput } from '@vuetify/v0'
import type { Employee } from './data'

export const columns: DataTableColumn<Employee>[] = [
{ key: 'name', title: 'Name', sortable: true, filterable: true },
{ key: 'department', title: 'Department', sortable: true },
export const columns: DataTableColumnTicketInput<Employee>[] = [
{ id: 'name', title: 'Name', sortable: true, filterable: true },
{ id: 'department', title: 'Department', sortable: true },
{
key: 'salary',
id: 'salary',
title: 'Salary',
sortable: true,
filterable: true,
Expand All @@ -17,5 +17,5 @@ export const columns: DataTableColumn<Employee>[] = [
return String(value).includes(query)
},
},
{ key: 'active', title: 'Status', sortable: true },
{ id: 'active', title: 'Status', sortable: true },
]
Original file line number Diff line number Diff line change
@@ -1,48 +1,43 @@
<script setup lang="ts">
import { createDataTable, ServerDataTableAdapter } from '@vuetify/v0'
import { shallowRef, watch } from 'vue'
import { fetchUsers } from './api'
import { fetchPage } from './api'
import { columns } from './columns'

import type { User } from './api'

const serverItems = shallowRef<User[]>([])
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
table.columns.onboard(columns)

async function load () {
loading.value = true

const result = await fetchUsers(
const result = await fetchPage(
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) {
const dir = table.sort.direction(key)
function arrow (id: string) {
const dir = table.sort.direction(id)
if (dir === 'asc') return '↑'
if (dir === 'desc') return '↓'
return ''
Expand Down Expand Up @@ -76,13 +71,13 @@
<thead>
<tr class="border-b border-divider bg-surface-tint">
<th
v-for="col in columns"
:key="col.key"
v-for="col in table.columns.values()"
:key="col.id"
class="px-4 py-3 text-left font-medium cursor-pointer select-none hover:text-primary transition-colors"
@click="table.sort.toggle(col.key)"
@click="table.sort.toggle(col.id)"
>
{{ col.title }}
<span class="ml-1 text-xs opacity-50">{{ sortIcon(col.key) }}</span>
<span class="ml-1 text-xs opacity-50">{{ arrow(col.id) }}</span>
</th>
</tr>
</thead>
Expand All @@ -109,7 +104,7 @@

<div class="flex items-center justify-between text-sm">
<span class="opacity-60">
{{ totalCount }} total
{{ total }} total
</span>

<div class="flex gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 fetchPage (
query: string,
sorts: SortEntry[],
page: number,
Expand All @@ -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
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { DataTableColumn } from '@vuetify/v0'
import type { DataTableColumnTicketInput } from '@vuetify/v0'
import type { User } from './api'

export const columns: DataTableColumn<User>[] = [
{ key: 'name', title: 'Name', sortable: true, filterable: true },
{ key: 'email', title: 'Email', sortable: true, filterable: true },
{ key: 'department', title: 'Department', sortable: true },
export const columns: DataTableColumnTicketInput<User>[] = [
{ id: 'name', title: 'Name', sortable: true, filterable: true },
{ id: 'email', title: 'Email', sortable: true, filterable: true },
{ id: 'department', title: 'Department', sortable: true },
]
Original file line number Diff line number Diff line change
Expand Up @@ -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.columns.onboard(columns)
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,
Expand All @@ -24,11 +25,11 @@
const stats = computed(() => ({
total: items.length,
filtered: table.items.value.length,
rendered: virtualItems.value.length,
rendered: visible.value.length,
}))

function sortIcon (key: string) {
const dir = table.sort.direction(key)
function arrow (id: string) {
const dir = table.sort.direction(id)
if (dir === 'asc') return '↑'
if (dir === 'desc') return '↓'
return ''
Expand Down Expand Up @@ -61,13 +62,13 @@
<thead class="sticky top-0 z-10 bg-surface">
<tr class="border-b border-divider bg-surface-tint">
<th
v-for="col in columns"
:key="col.key"
v-for="col in table.columns.values()"
:key="col.id"
class="px-4 py-3 text-left font-medium cursor-pointer select-none hover:text-primary transition-colors"
@click="table.sort.toggle(col.key)"
@click="table.sort.toggle(col.id)"
>
{{ col.title }}
<span class="ml-1 text-xs opacity-50">{{ sortIcon(col.key) }}</span>
<span class="ml-1 text-xs opacity-50">{{ arrow(col.id) }}</span>
</th>
</tr>
</thead>
Expand All @@ -76,7 +77,7 @@
<tr :style="{ height: `${offset}px` }" />

<tr
v-for="item in virtualItems"
v-for="item in visible"
:key="item.raw.id"
class="h-[40px] hover:bg-surface-tint transition-colors"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { DataTableColumn } from '@vuetify/v0'
import type { DataTableColumnTicketInput } from '@vuetify/v0'
import type { User } from './data'

export const columns: DataTableColumn<User>[] = [
{ key: 'name', title: 'Name', sortable: true, filterable: true },
{ key: 'email', title: 'Email', sortable: true, filterable: true },
{ key: 'score', title: 'Score', sortable: true, sort: (a: unknown, b: unknown) => Number(a) - Number(b) },
export const columns: DataTableColumnTicketInput<User>[] = [
{ id: 'name', title: 'Name', sortable: true, filterable: true },
{ id: 'email', title: 'Email', sortable: true, filterable: true },
{ id: 'score', title: 'Score', sortable: true, sort: (a: unknown, b: unknown) => Number(a) - Number(b) },
]
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Loading
Loading