Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
232b2e0
feat(createDataGrid): add row ordering state management
johnleider Mar 30, 2026
a2b0850
feat(createDataGrid): add cell editing with validation and dirty trac…
johnleider Mar 30, 2026
9b5f49a
feat(createDataGrid): add row spanning computation
johnleider Mar 30, 2026
60d0b51
feat(createDataGrid): add column layout with sizing, pinning, resizin…
johnleider Mar 30, 2026
e6b926b
feat(createDataGrid): add grid adapters (client, server, virtual)
johnleider Mar 30, 2026
785d1cc
feat(createDataGrid): add main factory with trinity pattern
johnleider Mar 30, 2026
29db7bc
feat(createDataGrid): add integration tests
johnleider Mar 30, 2026
ea5a3b7
refactor(createDataGrid): simplify adapters, layout, and factory
johnleider Mar 30, 2026
50b2913
refactor(createDataGrid): fix validation, editing, and pattern compli…
johnleider Apr 12, 2026
57e268b
fix(createDataGrid): use isUndefined guard for item check
johnleider Apr 12, 2026
0e6cdcd
refactor(createDataGrid): shorten multi-word variable names
johnleider Apr 12, 2026
f6cc2b3
docs(createDataGrid): add documentation page with examples
johnleider Apr 12, 2026
6b2d13e
docs(createDataGrid): redesign examples with distinct use cases
johnleider Apr 12, 2026
150977c
docs(createDataGrid): fix pinned grid bugs and add table-fixed
johnleider Apr 12, 2026
b462f44
docs(createDataGrid): fix resize by moving listeners to document
johnleider Apr 12, 2026
66920e3
docs(createDataGrid): truncate cells to prevent row overflow on resize
johnleider Apr 12, 2026
eb2490e
docs(createDataGrid): prevent sort toggle when clicking resize handle
johnleider Apr 12, 2026
4d25957
docs(createDataGrid): use useToggleScope + useEventListener for resize
johnleider Apr 12, 2026
35eb072
docs(createDataGrid): use useClickOutside and useHotkey for editing
johnleider Apr 12, 2026
5b6d861
docs(createDataGrid): add pin/unpin controls and fix resize on frozen…
johnleider Apr 12, 2026
869c257
docs(createDataGrid): fix lint prefer-array-some
johnleider Apr 12, 2026
f6980ed
docs(createDataGrid): rewrite basic example to showcase all different…
johnleider Apr 12, 2026
4eeb40f
feat(createDataGrid): add performance benchmarks
johnleider Apr 12, 2026
9b1bdbf
refactor(createDataGrid): align validate signature with v0 Rule pattern
johnleider Apr 12, 2026
ce5e05b
refactor(createDataGrid): compose layout from registry + group
johnleider Apr 12, 2026
bdb1ce7
refactor(createDataGrid): align benchmarks for cross-composable compa…
johnleider Apr 12, 2026
0978fad
perf(createDataTable): optimize sort pipeline with Intl.Collator reuse
johnleider Apr 12, 2026
b3370fe
chore(typed-router): regenerate after rebase
johnleider May 12, 2026
bf6255c
chore(createDataGrid): align with renamed DataTable adapter exports
johnleider May 12, 2026
6574984
chore(createDataGrid): adopt master conventions from past month
johnleider May 12, 2026
49c7826
docs(createDataGrid): normalize page flow and enrich feature examples
johnleider May 12, 2026
5852214
docs(createDataGrid): reorder page sections and set per-column minSize
johnleider May 12, 2026
875b124
docs(createDataGrid): allow horizontal scroll on spanning example
johnleider May 12, 2026
f557feb
fix(createDataGrid): pinned columns resize across regions
johnleider May 12, 2026
824ff7b
chore(createDataGrid): adopt registry pattern from createDataTable
johnleider May 12, 2026
82c2712
docs(createDataGrid): sweep multi-word identifiers in examples
johnleider May 12, 2026
5a02937
refactor(createDataGrid): adapt to master's createDataTable column re…
johnleider May 28, 2026
b96dd20
refactor(createDataGrid): row ordering via createSortable; drop adapt…
johnleider May 28, 2026
44876df
refactor(createDataGrid): drop parallel column registry; layout reads…
johnleider May 28, 2026
3d12cc6
fix(createDataGrid): prune editing state on row unregister; drop ref-…
johnleider May 28, 2026
54a51e8
fix(createDataGrid): track column changes in row spanning
johnleider May 28, 2026
13cca90
docs(createDataGrid): add to maturity.json, READMEs, and composables …
johnleider May 28, 2026
83eccb3
fix(createDataGrid): measure right-region offsets from the right edge
johnleider May 28, 2026
d7609db
docs(createDataGrid): editing example uses createTimeline for undo/redo
johnleider May 28, 2026
ba7fdc6
docs(createDataGrid): rewrite row-spanning example as portfolio holdings
johnleider May 28, 2026
4534ed9
docs(createDataGrid): remove orphaned BasicGrid example
johnleider May 29, 2026
0993d6f
docs(createDataGrid): use Array#toReversed in editing history
johnleider May 29, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef, useTemplateRef } from 'vue'

