@leavepulse/ui 0.29.0 → 0.29.1

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.
@@ -0,0 +1,44 @@
1
+
2
+ /*
3
+ * Rows arriving, leaving and moving as the filter changes.
4
+ *
5
+ * A row cannot be translated on its Y axis without the table reflowing around
6
+ * it, so the entrance is opacity plus a small horizontal offset — enough to
7
+ * read as movement, not enough to fight the column grid. `--ease-emphasized`
8
+ * because this is interface responding to a click, not data being drawn.
9
+ *
10
+ * The leaving row is taken out of the flow, or the rows below it wait for the
11
+ * fade before closing the gap and the whole list lurches at the end instead of
12
+ * gliding.
13
+ */
14
+ .lp-row-enter-active[data-v-7884aed8],
15
+ .lp-row-leave-active[data-v-7884aed8] {
16
+ transition:
17
+ opacity var(--duration-fast) var(--ease-emphasized),
18
+ transform var(--duration-fast) var(--ease-emphasized);
19
+ }
20
+ .lp-row-move[data-v-7884aed8] {
21
+ transition: transform var(--duration-medium) var(--ease-emphasized);
22
+ }
23
+ .lp-row-enter-from[data-v-7884aed8],
24
+ .lp-row-leave-to[data-v-7884aed8] {
25
+ opacity: 0;
26
+ transform: translateX(-0.5rem);
27
+ }
28
+ .lp-row-leave-active[data-v-7884aed8] {
29
+ position: absolute;
30
+ width: 100%;
31
+ }
32
+
33
+ /* A filter that redraws the list is information, not decoration: with motion
34
+ turned down the rows change without animating rather than not changing. */
35
+ @media (prefers-reduced-motion: reduce) {
36
+ .lp-row-enter-active[data-v-7884aed8],
37
+ .lp-row-leave-active[data-v-7884aed8],
38
+ .lp-row-move[data-v-7884aed8] {
39
+ transition: none;
40
+ }
41
+ .lp-row-leave-active[data-v-7884aed8] {
42
+ position: static;
43
+ }
44
+ }
@@ -26,6 +26,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
26
26
  },
27
27
  setup(__props) {
28
28
  const props = __props;
29
+ function fill(tpl, vars) {
30
+ return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
31
+ }
32
+ const text = computed(() => ({
33
+ firewallRules: props.data.labels?.firewallRules ?? "{n} firewall rule(s)",
34
+ services: props.data.labels?.services ?? "{n} service(s)",
35
+ servicesStale: props.data.labels?.servicesStale ?? "{n} service(s) as of {age} ago - not current",
36
+ noContact: props.data.labels?.noContact ?? "No contact for {age} - everything shown here is that old",
37
+ old: props.data.labels?.old ?? "old"
38
+ }));
29
39
  const DEFAULT_ROLES = {
30
40
  compute: { color: "var(--color-node-compute)", icon: "▣" },
31
41
  host: { color: "var(--color-node-compute)", icon: "▣" },
@@ -50,11 +60,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
50
60
  );
51
61
  return (_ctx, _cache) => {
52
62
  return openBlock(), createElementBlock("div", {
53
- class: normalizeClass(["w-[168px] overflow-hidden rounded-card border bg-surface-raised shadow-panel transition-[border-color,box-shadow]", __props.selected ? "border-[var(--accent)]" : "border-[var(--accent-line)]"]),
63
+ class: normalizeClass(["w-[168px] overflow-hidden rounded-card border bg-surface-raised shadow-panel transition-[border-color,box-shadow]", [
64
+ __props.selected ? "border-[var(--accent)]" : __props.data.online === false ? "border-dashed border-danger/50" : "border-[var(--accent-line)]"
65
+ ]]),
54
66
  style: normalizeStyle({
55
67
  "--accent": accent.value,
56
68
  "--accent-line": accentLine.value,
57
- opacity: __props.data.dimmed ? 0.3 : __props.data.online === false ? 0.5 : 1,
69
+ opacity: __props.data.dimmed ? 0.3 : __props.data.online === false ? 0.75 : 1,
58
70
  ...__props.selected ? { boxShadow: "0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent), var(--shadow-panel)" } : {}
59
71
  })
60
72
  }, [
@@ -68,7 +80,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
68
80
  position: unref(Position).Right,
69
81
  class: "!size-2 !border-2 !border-[var(--accent)] !bg-surface"
70
82
  }, null, 8, ["position"]),
71
- _cache[3] || (_cache[3] = createElementVNode("div", { class: "h-[3px] bg-[var(--accent)]" }, null, -1)),
83
+ createElementVNode("div", {
84
+ class: normalizeClass(["h-[3px]", __props.data.online === false ? "bg-danger/50" : "bg-[var(--accent)]"])
85
+ }, null, 2),
72
86
  createElementVNode("div", _hoisted_1, [
73
87
  createElementVNode("div", _hoisted_2, [
74
88
  createElementVNode("span", _hoisted_3, toDisplayString(icon.value), 1),
@@ -79,14 +93,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
79
93
  }, null, 2)) : createCommentVNode("", true)
80
94
  ]),
81
95
  createElementVNode("div", _hoisted_5, [
82
- createElementVNode("span", _hoisted_6, toDisplayString(__props.data.role), 1),
96
+ createElementVNode("span", _hoisted_6, toDisplayString(__props.data.roleLabel ?? __props.data.role), 1),
83
97
  __props.data.overlay ? (openBlock(), createElementBlock("span", _hoisted_7, toDisplayString(__props.data.overlay), 1)) : createCommentVNode("", true)
84
98
  ]),
85
99
  __props.data.firewallCount || __props.data.serviceCount !== void 0 || __props.data.staleFor ? (openBlock(), createElementBlock("div", _hoisted_8, [
86
100
  __props.data.firewallCount ? (openBlock(), createElementBlock("span", {
87
101
  key: 0,
88
102
  class: "inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger",
89
- title: `${__props.data.firewallCount} firewall rule(s)`
103
+ title: fill(text.value.firewallRules, { n: __props.data.firewallCount })
90
104
  }, [
91
105
  _cache[0] || (_cache[0] = createElementVNode("span", null, "⛨", -1)),
92
106
  createTextVNode(toDisplayString(__props.data.firewallCount), 1)
@@ -94,7 +108,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
94
108
  __props.data.serviceCount !== void 0 ? (openBlock(), createElementBlock("span", {
95
109
  key: 1,
96
110
  class: normalizeClass(["inline-flex items-center gap-1 rounded-pill bg-surface-soft px-1.5 py-0.5 text-[9px] font-medium text-muted", __props.data.staleFor ? "line-through decoration-danger/70" : ""]),
97
- title: __props.data.staleFor ? `${__props.data.serviceCount} service(s) as of ${__props.data.staleFor} ago — not current` : `${__props.data.serviceCount} service(s)`
111
+ title: __props.data.staleFor ? fill(text.value.servicesStale, { n: __props.data.serviceCount, age: __props.data.staleFor }) : fill(text.value.services, { n: __props.data.serviceCount })
98
112
  }, [
99
113
  _cache[1] || (_cache[1] = createElementVNode("span", null, "◇", -1)),
100
114
  createTextVNode(toDisplayString(__props.data.serviceCount), 1)
@@ -102,10 +116,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
102
116
  __props.data.staleFor ? (openBlock(), createElementBlock("span", {
103
117
  key: 2,
104
118
  class: "inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger",
105
- title: `No contact for ${__props.data.staleFor} — everything shown here is that old`
119
+ title: fill(text.value.noContact, { age: __props.data.staleFor })
106
120
  }, [
107
121
  _cache[2] || (_cache[2] = createElementVNode("span", null, "⚠", -1)),
108
- createTextVNode(toDisplayString(__props.data.staleFor) + " old ", 1)
122
+ createTextVNode(toDisplayString(__props.data.staleFor) + " " + toDisplayString(text.value.old), 1)
109
123
  ], 8, _hoisted_11)) : createCommentVNode("", true)
110
124
  ])) : createCommentVNode("", true)
111
125
  ])
@@ -1,4 +1,4 @@
1
- import { computed, readonly, shallowRef, defineComponent, onMounted, openBlock, createBlock, withCtx, createElementVNode, normalizeClass, createElementBlock, normalizeStyle, createVNode, toDisplayString, createCommentVNode } from "vue";
1
+ import { computed, readonly, shallowRef, defineComponent, onMounted, ref, openBlock, createBlock, withCtx, createElementVNode, normalizeClass, createElementBlock, normalizeStyle, createVNode, toDisplayString, createCommentVNode } from "vue";
2
2
  import { prefersReducedMotion, easingToken } from "../composables/easing.js";
3
3
  import { _ as _sfc_main$1 } from "./LpContextMenu-D0mzXqA7.js";
4
4
  import { _ as _sfc_main$2 } from "./LpIcon-CCnX5_2j.js";
@@ -592,8 +592,15 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
592
592
  onSelect: () => applyTheme2(t)
593
593
  }))
594
594
  );
595
+ const hydrated = ref(false);
596
+ onMounted(() => {
597
+ hydrated.value = true;
598
+ });
599
+ const labelName = computed(
600
+ () => hydrated.value ? activeName.value : list.value[0]?.name ?? ""
601
+ );
595
602
  const triggerLabel = computed(
596
- () => props["aria-label"] ?? `Theme: ${activeName.value}. Click to switch, right-click to choose.`
603
+ () => props["aria-label"] ?? `Theme: ${labelName.value}. Click to switch, right-click to choose.`
597
604
  );
598
605
  return (_ctx, _cache) => {
599
606
  return openBlock(), createBlock(_sfc_main$1, { items: menu.value }, {
@@ -629,7 +636,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
629
636
  size: ICON_SIZE[__props.size]
630
637
  }, null, 8, ["name", "size"])
631
638
  ], 2)),
