@revenexx/studio-shared 0.1.0

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/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @revenexx/studio-shared
2
+
3
+ Shared revenexx **Studio shell primitives** (`PageHeader`, `SidebarNav`,
4
+ `StateCard`, `StudioCard`, `TabBar`, `KpiTile`, `DataTable`, …) plus the
5
+ **platform provider contract** (`usePlatformAuth` / `usePlatformTenant`).
6
+
7
+ It's a **Nuxt layer** (ships source): you `extends` it, then add a studio
8
+ module (`@revenexx/studio-integrations`, `@revenexx/studio-commerce`). It's a
9
+ peer/base of those modules — install it alongside them.
10
+
11
+ ## Install
12
+
13
+ Public on npm — no registry config or token needed:
14
+
15
+ ```bash
16
+ pnpm add @revenexx/studio-shared @revenexx/studio
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```ts
22
+ // nuxt.config.ts
23
+ export default defineNuxtConfig({
24
+ extends: ['@revenexx/studio-shared'],
25
+ modules: ['@solar-icons/nuxt'], // provides the #solar-icons the primitives use
26
+ })
27
+ ```
28
+
29
+ The components auto-import by bare name (`<PageHeader>`, `<DataTable>`, …). Types
30
+ (`DataTableColumn`, `SidebarNavSection`, `PlatformAuth`, `PlatformTenant`) import
31
+ from `@revenexx/studio-shared`.
32
+
33
+ ## ⚠️ Tailwind: you MUST scan this package
34
+
35
+ The primitives ship **source**, not precompiled CSS — Tailwind generates their
36
+ classes from **your** build. Add this package to your Tailwind sources, or
37
+ classes used only here (e.g. the `TabBar` active 2px underline: `inset-x-2.5` +
38
+ `bg-primary/40`) **won't be generated** and styling silently breaks:
39
+
40
+ ```css
41
+ /* your app's main tailwind entry */
42
+ @import "tailwindcss";
43
+ @import "@revenexx/studio/tokens"; /* design-system tokens */
44
+ @import "@revenexx/studio/fonts";
45
+ @source "../node_modules/@revenexx/studio-shared/components";
46
+ ```
47
+
48
+ Adjust the relative path to reach `node_modules` from your CSS file.
49
+
50
+ ## Peers
51
+ `@revenexx/studio` (design system + tokens), `nuxt` ^4, `vue` ^3.5, plus the
52
+ consumer must register `@solar-icons/nuxt`.
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { Widget as Columns, CheckCircle as Check } from '#solar-icons'
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuLabel,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from '@revenexx/studio'
11
+ import type { DataTableColumn } from './types'
12
+
13
+ const props = defineProps<{
14
+ columns: DataTableColumn[]
15
+ /** Set of currently visible column keys. */
16
+ visible: Set<string>
17
+ }>()
18
+
19
+ const emit = defineEmits<{
20
+ toggle: [key: string]
21
+ }>()
22
+
23
+ const toggleable = computed(() => props.columns.filter(c => c.toggleable !== false))
24
+ </script>
25
+
26
+ <template>
27
+ <DropdownMenu>
28
+ <DropdownMenuTrigger as-child>
29
+ <button
30
+ type="button"
31
+ class="flex size-7 items-center justify-center rounded-md border border-black/[0.06] bg-white text-muted-foreground transition hover:text-foreground"
32
+ title="Columns"
33
+ >
34
+ <Columns :size="15" />
35
+ </button>
36
+ </DropdownMenuTrigger>
37
+ <DropdownMenuContent align="end" class="w-48">
38
+ <DropdownMenuLabel class="text-sm text-muted-foreground">
39
+ Columns
40
+ </DropdownMenuLabel>
41
+ <DropdownMenuSeparator />
42
+ <DropdownMenuItem
43
+ v-for="col in toggleable"
44
+ :key="col.key"
45
+ class="justify-between gap-2"
46
+ @select.prevent="emit('toggle', col.key)"
47
+ >
48
+ <span class="truncate">{{ col.label }}</span>
49
+ <Check
50
+ v-if="visible.has(col.key)"
51
+ :size="14"
52
+ class="text-muted-foreground shrink-0"
53
+ />
54
+ </DropdownMenuItem>
55
+ </DropdownMenuContent>
56
+ </DropdownMenu>
57
+ </template>
@@ -0,0 +1,518 @@
1
+ <script setup lang="ts" generic="T">
2
+ import { AltArrowDown, Filter, Magnifier as Search } from '#solar-icons'
3
+ import {
4
+ Button,
5
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
6
+ DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
7
+ Input,
8
+ } from '@revenexx/studio'
9
+ import type { Component } from 'vue'
10
+ import SortHeader from '../Shared/SortHeader.vue'
11
+ import ColumnsMenu from './ColumnsMenu.vue'
12
+ import FilterMenu from './FilterMenu.vue'
13
+ import type { DataTableColumn, DataTableFilter, DataTableState, RowKey, SortState } from './types'
14
+
15
+ // One DataTable to rule them all. Two operating modes:
16
+ //
17
+ // Static (default): the table holds search/sort/page state internally
18
+ // and filters/sorts/paginates the `data` array in-memory.
19
+ //
20
+ // Server (`server-mode`): the page owns the data pipeline. The table
21
+ // just renders `data` (the current page) and emits `state-change`
22
+ // whenever search / sort / page changes — the page should re-fetch.
23
+ // Pass `total` so the footer knows the un-paginated count.
24
+ //
25
+ // Per-row rendering is slot-based: `#cell-<key>` receives `{ row }`.
26
+ // Cells fall back to the column's sortGetter value if no slot is given.
27
+
28
+ const props = withDefaults(defineProps<{
29
+ data: T[]
30
+ columns: DataTableColumn<T>[]
31
+ rowKey: RowKey<T>
32
+
33
+ // Toolbar
34
+ search?: boolean | { placeholder?: string }
35
+ columnsMenu?: boolean
36
+ filter?: boolean
37
+ /** Built-in status/dimension filter pills (static mode). One dimension, or
38
+ * several (each renders as its own sub-menu). */
39
+ filterTabs?: DataTableFilter<T> | DataTableFilter<T>[]
40
+
41
+ // Pagination
42
+ pagination?: boolean | { pageSize?: number }
43
+
44
+ // Sort
45
+ defaultSort?: { column: string, direction: 'asc' | 'desc' }
46
+
47
+ // Server mode
48
+ serverMode?: boolean
49
+ total?: number
50
+ loading?: boolean
51
+
52
+ // Empty state
53
+ emptyText?: string
54
+ }>(), {
55
+ search: true,
56
+ columnsMenu: true,
57
+ filter: false,
58
+ pagination: true,
59
+ serverMode: false,
60
+ loading: false,
61
+ })
62
+
63
+ const emit = defineEmits<{
64
+ rowClick: [row: T]
65
+ stateChange: [state: DataTableState]
66
+ }>()
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Internal state
70
+ // ---------------------------------------------------------------------------
71
+ const query = ref('')
72
+ // `sort` and `page` are optionally controlled: a parent in server mode
73
+ // can `v-model:sort` / `v-model:page` to stay authoritative over the data
74
+ // pipeline (e.g. AppRenderer/ListView driving PostgREST). Left unbound,
75
+ // they behave as plain local state seeded from `defaultSort`.
76
+ const sort = defineModel<SortState>('sort', {
77
+ default: () => ({
78
+ column: null,
79
+ direction: null,
80
+ }),
81
+ })
82
+ const page = defineModel<number>('page', { default: 0 })
83
+ if (props.defaultSort && sort.value.column == null) {
84
+ sort.value = { column: props.defaultSort.column, direction: props.defaultSort.direction }
85
+ }
86
+ // Page size is local state (the footer lets the user change it), seeded from
87
+ // the `pagination.pageSize` prop. Defaults to 20.
88
+ const pageSize = ref(
89
+ typeof props.pagination === 'object' && props.pagination?.pageSize
90
+ ? props.pagination.pageSize
91
+ : 20,
92
+ )
93
+ const pageSizeOptions = computed(() => Array.from(new Set([10, 20, 50, 100, pageSize.value])).sort((a, b) => a - b))
94
+
95
+ // Column visibility — initialised once from column defaults.
96
+ const visibleKeys = ref<Set<string>>(
97
+ new Set(props.columns.filter(c => c.visible !== false).map(c => c.key)),
98
+ )
99
+ function toggleColumn(key: string) {
100
+ const next = new Set(visibleKeys.value)
101
+ if (next.has(key)) next.delete(key)
102
+ else next.add(key)
103
+ visibleKeys.value = next
104
+ }
105
+
106
+ const visibleColumns = computed(() =>
107
+ props.columns.filter(c => visibleKeys.value.has(c.key)),
108
+ )
109
+
110
+ // ColumnsMenu is non-generic (DataTableColumn<unknown>[]); the per-row getters
111
+ // make DataTableColumn<T> invariant in T, so widen for the menu prop.
112
+ const menuColumns = computed(() => props.columns as unknown as DataTableColumn[])
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Static-mode data pipeline (search → sort → paginate). Skipped in
116
+ // server mode; in server mode the page is expected to do this work and
117
+ // pass back the already-prepared `data`.
118
+ // ---------------------------------------------------------------------------
119
+ function searchOf(col: DataTableColumn<T>, row: T): string {
120
+ if (col.searchGetter) return col.searchGetter(row).toLowerCase()
121
+ if (col.sortGetter) {
122
+ const v = col.sortGetter(row)
123
+ return v == null ? '' : String(v).toLowerCase()
124
+ }
125
+ const raw = (row as Record<string, unknown>)[col.key]
126
+ return raw == null ? '' : String(raw).toLowerCase()
127
+ }
128
+
129
+ // Filter dimensions (static mode) run before search. One or many dimensions,
130
+ // each rendered as a sub-menu under the generic "Filter" dropdown.
131
+ interface FilterDim { key: string, label: string, getter: (row: T) => string | null | undefined, options: { value: string, label: string }[], allLabel?: string }
132
+ const filterDims = computed<FilterDim[]>(() => {
133
+ const raw = props.filterTabs
134
+ if (!raw) return []
135
+ const arr = Array.isArray(raw) ? raw : [raw]
136
+ return arr.map((f, i) => ({ key: f.key ?? f.label ?? `dim${i}`, label: f.label ?? 'Status', getter: f.getter, options: f.options, allLabel: f.allLabel }))
137
+ })
138
+ const activeFilters = ref<Record<string, string | null>>({})
139
+ const anyFilterActive = computed(() => Object.values(activeFilters.value).some(Boolean))
140
+ function setFilter(key: string, value: string | null) {
141
+ activeFilters.value = { ...activeFilters.value, [key]: value }
142
+ }
143
+ function dimCounts(d: FilterDim): Record<string, number> {
144
+ const m: Record<string, number> = {}
145
+ for (const row of props.data) { const k = String(d.getter(row) ?? ''); m[k] = (m[k] ?? 0) + 1 }
146
+ return m
147
+ }
148
+ const filtered = computed(() => {
149
+ if (props.serverMode || !filterDims.value.length) return props.data
150
+ const active = filterDims.value.filter(d => activeFilters.value[d.key])
151
+ if (!active.length) return props.data
152
+ return props.data.filter(row => active.every(d => String(d.getter(row) ?? '') === activeFilters.value[d.key]))
153
+ })
154
+
155
+ const searched = computed(() => {
156
+ if (props.serverMode) return props.data
157
+ const q = query.value.trim().toLowerCase()
158
+ if (!q) return filtered.value
159
+ return filtered.value.filter(row =>
160
+ props.columns.some(col => searchOf(col, row).includes(q)),
161
+ )
162
+ })
163
+
164
+ function sortKey(col: DataTableColumn<T>, row: T) {
165
+ if (col.sortGetter) return col.sortGetter(row)
166
+ const raw = (row as Record<string, unknown>)[col.key]
167
+ return raw as never
168
+ }
169
+
170
+ const sorted = computed(() => {
171
+ if (props.serverMode) return searched.value
172
+ const col = sort.value.column
173
+ const dir = sort.value.direction
174
+ if (!col || !dir) return searched.value
175
+ const c = props.columns.find(x => x.key === col)
176
+ if (!c) return searched.value
177
+ const sign = dir === 'asc' ? 1 : -1
178
+ return [...searched.value].sort((a, b) => {
179
+ const av = sortKey(c, a)
180
+ const bv = sortKey(c, b)
181
+ const aNull = av == null || av === ''
182
+ const bNull = bv == null || bv === ''
183
+ if (aNull && bNull) return 0
184
+ if (aNull) return 1
185
+ if (bNull) return -1
186
+ const aCmp = typeof av === 'boolean' ? (av ? 1 : 0) : av
187
+ const bCmp = typeof bv === 'boolean' ? (bv ? 1 : 0) : bv
188
+ if (aCmp < bCmp) return -1 * sign
189
+ if (aCmp > bCmp) return 1 * sign
190
+ return 0
191
+ })
192
+ })
193
+
194
+ const totalCount = computed(() =>
195
+ props.serverMode ? (props.total ?? props.data.length) : sorted.value.length,
196
+ )
197
+
198
+ const paginated = computed(() => {
199
+ if (props.serverMode || !props.pagination) return sorted.value
200
+ const start = page.value * pageSize.value
201
+ return sorted.value.slice(start, start + pageSize.value)
202
+ })
203
+
204
+ const displayedRows = computed(() => paginated.value)
205
+
206
+ // Guarantee unique v-for keys. External data sources (e.g. the mail gateway's
207
+ // queued/held messages) can hand back rows with a null or duplicated row-key;
208
+ // duplicate keys make Vue's keyed patch crash on the next re-sort/re-filter
209
+ // with "Cannot read properties of null (reading 'nextSibling')". Suffixing
210
+ // repeats keeps every key distinct without changing what the page passed.
211
+ const keyedRows = computed<{ row: T, key: string | number }[]>(() => {
212
+ const seen = new Map<string | number, number>()
213
+ return displayedRows.value.map((row) => {
214
+ const base = rowKeyFor(row) ?? '∅'
215
+ const n = seen.get(base) ?? 0
216
+ seen.set(base, n + 1)
217
+ return { row, key: n === 0 ? base : `${base}#${n}` }
218
+ })
219
+ })
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // State-change hook for server mode
223
+ // ---------------------------------------------------------------------------
224
+ function emitState() {
225
+ emit('stateChange', {
226
+ search: query.value,
227
+ sort: { ...sort.value },
228
+ page: page.value,
229
+ pageSize: pageSize.value,
230
+ })
231
+ }
232
+ watch([query, sort, page, pageSize], () => {
233
+ if (props.serverMode) emitState()
234
+ })
235
+
236
+ // Reset to first page whenever search / sort / filter / page-size changes.
237
+ watch([query, sort, pageSize, activeFilters], () => {
238
+ page.value = 0
239
+ }, { deep: true })
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Header sort
243
+ // ---------------------------------------------------------------------------
244
+ function toggleSort(column: string) {
245
+ if (sort.value.column !== column) {
246
+ sort.value = { column, direction: 'asc' }
247
+ return
248
+ }
249
+ if (sort.value.direction === 'asc') {
250
+ sort.value = { column, direction: 'desc' }
251
+ return
252
+ }
253
+ sort.value = { column: null, direction: null }
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Misc
258
+ // ---------------------------------------------------------------------------
259
+ function rowKeyFor(row: T): string | number {
260
+ if (typeof props.rowKey === 'function') return props.rowKey(row)
261
+ return row[props.rowKey] as unknown as string | number
262
+ }
263
+
264
+ const gridTemplate = computed(() => {
265
+ const tracks = visibleColumns.value.map(c => c.width ?? 'minmax(0,1fr)')
266
+ return `grid-template-columns: ${tracks.join(' ')};`
267
+ })
268
+
269
+ const placeholder = computed(() =>
270
+ typeof props.search === 'object' ? (props.search.placeholder ?? 'Search…') : 'Search…',
271
+ )
272
+
273
+ const slots = useSlots()
274
+ const showToolbar = computed(() =>
275
+ props.search || props.columnsMenu || props.filter || !!props.filterTabs || !!slots.actions || !!slots['toolbar-left'],
276
+ )
277
+
278
+ // Leading icon tile for a (primary) column. `cellIcon` is a component — we must
279
+ // NOT call it: Solar icons are functional components (typeof === 'function'), so
280
+ // a `typeof ci === 'function'` "is this a getter?" check wrongly invokes the
281
+ // icon as `icon(row)` and it crashes destructuring its render context. For a
282
+ // per-row icon, use a `#cell-<key>` slot instead.
283
+ function cellIconFor(col: DataTableColumn<T>): Component | null {
284
+ return col.cellIcon ?? null
285
+ }
286
+ const showFooter = computed(() => props.pagination !== false)
287
+
288
+ // Pagination
289
+ const lastPage = computed(() =>
290
+ pageSize.value > 0 ? Math.max(0, Math.ceil(totalCount.value / pageSize.value) - 1) : 0,
291
+ )
292
+ const rangeStart = computed(() => totalCount.value === 0 ? 0 : page.value * pageSize.value + 1)
293
+ const rangeEnd = computed(() =>
294
+ Math.min((page.value + 1) * pageSize.value, totalCount.value),
295
+ )
296
+
297
+ function prevPage() {
298
+ if (page.value > 0) page.value -= 1
299
+ }
300
+ function nextPage() {
301
+ if (page.value < lastPage.value) page.value += 1
302
+ }
303
+
304
+ // Default cell value when no slot is provided.
305
+ function defaultCell(col: DataTableColumn<T>, row: T): string {
306
+ const v = col.sortGetter
307
+ ? col.sortGetter(row)
308
+ : (row as Record<string, unknown>)[col.key]
309
+ if (v == null) return '—'
310
+ return String(v)
311
+ }
312
+ </script>
313
+
314
+ <template>
315
+ <div class="rounded-lg border border-black/[0.06] overflow-hidden flex flex-col">
316
+ <!-- Toolbar ------------------------------------------------------ -->
317
+ <!-- Toolbar: LEFT = search · filter · columns · RIGHT = actions.
318
+ (Pagination always lives in the footer.) -->
319
+ <div
320
+ v-if="showToolbar"
321
+ class="flex items-center justify-between gap-2 px-4 py-3 bg-white/55 backdrop-blur-md"
322
+ >
323
+ <div class="flex items-center gap-2 min-w-0">
324
+ <div v-if="search" class="relative w-[21rem] max-w-full">
325
+ <Search :size="13" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
326
+ <Input
327
+ v-model="query"
328
+ :placeholder="placeholder"
329
+ class="h-7 pl-8 text-sm bg-white"
330
+ />
331
+ </div>
332
+ <!-- Generic "Filter" dropdown — each dimension is a labelled sub-menu. -->
333
+ <DropdownMenu v-if="filterDims.length">
334
+ <DropdownMenuTrigger as-child>
335
+ <Button
336
+ variant="outline"
337
+ size="sm"
338
+ class="h-7 gap-1.5 text-sm"
339
+ :class="anyFilterActive ? 'border-primary/30 text-primary' : ''"
340
+ >
341
+ <Filter :size="14" />
342
+ Filter
343
+ <AltArrowDown :size="12" class="opacity-60" />
344
+ </Button>
345
+ </DropdownMenuTrigger>
346
+ <DropdownMenuContent align="start" class="w-48">
347
+ <DropdownMenuSub v-for="d in filterDims" :key="d.key">
348
+ <DropdownMenuSubTrigger class="gap-2">
349
+ <span class="flex-1">{{ d.label }}</span>
350
+ <span v-if="activeFilters[d.key]" class="text-xs text-primary capitalize">{{ activeFilters[d.key] }}</span>
351
+ </DropdownMenuSubTrigger>
352
+ <DropdownMenuSubContent class="w-48">
353
+ <DropdownMenuItem class="gap-2" @select="setFilter(d.key, null)">
354
+ <span class="flex-1">{{ d.allLabel ?? 'All' }}</span>
355
+ <span class="tabular-nums text-muted-foreground">{{ data.length }}</span>
356
+ </DropdownMenuItem>
357
+ <DropdownMenuSeparator />
358
+ <DropdownMenuItem
359
+ v-for="o in d.options"
360
+ :key="o.value"
361
+ class="gap-2"
362
+ @select="setFilter(d.key, o.value)"
363
+ >
364
+ <span class="flex-1">{{ o.label }}</span>
365
+ <span class="tabular-nums text-muted-foreground">{{ dimCounts(d)[o.value] ?? 0 }}</span>
366
+ </DropdownMenuItem>
367
+ </DropdownMenuSubContent>
368
+ </DropdownMenuSub>
369
+ </DropdownMenuContent>
370
+ </DropdownMenu>
371
+ <FilterMenu v-if="filter">
372
+ <slot name="filter-menu" />
373
+ </FilterMenu>
374
+ <ColumnsMenu
375
+ v-if="columnsMenu"
376
+ :columns="menuColumns"
377
+ :visible="visibleKeys"
378
+ @toggle="toggleColumn"
379
+ />
380
+ <slot name="toolbar-left" />
381
+ </div>
382
+ <div class="flex items-center gap-1.5 ml-auto shrink-0">
383
+ <slot name="actions" />
384
+ </div>
385
+ </div>
386
+
387
+ <!-- Header row -------------------------------------------------- -->
388
+ <div
389
+ class="grid items-center gap-2 border-t rounded-t-lg border-gray-200 bg-gray-100 px-4 py-2.5 text-sm font-medium uppercase tracking-normal text-muted-foreground"
390
+ :style="gridTemplate"
391
+ >
392
+ <div
393
+ v-for="col in visibleColumns"
394
+ :key="col.key"
395
+ :class="col.alignRight ? 'text-right' : ''"
396
+ >
397
+ <SortHeader
398
+ v-if="col.sortable"
399
+ :column="col.key"
400
+ :active="sort.column"
401
+ :direction="sort.direction"
402
+ :align-right="col.alignRight"
403
+ @toggle="toggleSort"
404
+ >
405
+ {{ col.label }}
406
+ </SortHeader>
407
+ <span v-else>{{ col.label }}</span>
408
+ </div>
409
+ </div>
410
+
411
+ <!-- Body --------------------------------------------------------- -->
412
+ <div
413
+ v-for="{ row, key } in keyedRows"
414
+ :key="key"
415
+ class="group grid items-center gap-2 border-t border-black/[0.04] px-4 py-3 cursor-pointer bg-gray-50 transition-colors"
416
+ :style="gridTemplate"
417
+ @click="emit('rowClick', row)"
418
+ >
419
+ <div
420
+ v-for="col in visibleColumns"
421
+ :key="col.key"
422
+ class="min-w-0"
423
+ :class="col.alignRight ? 'text-right' : ''"
424
+ >
425
+ <slot :name="`cell-${col.key}`" :row="row">
426
+ <!-- Primary column: bold record anchor + optional tinted icon tile. -->
427
+ <div v-if="col.primary" class="flex items-center gap-2.5 min-w-0">
428
+ <span
429
+ v-if="cellIconFor(col)"
430
+ class="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
431
+ >
432
+ <component :is="cellIconFor(col)" :size="13" />
433
+ </span>
434
+ <span class="text-sm font-semibold text-[#0B0522] truncate transition-colors group-hover:text-primary">{{ defaultCell(col, row) }}</span>
435
+ </div>
436
+ <span v-else class="text-sm truncate">{{ defaultCell(col, row) }}</span>
437
+ </slot>
438
+ </div>
439
+ </div>
440
+
441
+ <!-- Loading skeleton (only when there are no rows yet) ----------- -->
442
+ <template v-if="loading && !displayedRows.length">
443
+ <div
444
+ v-for="n in 6"
445
+ :key="`sk-${n}`"
446
+ class="grid items-center gap-2 border-t border-black/[0.04] bg-gray-50 px-4 py-3"
447
+ :style="gridTemplate"
448
+ >
449
+ <div
450
+ v-for="col in visibleColumns"
451
+ :key="col.key"
452
+ :class="col.alignRight ? 'flex justify-end' : ''"
453
+ >
454
+ <div
455
+ class="h-3 rounded bg-black/[0.07] animate-pulse"
456
+ :class="col.alignRight ? 'w-10' : 'w-2/3'"
457
+ />
458
+ </div>
459
+ </div>
460
+ </template>
461
+
462
+ <!-- Empty -------------------------------------------------------- -->
463
+ <div
464
+ v-if="!displayedRows.length && !loading"
465
+ class="px-3 py-8 text-center text-sm text-muted-foreground"
466
+ >
467
+ <slot name="empty">
468
+ {{ emptyText ?? 'No entries.' }}
469
+ </slot>
470
+ </div>
471
+
472
+ <!-- Footer ------------------------------------------------------- -->
473
+ <div
474
+ v-if="showFooter"
475
+ class="flex items-center justify-between gap-2 border-t border-gray-200 bg-white/55 backdrop-blur-md px-4 py-3 text-sm text-muted-foreground/60"
476
+ >
477
+ <div class="flex items-center gap-2.5">
478
+ <span class="tabular-nums">
479
+ <span v-if="totalCount === 0">No results</span>
480
+ <span v-else>Showing {{ rangeStart }}–{{ rangeEnd }} of {{ totalCount }}</span>
481
+ </span>
482
+ <span class="text-muted-foreground/40">-</span>
483
+ <label class="flex items-center gap-1.5">
484
+ <span class="hidden sm:inline">Rows per page</span>
485
+ <select
486
+ v-model.number="pageSize"
487
+ class="h-7 rounded-md border border-input bg-white px-1.5 text-sm tabular-nums focus:outline-none focus:ring-1 focus:ring-ring"
488
+ >
489
+ <option v-for="s in pageSizeOptions" :key="s" :value="s">{{ s }}</option>
490
+ </select>
491
+ </label>
492
+ </div>
493
+ <div v-if="lastPage > 0" class="flex items-center gap-2">
494
+ <Button
495
+ variant="outline"
496
+ size="sm"
497
+ class="h-7 text-sm"
498
+ :disabled="page === 0 || loading"
499
+ @click="prevPage"
500
+ >
501
+ Previous
502
+ </Button>
503
+ <span class="tabular-nums">
504
+ {{ page + 1 }} / {{ lastPage + 1 }}
505
+ </span>
506
+ <Button
507
+ variant="outline"
508
+ size="sm"
509
+ class="h-7 text-sm"
510
+ :disabled="page >= lastPage || loading"
511
+ @click="nextPage"
512
+ >
513
+ Next
514
+ </Button>
515
+ </div>
516
+ </div>
517
+ </div>
518
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import { Filter } from '#solar-icons'
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuLabel,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from '@revenexx/studio'
11
+
12
+ // Placeholder filter affordance — exposes the icon + dropdown shell so
13
+ // pages can mount custom filter chips inside via the default slot. Wire
14
+ // up actual filter state at the call site.
15
+ </script>
16
+
17
+ <template>
18
+ <DropdownMenu>
19
+ <DropdownMenuTrigger as-child>
20
+ <button
21
+ type="button"
22
+ class="flex size-7 items-center justify-center rounded-md border border-black/[0.06] bg-white text-muted-foreground transition hover:text-foreground"
23
+ title="Filter"
24
+ >
25
+ <Filter :size="12" />
26
+ </button>
27
+ </DropdownMenuTrigger>
28
+ <DropdownMenuContent align="end" class="w-56">
29
+ <DropdownMenuLabel class="text-sm text-muted-foreground">
30
+ Filter
31
+ </DropdownMenuLabel>
32
+ <DropdownMenuSeparator />
33
+ <slot>
34
+ <DropdownMenuItem disabled>
35
+ No filters defined.
36
+ </DropdownMenuItem>
37
+ </slot>
38
+ </DropdownMenuContent>
39
+ </DropdownMenu>
40
+ </template>