import {
mdiArrowDown,
mdiArrowUp,
mdiPackageVariantClosed,
mdiPencilOutline,
mdiRedo,
mdiRefresh,
mdiUndo,
} from '@mdi/js'

import {
createDataGrid,
createTimeline,
useClickOutside,
useHotkey,
useToggleScope,
} from '@vuetify/v0'
import type { ID } from '@vuetify/v0'

import { columns } from './columns'
import { products } from './data'

interface EditEntry {
row: ID
column: string
from: unknown
to: unknown
}

const input = ref('')
const editRef = useTemplateRef<HTMLInputElement[]>('edit-input')
const canRedo = shallowRef(false)

const timeline = createTimeline<{ id?: ID, value: EditEntry }>({
size: 50,
reactive: true,
})

function applyValue (row: ID, column: string, value: unknown) {
const item = products.find(p => p.id === row)
if (!item) return
const coerced = column === 'price' || column === 'quantity' ? Number(value) : value
;(item as Record<string, unknown>)[column] = coerced
}

const grid = createDataGrid({
columns,
editing: {
onEdit (row, column, value, item) {
const from = item ? item[column as keyof typeof item] : undefined
timeline.register({ value: { row, column, from, to: value } })
applyValue(row, column, value)
canRedo.value = false
},
},
})

grid.onboard(products.map(value => ({ id: value.id, value })))

const history = computed(() => timeline.values().toReversed())
const editedCells = computed(
() => new Set(timeline.values().map(t => `${t.value.row}:${t.value.column}`)),
)

function cellKey (row: ID, column: string) {
return `${row}:${column}`
}

function isEditing (row: ID, column: string) {
const cell = grid.editing.active.value
return cell?.row === row && cell?.column === column
}

function isEdited (row: ID, column: string) {
return editedCells.value.has(cellKey(row, column))
}

function isEditable (column: string) {
return columns.find(c => c.id === column)?.editable === true
}

function isSortable (column: string) {
const col = columns.find(c => c.id === column)
return col?.sortable === true || col?.sort !== undefined
}

function onEdit (row: ID, column: string, value: unknown) {
if (!isEditable(column)) return

grid.editing.edit(row, column)
input.value = String(value ?? '')

nextTick(() => {
const el = editRef.value?.[0]
el?.focus()
el?.select()
})
}

function onCommit () {
grid.editing.commit(input.value)
}

function onCancel () {
grid.editing.cancel()
}

function onUndo () {
const ticket = timeline.undo()
if (!ticket) return
applyValue(ticket.value.row, ticket.value.column, ticket.value.from)
canRedo.value = true
}

function onRedo () {
const ticket = timeline.redo()
if (!ticket) return
applyValue(ticket.value.row, ticket.value.column, ticket.value.to)
}

function onClear () {
timeline.clear()
canRedo.value = false
}

const cell = useTemplateRef<HTMLTableCellElement>('active-cell')

useToggleScope(
() => !!grid.editing.active.value,
() => {
useClickOutside(cell, () => onCancel())
useHotkey('escape', () => onCancel(), { inputs: true })
},
)