632
- wantsLabel.value ? (openBlock(), createElementBlock("span", _hoisted_2, toDisplayString(activeName.value), 1)) : createCommentVNode("", true)
639
+ wantsLabel.value ? (openBlock(), createElementBlock("span", _hoisted_2, toDisplayString(labelName.value), 1)) : createCommentVNode("", true)
633
640
  ], 10, _hoisted_1)
634
641
  ]),
635
642
  _: 1
@@ -1,9 +1,9 @@
1
- import { defineComponent, markRaw, computed, watch, openBlock, createBlock, unref, withCtx, createVNode, createCommentVNode, normalizeClass, createElementVNode, createElementBlock, Fragment, renderList, createTextVNode, toDisplayString } from "vue";
1
+ import { defineComponent, markRaw, computed, watch, openBlock, createBlock, unref, withCtx, createVNode, createCommentVNode, normalizeClass, createElementVNode, createElementBlock, Fragment, renderList, createTextVNode, toDisplayString, renderSlot } from "vue";
2
2
  import { Background } from "@vue-flow/background";
3
3
  import { Controls } from "@vue-flow/controls";
4
4
  import { MarkerType, useVueFlow, VueFlow, SelectionMode, Panel } from "@vue-flow/core";
5
5
  import { MiniMap } from "@vue-flow/minimap";
6
- import { _ as _sfc_main$3 } from "./LpInfraNode-qIL3l7IO.js";
6
+ import { _ as _sfc_main$3 } from "./LpInfraNode-Ds_8SZgN.js";
7
7
  import { _ as _sfc_main$1 } from "./LpLaneNode-HtnQf2FF.js";
8
8
  import { _ as _sfc_main$2 } from "./LpServiceNode-D8uJ8sGk.js";
9
9
  import { _ as _sfc_main$4 } from "./LpWaypointEdge-D8yHlQiw.js";
@@ -245,13 +245,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
245
245
  ])),
246
246
  createTextVNode(" " + toDisplayString(l.label), 1)
247
247
  ]);
248
- }), 128))
248
+ }), 128)),
249
+ renderSlot(_ctx.$slots, "legend-extra")
249
250
  ])
250
251
  ]),
251
- _: 1
252
+ _: 3
252
253
  }, 8, ["position", "class"])) : createCommentVNode("", true)
253
254
  ]),
254
- _: 1
255
+ _: 3
255
256
  }, 8, ["nodes", "edges", "nodes-connectable", "selection-on-drag", "pan-on-drag", "selection-key-code", "selection-mode"]);
256
257
  };
257
258
  }
