@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.
- package/dist/assets/LpTable-DMOyAUOh.css +44 -0
- package/dist/chunks/{LpInfraNode-qIL3l7IO.js → LpInfraNode-Ds_8SZgN.js} +22 -8
- package/dist/chunks/{LpThemeSwitcher-7mAK6bI7.js → LpThemeSwitcher-Dj4VcTu7.js} +10 -3
- package/dist/chunks/{LpTopologyCanvas-ebP67CLx.js → LpTopologyCanvas-Cm1L39uL.js} +6 -5
- package/dist/components/LpInfraNode.vue.d.ts +30 -0
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpTable.vue.js +278 -2
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.d.ts +11 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/composables/useFormDraft.d.ts +67 -0
- package/dist/composables/useFormDraft.js +124 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +24 -22
- package/package.json +1 -1
- package/src/components/LpInfraNode.vue +75 -9
- package/src/components/LpTable.vue +96 -13
- package/src/components/LpThemeSwitcher.vue +25 -3
- package/src/components/LpTopologyCanvas.vue +4 -0
- package/src/composables/useFormDraft.ts +259 -0
- package/src/index.ts +3 -0
- package/dist/chunks/LpTable-DTj52qlg.js +0 -266
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ref, watch, toValue, onScopeDispose } from "vue";
|
|
2
|
+
const ENVELOPE_VERSION = 1;
|
|
3
|
+
function storage() {
|
|
4
|
+
try {
|
|
5
|
+
if (typeof window === "undefined") return null;
|
|
6
|
+
return window.localStorage;
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
12
|
+
function useFormDraft(options) {
|
|
13
|
+
const hasDraft = ref(false);
|
|
14
|
+
const savedAt = ref(null);
|
|
15
|
+
const maxAge = options.maxAgeMs ?? WEEK_MS;
|
|
16
|
+
function currentKey() {
|
|
17
|
+
return String(toValue(options.key) || "").trim();
|
|
18
|
+
}
|
|
19
|
+
function read() {
|
|
20
|
+
const store = storage();
|
|
21
|
+
const key = currentKey();
|
|
22
|
+
if (!store || !key) return null;
|
|
23
|
+
let raw = null;
|
|
24
|
+
try {
|
|
25
|
+
raw = store.getItem(key);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
if (!parsed || parsed.version !== ENVELOPE_VERSION) {
|
|
33
|
+
discard();
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (maxAge > 0) {
|
|
37
|
+
const age = Date.now() - Date.parse(parsed.savedAt);
|
|
38
|
+
if (age > maxAge) {
|
|
39
|
+
discard();
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return parsed.value;
|
|
44
|
+
} catch {
|
|
45
|
+
discard();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function write(value) {
|
|
50
|
+
const store = storage();
|
|
51
|
+
const key = currentKey();
|
|
52
|
+
if (!store || !key) return;
|
|
53
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
54
|
+
try {
|
|
55
|
+
store.setItem(
|
|
56
|
+
key,
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
version: ENVELOPE_VERSION,
|
|
59
|
+
savedAt: stamp,
|
|
60
|
+
value
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
hasDraft.value = true;
|
|
67
|
+
savedAt.value = stamp;
|
|
68
|
+
}
|
|
69
|
+
function discard() {
|
|
70
|
+
const store = storage();
|
|
71
|
+
const key = currentKey();
|
|
72
|
+
hasDraft.value = false;
|
|
73
|
+
savedAt.value = null;
|
|
74
|
+
if (!store || !key) return;
|
|
75
|
+
try {
|
|
76
|
+
store.removeItem(key);
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function save() {
|
|
81
|
+
write(toValue(options.value));
|
|
82
|
+
}
|
|
83
|
+
watch(
|
|
84
|
+
() => currentKey(),
|
|
85
|
+
() => {
|
|
86
|
+
hasDraft.value = false;
|
|
87
|
+
savedAt.value = null;
|
|
88
|
+
const store = storage();
|
|
89
|
+
const key = currentKey();
|
|
90
|
+
if (!store || !key) return;
|
|
91
|
+
let raw = null;
|
|
92
|
+
try {
|
|
93
|
+
raw = store.getItem(key);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!raw) return;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
if (parsed?.version !== ENVELOPE_VERSION) return;
|
|
101
|
+
hasDraft.value = true;
|
|
102
|
+
savedAt.value = parsed.savedAt;
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ immediate: true }
|
|
107
|
+
);
|
|
108
|
+
watch(
|
|
109
|
+
() => [toValue(options.value), toValue(options.dirty), toValue(options.disabled)],
|
|
110
|
+
([value, dirty, disabled]) => {
|
|
111
|
+
if (disabled) return;
|
|
112
|
+
if (options.dirty !== void 0 && !dirty) return;
|
|
113
|
+
write(value);
|
|
114
|
+
},
|
|
115
|
+
{ deep: true }
|
|
116
|
+
);
|
|
117
|
+
onScopeDispose(() => {
|
|
118
|
+
hasDraft.value = false;
|
|
119
|
+
});
|
|
120
|
+
return { hasDraft, savedAt, read, discard, save };
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
useFormDraft
|
|
124
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -133,6 +133,8 @@ export { useReveal } from './composables/useReveal';
|
|
|
133
133
|
export type { UseReveal, UseRevealOptions } from './composables/useReveal';
|
|
134
134
|
export { useRovingFocus } from './composables/useRovingFocus';
|
|
135
135
|
export type { RovingItem, UseRovingFocusOptions } from './composables/useRovingFocus';
|
|
136
|
+
export { useFormDraft } from './composables/useFormDraft';
|
|
137
|
+
export type { UseFormDraft, UseFormDraftOptions } from './composables/useFormDraft';
|
|
136
138
|
export { useSettled } from './composables/useSettled';
|
|
137
139
|
export type { UseSettledOptions } from './composables/useSettled';
|
|
138
140
|
export { useShift } from './composables/useShift';
|
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-
|
|
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 {
|
|
69
|
-
import { _ as
|
|
70
|
-
import { _ as
|
|
71
|
-
import { _ as
|
|
72
|
-
import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-
|
|
73
|
-
import { D, _ as
|
|
74
|
-
import { _ as
|
|
75
|
-
import { _ as
|
|
76
|
-
import { _ as
|
|
77
|
-
import { _ as
|
|
78
|
-
import { _ as
|
|
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";
|
|
@@ -85,6 +85,7 @@ import { useInputFilter } from "./composables/useInputFilter.js";
|
|
|
85
85
|
import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
|
|
86
86
|
import { useReveal } from "./composables/useReveal.js";
|
|
87
87
|
import { useRovingFocus } from "./composables/useRovingFocus.js";
|
|
88
|
+
import { useFormDraft } from "./composables/useFormDraft.js";
|
|
88
89
|
import { useSettled } from "./composables/useSettled.js";
|
|
89
90
|
import { useShift } from "./composables/useShift.js";
|
|
90
91
|
import { useTilt } from "./composables/useTilt.js";
|
|
@@ -185,16 +186,16 @@ export {
|
|
|
185
186
|
_56 as LpStat,
|
|
186
187
|
_57 as LpStepper,
|
|
187
188
|
_58 as LpSwitch,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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,
|
|
198
199
|
N as NO_BACKDROP_ATTR,
|
|
199
200
|
OSM_TILES,
|
|
200
201
|
T as THEME_COOKIE,
|
|
@@ -265,6 +266,7 @@ export {
|
|
|
265
266
|
m as themeToCssVars,
|
|
266
267
|
tileUrl,
|
|
267
268
|
unproject,
|
|
269
|
+
useFormDraft,
|
|
268
270
|
useHotkeys,
|
|
269
271
|
useInputFilter,
|
|
270
272
|
useLayout,
|
package/package.json
CHANGED
|
@@ -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="
|
|
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.
|
|
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
|
-
|
|
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="
|
|
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
|
-
?
|
|
141
|
-
:
|
|
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="
|
|
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
|
-
//
|
|
81
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
</
|
|
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: ${
|
|
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">{{
|
|
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>
|