@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.
Files changed (34) hide show
  1. package/dist/assets/{LpTable-ChxURw0m.css → LpTable-Bz3uJNa3.css} +8 -8
  2. package/dist/chunks/{LpAppShell-CGX0DlIW.js → LpAppShell-BJk3b-u4.js} +1 -1
  3. package/dist/chunks/{LpAvatar-CzCE5cXg.js → LpAvatar-D3l4nabg.js} +1 -1
  4. package/dist/chunks/{LpCard-DSefTGEH.js → LpCard-D0M2ZCnB.js} +1 -1
  5. package/dist/chunks/{LpChart-UiU4RhXP.js → LpChart-Bmw7y34Z.js} +1 -1
  6. package/dist/chunks/{LpContextMenu-D0mzXqA7.js → LpContextMenu-B6WBLleS.js} +55 -8
  7. package/dist/chunks/{LpFileTree-BaV7f0O7.js → LpFileTree-DfFqY__1.js} +1 -1
  8. package/dist/chunks/{LpFileTreeNode-BzpBa7R7.js → LpFileTreeNode-COchUMNn.js} +1 -1
  9. package/dist/chunks/{LpLogViewer-CKmEQYnm.js → LpLogViewer-CVaGfTF_.js} +1 -1
  10. package/dist/chunks/{LpNotificationBell-Dvu-b6Vn.js → LpNotificationBell-CTsIcPqX.js} +1 -1
  11. package/dist/chunks/{LpPie-BkjWlfSw.js → LpPie-BL1vTDSM.js} +1 -1
  12. package/dist/chunks/{LpSidebar-CWehDCxN.js → LpSidebar-C_Mr-JPI.js} +1 -1
  13. package/dist/chunks/{LpSidebarNav-5RGNr2D3.js → LpSidebarNav-D6v12-36.js} +1 -1
  14. package/dist/chunks/{LpThemeSwitcher-Dj4VcTu7.js → LpThemeSwitcher-r1te9nB2.js} +1 -1
  15. package/dist/components/LpAppShell.vue.js +1 -1
  16. package/dist/components/LpAvatar.vue.js +1 -1
  17. package/dist/components/LpCard.vue.js +1 -1
  18. package/dist/components/LpChart.vue.js +1 -1
  19. package/dist/components/LpContextMenu.vue.d.ts +7 -0
  20. package/dist/components/LpContextMenu.vue.js +1 -1
  21. package/dist/components/LpFileTree.vue.js +1 -1
  22. package/dist/components/LpFileTreeNode.vue.js +1 -1
  23. package/dist/components/LpLogViewer.vue.js +1 -1
  24. package/dist/components/LpNotificationBell.vue.js +1 -1
  25. package/dist/components/LpPie.vue.js +1 -1
  26. package/dist/components/LpSidebar.vue.js +1 -1
  27. package/dist/components/LpSidebarNav.vue.js +1 -1
  28. package/dist/components/LpTable.vue.d.ts +24 -0
  29. package/dist/components/LpTable.vue.js +247 -118
  30. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  31. package/dist/index.js +12 -12
  32. package/package.json +1 -1
  33. package/src/components/LpContextMenu.vue +49 -0
  34. 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 props.rows
183
+ if (!s) return filteredRows.value
93
184
  const col = props.columns.find((c) => c.key === s.key)
94
- if (!col?.sortable) return props.rows
185
+ if (!col?.sortable) return filteredRows.value
95
186
  const factor = s.dir === "asc" ? 1 : -1
96
- return [...props.rows].sort((a, b) => compare(a[s.key], b[s.key]) * factor)
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 columns"
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 columns"
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