@@ -1,6 +1,12 @@
1
1
  export interface InfraNodeData {
2
2
  name: string;
3
+ /** Role key: selects the colour/glyph, and shown as-is unless `roleLabel`. */
3
4
  role: string;
5
+ /**
6
+ * What to print for the role. The key itself ("host", "proxy") is an API
7
+ * value, not a word for an operator to read in their own language.
8
+ */
9
+ roleLabel?: string;
4
10
  overlay?: string;
5
11
  online?: boolean;
6
12
  kind?: string;
@@ -18,6 +24,30 @@ export interface InfraNodeData {
18
24
  staleFor?: string;
19
25
  /** Fade the node back (search miss / not part of the focused node's graph). */
20
26
  dimmed?: boolean;
27
+ /**
28
+ * Wording for this node's badges. Carried in `data` because that is what
29
+ * LpTopologyCanvas forwards to the node renderer; a component prop would
30
+ * never reach it.
31
+ */
32
+ labels?: InfraNodeLabels;
33
+ }
34
+ /**
35
+ * Wording for the badges, so the node speaks the app's language.
36
+ *
37
+ * The kit ships English defaults; a localised app passes its own. `{n}` is the
38
+ * count, `{age}` the value of `staleFor`.
39
+ */
40
+ export interface InfraNodeLabels {
41
+ /** Tooltip on the firewall badge. Default: "{n} firewall rule(s)". */
42
+ firewallRules?: string;
43
+ /** Tooltip on the service badge. Default: "{n} service(s)". */
44
+ services?: string;
45
+ /** Tooltip on the service badge when readings are stale. */
46
+ servicesStale?: string;
47
+ /** Tooltip on the staleness badge. */
48
+ noContact?: string;
49
+ /** Suffix after the age, e.g. "old" in "4m old". */
50
+ old?: string;
21
51
  }
22
52
  type __VLS_Props = {
23
53
  data: InfraNodeData;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpInfraNode-qIL3l7IO.js";
1
+ import { _ as _sfc_main } from "../chunks/LpInfraNode-Ds_8SZgN.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,280 @@
1
- import { _ as _sfc_main } from "../chunks/LpTable-DTj52qlg.js";
1
+ import { defineComponent, ref, computed, watch, onBeforeUnmount, openBlock, createBlock, withCtx, createElementVNode, normalizeClass, createElementBlock, createVNode, createCommentVNode, Fragment, renderList, normalizeStyle, createTextVNode, toDisplayString, TransitionGroup, unref, mergeProps, withModifiers, renderSlot } from "vue";
2
+ import { _ as _sfc_main$2 } from "../chunks/LpCheckbox-BO0zuuPh.js";
3
+ import { _ as _sfc_main$4 } from "../chunks/LpContextMenu-D0mzXqA7.js";
4
+ import { _ as _sfc_main$3 } from "../chunks/LpIcon-CCnX5_2j.js";
5
+ import { _ as _sfc_main$1 } from "../chunks/LpScrollArea-D8xyKXcF.js";
6
+ import { useRovingFocus } from "../composables/useRovingFocus.js";
7
+ import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
8
+ import '../assets/LpTable-DMOyAUOh.css';const _hoisted_1 = { class: "w-full border-collapse text-sm" };
9
+ const _hoisted_2 = { class: "border-b border-line bg-surface-soft" };
10
+ const _hoisted_3 = {
11
+ key: 0,
12
+ class: "w-px px-4 py-3"
13
+ };
14
+ const _hoisted_4 = ["aria-sort"];
15
+ const _hoisted_5 = ["onClick"];
16
+ const _hoisted_6 = { key: "lp-table-empty" };
17
+ const _hoisted_7 = ["colspan"];
18
+ const _hoisted_8 = ["onClick"];
19
+ const _sfc_main = /* @__PURE__ */ defineComponent({
20
+ __name: "LpTable",
21
+ props: {
22
+ columns: {},
23
+ rows: {},
24
+ rowKey: {},
25
+ emptyLabel: { default: "Nothing here yet" },
26
+ emptyIcon: { default: "lucide:inbox" },
27
+ selectable: { type: Boolean, default: false },
28
+ selected: { default: () => [] },
29
+ sort: { default: null },
30
+ stickyHeader: { type: Boolean },
31
+ rowMenu: {}
32
+ },
33
+ emits: ["update:selected", "update:sort", "rowClick"],
34
+ setup(__props, { emit: __emit }) {
35
+ const props = __props;
36
+ const emit = __emit;
37
+ function keyFor(row, index) {
38
+ if (typeof props.rowKey === "function") return props.rowKey(row);
39
+ if (props.rowKey) return row[props.rowKey];
40
+ return index;
41
+ }
42
+ function alignClass(align) {
43
+ if (align === "right") return "text-right";
44
+ if (align === "center") return "text-center";
45
+ return "text-left";
46
+ }
47
+ function menuFor(row) {
48
+ return props.rowMenu?.(row) ?? [];
49
+ }
50
+ const ownSort = ref(null);
51
+ const activeSort = computed(() => props.sort ?? ownSort.value);
52
+ const displayRows = computed(() => {
53
+ const s = activeSort.value;
54
+ if (!s) return props.rows;
55
+ const col = props.columns.find((c) => c.key === s.key);
56
+ if (!col?.sortable) return props.rows;
57
+ const factor = s.dir === "asc" ? 1 : -1;
58
+ return [...props.rows].sort((a, b) => compare(a[s.key], b[s.key]) * factor);
59
+ });
60
+ function compare(a, b) {
61
+ if (a == null && b == null) return 0;
62
+ if (a == null) return -1;
63
+ if (b == null) return 1;
64
+ if (typeof a === "number" && typeof b === "number") return a - b;
65
+ return String(a).localeCompare(String(b), void 0, { numeric: true });
66
+ }
67
+ function toggleSort(col) {
68
+ if (!col.sortable) return;
69
+ const s = activeSort.value;
70
+ let next;
71
+ if (!s || s.key !== col.key) next = { key: col.key, dir: "asc" };
72
+ else if (s.dir === "asc") next = { key: col.key, dir: "desc" };
73
+ else next = null;
74
+ ownSort.value = next;
75
+ emit("update:sort", next);
76
+ }
77
+ function sortIcon(col) {
78
+ const s = activeSort.value;
79
+ if (s?.key !== col.key) return "lucide:chevrons-up-down";
80
+ return s.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down";
81
+ }
82
+ const selectedSet = computed(() => new Set(props.selected));
83
+ const allKeys = computed(() => props.rows.map((r, i) => keyFor(r, i)));
84
+ const allChecked = computed(
85
+ () => allKeys.value.length > 0 && allKeys.value.every((k) => selectedSet.value.has(k))
86
+ );
87
+ const someChecked = computed(
88
+ () => !allChecked.value && allKeys.value.some((k) => selectedSet.value.has(k))
89
+ );
90
+ function toggleAll(checked) {
91
+ emit("update:selected", checked ? [...allKeys.value] : []);
92
+ }
93
+ function toggleRow(key, checked) {
94
+ const next = new Set(selectedSet.value);
95
+ if (checked) next.add(key);
96
+ else next.delete(key);
97
+ emit("update:selected", [...next]);
98
+ }
99
+ const colSpan = computed(() => props.columns.length + (props.selectable ? 1 : 0));
100
+ const rovingItems = computed(
101
+ () => displayRows.value.map((row, index) => ({
102
+ id: String(keyFor(row, index)),
103
+ label: String(row[props.columns[0]?.key ?? ""] ?? "")
104
+ }))
105
+ );
106
+ const rowByKey = computed(() => {
107
+ const map = /* @__PURE__ */ new Map();
108
+ displayRows.value.forEach((row, index) => map.set(String(keyFor(row, index)), row));
109
+ return map;
110
+ });
111
+ const roving = useRovingFocus(rovingItems, {
112
+ onActivate: (id) => {
113
+ const row = rowByKey.value.get(id);
114
+ if (!row) return;
115
+ emit("rowClick", row);
116
+ }
117
+ });
118
+ function onRowKeydown(event) {
119
+ if (event.key === " " && props.selectable && roving.activeId.value) {
120
+ event.preventDefault();
121
+ const key = roving.activeId.value;
122
+ const row = rowByKey.value.get(key);
123
+ if (row) {
124
+ const rowKey = keyFor(row, displayRows.value.indexOf(row));
125
+ toggleRow(rowKey, !selectedSet.value.has(rowKey));
126
+ }
127
+ return;
128
+ }
129
+ roving.onKeydown(event);
130
+ }
131
+ const headEl = ref(null);
132
+ const headHeight = ref(0);
133
+ let ro;
134
+ watch(
135
+ [headEl, () => props.stickyHeader],
136
+ ([el, sticky]) => {
137
+ ro?.disconnect();
138
+ if (!el || !sticky || typeof ResizeObserver === "undefined") {
139
+ headHeight.value = 0;
140
+ return;
141
+ }
142
+ ro = new ResizeObserver(() => {
143
+ headHeight.value = el.offsetHeight;
144
+ });
145
+ ro.observe(el);
146
+ headHeight.value = el.offsetHeight;
147
+ },
148
+ { immediate: true, flush: "post" }
149
+ );
150
+ onBeforeUnmount(() => ro?.disconnect());
151
+ const barInsetTop = computed(
152
+ () => props.stickyHeader && headHeight.value ? `${headHeight.value}px` : void 0
153
+ );
154
+ return (_ctx, _cache) => {
155
+ return openBlock(), createBlock(_sfc_main$1, {
156
+ fade: false,
157
+ class: "rounded-card border border-line",
158
+ "bar-inset-top": barInsetTop.value
159
+ }, {
160
+ default: withCtx(() => [
161
+ createElementVNode("table", _hoisted_1, [
162
+ createElementVNode("thead", {
163
+ ref_key: "headEl",
164
+ ref: headEl,
165
+ class: normalizeClass(__props.stickyHeader ? "sticky top-0 z-10" : "")
166
+ }, [
167
+ createElementVNode("tr", _hoisted_2, [
168
+ __props.selectable ? (openBlock(), createElementBlock("th", _hoisted_3, [
169
+ createVNode(_sfc_main$2, {
170
+ "model-value": allChecked.value,
171
+ indeterminate: someChecked.value,
172
+ "onUpdate:modelValue": toggleAll
173
+ }, null, 8, ["model-value", "indeterminate"])
174
+ ])) : createCommentVNode("", true),
175
+ (openBlock(true), createElementBlock(Fragment, null, renderList(__props.columns, (col) => {
176
+ return openBlock(), createElementBlock("th", {
177
+ key: col.key,
178
+ style: normalizeStyle(col.width ? { width: col.width } : void 0),
179
+ class: normalizeClass(["px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted", alignClass(col.align)]),
180
+ "aria-sort": activeSort.value?.key === col.key ? activeSort.value.dir === "asc" ? "ascending" : "descending" : void 0
181
+ }, [
182
+ col.sortable ? (openBlock(), createElementBlock("button", {
183
+ key: 0,
184
+ type: "button",
185
+ class: normalizeClass(["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", [
186
+ col.align === "right" ? "flex-row-reverse" : "",
187
+ activeSort.value?.key === col.key ? "text-ink" : ""
188
+ ]]),
189
+ onClick: ($event) => toggleSort(col)
190
+ }, [
191
+ createTextVNode(toDisplayString(col.label) + " ", 1),
192
+ createVNode(_sfc_main$3, {
193
+ name: sortIcon(col),
194
+ size: 13,
195
+ class: normalizeClass([
196
+ "shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]",
197
+ activeSort.value?.key === col.key ? "opacity-100" : "opacity-40 group-hover/sort:opacity-70"
198
+ ])
199
+ }, null, 8, ["name", "class"])
200
+ ], 10, _hoisted_5)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
201
+ createTextVNode(toDisplayString(col.label), 1)
202
+ ], 64))
203
+ ], 14, _hoisted_4);
204
+ }), 128))
205
+ ])
206
+ ], 2),
207
+ createVNode(TransitionGroup, {
208
+ tag: "tbody",
209
+ name: "lp-row",
210
+ ref: (el) => unref(roving).setContainer(el?.$el ?? null)
211
+ }, {
212
+ default: withCtx(() => [
213
+ displayRows.value.length === 0 ? (openBlock(), createElementBlock("tr", _hoisted_6, [
214
+ createElementVNode("td", {
215
+ colspan: colSpan.value,
216
+ class: "px-4 py-10 text-center text-muted"
217
+ }, [
218
+ createVNode(_sfc_main$3, {
219
+ name: __props.emptyIcon,
220
+ size: 22,
221
+ class: "mx-auto mb-2 opacity-60"
222
+ }, null, 8, ["name"]),
223
+ createElementVNode("div", null, toDisplayString(__props.emptyLabel), 1)
224
+ ], 8, _hoisted_7)
225
+ ])) : createCommentVNode("", true),
226
+ (openBlock(true), createElementBlock(Fragment, null, renderList(displayRows.value, (row, index) => {
227
+ return openBlock(), createBlock(_sfc_main$4, {
228
+ key: keyFor(row, index),
229
+ items: menuFor(row)
230
+ }, {
231
+ default: withCtx(() => [
232
+ createElementVNode("tr", mergeProps({
233
+ class: ["group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40", selectedSet.value.has(keyFor(row, index)) ? "bg-brand-soft/40" : ""]
234
+ }, { ref_for: true }, unref(roving).itemProps(String(keyFor(row, index))), {
235
+ onClick: ($event) => emit("rowClick", row),
236
+ onKeydown: onRowKeydown
237
+ }), [
238
+ __props.selectable ? (openBlock(), createElementBlock("td", {
239
+ key: 0,
240
+ class: "w-px px-4 py-3",
241
+ onClick: _cache[0] || (_cache[0] = withModifiers(() => {
242
+ }, ["stop"]))
243
+ }, [
244
+ createVNode(_sfc_main$2, {
245
+ "model-value": selectedSet.value.has(keyFor(row, index)),
246
+ "onUpdate:modelValue": (v) => toggleRow(keyFor(row, index), v)
247
+ }, null, 8, ["model-value", "onUpdate:modelValue"])
248
+ ])) : createCommentVNode("", true),
249
+ (openBlock(true), createElementBlock(Fragment, null, renderList(__props.columns, (col) => {
250
+ return openBlock(), createElementBlock("td", {
251
+ key: col.key,
252
+ class: normalizeClass(["px-4 py-3 text-ink", alignClass(col.align)])
253
+ }, [
254
+ renderSlot(_ctx.$slots, `cell-${col.key}`, {
255
+ row,
256
+ value: row[col.key]
257
+ }, () => [
258
+ createTextVNode(toDisplayString(row[col.key]), 1)
259
+ ], true)
260
+ ], 2);
261
+ }), 128))
262
+ ], 16, _hoisted_8)
263
+ ]),
264
+ _: 2
265
+ }, 1032, ["items"]);
266
+ }), 128))
267
+ ]),
268
+ _: 3
269
+ }, 512)
270
+ ])
271
+ ]),
272
+ _: 3
273
+ }, 8, ["bar-inset-top"]);
274
+ };
275
+ }
276
+ });
277
+ const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-7884aed8"]]);
2
278
  export {
3
- _sfc_main as default
279
+ LpTable as default
4
280
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-7mAK6bI7.js";
1
+ import { _ as _sfc_main } from "../chunks/LpThemeSwitcher-Dj4VcTu7.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -121,7 +121,11 @@ type __VLS_Props = {
121
121
  controls?: boolean;
122
122
  };
