@leavepulse/ui 0.28.5 → 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
  };
@@ -0,0 +1,67 @@
1
+ import { MaybeRefOrGetter, Ref } from 'vue';
2
+ export interface UseFormDraftOptions<T> {
3
+ /**
4
+ * Where this draft lives. Include whatever identifies the subject — a draft
5
+ * for device A must not surface while editing device B.
6
+ */
7
+ key: MaybeRefOrGetter<string>;
8
+ /** The form's current value. Watched deeply; every change rewrites the draft. */
9
+ value: MaybeRefOrGetter<T>;
10
+ /**
11
+ * Whether there is anything worth keeping. Return false while the form still
12
+ * matches what was loaded, so an untouched form leaves nothing behind and no
13
+ * later visit is offered its own starting state back as a "draft".
14
+ */
15
+ dirty?: MaybeRefOrGetter<boolean>;
16
+ /**
17
+ * How long a draft stays offerable, in milliseconds. Older ones are dropped
18
+ * on read: a fortnight-old draft of a record that has been edited twice since
19
+ * is not a rescue, it is a way to overwrite current facts with stale ones.
20
+ * Defaults to a week. `0` keeps them indefinitely.
21
+ */
22
+ maxAgeMs?: number;
23
+ /** Skip the write without tearing the composable out of the component. */
24
+ disabled?: MaybeRefOrGetter<boolean>;
25
+ }
26
+ export interface UseFormDraft<T> {
27
+ /** A draft was found for this key, and has not been restored or discarded. */
28
+ hasDraft: Ref<boolean>;
29
+ /** When that draft was written, ISO-8601, or `null` when there is none. */
30
+ savedAt: Ref<string | null>;
31
+ /**
32
+ * The stored value, or `null`. Reading does not apply it — the caller decides
33
+ * what to do with each field, because only it knows the form's shape.
34
+ */
35
+ read: () => T | null;
36
+ /** Forget the draft. Call after applying it, and after a successful save. */
37
+ discard: () => void;
38
+ /** Write now, ignoring `dirty`. For "save and come back to this later". */
39
+ save: () => void;
40
+ }
41
+ /**
42
+ * Keeps an unsaved form in browser storage, so losing the page does not lose
43
+ * what was typed into it.
44
+ *
45
+ * ```ts
46
+ * const draft = useFormDraft({
47
+ * key: () => `lp.draft.device-power:${deviceId.value}`,
48
+ * value: () => interfaces.value,
49
+ * dirty: () => hasUnsavedChanges.value,
50
+ * })
51
+ * ```
52
+ * ```vue
53
+ * <LpAlert v-if="draft.hasDraft.value" variant="info">
54
+ * {{ t("draft.found", { when: draft.savedAt.value }) }}
55
+ * <LpButton @click="applyDraft">Restore</LpButton>
56
+ * <LpButton variant="ghost" @click="draft.discard()">Discard</LpButton>
57
+ * </LpAlert>
58
+ * ```
59
+ *
60
+ * `hasDraft` answers for the key as it is now, so a form that switches subject
61
+ * re-reads rather than carrying the previous subject's answer.
62
+ *
63
+ * Call `discard()` once the record is saved. A draft that outlives the save it
64
+ * describes is offered back on the next visit as though it were newer than the
65
+ * record, which is how a form helpfully undoes somebody's work.
66
+ */
67
+ export declare function useFormDraft<T>(options: UseFormDraftOptions<T>): UseFormDraft<T>;