@stacksjs/defaults 0.70.310 → 0.70.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.70.310",
5
+ "version": "0.70.312",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.310",
5
+ "version": "0.70.312",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -1,11 +1,38 @@
1
1
  <script client>
2
+ /**
3
+ * The data table.
4
+ *
5
+ * Everything past the plain markup is opt-in, and every default matches the
6
+ * behaviour this component had before those options existed, so an existing
7
+ * `<Table :columns :data />` renders exactly as it did.
8
+ *
9
+ * The one thing that changed by default is sorting. It used to only emit a
10
+ * `sort` event and wait for the parent to hand back reordered rows, which
11
+ * meant a table whose parent ignored the event had sort arrows that did
12
+ * nothing. It now sorts what it was given. A parent that sorts server-side
13
+ * sets `manual-sort` and keeps the event.
14
+ */
2
15
  interface DashboardTableColumn {
3
16
  key: string
4
17
  label: string
5
18
  align?: 'left' | 'center' | 'right'
6
19
  sortable?: boolean
20
+ /** Show a filter control for this column in the filter row. */
21
+ filterable?: boolean
22
+ /** Constrains the filter to a dropdown of these values. */
23
+ filterOptions?: string[]
24
+ /** Include this column in the global search. Defaults to true. */
25
+ searchable?: boolean
26
+ /**
27
+ * How the value is compared and rendered. Kept as a string rather than a
28
+ * formatter function because props cross an attribute boundary, where a
29
+ * function does not survive.
30
+ */
31
+ type?: 'text' | 'number' | 'currency' | 'date' | 'boolean'
7
32
  width?: string
8
33
  class?: string
34
+ /** Hidden until switched on in the column menu. */
35
+ hidden?: boolean
9
36
  }
10
37
 
11
38
  type DashboardTableRow = Record<string, any>
@@ -24,12 +51,184 @@ const emptyTitle = useReactiveProp('emptyTitle', 'No data')
24
51
  const emptyDescription = useReactiveProp('emptyDescription', 'There are no items to display.')
25
52
  const rowKey = useReactiveProp('rowKey', 'id')
26
53
  const hoverable = useReactiveProp('hoverable', true)
54
+
55
+ const searchable = useReactiveProp('searchable', false)
56
+ const searchPlaceholder = useReactiveProp('searchPlaceholder', 'Search')
57
+ const filterable = useReactiveProp('filterable', false)
58
+ const paginated = useReactiveProp('paginated', false)
59
+ const pageSize = useReactiveProp('pageSize', 25)
60
+ const pageSizeOptions = useReactiveProp<number[]>('pageSizeOptions', [10, 25, 50, 100])
61
+ const manualSort = useReactiveProp('manualSort', false)
62
+ const columnToggle = useReactiveProp('columnToggle', false)
63
+ const exportable = useReactiveProp('exportable', false)
64
+ const exportFilename = useReactiveProp('exportFilename', 'table')
65
+ const stickyFirstColumn = useReactiveProp('stickyFirstColumn', false)
66
+
27
67
  const selectedKeys = state<TableKey[]>([])
28
68
  const sortKey = state('')
29
69
  const sortDirection = state<SortDirection>('asc')
70
+ const query = state('')
71
+ const columnFilters = state<Record<string, string>>({})
72
+ const hiddenColumns = state<string[]>([])
73
+ const page = state(1)
74
+ const columnMenuOpen = state(false)
30
75
  const emit = defineEmits()
31
76
 