123
123
  declare function applyViewport(v: CanvasViewport): Promise<void>;
124
- declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {
124
+ declare var __VLS_29: {};
125
+ type __VLS_Slots = {} & {
126
+ 'legend-extra'?: (props: typeof __VLS_29) => any;
127
+ };
128
+ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
125
129
  fitView: import('@vue-flow/core').FitView;
126
130
  setViewport: typeof applyViewport;
127
131
  }, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
@@ -191,5 +195,11 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {
191
195
  lanes: TopologyLane[];
192
196
  minimap: boolean;
193
197
  }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
198
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
194
199
  declare const _default: typeof __VLS_export;
195
200
  export default _default;
201
+ type __VLS_WithSlots<T, S> = T & {
202
+ new (): {
203
+ $slots: S;
204
+ };
205
+ };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTopologyCanvas-ebP67CLx.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTopologyCanvas-Cm1L39uL.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, format
30
30
  import { _ as _25 } from "./chunks/LpFormField-fV_2ZdCk.js";
31
31
  import { _ as _26 } from "./chunks/LpIcon-CCnX5_2j.js";
32
32
  import { default as default4 } from "./components/LpImageEditor.vue.js";
33
- import { _ as _27 } from "./chunks/LpInfraNode-qIL3l7IO.js";
33
+ import { _ as _27 } from "./chunks/LpInfraNode-Ds_8SZgN.js";
34
34
  import { _ as _28 } from "./chunks/LpInput-CYcTL9gj.js";
35
35
  import { _ as _29 } from "./chunks/LpLightbox-Ci35Y_xP.js";
36
36
  import { fileNameOf } from "./components/lightbox.js";
@@ -65,17 +65,17 @@ import { _ as _55 } from "./chunks/LpSparkline-C6xQ2dwv.js";
65
65
  import { _ as _56 } from "./chunks/LpStat-CDanpzU0.js";
66
66
  import { _ as _57 } from "./chunks/LpStepper-Wtl-6nUh.js";
67
67
  import { _ as _58 } from "./chunks/LpSwitch-v6fnccSM.js";
