@stacksjs/defaults 0.70.308 → 0.70.311
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.
package/ide/vscode/package.json
CHANGED
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.
|
|
5
|
+
"version": "0.70.311",
|
|
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
|
-
|
|
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
|
|
42
|
-
const next = selectedKeys().filter(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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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-
|
|
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
|
|
140
|
-
<th
|
|
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
|
|
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
|
|
565
|
+
<template :for="(column, columnIndex) in visibleColumns()">
|
|
176
566
|
<td :class="cellClass(column, columnIndex)">
|
|
177
|
-
|
|
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
|
-
|
|
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>
|