32
- const visibleKeys = derived(() => rows().map(row => row[rowKey()] as TableKey))
77
+ /* ---------------------------------------------------------------- columns */
78
+
79
+ const visibleColumns = derived(() =>
80
+ columns().filter(column => !hiddenColumns().includes(column.key)),
81
+ )
82
+
83
+ // Seed from `hidden` once, so a column can ship hidden without the parent
84
+ // having to manage the list.
85
+ effect(() => {
86
+ const initial = columns().filter(column => column.hidden).map(column => column.key)
87
+ if (initial.length && hiddenColumns().length === 0)
88
+ hiddenColumns.set(initial)
89
+ })
90
+
91
+ function toggleColumn(key: string): void {
92
+ const next = hiddenColumns().includes(key)
93
+ ? hiddenColumns().filter(hidden => hidden !== key)
94
+ : [...hiddenColumns(), key]
95
+ hiddenColumns.set(next)
96
+ emit('column-visibility-change', next)
97
+ }
98
+
99
+ /* ----------------------------------------------------------------- values */
100
+
101
+ /** Text used for searching, filtering and CSV. Never the formatted string. */
102
+ function rawValue(row: DashboardTableRow, key: string): any {
103
+ return row?.[key]
104
+ }
105
+
106
+ function displayValue(row: DashboardTableRow, column: DashboardTableColumn): string {
107
+ const value = rawValue(row, column.key)
108
+ if (value === null || value === undefined || value === '')
109
+ return '-'
110
+
111
+ if (column.type === 'currency') {
112
+ const amount = Number(value)
113
+ return Number.isFinite(amount)
114
+ ? amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' })
115
+ : String(value)
116
+ }
117
+ if (column.type === 'number') {
118
+ const amount = Number(value)
119
+ return Number.isFinite(amount) ? amount.toLocaleString() : String(value)
120
+ }
121
+ if (column.type === 'date') {
122
+ const at = new Date(value)
123
+ return Number.isNaN(at.getTime()) ? String(value) : at.toLocaleDateString()
124
+ }
125
+ if (column.type === 'boolean')
126
+ return value ? 'Yes' : 'No'
127
+
128
+ return String(value)
129
+ }
130
+
131
+ /* ------------------------------------------------- search, filter, sort */
132
+
133
+ const searchableKeys = derived(() =>
134
+ columns().filter(column => column.searchable !== false).map(column => column.key),
135
+ )
136
+
137
+ const searched = derived(() => {
138
+ const term = query().trim().toLowerCase()
139
+ if (!term)
140
+ return rows()
141
+
142
+ const keys = searchableKeys()
143
+ return rows().filter(row => keys.some((key) => {
144
+ const value = rawValue(row, key)
145
+ return value !== null && value !== undefined && String(value).toLowerCase().includes(term)
146
+ }))
147
+ })
148
+
149
+ const filtered = derived(() => {
150
+ const active = Object.entries(columnFilters()).filter(([, value]) => value !== '' && value !== undefined)
151
+ if (!active.length)
152
+ return searched()
153
+
154
+ return searched().filter(row => active.every(([key, value]) => {
155
+ const cell = rawValue(row, key)
156
+ if (cell === null || cell === undefined)
157
+ return false
158
+ return String(cell).toLowerCase().includes(String(value).toLowerCase())
159
+ }))
160
+ })
161
+
162
+ /** Nulls sort last in both directions: "missing" is not a small value. */
163
+ function compare(a: any, b: any, type: DashboardTableColumn['type']): number {
164
+ const aMissing = a === null || a === undefined || a === ''
165
+ const bMissing = b === null || b === undefined || b === ''
166
+ if (aMissing && bMissing)
167
+ return 0
168
+ if (aMissing)
169
+ return 1
170
+ if (bMissing)
171
+ return -1
172
+
173
+ if (type === 'number' || type === 'currency')
174
+ return Number(a) - Number(b)
175
+ if (type === 'date')
176
+ return new Date(a).getTime() - new Date(b).getTime()
177
+ if (type === 'boolean')
178
+ return (a ? 1 : 0) - (b ? 1 : 0)
179
+
180
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })
181
+ }
182
+
183
+ const sorted = derived(() => {
184
+ const key = sortKey()
185
+ if (!key || manualSort())
186
+ return filtered()
187
+
188
+ const column = columns().find(candidate => candidate.key === key)
189
+ const direction = sortDirection() === 'asc' ? 1 : -1
190
+
191
+ // Copy first: sorting the prop array in place would mutate the parent's data.
192
+ return filtered().slice().sort((a, b) => direction * compare(rawValue(a, key), rawValue(b, key), column?.type))
193
+ })
194
+
195
+ /* ------------------------------------------------------------- pagination */
196
+
197
+ const pageCount = derived(() => (paginated() ? Math.max(1, Math.ceil(sorted().length / pageSize())) : 1))
198
+
199
+ // A filter that shortens the list must not strand the viewer on a page that
200
+ // no longer exists.
201
+ effect(() => {
202
+ if (page() > pageCount())
203
+ page.set(pageCount())
204
+ })
205
+
206
+ const visibleRows = derived(() => {
207
+ if (!paginated())
208
+ return sorted()
209
+ const start = (page() - 1) * pageSize()
210
+ return sorted().slice(start, start + pageSize())
211
+ })
212
+
213
+ const rangeStart = derived(() => (sorted().length === 0 ? 0 : (page() - 1) * pageSize() + 1))
214
+ const rangeEnd = derived(() => Math.min(page() * pageSize(), sorted().length))
215
+
216
+ function goToPage(next: number): void {
217
+ const target = Math.min(Math.max(1, next), pageCount())
218
+ page.set(target)
219
+ emit('page-change', target)
220
+ }
221
+
222
+ function changePageSize(event: Event): void {
223
+ const size = Number((event.target as HTMLSelectElement).value)
224
+ pageSize.set(size)
225
+ page.set(1)
226
+ emit('page-size-change', size)
227
+ }
228
+
229
+ /* -------------------------------------------------------------- selection */
230
+
231
+ const visibleKeys = derived(() => visibleRows().map(row => row[rowKey()] as TableKey))
33
232
  const allSelected = derived(() =>
34
233
  visibleKeys().length > 0 && visibleKeys().every(key => selectedKeys().includes(key)),
35
234
  )