68
- import { _ as _59 } from "./chunks/LpTable-DTj52qlg.js";
69
- import { _ as _60 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
70
- import { _ as _61 } from "./chunks/LpTabs-Cefe_lys.js";
71
- import { _ as _62 } from "./chunks/LpTextarea-BMIOfw5l.js";
72
- import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-7mAK6bI7.js";
73
- import { D, _ as _63, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-7mAK6bI7.js";
74
- import { _ as _64 } from "./chunks/LpTilt-DuyKbpkA.js";
75
- import { _ as _65 } from "./chunks/LpToaster-gDB1EaU3.js";
76
- import { _ as _66 } from "./chunks/LpTooltip-hCLPAqMG.js";
77
- import { _ as _67 } from "./chunks/LpTopologyCanvas-ebP67CLx.js";
78
- import { _ as _68 } from "./chunks/LpUptimeBar-DcQ8HikG.js";
68
+ import { default as default6 } from "./components/LpTable.vue.js";
69
+ import { _ as _59 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
70
+ import { _ as _60 } from "./chunks/LpTabs-Cefe_lys.js";
71
+ import { _ as _61 } from "./chunks/LpTextarea-BMIOfw5l.js";
72
+ import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-Dj4VcTu7.js";
73
+ import { D, _ as _62, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-Dj4VcTu7.js";
74
+ import { _ as _63 } from "./chunks/LpTilt-DuyKbpkA.js";
75
+ import { _ as _64 } from "./chunks/LpToaster-gDB1EaU3.js";
76
+ import { _ as _65 } from "./chunks/LpTooltip-hCLPAqMG.js";
77
+ import { _ as _66 } from "./chunks/LpTopologyCanvas-Cm1L39uL.js";
78
+ import { _ as _67 } from "./chunks/LpUptimeBar-DcQ8HikG.js";
79
79
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
80
80
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
81
81
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -186,16 +186,16 @@ export {
186
186
  _56 as LpStat,
187
187
  _57 as LpStepper,
188
188
  _58 as LpSwitch,
189
- _59 as LpTable,
190
- _60 as LpTableOfContents,
191
- _61 as LpTabs,
192
- _62 as LpTextarea,
193
- _63 as LpThemeSwitcher,
194
- _64 as LpTilt,
195
- _65 as LpToaster,
196
- _66 as LpTooltip,
197
- _67 as LpTopologyCanvas,
198
- _68 as LpUptimeBar,
189
+ default6 as LpTable,
190
+ _59 as LpTableOfContents,
191
+ _60 as LpTabs,
192
+ _61 as LpTextarea,
193
+ _62 as LpThemeSwitcher,
194
+ _63 as LpTilt,
195
+ _64 as LpToaster,
196
+ _65 as LpTooltip,
197
+ _66 as LpTopologyCanvas,
198
+ _67 as LpUptimeBar,
199
199
  N as NO_BACKDROP_ATTR,
200
200
  OSM_TILES,
201
201
  T as THEME_COOKIE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -13,7 +13,13 @@ import { computed } from "vue"
13
13
 
14
14
  export interface InfraNodeData {
15
15
  name: string
16
+ /** Role key: selects the colour/glyph, and shown as-is unless `roleLabel`. */
16
17
  role: string
18
+ /**
19
+ * What to print for the role. The key itself ("host", "proxy") is an API
20
+ * value, not a word for an operator to read in their own language.
21
+ */
22
+ roleLabel?: string
17
23
  overlay?: string
18
24
  online?: boolean
19
25
  kind?: string
@@ -31,6 +37,31 @@ export interface InfraNodeData {
31
37
  staleFor?: string
32
38
  /** Fade the node back (search miss / not part of the focused node's graph). */
33
39
  dimmed?: boolean
40
+ /**
41
+ * Wording for this node's badges. Carried in `data` because that is what
42
+ * LpTopologyCanvas forwards to the node renderer; a component prop would
43
+ * never reach it.
44
+ */
45
+ labels?: InfraNodeLabels
46
+ }
47
+
48
+ /**
49
+ * Wording for the badges, so the node speaks the app's language.
50
+ *
51
+ * The kit ships English defaults; a localised app passes its own. `{n}` is the
52
+ * count, `{age}` the value of `staleFor`.
53
+ */
54
+ export interface InfraNodeLabels {
55
+ /** Tooltip on the firewall badge. Default: "{n} firewall rule(s)". */
56
+ firewallRules?: string
57
+ /** Tooltip on the service badge. Default: "{n} service(s)". */
58
+ services?: string
59
+ /** Tooltip on the service badge when readings are stale. */
60
+ servicesStale?: string
61
+ /** Tooltip on the staleness badge. */
62
+ noContact?: string
63
+ /** Suffix after the age, e.g. "old" in "4m old". */
64
+ old?: string
34
65
  }
35
66
 
36
67
  const props = defineProps<{
@@ -40,6 +71,21 @@ const props = defineProps<{
40
71
  roles?: Record<string, InfraRoleStyle>
41
72
  }>()
42
73
 
74
+ /** Fill `{n}` / `{age}` in a label template. */
75
+ function fill(tpl: string, vars: Record<string, string | number>): string {
76
+ return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""))
77
+ }
78
+
79
+ const text = computed(() => ({
80
+ firewallRules: props.data.labels?.firewallRules ?? "{n} firewall rule(s)",
81
+ services: props.data.labels?.services ?? "{n} service(s)",
82
+ servicesStale:
83
+ props.data.labels?.servicesStale ?? "{n} service(s) as of {age} ago - not current",
84
+ noContact:
85
+ props.data.labels?.noContact ?? "No contact for {age} - everything shown here is that old",
86
+ old: props.data.labels?.old ?? "old",
87
+ }))
88
+
43
89
  /*
44
90
  * Role → look. Colours are token references, never literals, so a theme (or a
45
91
  * consumer) restyles the graph without forking this component; the glyph keeps
@@ -80,13 +126,28 @@ const accentLine = computed(
80
126
  </script>
81
127
 
82
128
  <template>
129
+ <!--
130
+ An offline host is drawn offline, not merely faded.
131
+
132
+ Opacity alone was the only signal the frame carried, and at 0.5 on a dark
133
+ canvas it is nearly invisible: a machine that had not reported for minutes
134
+ sat in a role-coloured frame that read as healthy, contradicted only by a
135
+ 7px dot. Offline now takes the frame and the role bar as well, so the three
136
+ signals on a node agree instead of arguing.
137
+ -->
83
138
  <div
84
139
  class="w-[168px] overflow-hidden rounded-card border bg-surface-raised shadow-panel transition-[border-color,box-shadow]"
85
- :class="selected ? 'border-[var(--accent)]' : 'border-[var(--accent-line)]'"
140
+ :class="[
141
+ selected
142
+ ? 'border-[var(--accent)]'
143
+ : data.online === false
144
+ ? 'border-dashed border-danger/50'
145
+ : 'border-[var(--accent-line)]',
146
+ ]"
86
147
  :style="{
87
148
  '--accent': accent,
88
149
  '--accent-line': accentLine,
89
- opacity: data.dimmed ? 0.3 : data.online === false ? 0.5 : 1,
150
+ opacity: data.dimmed ? 0.3 : data.online === false ? 0.75 : 1,
90
151
  ...(selected
91
152
  ? { boxShadow: '0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent), var(--shadow-panel)' }
92
153
  : {}),
@@ -103,7 +164,12 @@ const accentLine = computed(
103
164
  class="!size-2 !border-2 !border-[var(--accent)] !bg-surface"
104
165
  />
105
166
 
106
- <div class="h-[3px] bg-[var(--accent)]" />
167
+ <!-- The role bar reads as "this node is alive and is a proxy"; on an
168
+ offline host it must not keep asserting the first half. -->
169
+ <div
170
+ class="h-[3px]"
171
+ :class="data.online === false ? 'bg-danger/50' : 'bg-[var(--accent)]'"
172
+ />
107
173
  <div class="px-[11px] pb-2.5 pt-[9px]">
108
174
  <div class="flex items-center gap-[7px]">
109
175
  <span class="text-[13px] text-[var(--accent)]">{{ icon }}</span>
@@ -116,7 +182,7 @@ const accentLine = computed(
116
182
  </div>
117
183
  <div class="mt-1.5 flex items-center justify-between">
118
184
  <span class="text-[10px] font-semibold uppercase tracking-wide text-[var(--accent)]">
119
- {{ data.role }}
185
+ {{ data.roleLabel ?? data.role }}
120
186
  </span>
121
187
  <span v-if="data.overlay" class="font-mono text-[10px] text-muted">{{ data.overlay }}</span>
122
188
  </div>
@@ -127,7 +193,7 @@ const accentLine = computed(
127
193
  <span
128
194
  v-if="data.firewallCount"
129
195
  class="inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger"
130
- :title="`${data.firewallCount} firewall rule(s)`"
196
+ :title="fill(text.firewallRules, { n: data.firewallCount! })"
131
197
  >
132
198
  <span>⛨</span>{{ data.firewallCount }}
133
199
  </span>
@@ -137,8 +203,8 @@ const accentLine = computed(
137
203
  :class="data.staleFor ? 'line-through decoration-danger/70' : ''"
138
204
  :title="
139
205
  data.staleFor
140
- ? `${data.serviceCount} service(s) as of ${data.staleFor} ago — not current`
141
- : `${data.serviceCount} service(s)`
206
+ ? fill(text.servicesStale, { n: data.serviceCount!, age: data.staleFor })
207
+ : fill(text.services, { n: data.serviceCount! })
142
208
  "
143
209
  >
144
210
  <span>◇</span>{{ data.serviceCount }}
@@ -146,9 +212,9 @@ const accentLine = computed(
146
212
  <span
147
213
  v-if="data.staleFor"
148
214
  class="inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger"
149
- :title="`No contact for ${data.staleFor} — everything shown here is that old`"
215
+ :title="fill(text.noContact, { age: data.staleFor })"
150
216
  >
151
- <span>⚠</span>{{ data.staleFor }} old
217
+ <span>⚠</span>{{ data.staleFor }} {{ text.old }}
152
218
  </span>
153
219
  </div>
154
220
  </div>
@@ -77,10 +77,18 @@ function menuFor(row: T): ContextMenuItemDef[] {
77
77
  }
78
78
 
79
79
  // ── sorting ──────────────────────────────────────────────────
80
- // Client-side sort is applied only when the parent doesn't drive `sort` via
81
- // v-model (server-side). We compare by raw cell value with a stable-ish coerce.
80
+ //
81
+ // The parent may drive `sort` through v-model, which is how a server-side sort
82
+ // is expressed. When it does not, the table keeps the state itself and sorts
83
+ // its own rows — otherwise `sortable` rendered a header that could be clicked,
84
+ // emitted an event nobody was listening for, and left the rows exactly as they
85
+ // were: a control that answers a click by doing nothing.
86
+ const ownSort = ref<SortState | null>(null)
87
+ const activeSort = computed(() => props.sort ?? ownSort.value)
88
+
89
+ // We compare by raw cell value with a stable-ish coerce.
82
90
  const displayRows = computed<T[]>(() => {
83
- const s = props.sort
91
+ const s = activeSort.value
84
92
  if (!s) return props.rows
85
93
  const col = props.columns.find((c) => c.key === s.key)
86
94
  if (!col?.sortable) return props.rows
@@ -98,18 +106,22 @@ function compare(a: unknown, b: unknown): number {
98
106
 
99
107
  function toggleSort(col: TableColumn<T>) {
100
108
  if (!col.sortable) return
101
- const s = props.sort
109
+ const s = activeSort.value
102
110
  // Cycle: none → asc → desc → none, scoped to the clicked column.
103
111
  let next: SortState | null
104
112
  if (!s || s.key !== col.key) next = { key: col.key, dir: "asc" }
105
113
  else if (s.dir === "asc") next = { key: col.key, dir: "desc" }
106
114
  else next = null
115
+ // Kept locally as well as emitted: a parent that binds v-model overwrites
116
+ // this on the way back, and one that does not still gets a table that sorts.
117
+ ownSort.value = next
107
118
  emit("update:sort", next)
108
119
  }
109
120
 
110
121
  function sortIcon(col: TableColumn<T>): string {
111
- if (props.sort?.key !== col.key) return "lucide:chevrons-up-down"
112
- return props.sort.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
122
+ const s = activeSort.value
123
+ if (s?.key !== col.key) return "lucide:chevrons-up-down"
124
+ return s.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
113
125
  }
114
126
 
115
127
  // ── selection ────────────────────────────────────────────────
@@ -237,7 +249,11 @@ const barInsetTop = computed(() =>
237
249
  class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
238
250
  :class="alignClass(col.align)"
239
251
  :aria-sort="
240
- sort?.key === col.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined
252
+ activeSort?.key === col.key
253
+ ? activeSort.dir === 'asc'
254
+ ? 'ascending'
255
+ : 'descending'
256
+ : undefined
241
257
  "
242
258
  >
243
259
  <button
@@ -246,7 +262,7 @@ const barInsetTop = computed(() =>
246
262
  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"
247
263
  :class="[
248
264
  col.align === 'right' ? 'flex-row-reverse' : '',
249
- sort?.key === col.key ? 'text-ink' : '',
265
+ activeSort?.key === col.key ? 'text-ink' : '',
250
266
  ]"
251
267
  @click="toggleSort(col)"
252
268
  >
@@ -255,7 +271,11 @@ const barInsetTop = computed(() =>
255
271
  :name="sortIcon(col)"
256
272
  :size="13"
257
273
  class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
258
- :class="sort?.key === col.key ? 'opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
274
+ :class="
275
+ activeSort?.key === col.key
276
+ ? 'opacity-100'
277
+ : 'opacity-40 group-hover/sort:opacity-70'
278
+ "
259
279
  />
260
280
  </button>
261
281
  <template v-else>{{ col.label }}</template>
@@ -266,8 +286,17 @@ const barInsetTop = computed(() =>
266
286
  steer, and a stray key from a control inside a cell must not move the
267
287
  cursor. The container is bound only so the composable can find rows
268
288
  to focus. -->
269
- <tbody :ref="(el) => roving.setContainer(el as HTMLElement | null)">
270
- <tr v-if="displayRows.length === 0">
289
+ <!-- Rows fade and settle rather than snapping in and out. Filtering a
290
+ list is a change the eye has to follow: rows vanishing between two
291
+ frames read as the table being redrawn, and the one that stayed is
292
+ impossible to keep track of. `move` is what carries the survivors to
293
+ their new position instead of teleporting them. -->
294
+ <TransitionGroup
295
+ tag="tbody"
296
+ name="lp-row"
297
+ :ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
298
+ >
299
+ <tr v-if="displayRows.length === 0" key="lp-table-empty">
271
300
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
272
301
  <LpIcon :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
273
302
  <div>{{ emptyLabel }}</div>
@@ -282,8 +311,12 @@ const barInsetTop = computed(() =>
282
311
  :key="keyFor(row, index)"
283
312
  :items="menuFor(row)"
284
313
  >
314
+ <!-- `group` so a cell's own content can answer the row's hover: a
315
+ link inside a row wants its underline to sweep in when the row
316
+ lights up, not when the pointer crosses the text exactly. The
317
+ class paints nothing by itself. -->
285
318
  <tr
286
- class="border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
319
+ class="group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
287
320
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
288
321
  v-bind="roving.itemProps(String(keyFor(row, index)))"
289
322
  @click="emit('rowClick', row)"
@@ -307,7 +340,57 @@ const barInsetTop = computed(() =>
307
340
  </td>
308
341
  </tr>
309
342
  </LpContextMenu>
310
- </tbody>
343
+ </TransitionGroup>
311
344
  </table>
312
345
  </LpScrollArea>
313
346
  </template>
347
+
348
+ <style scoped>
349
+ /*
350
+ * Rows arriving, leaving and moving as the filter changes.
351
+ *
352
+ * A row cannot be translated on its Y axis without the table reflowing around
353
+ * it, so the entrance is opacity plus a small horizontal offset — enough to
354
+ * read as movement, not enough to fight the column grid. `--ease-emphasized`
355
+ * because this is interface responding to a click, not data being drawn.
356
+ *
357
+ * The leaving row is taken out of the flow, or the rows below it wait for the
358
+ * fade before closing the gap and the whole list lurches at the end instead of
359
+ * gliding.
360
+ */
361
+ .lp-row-enter-active,
362
+ .lp-row-leave-active {
363
+ transition:
364
+ opacity var(--duration-fast) var(--ease-emphasized),
365
+ transform var(--duration-fast) var(--ease-emphasized);
366
+ }
367
+
368
+ .lp-row-move {
369
+ transition: transform var(--duration-medium) var(--ease-emphasized);
370
+ }
371
+
372
+ .lp-row-enter-from,
373
+ .lp-row-leave-to {
374
+ opacity: 0;
375
+ transform: translateX(-0.5rem);
376
+ }
377
+
378
+ .lp-row-leave-active {
379
+ position: absolute;
380
+ width: 100%;
381
+ }
382
+
383
+ /* A filter that redraws the list is information, not decoration: with motion
384
+ turned down the rows change without animating rather than not changing. */
385
+ @media (prefers-reduced-motion: reduce) {
386
+ .lp-row-enter-active,
387
+ .lp-row-leave-active,
388
+ .lp-row-move {
389
+ transition: none;
390
+ }
391
+
392
+ .lp-row-leave-active {
393
+ position: static;
394
+ }
395
+ }
396
+ </style>
@@ -11,7 +11,7 @@
11
11
  * and the choice survives a reload (anti-flash bootstrap). The component never
12
12
  * fetches; it just drives the theme engine.
13
13
  */
14
- import { computed, onMounted } from "vue"
14
+ import { computed, onMounted, ref } from "vue"
15
15
  import { presets } from "../theme/presets"
16
16
  import type { TokenSet } from "../theme/tokens"
17
17
  import { useTheme } from "../theme/useTheme"
@@ -153,8 +153,30 @@ const menu = computed<ContextMenuItemDef[]>(() =>
153
153
  })),
154
154
  )
155
155
 
156
+ /*
157
+ * The theme the SERVER rendered is not necessarily the one the client restores:
158
+ * SSR paints from the `lp-theme` cookie (or the default), while on the client
159
+ * `bootstrapTheme()` deliberately wins from localStorage, which holds the full
160
+ * token set including custom themes the server cannot name. The colours cope —
161
+ * they are CSS variables, reapplied on boot — but a label that spells the theme
162
+ * OUT is text, and Vue compares text: every SSR consumer logged a hydration
163
+ * mismatch on this attribute alone.
164
+ *
165
+ * So the label says what the server said until the component is mounted, then
166
+ * names the theme actually in effect. Nobody reads a tooltip in the frame
167
+ * before hydration, and the a11y name settles on the truth.
168
+ */
169
+ const hydrated = ref(false)
170
+ onMounted(() => {
171
+ hydrated.value = true
172
+ })
173
+
174
+ const labelName = computed(() =>
175
+ hydrated.value ? activeName.value : (list.value[0]?.name ?? ""),
176
+ )
177
+
156
178
  const triggerLabel = computed(
157
- () => props["aria-label"] ?? `Theme: ${activeName.value}. Click to switch, right-click to choose.`,
179
+ () => props["aria-label"] ?? `Theme: ${labelName.value}. Click to switch, right-click to choose.`,
158
180
  )
159
181
  </script>
160
182
 
@@ -195,7 +217,7 @@ const triggerLabel = computed(
195
217
  />
196
218
  </span>
197
219
 
198
- <span v-if="wantsLabel" class="text-sm font-medium">{{ activeName }}</span>
220
+ <span v-if="wantsLabel" class="text-sm font-medium">{{ labelName }}</span>
199
221
  </button>
200
222
  </LpContextMenu>
201
223
  </template>
@@ -439,6 +439,10 @@ defineExpose({ fitView, setViewport: applyViewport })
439
439
  </svg>
440
440
  {{ l.label }}
441
441
  </div>
442
+ <!-- Edges are only half of what a graph encodes; a node also carries
443
+ state (offline, stale readings) that the legend cannot know the
444
+ meaning of. The host explains its own node encoding here. -->
445
+ <slot name="legend-extra" />
442
446
  </div>
443
447
  </Panel>
444
448
  </VueFlow>
@@ -1,266 +0,0 @@
1
- import { defineComponent, computed, ref, watch, onBeforeUnmount, openBlock, createBlock, withCtx, createElementVNode, normalizeClass, createElementBlock, createVNode, createCommentVNode, Fragment, renderList, normalizeStyle, createTextVNode, toDisplayString, unref, mergeProps, withModifiers, renderSlot } from "vue";
2
- import { _ as _sfc_main$2 } from "./LpCheckbox-BO0zuuPh.js";
3
- import { _ as _sfc_main$4 } from "./LpContextMenu-D0mzXqA7.js";
4
- import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
5
- import { _ as _sfc_main$1 } from "./LpScrollArea-D8xyKXcF.js";
6
- import { useRovingFocus } from "../composables/useRovingFocus.js";
7
- const _hoisted_1 = { class: "w-full border-collapse text-sm" };
8
- const _hoisted_2 = { class: "border-b border-line bg-surface-soft" };
9
- const _hoisted_3 = {
10
- key: 0,
11
- class: "w-px px-4 py-3"
12
- };
13
- const _hoisted_4 = ["aria-sort"];
14
- const _hoisted_5 = ["onClick"];
15
- const _hoisted_6 = { key: 0 };
16
- const _hoisted_7 = ["colspan"];
17
- const _hoisted_8 = ["onClick"];
18
- const _sfc_main = /* @__PURE__ */ defineComponent({
19
- __name: "LpTable",
20
- props: {
21
- columns: {},
22
- rows: {},
23
- rowKey: {},
24
- emptyLabel: { default: "Nothing here yet" },
25
- emptyIcon: { default: "lucide:inbox" },
26
- selectable: { type: Boolean, default: false },
27
- selected: { default: () => [] },
28
- sort: { default: null },
29
- stickyHeader: { type: Boolean },
30
- rowMenu: {}
31
- },
32
- emits: ["update:selected", "update:sort", "rowClick"],
33
- setup(__props, { emit: __emit }) {
34
- const props = __props;
35
- const emit = __emit;
36
- function keyFor(row, index) {
37
- if (typeof props.rowKey === "function") return props.rowKey(row);
38
- if (props.rowKey) return row[props.rowKey];
39
- return index;
40
- }
41
- function alignClass(align) {
42
- if (align === "right") return "text-right";
43
- if (align === "center") return "text-center";
44
- return "text-left";
45
- }
46
- function menuFor(row) {
47
- return props.rowMenu?.(row) ?? [];
48
- }
49
- const displayRows = computed(() => {
50
- const s = props.sort;
51
- if (!s) return props.rows;
52
- const col = props.columns.find((c) => c.key === s.key);
53
- if (!col?.sortable) return props.rows;
54
- const factor = s.dir === "asc" ? 1 : -1;
55
- return [...props.rows].sort((a, b) => compare(a[s.key], b[s.key]) * factor);
56
- });
57
- function compare(a, b) {
58
- if (a == null && b == null) return 0;
59
- if (a == null) return -1;
60
- if (b == null) return 1;
61
- if (typeof a === "number" && typeof b === "number") return a - b;
62
- return String(a).localeCompare(String(b), void 0, { numeric: true });
63
- }
64
- function toggleSort(col) {
65
- if (!col.sortable) return;
66
- const s = props.sort;
67
- let next;
68
- if (!s || s.key !== col.key) next = { key: col.key, dir: "asc" };
69
- else if (s.dir === "asc") next = { key: col.key, dir: "desc" };
70
- else next = null;
71
- emit("update:sort", next);
72
- }
73
- function sortIcon(col) {
74
- if (props.sort?.key !== col.key) return "lucide:chevrons-up-down";
75
- return props.sort.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down";
76
- }
77
- const selectedSet = computed(() => new Set(props.selected));
78
- const allKeys = computed(() => props.rows.map((r, i) => keyFor(r, i)));
79
- const allChecked = computed(
80
- () => allKeys.value.length > 0 && allKeys.value.every((k) => selectedSet.value.has(k))
81
- );
82
- const someChecked = computed(
83
- () => !allChecked.value && allKeys.value.some((k) => selectedSet.value.has(k))
84
- );
85
- function toggleAll(checked) {
86
- emit("update:selected", checked ? [...allKeys.value] : []);
87
- }
88
- function toggleRow(key, checked) {
89
- const next = new Set(selectedSet.value);
90
- if (checked) next.add(key);
91
- else next.delete(key);
92
- emit("update:selected", [...next]);
93
- }
94
- const colSpan = computed(() => props.columns.length + (props.selectable ? 1 : 0));
95
- const rovingItems = computed(
96
- () => displayRows.value.map((row, index) => ({
97
- id: String(keyFor(row, index)),
98
- label: String(row[props.columns[0]?.key ?? ""] ?? "")
99
- }))
100
- );
101
- const rowByKey = computed(() => {
102
- const map = /* @__PURE__ */ new Map();
103
- displayRows.value.forEach((row, index) => map.set(String(keyFor(row, index)), row));
104
- return map;
105
- });
106
- const roving = useRovingFocus(rovingItems, {
107
- onActivate: (id) => {
108
- const row = rowByKey.value.get(id);
109
- if (!row) return;
110
- emit("rowClick", row);
111
- }
112
- });
113
- function onRowKeydown(event) {
114
- if (event.key === " " && props.selectable && roving.activeId.value) {
115
- event.preventDefault();
116
- const key = roving.activeId.value;
117
- const row = rowByKey.value.get(key);
118
- if (row) {
119
- const rowKey = keyFor(row, displayRows.value.indexOf(row));
120
- toggleRow(rowKey, !selectedSet.value.has(rowKey));
121
- }
122
- return;
123
- }
124
- roving.onKeydown(event);
125
- }
126
- const headEl = ref(null);
127
- const headHeight = ref(0);
128
- let ro;
129
- watch(
130
- [headEl, () => props.stickyHeader],
131
- ([el, sticky]) => {
132
- ro?.disconnect();
133
- if (!el || !sticky || typeof ResizeObserver === "undefined") {
134
- headHeight.value = 0;
135
- return;
136
- }
137
- ro = new ResizeObserver(() => {
138
- headHeight.value = el.offsetHeight;
139
- });
140
- ro.observe(el);
141
- headHeight.value = el.offsetHeight;
142
- },
143
- { immediate: true, flush: "post" }
144
- );
145
- onBeforeUnmount(() => ro?.disconnect());
146
- const barInsetTop = computed(
147
- () => props.stickyHeader && headHeight.value ? `${headHeight.value}px` : void 0
148
- );
149
- return (_ctx, _cache) => {
150
- return openBlock(), createBlock(_sfc_main$1, {
151
- fade: false,
152
- class: "rounded-card border border-line",
153
- "bar-inset-top": barInsetTop.value
154
- }, {
155
- default: withCtx(() => [
156
- createElementVNode("table", _hoisted_1, [
157
- createElementVNode("thead", {
158
- ref_key: "headEl",
159
- ref: headEl,
160
- class: normalizeClass(__props.stickyHeader ? "sticky top-0 z-10" : "")
161
- }, [
162
- createElementVNode("tr", _hoisted_2, [
163
- __props.selectable ? (openBlock(), createElementBlock("th", _hoisted_3, [
164
- createVNode(_sfc_main$2, {
165
- "model-value": allChecked.value,
166
- indeterminate: someChecked.value,
167
- "onUpdate:modelValue": toggleAll
168
- }, null, 8, ["model-value", "indeterminate"])
169
- ])) : createCommentVNode("", true),
170
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.columns, (col) => {
171
- return openBlock(), createElementBlock("th", {
172
- key: col.key,
173
- style: normalizeStyle(col.width ? { width: col.width } : void 0),
174
- class: normalizeClass(["px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted", alignClass(col.align)]),
175
- "aria-sort": __props.sort?.key === col.key ? __props.sort.dir === "asc" ? "ascending" : "descending" : void 0
176
- }, [
177
- col.sortable ? (openBlock(), createElementBlock("button", {
178
- key: 0,
179
- type: "button",
180
- class: normalizeClass(["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", [
181
- col.align === "right" ? "flex-row-reverse" : "",
182
- __props.sort?.key === col.key ? "text-ink" : ""
183
- ]]),
184
- onClick: ($event) => toggleSort(col)
185
- }, [
186
- createTextVNode(toDisplayString(col.label) + " ", 1),
187
- createVNode(_sfc_main$3, {
188
- name: sortIcon(col),
189
- size: 13,
190
- class: normalizeClass(["shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]", __props.sort?.key === col.key ? "opacity-100" : "opacity-40 group-hover/sort:opacity-70"])
191
- }, null, 8, ["name", "class"])
192
- ], 10, _hoisted_5)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
193
- createTextVNode(toDisplayString(col.label), 1)
194
- ], 64))
195
- ], 14, _hoisted_4);
196
- }), 128))
197
- ])
198
- ], 2),
199
- createElementVNode("tbody", {
200
- ref: (el) => unref(roving).setContainer(el)
201
- }, [
202
- displayRows.value.length === 0 ? (openBlock(), createElementBlock("tr", _hoisted_6, [
203
- createElementVNode("td", {
204
- colspan: colSpan.value,
205
- class: "px-4 py-10 text-center text-muted"
206
- }, [
207
- createVNode(_sfc_main$3, {
208
- name: __props.emptyIcon,
209
- size: 22,
210
- class: "mx-auto mb-2 opacity-60"
211
- }, null, 8, ["name"]),
212
- createElementVNode("div", null, toDisplayString(__props.emptyLabel), 1)
213
- ], 8, _hoisted_7)
214
- ])) : createCommentVNode("", true),
215
- (openBlock(true), createElementBlock(Fragment, null, renderList(displayRows.value, (row, index) => {
216
- return openBlock(), createBlock(_sfc_main$4, {
217
- key: keyFor(row, index),
218
- items: menuFor(row)
219
- }, {
220
- default: withCtx(() => [
221
- createElementVNode("tr", mergeProps({
222
- class: ["border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40", selectedSet.value.has(keyFor(row, index)) ? "bg-brand-soft/40" : ""]
223
- }, { ref_for: true }, unref(roving).itemProps(String(keyFor(row, index))), {
224
- onClick: ($event) => emit("rowClick", row),
225
- onKeydown: onRowKeydown
226
- }), [
227
- __props.selectable ? (openBlock(), createElementBlock("td", {
228
- key: 0,
229
- class: "w-px px-4 py-3",
230
- onClick: _cache[0] || (_cache[0] = withModifiers(() => {
231
- }, ["stop"]))
232
- }, [
233
- createVNode(_sfc_main$2, {
234
- "model-value": selectedSet.value.has(keyFor(row, index)),
235
- "onUpdate:modelValue": (v) => toggleRow(keyFor(row, index), v)
236
- }, null, 8, ["model-value", "onUpdate:modelValue"])
237
- ])) : createCommentVNode("", true),
238
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.columns, (col) => {
239
- return openBlock(), createElementBlock("td", {
240
- key: col.key,
241
- class: normalizeClass(["px-4 py-3 text-ink", alignClass(col.align)])
242
- }, [
243
- renderSlot(_ctx.$slots, `cell-${col.key}`, {
244
- row,
245
- value: row[col.key]
246
- }, () => [
247
- createTextVNode(toDisplayString(row[col.key]), 1)
248
- ])
249
- ], 2);
250
- }), 128))
251
- ], 16, _hoisted_8)
252
- ]),
253
- _: 2
254
- }, 1032, ["items"]);
255
- }), 128))
256
- ], 512)
257
- ])
258
- ]),
259
- _: 3
260
- }, 8, ["bar-inset-top"]);
261
- };
262
- }
263
- });
264
- export {
265
- _sfc_main as _
266
- };