@leavepulse/ui 0.29.2 → 0.29.4
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/dist/assets/{LpTable-ChxURw0m.css → LpTable-Bz3uJNa3.css} +8 -8
- package/dist/chunks/{LpAppShell-CGX0DlIW.js → LpAppShell-BJk3b-u4.js} +1 -1
- package/dist/chunks/{LpAvatar-CzCE5cXg.js → LpAvatar-D3l4nabg.js} +1 -1
- package/dist/chunks/{LpCard-DSefTGEH.js → LpCard-D0M2ZCnB.js} +1 -1
- package/dist/chunks/{LpChart-UiU4RhXP.js → LpChart-Bmw7y34Z.js} +1 -1
- package/dist/chunks/{LpContextMenu-D0mzXqA7.js → LpContextMenu-B6WBLleS.js} +55 -8
- package/dist/chunks/{LpFileTree-BaV7f0O7.js → LpFileTree-DfFqY__1.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-BzpBa7R7.js → LpFileTreeNode-COchUMNn.js} +1 -1
- package/dist/chunks/{LpLogViewer-CKmEQYnm.js → LpLogViewer-CVaGfTF_.js} +1 -1
- package/dist/chunks/{LpNotificationBell-Dvu-b6Vn.js → LpNotificationBell-CTsIcPqX.js} +1 -1
- package/dist/chunks/{LpPie-BkjWlfSw.js → LpPie-BL1vTDSM.js} +1 -1
- package/dist/chunks/{LpSidebar-CWehDCxN.js → LpSidebar-C_Mr-JPI.js} +1 -1
- package/dist/chunks/{LpSidebarNav-5RGNr2D3.js → LpSidebarNav-D6v12-36.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-Dj4VcTu7.js → LpThemeSwitcher-r1te9nB2.js} +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.d.ts +7 -0
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +24 -0
- package/dist/components/LpTable.vue.js +247 -118
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/index.js +12 -12
- package/package.json +1 -1
- package/src/components/LpContextMenu.vue +49 -0
- package/src/components/LpTable.vue +205 -6
|
@@ -13,6 +13,21 @@ export interface TableColumn<Row> {
|
|
|
13
13
|
width?: string
|
|
14
14
|
/** Allow clicking the header to sort by this column. */
|
|
15
15
|
sortable?: boolean
|
|
16
|
+
/**
|
|
17
|
+
* Offer "show only these values" in the header's menu, built from the values
|
|
18
|
+
* actually present in the rows. For a column that holds a small vocabulary —
|
|
19
|
+
* a type, a state, a site — where the question is "just the mains leads,
|
|
20
|
+
* please". Pointless on a column of unique values, so it is opt-in.
|
|
21
|
+
*/
|
|
22
|
+
filterable?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* How a raw cell value reads in that filter list. `power_ac` is the stored
|
|
25
|
+
* vocabulary; the menu should say what the column itself says.
|
|
26
|
+
*/
|
|
27
|
+
valueLabel?: (value: unknown) => string
|
|
28
|
+
/** Keep this column out of the show/hide list — an actions column has no
|
|
29
|
+
* name to offer and nothing to gain from being hidden. */
|
|
30
|
+
alwaysVisible?: boolean
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export interface SortState {
|
|
@@ -42,6 +57,15 @@ const props = withDefaults(
|
|
|
42
57
|
/** Per-row right-click menu. Return [] (or omit the prop) to disable it for
|
|
43
58
|
* a row; the row then keeps the browser's native context menu. */
|
|
44
59
|
rowMenu?: (row: T) => ContextMenuItemDef[]
|
|
60
|
+
/** Wording for the header's right-click menu. Passed in because the kit
|
|
61
|
+
* carries no translations: an app that leaves these alone gets English. */
|
|
62
|
+
sortAscLabel?: string
|
|
63
|
+
sortDescLabel?: string
|
|
64
|
+
sortClearLabel?: string
|
|
65
|
+
/** Wording for the value filter and the column chooser. */
|
|
66
|
+
filterLabel?: string
|
|
67
|
+
showAllLabel?: string
|
|
68
|
+
columnsLabel?: string
|
|
45
69
|
}>(),
|
|
46
70
|
{
|
|
47
71
|
emptyLabel: "Nothing here yet",
|
|
@@ -49,6 +73,12 @@ const props = withDefaults(
|
|
|
49
73
|
selectable: false,
|
|
50
74
|
selected: () => [],
|
|
51
75
|
sort: null,
|
|
76
|
+
sortAscLabel: "Sort ascending",
|
|
77
|
+
sortDescLabel: "Sort descending",
|
|
78
|
+
sortClearLabel: "Clear sorting",
|
|
79
|
+
filterLabel: "Show only",
|
|
80
|
+
showAllLabel: "Show all",
|
|
81
|
+
columnsLabel: "Columns",
|
|
52
82
|
},
|
|
53
83
|
)
|
|
54
84
|
|
|
@@ -86,14 +116,75 @@ function menuFor(row: T): ContextMenuItemDef[] {
|
|
|
86
116
|
const ownSort = ref<SortState | null>(null)
|
|
87
117
|
const activeSort = computed(() => props.sort ?? ownSort.value)
|
|
88
118
|
|
|
119
|
+
/*
|
|
120
|
+
* ── hiding values, and hiding columns ────────────────────────────────────────
|
|
121
|
+
*
|
|
122
|
+
* Both live here rather than in each page for the same reason the sort does: a
|
|
123
|
+
* table that can be sorted but not narrowed sends every caller off to build its
|
|
124
|
+
* own filter bar, and six of those are six different ideas of what filtering
|
|
125
|
+
* looks like.
|
|
126
|
+
*
|
|
127
|
+
* State is the *hidden* set, not the shown one. A column that gains a new value
|
|
128
|
+
* — a cable type nobody had recorded yet — then appears by default instead of
|
|
129
|
+
* being invisible until someone notices the list has an unticked entry they
|
|
130
|
+
* never saw.
|
|
131
|
+
*/
|
|
132
|
+
const hiddenValues = ref<Record<string, Set<unknown>>>({})
|
|
133
|
+
const hiddenColumns = ref<Set<string>>(new Set())
|
|
134
|
+
|
|
135
|
+
const visibleColumns = computed(() =>
|
|
136
|
+
props.columns.filter((c) => !hiddenColumns.value.has(c.key)),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
/** The distinct values a filterable column holds, in first-seen order. */
|
|
140
|
+
function valuesOf(col: TableColumn<T>): unknown[] {
|
|
141
|
+
const seen: unknown[] = []
|
|
142
|
+
for (const row of props.rows) {
|
|
143
|
+
const v = row[col.key]
|
|
144
|
+
if (!seen.includes(v)) seen.push(v)
|
|
145
|
+
}
|
|
146
|
+
return seen
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isValueHidden(key: string, value: unknown): boolean {
|
|
150
|
+
return hiddenValues.value[key]?.has(value) ?? false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toggleValue(key: string, value: unknown) {
|
|
154
|
+
const next = new Set(hiddenValues.value[key] ?? [])
|
|
155
|
+
if (next.has(value)) next.delete(value)
|
|
156
|
+
else next.add(value)
|
|
157
|
+
hiddenValues.value = { ...hiddenValues.value, [key]: next }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function showAllValues(key: string) {
|
|
161
|
+
hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toggleColumn(key: string) {
|
|
165
|
+
const next = new Set(hiddenColumns.value)
|
|
166
|
+
if (next.has(key)) next.delete(key)
|
|
167
|
+
else next.add(key)
|
|
168
|
+
hiddenColumns.value = next
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Rows left once the per-column value filters have been applied. */
|
|
172
|
+
const filteredRows = computed<T[]>(() => {
|
|
173
|
+
const active = Object.entries(hiddenValues.value).filter(([, set]) => set.size)
|
|
174
|
+
if (!active.length) return props.rows
|
|
175
|
+
return props.rows.filter((row) =>
|
|
176
|
+
active.every(([key, hidden]) => !hidden.has(row[key])),
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
89
180
|
// We compare by raw cell value with a stable-ish coerce.
|
|
90
181
|
const displayRows = computed<T[]>(() => {
|
|
91
182
|
const s = activeSort.value
|
|
92
|
-
if (!s) return
|
|
183
|
+
if (!s) return filteredRows.value
|
|
93
184
|
const col = props.columns.find((c) => c.key === s.key)
|
|
94
|
-
if (!col?.sortable) return
|
|
185
|
+
if (!col?.sortable) return filteredRows.value
|
|
95
186
|
const factor = s.dir === "asc" ? 1 : -1
|
|
96
|
-
return [...
|
|
187
|
+
return [...filteredRows.value].sort((a, b) => compare(a[s.key], b[s.key]) * factor)
|
|
97
188
|
})
|
|
98
189
|
|
|
99
190
|
function compare(a: unknown, b: unknown): number {
|
|
@@ -124,6 +215,101 @@ function sortIcon(col: TableColumn<T>): string {
|
|
|
124
215
|
return s.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
|
|
125
216
|
}
|
|
126
217
|
|
|
218
|
+
/**
|
|
219
|
+
* What right-clicking a header offers: the directions by name.
|
|
220
|
+
*
|
|
221
|
+
* The click cycles none → asc → desc → none, which is right for the common
|
|
222
|
+
* case and clumsy for the rest — going back to unsorted from ascending is two
|
|
223
|
+
* more clicks. Naming the three states makes each one reachable at once.
|
|
224
|
+
*
|
|
225
|
+
* Empty for a column that does not sort, and LpContextMenu passes an empty
|
|
226
|
+
* list straight through to the browser's own menu.
|
|
227
|
+
*/
|
|
228
|
+
function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
|
|
229
|
+
const s = activeSort.value
|
|
230
|
+
const set = (next: SortState | null) => () => {
|
|
231
|
+
ownSort.value = next
|
|
232
|
+
emit("update:sort", next)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const items: ContextMenuItemDef[] = []
|
|
236
|
+
|
|
237
|
+
if (col.sortable) {
|
|
238
|
+
items.push(
|
|
239
|
+
{
|
|
240
|
+
label: props.sortAscLabel,
|
|
241
|
+
icon: "lucide:arrow-up",
|
|
242
|
+
disabled: s?.key === col.key && s.dir === "asc",
|
|
243
|
+
onSelect: set({ key: col.key, dir: "asc" }),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: props.sortDescLabel,
|
|
247
|
+
icon: "lucide:arrow-down",
|
|
248
|
+
disabled: s?.key === col.key && s.dir === "desc",
|
|
249
|
+
onSelect: set({ key: col.key, dir: "desc" }),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
label: props.sortClearLabel,
|
|
253
|
+
icon: "lucide:x",
|
|
254
|
+
disabled: !s,
|
|
255
|
+
onSelect: set(null),
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// "Show only the mains leads" asked of the column that says which is which,
|
|
261
|
+
// rather than of a filter bar somewhere else on the page.
|
|
262
|
+
if (col.filterable) {
|
|
263
|
+
const hidden = hiddenValues.value[col.key]
|
|
264
|
+
items.push({
|
|
265
|
+
label: props.filterLabel,
|
|
266
|
+
icon: "lucide:filter",
|
|
267
|
+
separatorBefore: items.length > 0,
|
|
268
|
+
children: [
|
|
269
|
+
...valuesOf(col).map((value) => ({
|
|
270
|
+
label: col.valueLabel?.(value) ?? String(value ?? "—"),
|
|
271
|
+
checked: !isValueHidden(col.key, value),
|
|
272
|
+
onSelect: () => toggleValue(col.key, value),
|
|
273
|
+
})),
|
|
274
|
+
{
|
|
275
|
+
label: props.showAllLabel,
|
|
276
|
+
icon: "lucide:list",
|
|
277
|
+
separatorBefore: true,
|
|
278
|
+
disabled: !hidden?.size,
|
|
279
|
+
onSelect: () => showAllValues(col.key),
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return items
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The menu for the header itself, away from any one column: which columns to
|
|
290
|
+
* show. Asked here because it is a question about the table rather than about
|
|
291
|
+
* a column — right-clicking `Type` to hide `Where` would be the wrong place.
|
|
292
|
+
*/
|
|
293
|
+
function tableMenu(): ContextMenuItemDef[] {
|
|
294
|
+
const offered = props.columns.filter((c) => !c.alwaysVisible && c.label)
|
|
295
|
+
if (!offered.length) return []
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
label: props.columnsLabel,
|
|
299
|
+
icon: "lucide:columns-3",
|
|
300
|
+
children: offered.map((col) => ({
|
|
301
|
+
label: col.label,
|
|
302
|
+
checked: !hiddenColumns.value.has(col.key),
|
|
303
|
+
// The last visible column cannot be hidden: a table with no columns is
|
|
304
|
+
// a blank card the operator has no way back from.
|
|
305
|
+
disabled:
|
|
306
|
+
!hiddenColumns.value.has(col.key) && visibleColumns.value.length <= 1,
|
|
307
|
+
onSelect: () => toggleColumn(col.key),
|
|
308
|
+
})),
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
}
|
|
312
|
+
|
|
127
313
|
// ── selection ────────────────────────────────────────────────
|
|
128
314
|
const selectedSet = computed(() => new Set(props.selected))
|
|
129
315
|
const allKeys = computed(() => props.rows.map((r, i) => keyFor(r, i)))
|
|
@@ -232,6 +418,10 @@ const barInsetTop = computed(() =>
|
|
|
232
418
|
class="rounded-card border border-line"
|
|
233
419
|
:bar-inset-top="barInsetTop"
|
|
234
420
|
>
|
|
421
|
+
<!-- The table-wide menu, which is what a right-click on empty header space
|
|
422
|
+
reaches: rows and headers carry their own and take the event first, so
|
|
423
|
+
this is the fallback rather than a competitor. -->
|
|
424
|
+
<LpContextMenu :items="tableMenu()">
|
|
235
425
|
<table class="w-full border-collapse text-sm">
|
|
236
426
|
<thead ref="headEl" :class="stickyHeader ? 'sticky top-0 z-10' : ''">
|
|
237
427
|
<tr class="border-b border-line bg-surface-soft">
|
|
@@ -243,7 +433,7 @@ const barInsetTop = computed(() =>
|
|
|
243
433
|
/>
|
|
244
434
|
</th>
|
|
245
435
|
<th
|
|
246
|
-
v-for="col in
|
|
436
|
+
v-for="col in visibleColumns"
|
|
247
437
|
:key="col.key"
|
|
248
438
|
:style="col.width ? { width: col.width } : undefined"
|
|
249
439
|
class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
|
|
@@ -256,8 +446,15 @@ const barInsetTop = computed(() =>
|
|
|
256
446
|
: undefined
|
|
257
447
|
"
|
|
258
448
|
>
|
|
449
|
+
<!-- The menu wraps the button rather than the cell: a component
|
|
450
|
+
cannot sit between `<tr>` and `<th>` — the template compiler
|
|
451
|
+
drops it there, silently — so the trigger goes on the one
|
|
452
|
+
element inside the header that is already interactive.
|
|
453
|
+
Right-clicking names the directions instead of making you cycle
|
|
454
|
+
through them: getting back to unsorted from ascending is two
|
|
455
|
+
more clicks otherwise. -->
|
|
456
|
+
<LpContextMenu v-if="col.sortable" :items="headerMenu(col)">
|
|
259
457
|
<button
|
|
260
|
-
v-if="col.sortable"
|
|
261
458
|
type="button"
|
|
262
459
|
class="group/sort -mx-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
263
460
|
:class="[
|
|
@@ -278,6 +475,7 @@ const barInsetTop = computed(() =>
|
|
|
278
475
|
"
|
|
279
476
|
/>
|
|
280
477
|
</button>
|
|
478
|
+
</LpContextMenu>
|
|
281
479
|
<template v-else>{{ col.label }}</template>
|
|
282
480
|
</th>
|
|
283
481
|
</tr>
|
|
@@ -329,7 +527,7 @@ const barInsetTop = computed(() =>
|
|
|
329
527
|
/>
|
|
330
528
|
</td>
|
|
331
529
|
<td
|
|
332
|
-
v-for="col in
|
|
530
|
+
v-for="col in visibleColumns"
|
|
333
531
|
:key="col.key"
|
|
334
532
|
class="px-4 py-3 text-ink"
|
|
335
533
|
:class="alignClass(col.align)"
|
|
@@ -342,6 +540,7 @@ const barInsetTop = computed(() =>
|
|
|
342
540
|
</LpContextMenu>
|
|
343
541
|
</TransitionGroup>
|
|
344
542
|
</table>
|
|
543
|
+
</LpContextMenu>
|
|
345
544
|
</LpScrollArea>
|
|
346
545
|
</template>
|
|
347
546
|
|