@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 +52 -0
- package/components/DataTable/ColumnsMenu.vue +57 -0
- package/components/DataTable/DataTable.vue +518 -0
- package/components/DataTable/FilterMenu.vue +40 -0
- package/components/DataTable/types.ts +82 -0
- package/components/Shared/BuildLogsFlyout.vue +127 -0
- package/components/Shared/GitDeploymentSettings.vue +87 -0
- package/components/Shared/KpiTile.vue +41 -0
- package/components/Shared/PageHeader.vue +34 -0
- package/components/Shared/SidebarNav.vue +100 -0
- package/components/Shared/SortHeader.vue +50 -0
- package/components/Shared/StateCard.vue +66 -0
- package/components/Shared/StudioCard.vue +24 -0
- package/components/Shared/TabBar.vue +50 -0
- package/components/Shared/ViewTab.vue +26 -0
- package/components/Shared/Wizard.vue +80 -0
- package/composables/usePlatformAuth.ts +35 -0
- package/composables/usePlatformTenant.ts +35 -0
- package/index.ts +12 -0
- package/nuxt.config.ts +23 -0
- package/package.json +29 -0
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>
|