useHotkey('ctrl+z', () => onUndo())
useHotkey('ctrl+shift+z', () => onRedo())
useHotkey('ctrl+y', () => onRedo())

function money (value: unknown) {
return `$${Number(value).toFixed(2)}`
}

const total = computed(() => products.reduce((sum, p) => sum + p.price * p.quantity, 0))
const low = computed(() => products.filter(p => p.quantity < 50).length)
</script>

<template>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-on-surface-variant" viewBox="0 0 24 24">
<path :d="mdiPackageVariantClosed" fill="currentColor" />
</svg>

<span class="text-on-surface-variant">Items</span>
<span class="tabular-nums font-medium">{{ products.length }}</span>
</div>

<div class="flex items-center gap-1.5">
<span class="text-on-surface-variant">Inventory value</span>
<span class="tabular-nums font-medium">${{ total.toFixed(0) }}</span>
</div>

<div class="flex items-center gap-1.5">
<span class="text-on-surface-variant">Low stock</span>

<span
class="tabular-nums font-medium px-1.5 rounded-full text-xs"
:class="low > 0 ? 'bg-warning/15 text-warning' : 'text-on-surface-variant'"
>
{{ low }}
</span>
</div>
</div>

<div class="flex items-center gap-2">
<div
v-if="editedCells.size > 0"
class="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-primary/15 text-primary"
>
<svg class="w-3 h-3" viewBox="0 0 24 24">
<path :d="mdiPencilOutline" fill="currentColor" />
</svg>

<span class="tabular-nums">{{ editedCells.size }} edited</span>
</div>

<button
class="flex items-center gap-1 px-2 py-1 text-xs border border-divider rounded hover:bg-surface-tint disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="timeline.size === 0"
title="Undo last edit (Ctrl+Z)"
@click="onUndo"
>
<svg class="w-3 h-3" viewBox="0 0 24 24">
<path :d="mdiUndo" fill="currentColor" />
</svg>

Undo
</button>

<button
class="flex items-center gap-1 px-2 py-1 text-xs border border-divider rounded hover:bg-surface-tint disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!canRedo"
title="Redo edit (Ctrl+Y)"
@click="onRedo"
>
<svg class="w-3 h-3" viewBox="0 0 24 24">
<path :d="mdiRedo" fill="currentColor" />
</svg>

Redo
</button>

<button
class="flex items-center gap-1 px-2 py-1 text-xs border border-divider rounded hover:bg-surface-tint disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="timeline.size === 0"
@click="onClear"
>
<svg class="w-3 h-3" viewBox="0 0 24 24">
<path :d="mdiRefresh" fill="currentColor" />
</svg>

Clear log
</button>
</div>
</div>

<div class="border border-divider rounded-lg overflow-hidden">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="border-b border-divider bg-surface-tint">
<th
v-for="col in grid.layout.columns.value"
:key="col.id"
class="px-3 py-2 font-medium select-none"
:class="[
col.id === 'price' || col.id === 'quantity' ? 'text-right' : 'text-left',
isSortable(col.id) ? 'cursor-pointer hover:bg-surface' : '',
]"
:style="{ width: col.size + '%' }"
@click="isSortable(col.id) && grid.sort.toggle(col.id)"
>
<div
class="flex items-center gap-1"
:class="col.id === 'price' || col.id === 'quantity' ? 'justify-end' : ''"
>
<span>{{ columns.find(c => c.id === col.id)?.title }}</span>

<svg
v-if="isSortable(col.id) && grid.sort.direction(col.id) !== 'none'"
class="w-3 h-3"
viewBox="0 0 24 24"
>
<path
:d="grid.sort.direction(col.id) === 'asc' ? mdiArrowUp : mdiArrowDown"
fill="currentColor"
/>
</svg>

<span
v-if="isEditable(col.id)"
class="text-[10px] text-on-surface-variant"
title="Editable"
>
<svg class="w-2.5 h-2.5" viewBox="0 0 24 24">
<path :d="mdiPencilOutline" fill="currentColor" />
</svg>
</span>
</div>
</th>
</tr>
</thead>

