@leavepulse/ui 0.29.3 → 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-B9Iko3XD.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 +19 -0
  29. package/dist/components/LpTable.vue.js +233 -143
  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 +166 -23
@@ -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 {
@@ -47,6 +62,10 @@ const props = withDefaults(
47
62
  sortAscLabel?: string
48
63
  sortDescLabel?: string
49
64
  sortClearLabel?: string
65
+ /** Wording for the value filter and the column chooser. */
66
+ filterLabel?: string
67
+ showAllLabel?: string
68
+ columnsLabel?: string
50
69
  }>(),
51
70
  {
52
71
  emptyLabel: "Nothing here yet",
@@ -57,6 +76,9 @@ const props = withDefaults(
57
76
  sortAscLabel: "Sort ascending",
58
77
  sortDescLabel: "Sort descending",
59
78
  sortClearLabel: "Clear sorting",
79
+ filterLabel: "Show only",
80
+ showAllLabel: "Show all",
81
+ columnsLabel: "Columns",
60
82
  },
61
83
  )
62
84
 
@@ -94,14 +116,75 @@ function menuFor(row: T): ContextMenuItemDef[] {
94
116
  const ownSort = ref<SortState | null>(null)
95
117
  const activeSort = computed(() => props.sort ?? ownSort.value)
96
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
+
97
180
  // We compare by raw cell value with a stable-ish coerce.
98
181
  const displayRows = computed<T[]>(() => {
99
182
  const s = activeSort.value
100
- if (!s) return props.rows
183
+ if (!s) return filteredRows.value
101
184
  const col = props.columns.find((c) => c.key === s.key)
102
- if (!col?.sortable) return props.rows
185
+ if (!col?.sortable) return filteredRows.value
103
186
  const factor = s.dir === "asc" ? 1 : -1
104
- 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)
105
188
  })
106
189
 
107
190
  function compare(a: unknown, b: unknown): number {
@@ -143,31 +226,86 @@ function sortIcon(col: TableColumn<T>): string {
143
226
  * list straight through to the browser's own menu.
144
227
  */
145
228
  function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
146
- if (!col.sortable) return []
147
229
  const s = activeSort.value
148
230
  const set = (next: SortState | null) => () => {
149
231
  ownSort.value = next
150
232
  emit("update:sort", next)
151
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 []
152
296
  return [
153
297
  {
154
- label: props.sortAscLabel,
155
- icon: "lucide:arrow-up",
156
- disabled: s?.key === col.key && s.dir === "asc",
157
- onSelect: set({ key: col.key, dir: "asc" }),
158
- },
159
- {
160
- label: props.sortDescLabel,
161
- icon: "lucide:arrow-down",
162
- disabled: s?.key === col.key && s.dir === "desc",
163
- onSelect: set({ key: col.key, dir: "desc" }),
164
- },
165
- {
166
- label: props.sortClearLabel,
167
- icon: "lucide:x",
168
- separatorBefore: true,
169
- disabled: !s,
170
- onSelect: set(null),
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
+ })),
171
309
  },
172
310
  ]
173
311
  }
@@ -280,6 +418,10 @@ const barInsetTop = computed(() =>
280
418
  class="rounded-card border border-line"
281
419
  :bar-inset-top="barInsetTop"
282
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()">
283
425
  <table class="w-full border-collapse text-sm">
284
426
  <thead ref="headEl" :class="stickyHeader ? 'sticky top-0 z-10' : ''">
285
427
  <tr class="border-b border-line bg-surface-soft">
@@ -291,7 +433,7 @@ const barInsetTop = computed(() =>
291
433
  />
292
434
  </th>
293
435
  <th
294
- v-for="col in columns"
436
+ v-for="col in visibleColumns"
295
437
  :key="col.key"
296
438
  :style="col.width ? { width: col.width } : undefined"
297
439
  class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
@@ -385,7 +527,7 @@ const barInsetTop = computed(() =>
385
527
  />
386
528
  </td>
387
529
  <td
388
- v-for="col in columns"
530
+ v-for="col in visibleColumns"
389
531
  :key="col.key"
390
532
  class="px-4 py-3 text-ink"
391
533
  :class="alignClass(col.align)"
@@ -398,6 +540,7 @@ const barInsetTop = computed(() =>
398
540
  </LpContextMenu>
399
541
  </TransitionGroup>
400
542
  </table>
543
+ </LpContextMenu>
401
544
  </LpScrollArea>
402
545
  </template>
403
546