@@ -37,13 +236,127 @@ const someSelected = derived(() =>
37
236
  !allSelected() && visibleKeys().some(key => selectedKeys().includes(key)),
38
237
  )
39
238
 
239
+ /*
240
+ * Drop selections whose row is gone from the data entirely. Deliberately
241
+ * checked against every row rather than the visible page: paginating or
242
+ * filtering must not silently discard a selection the viewer made two pages
243
+ * back, which is what a "select all" then "delete" would act on.
244
+ */
40
245
  effect(() => {
41
- const valid = new Set(visibleKeys())
42
- const next = selectedKeys().filter(key => valid.has(key))
246
+ const present = new Set(rows().map(row => row[rowKey()] as TableKey))
247
+ const next = selectedKeys().filter(key => present.has(key))
43
248
  if (next.length !== selectedKeys().length)
44
249
  selectedKeys.set(next)
45
250
  })
46
251
 
252
+ function emitSelection(next: TableKey[]): void {
253
+ selectedKeys.set(next)
254
+ emit('update:selectedKeys', next)
255
+ emit('selection-change', next)
256
+ }
257
+
258
+ function toggleAll(event: Event): void {
259
+ const checked = (event.target as HTMLInputElement).checked
260
+ const onPage = visibleKeys()
261
+ const next = checked
262
+ ? [...new Set([...selectedKeys(), ...onPage])]
263
+ : selectedKeys().filter(key => !onPage.includes(key))
264
+ emitSelection(next)
265
+ }
266
+
267
+ function toggleRow(row: DashboardTableRow, event: Event): void {
268
+ const key = row[rowKey()] as TableKey
269
+ const checked = (event.target as HTMLInputElement).checked
270
+ const next = checked
271
+ ? [...new Set([...selectedKeys(), key])]
272
+ : selectedKeys().filter(selected => selected !== key)
273
+ emitSelection(next)
274
+ }
275
+
276
+ /* ------------------------------------------------------------------ sort */
277
+
278
+ function sort(column: DashboardTableColumn): void {
279
+ if (!column.sortable)
280
+ return
281
+ const direction: SortDirection = sortKey() === column.key && sortDirection() === 'asc' ? 'desc' : 'asc'
282
+ sortKey.set(column.key)
283
+ sortDirection.set(direction)
284
+ emit('sort', { key: column.key, direction })
285
+ }
286
+
287
+ function sortLabel(column: DashboardTableColumn): string {
288
+ if (sortKey() !== column.key)
289
+ return `Sort by ${column.label}`
290
+ return `Sort by ${column.label} ${sortDirection() === 'asc' ? 'descending' : 'ascending'}`
291
+ }
292
+
293
+ /** Mirrors sort state into aria-sort so screen readers announce it. */
294
+ function ariaSort(column: DashboardTableColumn): string {
295
+ if (!column.sortable || sortKey() !== column.key)
296
+ return 'none'
297
+ return sortDirection() === 'asc' ? 'ascending' : 'descending'
298
+ }
299
+
300
+ /* ---------------------------------------------------------------- filters */
301
+
302
+ function setFilter(key: string, event: Event): void {
303
+ const value = (event.target as HTMLInputElement | HTMLSelectElement).value
304
+ columnFilters.set({ ...columnFilters(), [key]: value })
305
+ page.set(1)
306
+ emit('filter-change', columnFilters())
307
+ }
308
+
309
+ function setQuery(event: Event): void {
310
+ query.set((event.target as HTMLInputElement).value)
311
+ page.set(1)
312
+ emit('search', query())
313
+ }
314
+
315
+ const hasActiveFilters = derived(() =>
316
+ query().trim() !== '' || Object.values(columnFilters()).some(value => value !== ''),
317
+ )
318
+
319
+ function clearFilters(): void {
320
+ query.set('')
321
+ columnFilters.set({})
322
+ page.set(1)
323
+ emit('search', '')
324
+ emit('filter-change', {})
325
+ }
326
+
327
+ /* ----------------------------------------------------------------- export */
328
+
329
+ /**
330
+ * CSV of what is on screen: current search, filters and sort, all columns
331
+ * that are visible, every page rather than just the current one. Exporting
332
+ * the raw prop would hand back something the viewer never asked for.
333
+ */
334
+ function exportCsv(): void {
335
+ const cols = visibleColumns()
336
+ const escape = (value: any): string => {
337
+ const text = value === null || value === undefined ? '' : String(value)
338
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
339
+ }
340
+
341
+ const lines = [
342
+ cols.map(column => escape(column.label)).join(','),
343
+ ...sorted().map(row => cols.map(column => escape(rawValue(row, column.key))).join(',')),
344
+ ]
345
+
346
+ const blob = new Blob([`${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
347
+ const url = URL.createObjectURL(blob)
348
+ const link = document.createElement('a')
349
+ link.href = url
350
+ link.download = `${exportFilename()}.csv`
351
+ document.body.appendChild(link)
352
+ link.click()
353
+ document.body.removeChild(link)
354
+ URL.revokeObjectURL(url)
355
+ emit('export', sorted().length)
356
+ }
357
+
358
+ /* ------------------------------------------------------------------ style */
359
+
47
360
  function alignClass(align: DashboardTableColumn['align']): string {
48
361
  if (align === 'center') return 'text-center'
49
362
  if (align === 'right') return 'text-right'
@@ -56,6 +369,7 @@ function headerClass(column: DashboardTableColumn, index: number): string {
56
369
  'pr-4 font-semibold text-xs uppercase tracking-wider text-stone-500 dark:text-neutral-400',
57
370
  compact() ? 'py-2' : 'py-3.5',
58
371
  alignClass(column.align),
372
+ index === 0 && stickyFirstColumn() ? 'sticky left-0 z-20 bg-stone-50 dark:bg-neutral-800' : '',
59
373
  column.class || '',
60
374
  ].filter(Boolean).join(' ')
61
375
  }
@@ -75,60 +389,100 @@ function cellClass(column: DashboardTableColumn, index: number): string {
75
389
  'pr-4 text-sm text-stone-700 dark:text-neutral-200',
76
390
  compact() ? 'py-2' : 'py-3.5',
77
391
  alignClass(column.align),
392
+ column.type === 'number' || column.type === 'currency' ? 'tabular-nums' : '',
393
+ index === 0 && stickyFirstColumn() ? 'sticky left-0 z-10 bg-white dark:bg-neutral-900' : '',
78
394
  column.class || '',
79
395
  ].filter(Boolean).join(' ')
80
396
  }
397
+ </script>
81
398
 
82
- function emitSelection(next: TableKey[]): void {
83
- selectedKeys.set(next)
84
- emit('update:selectedKeys', next)
85
- emit('selection-change', next)
86
- }
399
+ <div class="overflow-hidden relative bg-white dark:bg-neutral-900 border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
400
+ <div :if="loading()" class="flex absolute inset-0 z-30 items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm" role="status">
401
+ <span aria-hidden="true" class="mr-2 h-5 w-5 text-blue-500 animate-spin i-hugeicons-loading-03"></span>
402
+ <span class="text-neutral-500 text-sm dark:text-neutral-400">Loading</span>
403
+ </div>
87
404
 
88
- function toggleAll(event: Event): void {
89
- const checked = (event.target as HTMLInputElement).checked
90
- emitSelection(checked ? [...visibleKeys()] : [])
91
- }
405
+ <!-- Toolbar: only rendered when something asked for it. -->
406
+ <div
407
+ :if="searchable() || columnToggle() || exportable() || $slots.toolbar"
408
+ class="flex flex-wrap gap-3 items-center justify-between px-4 py-3 border-b border-stone-200/80 dark:border-neutral-700"
409
+ >
410
+ <div class="flex flex-wrap gap-2 items-center">
411
+ <div :if="searchable()" class="relative">
412
+ <span aria-hidden="true" class="absolute left-2.5 top-1/2 h-4 w-4 text-neutral-400 -translate-y-1/2 i-hugeicons-search-01"></span>
413
+ <input
414
+ type="search"
415
+ :placeholder="searchPlaceholder()"
416
+ :aria-label="searchPlaceholder()"
417
+ :value="query()"
418
+ @input="setQuery($event)"
419
+ class="pl-8 pr-3 py-1.5 w-56 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
420
+ />
421
+ </div>
92
422
 
93
- function toggleRow(row: DashboardTableRow, event: Event): void {
94
- const key = row[rowKey()] as TableKey
95
- const checked = (event.target as HTMLInputElement).checked
96
- const next = checked
97
- ? [...new Set([...selectedKeys(), key])]
98
- : selectedKeys().filter(selected => selected !== key)
99
- emitSelection(next)
100
- }
423
+ <button
424
+ :if="hasActiveFilters()"
425
+ type="button"
426
+ @click="clearFilters()"
427
+ class="px-2.5 py-1.5 text-neutral-600 text-xs dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
428
+ >
429
+ Clear filters
430
+ </button>
101
431
 
102
- function sort(column: DashboardTableColumn): void {
103
- if (!column.sortable)
104
- return
105
- const direction: SortDirection = sortKey() === column.key && sortDirection() === 'asc' ? 'desc' : 'asc'
106
- sortKey.set(column.key)
107
- sortDirection.set(direction)
108
- emit('sort', { key: column.key, direction })
109
- }
432
+ <slot name="toolbar" />
433
+ </div>
110
434
 
111
- function sortLabel(column: DashboardTableColumn): string {
112
- if (sortKey() !== column.key)
113
- return `Sort by ${column.label}`
114
- return `Sort by ${column.label} ${sortDirection() === 'asc' ? 'descending' : 'ascending'}`
115
- }
116
- </script>
435
+ <div class="flex gap-2 items-center">
436
+ <div :if="columnToggle()" class="relative">
437
+ <button
438
+ type="button"
439
+ aria-haspopup="true"
440
+ :aria-expanded="columnMenuOpen() ? 'true' : 'false'"
441
+ @click="columnMenuOpen.set(!columnMenuOpen())"
442
+ class="inline-flex gap-1.5 items-center px-2.5 py-1.5 text-neutral-600 text-xs dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600"
443
+ >
444
+ <span aria-hidden="true" class="h-3.5 w-3.5 i-hugeicons-layout-table-01"></span>
445
+ Columns
446
+ </button>
117
447
 
118
- <div class="overflow-hidden relative bg-white dark:bg-neutral-900 border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
119
- <div :if="loading()" class="flex absolute inset-0 z-10 items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm" role="status">
120
- <span aria-hidden="true" class="mr-2 h-5 w-5 text-blue-500 animate-spin i-hugeicons-loading-03"></span>
121
- <span class="text-neutral-500 text-sm dark:text-neutral-400">Loading</span>
448
+ <div
449
+ :if="columnMenuOpen()"
450
+ class="absolute right-0 z-40 mt-1 p-2 w-52 bg-white dark:bg-neutral-800 border border-stone-200 rounded-lg dark:border-neutral-700 shadow-lg"
451
+ >
452
+ <template :for="column in columns()">
453
+ <label class="flex gap-2 items-center px-2 py-1.5 text-neutral-700 text-sm dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-700 rounded cursor-pointer">
454
+ <input
455
+ type="checkbox"
456
+ :checked="!hiddenColumns().includes(column.key)"
457
+ @change="toggleColumn(column.key)"
458
+ class="h-3.5 w-3.5 text-blue-600 border-stone-300 rounded dark:border-neutral-600"
459
+ />
460
+ {{ column.label }}
461
+ </label>
462
+ </template>
463
+ </div>
464
+ </div>
465
+
466
+ <button
467
+ :if="exportable()"
468
+ type="button"
469
+ @click="exportCsv()"
470
+ class="inline-flex gap-1.5 items-center px-2.5 py-1.5 text-neutral-600 text-xs dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600"
471
+ >
472
+ <span aria-hidden="true" class="h-3.5 w-3.5 i-hugeicons-download-04"></span>
473
+ Export CSV
474
+ </button>
475
+ </div>
122
476
  </div>
123
477
 
124
478
  <div class="overflow-x-auto">
125
479
  <table class="w-full">
126
- <thead :class="'bg-stone-50/80 dark:bg-neutral-800/50 ' + (stickyHeader() ? 'sticky top-0 z-10' : '')">
480
+ <thead :class="'bg-stone-50/80 dark:bg-neutral-800/50 ' + (stickyHeader() ? 'sticky top-0 z-20' : '')">
127
481
  <tr>
128
- <th :if="selectable()" class="pl-6 pr-2 py-3 w-12">
482
+ <th :if="selectable()" scope="col" class="pl-6 pr-2 py-3 w-12">
129
483
  <input
130
484
  type="checkbox"
131
- aria-label="Select all rows"
485
+ aria-label="Select all rows on this page"
132
486
  class="h-4 w-4 text-blue-600 dark:bg-neutral-700 border-stone-300 rounded dark:border-neutral-600 focus:ring-blue-500"
133
487
  :checked="allSelected()"
134
488
  :indeterminate="someSelected()"
@@ -136,8 +490,13 @@ function sortLabel(column: DashboardTableColumn): string {
136
490
  />
137
491
  </th>
138
492
 
139
- <template :for="(column, index) in columns()">
140
- <th :class="headerClass(column, index)" :style="column.width ? 'width: ' + column.width : undefined">
493
+ <template :for="(column, index) in visibleColumns()">
494
+ <th
495
+ scope="col"
496
+ :class="headerClass(column, index)"
497
+ :style="column.width ? 'width: ' + column.width : undefined"
498
+ :aria-sort="ariaSort(column)"
499
+ >
141
500
  <button
142
501
  :if="column.sortable"
143
502
  type="button"
@@ -155,12 +514,43 @@ function sortLabel(column: DashboardTableColumn): string {
155
514
  </th>
156
515
  </template>
157
516
 
158
- <th :if="$slots.actions" class="px-4 py-3 w-12"><span class="sr-only">Actions</span></th>
517
+ <th :if="$slots.actions" scope="col" class="px-4 py-3 w-12"><span class="sr-only">Actions</span></th>
518
+ </tr>
519
+
520
+ <!-- Per-column filter row. -->
521
+ <tr :if="filterable()" class="border-stone-200/70 border-t dark:border-neutral-700">
522
+ <td :if="selectable()" class="pl-6 pr-2 py-2"></td>
523
+ <template :for="(column, index) in visibleColumns()">
524
+ <td :class="(index === 0 && !selectable() ? 'pl-6' : 'pl-4') + ' pr-4 py-2'">
525
+ <select
526
+ :if="column.filterable && column.filterOptions"
527
+ :aria-label="'Filter by ' + column.label"
528
+ :value="columnFilters()[column.key] || ''"
529
+ @change="setFilter(column.key, $event)"
530
+ class="px-1.5 py-1 w-full text-xs bg-white dark:bg-neutral-800 border border-stone-300 rounded dark:border-neutral-600"
531
+ >
532
+ <option value="">All</option>
533
+ <template :for="option in column.filterOptions">
534
+ <option :value="option">{{ option }}</option>
535
+ </template>
536
+ </select>
537
+ <input
538
+ :else-if="column.filterable"
539
+ type="text"
540
+ :aria-label="'Filter by ' + column.label"
541
+ :placeholder="column.label"
542
+ :value="columnFilters()[column.key] || ''"
543
+ @input="setFilter(column.key, $event)"
544
+ class="px-1.5 py-1 w-full text-xs bg-white dark:bg-neutral-800 border border-stone-300 rounded dark:border-neutral-600"
545
+ />
546
+ </td>
547
+ </template>
548
+ <td :if="$slots.actions" class="px-4 py-2"></td>
159
549
  </tr>
160
550
  </thead>
161
551
 
162
552
  <tbody class="divide-stone-100 divide-y dark:divide-neutral-700">
163
- <template :for="(row, rowIndex) in rows()">
553
+ <template :for="(row, rowIndex) in visibleRows()">
164
554
  <tr :class="rowClass(rowIndex)" :data-row-id="String(row[rowKey()])">
165
555
  <td :if="selectable()" class="pl-6 pr-2 w-12" :class="compact() ? 'py-2' : 'py-3.5'">
166
556
  <input
@@ -172,9 +562,11 @@ function sortLabel(column: DashboardTableColumn): string {
172
562
  />
173
563
  </td>
174
564
 
175
- <template :for="(column, columnIndex) in columns()">
565
+ <template :for="(column, columnIndex) in visibleColumns()">
176
566
  <td :class="cellClass(column, columnIndex)">
177
- {{ row[column.key] }}
567
+ <slot name="cell" :row="row" :column="column" :value="row[column.key]">
568
+ {{ displayValue(row, column) }}
569
+ </slot>
178
570
  </td>
179
571
  </template>
180
572
 
@@ -187,7 +579,19 @@ function sortLabel(column: DashboardTableColumn): string {
187
579
  </table>
188
580
  </div>
189
581
 
190
- <div :if="rows().length === 0 && !loading()" class="flex flex-col items-center justify-center px-4 py-12">
582
+ <!--
583
+ Two different empty states. "No data" and "nothing matched your filter"
584
+ call for different next actions, and showing the first when the viewer has
585
+ typed a search reads as though their data disappeared.
586
+ -->
587
+ <div :if="visibleRows().length === 0 && !loading() && hasActiveFilters()" class="flex flex-col items-center justify-center px-4 py-12">
588
+ <span aria-hidden="true" class="mb-3 p-3 h-12 w-12 text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full i-hugeicons-search-remove"></span>
589
+ <p class="font-medium text-neutral-700 text-sm dark:text-neutral-200">No matches</p>
590
+ <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">No rows match the current search or filters.</p>
591
+ <button type="button" @click="clearFilters()" class="mt-3 text-blue-600 text-sm dark:text-blue-400 hover:underline">Clear filters</button>
592
+ </div>
593
+
594
+ <div :if="visibleRows().length === 0 && !loading() && !hasActiveFilters()" class="flex flex-col items-center justify-center px-4 py-12">
191
595
  <template :if="$slots.empty">
192
596
  <slot name="empty" />
193
597
  </template>
@@ -198,6 +602,49 @@ function sortLabel(column: DashboardTableColumn): string {
198
602
  </template>
199
603
  </div>
200
604
 
605
+ <div
606
+ :if="paginated() && sorted().length > 0"
607
+ class="flex flex-wrap gap-3 items-center justify-between px-4 py-3 bg-neutral-50/50 dark:bg-neutral-800/25 border-neutral-200 border-t dark:border-neutral-700"
608
+ >
609
+ <div class="flex gap-3 items-center text-neutral-500 text-sm dark:text-neutral-400">
610
+ <span>Showing {{ rangeStart() }}-{{ rangeEnd() }} of {{ sorted().length }}</span>
611
+ <label class="flex gap-1.5 items-center">
612
+ <span class="sr-only">Rows per page</span>
613
+ <select
614
+ :value="String(pageSize())"
615
+ @change="changePageSize($event)"
616
+ class="px-1.5 py-1 text-xs bg-white dark:bg-neutral-800 border border-stone-300 rounded dark:border-neutral-600"
617
+ >
618
+ <template :for="option in pageSizeOptions()">
619
+ <option :value="String(option)">{{ option }} / page</option>
620
+ </template>
621
+ </select>
622
+ </label>
623
+ </div>
624
+
625
+ <nav class="flex gap-1 items-center" aria-label="Pagination">
626
+ <button
627
+ type="button"
628
+ aria-label="Previous page"
629
+ :disabled="page() <= 1"
630
+ @click="goToPage(page() - 1)"
631
+ class="p-1.5 text-neutral-600 dark:text-neutral-300 hover:bg-white dark:hover:bg-neutral-800 border border-stone-300 rounded dark:border-neutral-600 disabled:opacity-40 disabled:cursor-not-allowed"
632
+ >
633
+ <span aria-hidden="true" class="h-4 w-4 i-hugeicons-arrow-left-01"></span>
634
+ </button>
635
+ <span class="px-2 tabular-nums text-neutral-600 text-sm dark:text-neutral-300">{{ page() }} / {{ pageCount() }}</span>
636
+ <button
637
+ type="button"
638
+ aria-label="Next page"
639
+ :disabled="page() >= pageCount()"
640
+ @click="goToPage(page() + 1)"
641
+ class="p-1.5 text-neutral-600 dark:text-neutral-300 hover:bg-white dark:hover:bg-neutral-800 border border-stone-300 rounded dark:border-neutral-600 disabled:opacity-40 disabled:cursor-not-allowed"
642
+ >
643
+ <span aria-hidden="true" class="h-4 w-4 i-hugeicons-arrow-right-01"></span>
644
+ </button>
645
+ </nav>
646
+ </div>
647
+
201
648
  <div :if="$slots.footer" class="px-4 py-3 bg-neutral-50/50 dark:bg-neutral-800/25 border-neutral-200 border-t dark:border-neutral-700">
202
649
  <slot name="footer" />
203
650
  </div>