<tbody class="divide-y divide-divider">
<tr
v-for="item in grid.items.value"
:key="item.id"
class="hover:bg-surface-tint/40"
>
<td
v-for="col in grid.layout.columns.value"
:key="col.id"
:ref="isEditing(item.id as ID, col.id) ? 'active-cell' : undefined"
class="px-3 py-2 transition-colors"
:class="[
col.id === 'price' || col.id === 'quantity' ? 'text-right' : 'text-left',
isEditing(item.id as ID, col.id) ? 'bg-primary/10 text-on-primary-container' : '',
isEditable(col.id) && !isEditing(item.id as ID, col.id) ? 'cursor-text hover:bg-surface-tint' : '',
]"
:style="{ width: col.size + '%' }"
@click="onEdit(item.id as ID, col.id, item[col.id])"
>
<template v-if="isEditing(item.id as ID, col.id)">
<div class="flex flex-col gap-1">
<input
ref="edit-input"
class="w-full bg-transparent outline-none border-none p-0 m-0 text-sm font-medium"
:class="col.id === 'price' || col.id === 'quantity' ? 'text-right' : 'text-left'"
:value="input"
@input="input = ($event.target as HTMLInputElement).value"
@keydown.enter="onCommit"
>

<span
v-if="grid.editing.error.value"
class="text-xs text-error"
>
{{ grid.editing.error.value }}
</span>
</div>
</template>

<template v-else>
<span
class="inline-flex items-center gap-1"
:class="isEdited(item.id as ID, col.id) ? 'text-primary font-medium' : ''"
>
<template v-if="col.id === 'price'">{{ money(item[col.id]) }}</template>
<template v-else-if="col.id === 'quantity'">{{ item[col.id] }}</template>
<template v-else>{{ item[col.id] }}</template>

<span
v-if="isEdited(item.id as ID, col.id)"
class="w-1 h-1 rounded-full bg-primary"
/>
</span>
</template>
</td>
</tr>
</tbody>
</table>
</div>

<div
v-if="timeline.size > 0"
class="border border-divider rounded-lg overflow-hidden"
>
<div class="px-3 py-2 bg-surface-tint text-xs font-medium border-b border-divider flex items-center justify-between">
<span>Edit history</span>
<span class="text-on-surface-variant tabular-nums">{{ timeline.size }} / 50</span>
</div>

<div class="divide-y divide-divider text-xs max-h-40 overflow-y-auto">
<div
v-for="(entry, index) in history"
:key="entry.id ?? index"
class="px-3 py-2 flex items-center gap-3"
>
<span class="text-on-surface-variant w-16 truncate">{{ entry.value.column }}</span>
<span class="text-on-surface-variant line-through tabular-nums">{{ entry.value.from }}</span>
<span class="text-on-surface-variant">&rarr;</span>
<span class="text-primary font-medium tabular-nums">{{ entry.value.to }}</span>
</div>
</div>
</div>

<div
v-else
class="text-xs text-on-surface-variant flex items-center gap-1.5"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24">
<path :d="mdiPencilOutline" fill="currentColor" />
</svg>

<span>Click an editable cell (Product, Price, Qty). Enter commits, Escape cancels, Ctrl+Z / Ctrl+Y to undo or redo.</span>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { DataGridColumn } from '@vuetify/v0'
import type { Product } from './data'

export const columns: DataGridColumn<Product>[] = [
{ id: 'name', title: 'Product', size: 28, minSize: 18, editable: true, validate: v => (typeof v === 'string' && v.length > 0) || 'Required' },
{ id: 'sku', title: 'SKU', size: 15, minSize: 10 },
{ id: 'price', title: 'Price', size: 15, minSize: 10, editable: true, validate: v => (Number(v) > 0) || 'Must be positive', sort: (a, b) => Number(a) - Number(b) },
{ id: 'quantity', title: 'Qty', size: 12, minSize: 8, editable: true, validate: v => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Must be 0+', sort: (a, b) => Number(a) - Number(b) },
{ id: 'category', title: 'Category', size: 30, minSize: 14, sortable: true },
]
Loading
Loading