@leavepulse/ui 0.28.2 → 0.28.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{LpAddressInput-BTqnUXZG.js → LpAddressInput-BoTkszLk.js} +3 -3
- package/dist/chunks/{LpAppShell-B5k0J3Cf.js → LpAppShell-CGX0DlIW.js} +11 -6
- package/dist/chunks/{LpCard-gu11ZmSt.js → LpCard-DSefTGEH.js} +7 -1
- package/dist/chunks/{LpChart-tNXs1ttr.js → LpChart-UiU4RhXP.js} +5 -4
- package/dist/chunks/{LpFormField-CesaPWPO.js → LpFormField-DEaakcHe.js} +1 -1
- package/dist/chunks/{LpInput-jwqXlAy0.js → LpInput-BXr40nqX.js} +1 -1
- package/dist/chunks/{LpPasswordInput-Bmo6q9Kd.js → LpPasswordInput-Bdv6o8ae.js} +1 -1
- package/dist/chunks/{LpSelect-Cw8-gL5F.js → LpSelect-ANIVtrpM.js} +1 -1
- package/dist/chunks/{LpSidebar-7Vpy2IJc.js → LpSidebar-CWehDCxN.js} +34 -8
- package/dist/chunks/LpSidebarNav-5RGNr2D3.js +185 -0
- package/dist/chunks/{LpTextarea-1ENb3ZAO.js → LpTextarea-tc8N4UO_.js} +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.d.ts +17 -11
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpSidebar.vue.d.ts +32 -10
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.d.ts +11 -2
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/index.js +10 -10
- package/package.json +1 -1
- package/src/components/LpAppShell.vue +27 -3
- package/src/components/LpCard.vue +7 -1
- package/src/components/LpChart.vue +23 -4
- package/src/components/LpFormField.vue +6 -2
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpSidebar.vue +67 -1
- package/src/components/LpSidebarNav.vue +83 -4
- package/src/components/sidebar.ts +18 -0
- package/dist/chunks/LpSidebarNav-CFX5NxGs.js +0 -126
|
@@ -35,45 +35,67 @@ type __VLS_Props = {
|
|
|
35
35
|
* flush, header-less look.
|
|
36
36
|
*/
|
|
37
37
|
divider?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Which collapsible groups are folded away (v-model:collapsed). Leave it
|
|
40
|
+
* unbound to let the sidebar fold long navs on its own — see
|
|
41
|
+
* `autoCollapseAfter`.
|
|
42
|
+
*/
|
|
43
|
+
collapsed?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Fold collapsible groups automatically once the nav has more than this
|
|
46
|
+
* many rows, counting headings and items alike.
|
|
47
|
+
*
|
|
48
|
+
* The reason is the reader, not the pixels: a short nav is taken in at a
|
|
49
|
+
* glance, a long one has to be searched, and searching a list you did not
|
|
50
|
+
* choose to have is the part that grates. Past the threshold the groups
|
|
51
|
+
* that are not today's work fold themselves, and the one holding the
|
|
52
|
+
* current page always stays open.
|
|
53
|
+
*
|
|
54
|
+
* 0 turns it off — the app then owns the state through `collapsed`.
|
|
55
|
+
*/
|
|
56
|
+
autoCollapseAfter?: number;
|
|
38
57
|
};
|
|
39
|
-
declare var __VLS_1: {},
|
|
58
|
+
declare var __VLS_1: {}, __VLS_13: {
|
|
40
59
|
item: SidebarItem;
|
|
41
60
|
active: boolean;
|
|
42
|
-
},
|
|
61
|
+
}, __VLS_15: {}, __VLS_17: {}, __VLS_27: {}, __VLS_39: {
|
|
43
62
|
item: SidebarItem;
|
|
44
63
|
active: boolean;
|
|
45
|
-
},
|
|
64
|
+
}, __VLS_41: {}, __VLS_43: {};
|
|
46
65
|
type __VLS_Slots = {} & {
|
|
47
66
|
header?: (props: typeof __VLS_1) => any;
|
|
48
67
|
} & {
|
|
49
|
-
item?: (props: typeof
|
|
68
|
+
item?: (props: typeof __VLS_13) => any;
|
|
50
69
|
} & {
|
|
51
|
-
actions?: (props: typeof
|
|
70
|
+
actions?: (props: typeof __VLS_15) => any;
|
|
52
71
|
} & {
|
|
53
|
-
footer?: (props: typeof
|
|
72
|
+
footer?: (props: typeof __VLS_17) => any;
|
|
54
73
|
} & {
|
|
55
|
-
header?: (props: typeof
|
|
74
|
+
header?: (props: typeof __VLS_27) => any;
|
|
56
75
|
} & {
|
|
57
|
-
item?: (props: typeof
|
|
76
|
+
item?: (props: typeof __VLS_39) => any;
|
|
58
77
|
} & {
|
|
59
|
-
actions?: (props: typeof
|
|
78
|
+
actions?: (props: typeof __VLS_41) => any;
|
|
60
79
|
} & {
|
|
61
|
-
footer?: (props: typeof
|
|
80
|
+
footer?: (props: typeof __VLS_43) => any;
|
|
62
81
|
};
|
|
63
82
|
declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
|
|
64
83
|
select: (item: SidebarItem) => any;
|
|
65
84
|
"update:modelValue": (id: string) => any;
|
|
66
85
|
"update:open": (value: boolean) => any;
|
|
86
|
+
"update:collapsed": (keys: string[]) => any;
|
|
67
87
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
68
88
|
onSelect?: ((item: SidebarItem) => any) | undefined;
|
|
69
89
|
"onUpdate:modelValue"?: ((id: string) => any) | undefined;
|
|
70
90
|
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
91
|
+
"onUpdate:collapsed"?: ((keys: string[]) => any) | undefined;
|
|
71
92
|
}>, {
|
|
72
93
|
skeletonRows: number;
|
|
73
94
|
skeletonHeader: boolean;
|
|
74
95
|
responsive: boolean;
|
|
75
96
|
mobileBreakpoint: "sm" | "md" | "lg" | "xl";
|
|
76
97
|
divider: boolean;
|
|
98
|
+
autoCollapseAfter: number;
|
|
77
99
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
78
100
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
79
101
|
declare const _default: typeof __VLS_export;
|
|
@@ -8,21 +8,30 @@ type __VLS_Props = {
|
|
|
8
8
|
loading?: boolean;
|
|
9
9
|
skeletonRows?: number;
|
|
10
10
|
skeletonHeader?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Keys of the groups currently folded away. The owner holds this so the
|
|
13
|
+
* choice can outlive the component (a setting, a stored preference) — the
|
|
14
|
+
* nav itself only reports the intent to change it.
|
|
15
|
+
*/
|
|
16
|
+
collapsedKeys?: string[];
|
|
11
17
|
};
|
|
12
|
-
declare var
|
|
18
|
+
declare var __VLS_63: {
|
|
13
19
|
item: SidebarItem;
|
|
14
20
|
active: boolean;
|
|
15
21
|
};
|
|
16
22
|
type __VLS_Slots = {} & {
|
|
17
|
-
item?: (props: typeof
|
|
23
|
+
item?: (props: typeof __VLS_63) => any;
|
|
18
24
|
};
|
|
19
25
|
declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
|
|
20
26
|
activate: (item: SidebarItem) => any;
|
|
27
|
+
"toggle-section": (key: string, collapsed: boolean) => any;
|
|
21
28
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
22
29
|
onActivate?: ((item: SidebarItem) => any) | undefined;
|
|
30
|
+
"onToggle-section"?: ((key: string, collapsed: boolean) => any) | undefined;
|
|
23
31
|
}>, {
|
|
24
32
|
skeletonRows: number;
|
|
25
33
|
skeletonHeader: boolean;
|
|
34
|
+
collapsedKeys: string[];
|
|
26
35
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
27
36
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
28
37
|
declare const _default: typeof __VLS_export;
|
|
@@ -15,4 +15,22 @@ export interface SidebarSection {
|
|
|
15
15
|
/** Optional heading above the group. */
|
|
16
16
|
title?: string;
|
|
17
17
|
items: SidebarItem[];
|
|
18
|
+
/**
|
|
19
|
+
* The heading folds the group away when clicked. Needs a `title` — there is
|
|
20
|
+
* nothing to click otherwise.
|
|
21
|
+
*
|
|
22
|
+
* A long nav is read by scanning headings, not items: past a certain length
|
|
23
|
+
* every item is equally far from the eye, and folding the groups that are
|
|
24
|
+
* not today's work restores that. Which groups start folded is the app's
|
|
25
|
+
* call (see `collapsedIds`), not this component's.
|
|
26
|
+
*/
|
|
27
|
+
collapsible?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for remembering this group's folded state. Defaults to `title`;
|
|
30
|
+
* set it when the title is translated, so the memory survives a language
|
|
31
|
+
* change.
|
|
32
|
+
*/
|
|
33
|
+
key?: string;
|
|
34
|
+
/** Icon for the heading — shown when the group is collapsible. */
|
|
35
|
+
icon?: string;
|
|
18
36
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
|
|
2
|
-
import { _ as _2 } from "./chunks/LpAddressInput-
|
|
2
|
+
import { _ as _2 } from "./chunks/LpAddressInput-BoTkszLk.js";
|
|
3
3
|
import { _ as _3 } from "./chunks/LpMapPicker-CXvoTrAe.js";
|
|
4
4
|
import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
|
|
5
5
|
import { _ as _4 } from "./chunks/LpAlert-BAYHDSS1.js";
|
|
6
|
-
import { _ as _5 } from "./chunks/LpAppShell-
|
|
6
|
+
import { _ as _5 } from "./chunks/LpAppShell-CGX0DlIW.js";
|
|
7
7
|
import { _ as _6 } from "./chunks/LpAutocomplete-DExlIZe_.js";
|
|
8
8
|
import { _ as _7 } from "./chunks/LpAvatar-CzCE5cXg.js";
|
|
9
9
|
import { _ as _8 } from "./chunks/LpBadge-BPodVcJa.js";
|
|
10
10
|
import { _ as _9 } from "./chunks/LpBreadcrumbs-5uvViCXi.js";
|
|
11
11
|
import { _ as _10 } from "./chunks/LpButton-5VXXCjwF.js";
|
|
12
12
|
import { _ as _11 } from "./chunks/LpCalendar-B7ZL0ty3.js";
|
|
13
|
-
import { _ as _12 } from "./chunks/LpCard-
|
|
14
|
-
import { _ as _13 } from "./chunks/LpChart-
|
|
13
|
+
import { _ as _12 } from "./chunks/LpCard-DSefTGEH.js";
|
|
14
|
+
import { _ as _13 } from "./chunks/LpChart-UiU4RhXP.js";
|
|
15
15
|
import { CHART_PALETTE, axisTicks, formatTick, niceScale, resolveSlices } from "./components/chart.js";
|
|
16
16
|
import { _ as _14 } from "./chunks/LpCheckbox-BO0zuuPh.js";
|
|
17
17
|
import { _ as _15 } from "./chunks/LpCodeBlock-rS_6mO3D.js";
|
|
@@ -27,11 +27,11 @@ import { default as default3 } from "./components/LpDropzone.vue.js";
|
|
|
27
27
|
import { _ as _23 } from "./chunks/LpEmptyState-BV3q7Qyi.js";
|
|
28
28
|
import { _ as _24 } from "./chunks/LpFileTree-BaV7f0O7.js";
|
|
29
29
|
import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
|
|
30
|
-
import { _ as _25 } from "./chunks/LpFormField-
|
|
30
|
+
import { _ as _25 } from "./chunks/LpFormField-DEaakcHe.js";
|
|
31
31
|
import { _ as _26 } from "./chunks/LpIcon-CCnX5_2j.js";
|
|
32
32
|
import { default as default4 } from "./components/LpImageEditor.vue.js";
|
|
33
33
|
import { _ as _27 } from "./chunks/LpInfraNode-qIL3l7IO.js";
|
|
34
|
-
import { _ as _28 } from "./chunks/LpInput-
|
|
34
|
+
import { _ as _28 } from "./chunks/LpInput-BXr40nqX.js";
|
|
35
35
|
import { _ as _29 } from "./chunks/LpLightbox-Ci35Y_xP.js";
|
|
36
36
|
import { fileNameOf } from "./components/lightbox.js";
|
|
37
37
|
import { _ as _30 } from "./chunks/LpLink-BHZ1CLlF.js";
|
|
@@ -42,7 +42,7 @@ import { _ as _34 } from "./chunks/LpNumberField-NPE8Bumx.js";
|
|
|
42
42
|
import { _ as _35 } from "./chunks/LpNumberFlow-amWYaeuA.js";
|
|
43
43
|
import { _ as _36 } from "./chunks/LpOtpInput-DqaT0Z75.js";
|
|
44
44
|
import { _ as _37 } from "./chunks/LpPagination-CeGuNe_q.js";
|
|
45
|
-
import { _ as _38 } from "./chunks/LpPasswordInput-
|
|
45
|
+
import { _ as _38 } from "./chunks/LpPasswordInput-Bdv6o8ae.js";
|
|
46
46
|
import { _ as _39 } from "./chunks/LpPie-BkjWlfSw.js";
|
|
47
47
|
import { _ as _40 } from "./chunks/LpPhoneInput-NXXjciJJ.js";
|
|
48
48
|
import { flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
|
|
@@ -54,11 +54,11 @@ import { _ as _45 } from "./chunks/LpRollingText-CM5gkSeF.js";
|
|
|
54
54
|
import { _ as _46 } from "./chunks/LpScrollArea-D8xyKXcF.js";
|
|
55
55
|
import { _ as _47 } from "./chunks/LpSegmented-BmpanyAo.js";
|
|
56
56
|
import { _ as _48 } from "./chunks/LpSection-aM6J_u51.js";
|
|
57
|
-
import { _ as _49 } from "./chunks/LpSelect-
|
|
57
|
+
import { _ as _49 } from "./chunks/LpSelect-ANIVtrpM.js";
|
|
58
58
|
import { _ as _50 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
|
|
59
59
|
import { _ as _51 } from "./chunks/LpShift-DXNsauQZ.js";
|
|
60
60
|
import { default as default5 } from "./components/LpShortcutsDialog.vue.js";
|
|
61
|
-
import { _ as _52 } from "./chunks/LpSidebar-
|
|
61
|
+
import { _ as _52 } from "./chunks/LpSidebar-CWehDCxN.js";
|
|
62
62
|
import { _ as _53 } from "./chunks/LpSkeleton-CTI-vBgW.js";
|
|
63
63
|
import { _ as _54 } from "./chunks/LpSlider-B1SF3pRV.js";
|
|
64
64
|
import { _ as _55 } from "./chunks/LpSparkline-C6xQ2dwv.js";
|
|
@@ -68,7 +68,7 @@ import { _ as _58 } from "./chunks/LpSwitch-v6fnccSM.js";
|
|
|
68
68
|
import { _ as _59 } from "./chunks/LpTable-DTj52qlg.js";
|
|
69
69
|
import { _ as _60 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
|
|
70
70
|
import { _ as _61 } from "./chunks/LpTabs-Cefe_lys.js";
|
|
71
|
-
import { _ as _62 } from "./chunks/LpTextarea-
|
|
71
|
+
import { _ as _62 } from "./chunks/LpTextarea-tc8N4UO_.js";
|
|
72
72
|
import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-7mAK6bI7.js";
|
|
73
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
74
|
import { _ as _64 } from "./chunks/LpTilt-DuyKbpkA.js";
|
package/package.json
CHANGED
|
@@ -39,6 +39,10 @@ const props = withDefaults(
|
|
|
39
39
|
/** Flat items, or sections for grouped nav. Forwarded to LpSidebar. */
|
|
40
40
|
items?: SidebarItem[]
|
|
41
41
|
sections?: SidebarSection[]
|
|
42
|
+
/** Folded nav groups (v-model:collapsed). Forwarded to LpSidebar. */
|
|
43
|
+
collapsed?: string[]
|
|
44
|
+
/** Fold long navs automatically; 0 disables. Forwarded to LpSidebar. */
|
|
45
|
+
autoCollapseAfter?: number
|
|
42
46
|
/** Active item id (v-model) when not using a custom `isActive`. */
|
|
43
47
|
modelValue?: string
|
|
44
48
|
/**
|
|
@@ -80,6 +84,7 @@ const props = withDefaults(
|
|
|
80
84
|
const emit = defineEmits<{
|
|
81
85
|
(e: "update:modelValue", id: string): void
|
|
82
86
|
(e: "update:open", value: boolean): void
|
|
87
|
+
(e: "update:collapsed", keys: string[]): void
|
|
83
88
|
(e: "select", item: SidebarItem): void
|
|
84
89
|
}>()
|
|
85
90
|
|
|
@@ -167,9 +172,12 @@ defineExpose({ openDrawer: _openDrawer })
|
|
|
167
172
|
:mobile-breakpoint="mobileBreakpoint"
|
|
168
173
|
:divider="divider"
|
|
169
174
|
:loading="loading"
|
|
175
|
+
:collapsed="collapsed"
|
|
176
|
+
:auto-collapse-after="autoCollapseAfter"
|
|
170
177
|
class="shrink-0"
|
|
171
178
|
@update:model-value="onModel"
|
|
172
179
|
@update:open="(v) => emit('update:open', v)"
|
|
180
|
+
@update:collapsed="(v) => emit('update:collapsed', v)"
|
|
173
181
|
@select="onSelect"
|
|
174
182
|
>
|
|
175
183
|
<template v-if="$slots.logo" #header>
|
|
@@ -188,8 +196,14 @@ defineExpose({ openDrawer: _openDrawer })
|
|
|
188
196
|
|
|
189
197
|
<!-- Main column: header + the single scroll region. -->
|
|
190
198
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
199
|
+
<!-- `relative` so a consumer can centre something — section tabs, a
|
|
200
|
+
search box — against the WHOLE bar. Centring it inside the title
|
|
201
|
+
block instead makes its position depend on how wide the actions are,
|
|
202
|
+
so the tabs slide sideways every time a button appears: measured at
|
|
203
|
+
59 px off centre with two actions and 77 px with three, which reads
|
|
204
|
+
as the navigation drifting rather than the page changing. -->
|
|
191
205
|
<header
|
|
192
|
-
class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6"
|
|
206
|
+
class="relative flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6"
|
|
193
207
|
>
|
|
194
208
|
<LpButton
|
|
195
209
|
:class="burgerClass"
|
|
@@ -218,8 +232,18 @@ defineExpose({ openDrawer: _openDrawer })
|
|
|
218
232
|
for a long one — each snapping every control to its left sideways.
|
|
219
233
|
Kept mounted regardless of the slot, so a page that only teleports
|
|
220
234
|
into it still gets the easing (an empty box measures zero wide and
|
|
221
|
-
costs nothing).
|
|
222
|
-
|
|
235
|
+
costs nothing).
|
|
236
|
+
|
|
237
|
+
`max-w` + horizontal scroll rather than `shrink-0`: a phone cannot
|
|
238
|
+
fit a title and three buttons on one line, and an unshrinkable box
|
|
239
|
+
resolved that by pushing its own contents past the right edge —
|
|
240
|
+
where there is nothing to scroll to and nothing to press. The
|
|
241
|
+
actions now cap at two thirds of the bar and scroll within it, so
|
|
242
|
+
the title keeps a third and every button stays reachable. -->
|
|
243
|
+
<LpShift
|
|
244
|
+
axis="width"
|
|
245
|
+
class="ml-auto flex max-w-[66%] shrink items-center gap-2 overflow-x-auto lp-scrollbar-none md:max-w-none md:shrink-0 md:overflow-visible"
|
|
246
|
+
>
|
|
223
247
|
<slot name="header-actions" />
|
|
224
248
|
</LpShift>
|
|
225
249
|
</header>
|
|
@@ -19,7 +19,13 @@ const card = tv({
|
|
|
19
19
|
// renders: two lines that look identical must not wrap differently.
|
|
20
20
|
// Anything that genuinely must not break (a code block, a log line) sets its
|
|
21
21
|
// own overflow and scrolls instead.
|
|
22
|
-
|
|
22
|
+
// min-w-0: a card is nearly always laid out by a grid or a flex row, and both
|
|
23
|
+
// give their children `min-width: auto` — meaning "never narrower than your
|
|
24
|
+
// contents". A card holding a form then refuses to shrink and pushes its own
|
|
25
|
+
// column past the viewport: measured at 490px inside a 358px grid, with the
|
|
26
|
+
// inputs ending up off-screen on a phone. The card is a container; it takes
|
|
27
|
+
// the width it is given and wraps what is inside it.
|
|
28
|
+
base: "min-w-0 rounded-card border bg-surface-raised break-words",
|
|
23
29
|
variants: {
|
|
24
30
|
variant: {
|
|
25
31
|
// raised opts into the skin painter (.lp-skin-panel) so it follows the
|
|
@@ -553,6 +553,25 @@ watch(
|
|
|
553
553
|
)
|
|
554
554
|
|
|
555
555
|
const clipId = `lp-chart-${Math.random().toString(36).slice(2, 9)}`
|
|
556
|
+
|
|
557
|
+
/*
|
|
558
|
+
* The gradient id for a series, with the series NAME reduced to id-safe
|
|
559
|
+
* characters.
|
|
560
|
+
*
|
|
561
|
+
* A name is a label — "used %", "in eth0", "read sda" — and putting it verbatim
|
|
562
|
+
* into an id produced `url(#lp-chart-x-g-used %)`, which is not a valid
|
|
563
|
+
* reference: the browser fails to resolve it and falls back to painting the
|
|
564
|
+
* shape black. The gradient was there, correct, and simply never reached, so an
|
|
565
|
+
* area chart of any series whose name held a space or a percent sign rendered
|
|
566
|
+
* as a solid black block under its line — while "cpu" and "memory" on the same
|
|
567
|
+
* page kept their colour, which is what made it look like a data problem rather
|
|
568
|
+
* than an escaping one.
|
|
569
|
+
*
|
|
570
|
+
* Index-suffixed so two names that differ only in punctuation ("in eth0" and
|
|
571
|
+
* "in-eth0") cannot collapse onto one id and share a colour.
|
|
572
|
+
*/
|
|
573
|
+
const gradientId = (name: string, index: number) =>
|
|
574
|
+
`${clipId}-g-${index}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}`
|
|
556
575
|
</script>
|
|
557
576
|
|
|
558
577
|
<template>
|
|
@@ -618,8 +637,8 @@ const clipId = `lp-chart-${Math.random().toString(36).slice(2, 9)}`
|
|
|
618
637
|
>
|
|
619
638
|
<defs>
|
|
620
639
|
<linearGradient
|
|
621
|
-
v-for="s in lines"
|
|
622
|
-
:id="
|
|
640
|
+
v-for="(s, i) in lines"
|
|
641
|
+
:id="gradientId(s.name, i)"
|
|
623
642
|
:key="s.name"
|
|
624
643
|
x1="0"
|
|
625
644
|
y1="0"
|
|
@@ -728,11 +747,11 @@ const clipId = `lp-chart-${Math.random().toString(36).slice(2, 9)}`
|
|
|
728
747
|
|
|
729
748
|
<!-- area fills -->
|
|
730
749
|
<path
|
|
731
|
-
v-for="s in lines"
|
|
750
|
+
v-for="(s, i) in lines"
|
|
732
751
|
v-show="type === 'area'"
|
|
733
752
|
:key="`f-${s.name}`"
|
|
734
753
|
:d="s.fill"
|
|
735
|
-
:fill="`url(#${
|
|
754
|
+
:fill="`url(#${gradientId(s.name, i)})`"
|
|
736
755
|
/>
|
|
737
756
|
|
|
738
757
|
<!-- lines -->
|
|
@@ -23,8 +23,12 @@ const id = useId()
|
|
|
23
23
|
</script>
|
|
24
24
|
|
|
25
25
|
<template>
|
|
26
|
-
<!-- `gap` covers label→control only; the message carries its own spacing.
|
|
27
|
-
|
|
26
|
+
<!-- `gap` covers label→control only; the message carries its own spacing.
|
|
27
|
+
min-w-0 because a field is laid out by a form grid, whose children default
|
|
28
|
+
to `min-width: auto` and so refuse to go narrower than the widest label or
|
|
29
|
+
placeholder inside them — which is how a two-column form ended up wider
|
|
30
|
+
than the phone holding it. -->
|
|
31
|
+
<div class="flex min-w-0 flex-col gap-1.5">
|
|
28
32
|
<Label v-if="label" :for="id" class="text-sm font-medium text-ink">
|
|
29
33
|
{{ label }}
|
|
30
34
|
<span v-if="required" class="text-danger">*</span>
|
|
Binary file
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* auto-closes the drawer. With `responsive` off the component renders exactly
|
|
14
14
|
* the static <nav> it always has — existing call sites are unaffected.
|
|
15
15
|
*/
|
|
16
|
-
import { computed, useId } from "vue"
|
|
16
|
+
import { computed, ref, useId } from "vue"
|
|
17
17
|
import LpDrawer from "./LpDrawer.vue"
|
|
18
18
|
import LpSidebarNav from "./LpSidebarNav.vue"
|
|
19
19
|
import type { SidebarItem, SidebarSection } from "./sidebar"
|
|
@@ -56,6 +56,25 @@ const props = withDefaults(
|
|
|
56
56
|
* flush, header-less look.
|
|
57
57
|
*/
|
|
58
58
|
divider?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Which collapsible groups are folded away (v-model:collapsed). Leave it
|
|
61
|
+
* unbound to let the sidebar fold long navs on its own — see
|
|
62
|
+
* `autoCollapseAfter`.
|
|
63
|
+
*/
|
|
64
|
+
collapsed?: string[]
|
|
65
|
+
/**
|
|
66
|
+
* Fold collapsible groups automatically once the nav has more than this
|
|
67
|
+
* many rows, counting headings and items alike.
|
|
68
|
+
*
|
|
69
|
+
* The reason is the reader, not the pixels: a short nav is taken in at a
|
|
70
|
+
* glance, a long one has to be searched, and searching a list you did not
|
|
71
|
+
* choose to have is the part that grates. Past the threshold the groups
|
|
72
|
+
* that are not today's work fold themselves, and the one holding the
|
|
73
|
+
* current page always stays open.
|
|
74
|
+
*
|
|
75
|
+
* 0 turns it off — the app then owns the state through `collapsed`.
|
|
76
|
+
*/
|
|
77
|
+
autoCollapseAfter?: number
|
|
59
78
|
}>(),
|
|
60
79
|
{
|
|
61
80
|
skeletonRows: 6,
|
|
@@ -63,12 +82,14 @@ const props = withDefaults(
|
|
|
63
82
|
responsive: false,
|
|
64
83
|
mobileBreakpoint: "md",
|
|
65
84
|
divider: true,
|
|
85
|
+
autoCollapseAfter: 14,
|
|
66
86
|
},
|
|
67
87
|
)
|
|
68
88
|
|
|
69
89
|
const emit = defineEmits<{
|
|
70
90
|
(e: "update:modelValue", id: string): void
|
|
71
91
|
(e: "update:open", value: boolean): void
|
|
92
|
+
(e: "update:collapsed", keys: string[]): void
|
|
72
93
|
(e: "select", item: SidebarItem): void
|
|
73
94
|
}>()
|
|
74
95
|
|
|
@@ -81,6 +102,47 @@ function itemActive(item: SidebarItem): boolean {
|
|
|
81
102
|
return props.isActive ? props.isActive(item) : item.id === props.modelValue
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
function keyOf(section: SidebarSection): string {
|
|
106
|
+
return section.key ?? section.title ?? ""
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
* Folded state. The app may own it (v-model:collapsed) — that is how a stored
|
|
111
|
+
* setting survives a reload; otherwise the sidebar keeps it for the session.
|
|
112
|
+
*/
|
|
113
|
+
const ownCollapsed = ref<string[] | null>(null)
|
|
114
|
+
|
|
115
|
+
/** Headings + items: what the eye actually has to walk past. */
|
|
116
|
+
const navLength = computed(
|
|
117
|
+
() => groups.value.reduce((n, g) => n + g.items.length + (g.title ? 1 : 0), 0),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* Nothing chosen yet and the nav is long: fold the collapsible groups, except
|
|
122
|
+
* the one the reader is standing in. Once anything is toggled the choice is a
|
|
123
|
+
* decision and this stops second-guessing it.
|
|
124
|
+
*/
|
|
125
|
+
const autoCollapsed = computed<string[]>(() => {
|
|
126
|
+
const limit = props.autoCollapseAfter
|
|
127
|
+
if (!limit || navLength.value <= limit) return []
|
|
128
|
+
return groups.value
|
|
129
|
+
.filter((g) => g.collapsible && !g.items.some(itemActive))
|
|
130
|
+
.map(keyOf)
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const collapsedKeys = computed<string[]>(
|
|
135
|
+
() => props.collapsed ?? ownCollapsed.value ?? autoCollapsed.value,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
function toggleSection(key: string, collapsed: boolean) {
|
|
139
|
+
const next = collapsed
|
|
140
|
+
? [...new Set([...collapsedKeys.value, key])]
|
|
141
|
+
: collapsedKeys.value.filter((k) => k !== key)
|
|
142
|
+
ownCollapsed.value = next
|
|
143
|
+
emit("update:collapsed", next)
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
function activate(item: SidebarItem) {
|
|
85
147
|
if (item.disabled) return
|
|
86
148
|
emit("update:modelValue", item.id)
|
|
@@ -139,7 +201,9 @@ defineOptions({ inheritAttrs: false })
|
|
|
139
201
|
:loading="loading"
|
|
140
202
|
:skeleton-rows="skeletonRows"
|
|
141
203
|
:skeleton-header="skeletonHeader"
|
|
204
|
+
:collapsed-keys="collapsedKeys"
|
|
142
205
|
@activate="activate"
|
|
206
|
+
@toggle-section="toggleSection"
|
|
143
207
|
>
|
|
144
208
|
<template v-if="$slots.item" #item="slotProps">
|
|
145
209
|
<slot name="item" v-bind="slotProps" />
|
|
@@ -184,7 +248,9 @@ defineOptions({ inheritAttrs: false })
|
|
|
184
248
|
:loading="loading"
|
|
185
249
|
:skeleton-rows="skeletonRows"
|
|
186
250
|
:skeleton-header="skeletonHeader"
|
|
251
|
+
:collapsed-keys="collapsedKeys"
|
|
187
252
|
@activate="activate"
|
|
253
|
+
@toggle-section="toggleSection"
|
|
188
254
|
>
|
|
189
255
|
<template v-if="$slots.item" #item="slotProps">
|
|
190
256
|
<slot name="item" v-bind="slotProps" />
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* without duplicating markup. Not exported on its own; an LpSidebar detail.
|
|
7
7
|
*/
|
|
8
8
|
import { Motion } from "motion-v"
|
|
9
|
+
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
|
|
10
|
+
import { computed } from "vue"
|
|
9
11
|
import { usePillTransition } from "../composables/usePillTransition"
|
|
10
12
|
import LpBadge from "./LpBadge.vue"
|
|
11
13
|
import LpContextMenu from "./LpContextMenu.vue"
|
|
@@ -23,14 +25,46 @@ const props = withDefaults(
|
|
|
23
25
|
loading?: boolean
|
|
24
26
|
skeletonRows?: number
|
|
25
27
|
skeletonHeader?: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Keys of the groups currently folded away. The owner holds this so the
|
|
30
|
+
* choice can outlive the component (a setting, a stored preference) — the
|
|
31
|
+
* nav itself only reports the intent to change it.
|
|
32
|
+
*/
|
|
33
|
+
collapsedKeys?: string[]
|
|
26
34
|
}>(),
|
|
27
|
-
{ skeletonRows: 6, skeletonHeader: true },
|
|
35
|
+
{ skeletonRows: 6, skeletonHeader: true, collapsedKeys: () => [] },
|
|
28
36
|
)
|
|
29
37
|
|
|
30
38
|
const emit = defineEmits<{
|
|
31
39
|
(e: "activate", item: SidebarItem): void
|
|
40
|
+
(e: "toggle-section", key: string, collapsed: boolean): void
|
|
32
41
|
}>()
|
|
33
42
|
|
|
43
|
+
/** `key` when the app set one, else the title — see SidebarSection.key. */
|
|
44
|
+
function sectionKey(section: SidebarSection): string {
|
|
45
|
+
return section.key ?? section.title ?? ""
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* A group folds only when the app both allows it and is not the group the
|
|
50
|
+
* reader is standing in: folding away the page you are on hides your own
|
|
51
|
+
* location, which is the one row a nav must always show.
|
|
52
|
+
*/
|
|
53
|
+
const foldedKeys = computed(() => {
|
|
54
|
+
const holdsActive = new Set(
|
|
55
|
+
props.groups
|
|
56
|
+
.filter((g) => g.items.some((i) => props.itemActive(i)))
|
|
57
|
+
.map(sectionKey),
|
|
58
|
+
)
|
|
59
|
+
return new Set(
|
|
60
|
+
props.collapsedKeys.filter((k) => k && !holdsActive.has(k)),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function isOpen(section: SidebarSection): boolean {
|
|
65
|
+
return !foldedKeys.value.has(sectionKey(section))
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
const pillTransition = usePillTransition()
|
|
35
69
|
</script>
|
|
36
70
|
|
|
@@ -56,14 +90,58 @@ const pillTransition = usePillTransition()
|
|
|
56
90
|
<!-- min-h-full keeps the content as tall as the viewport so the #actions
|
|
57
91
|
block's mt-auto can still pin it to the bottom. -->
|
|
58
92
|
<LpScrollArea v-else class="min-h-0 flex-1" content-class="flex min-h-full flex-col gap-4">
|
|
59
|
-
<
|
|
93
|
+
<CollapsibleRoot
|
|
94
|
+
v-for="(section, si) in groups"
|
|
95
|
+
:key="si"
|
|
96
|
+
as="div"
|
|
97
|
+
class="flex flex-col gap-0.5"
|
|
98
|
+
:open="isOpen(section)"
|
|
99
|
+
:disabled="!section.collapsible || !section.title"
|
|
100
|
+
@update:open="(o: boolean) => emit('toggle-section', sectionKey(section), !o)"
|
|
101
|
+
>
|
|
102
|
+
<!-- A plain heading while the group is fixed; a control once it folds,
|
|
103
|
+
because a thing that reacts to a click must look like one. -->
|
|
104
|
+
<CollapsibleTrigger
|
|
105
|
+
v-if="section.title && section.collapsible"
|
|
106
|
+
class="group/section mt-2 flex cursor-pointer items-center gap-2 rounded-control px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/[0.04] hover:text-ink focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
107
|
+
>
|
|
108
|
+
<LpIcon
|
|
109
|
+
v-if="section.icon"
|
|
110
|
+
:name="section.icon"
|
|
111
|
+
:size="14"
|
|
112
|
+
class="shrink-0"
|
|
113
|
+
/>
|
|
114
|
+
<span class="min-w-0 flex-1 truncate text-left">{{ section.title }}</span>
|
|
115
|
+
<!-- The count stands in for the items while they are hidden, so a
|
|
116
|
+
folded group still says how much is inside it.
|
|
117
|
+
|
|
118
|
+
It is always rendered and merely fades, never removed: taking it
|
|
119
|
+
out of the row changes the heading's height, and a heading that
|
|
120
|
+
resizes under the cursor makes its own click feel like a misfire. -->
|
|
121
|
+
<LpBadge
|
|
122
|
+
tone="neutral"
|
|
123
|
+
class="shrink-0 transition-opacity duration-[var(--duration-fast)] motion-reduce:transition-none"
|
|
124
|
+
:class="isOpen(section) ? 'opacity-0' : 'opacity-100'"
|
|
125
|
+
:aria-hidden="isOpen(section) || undefined"
|
|
126
|
+
>
|
|
127
|
+
{{ section.items.length }}
|
|
128
|
+
</LpBadge>
|
|
129
|
+
<LpIcon
|
|
130
|
+
name="lucide:chevron-down"
|
|
131
|
+
:size="14"
|
|
132
|
+
class="shrink-0 text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[state=closed]/section:-rotate-90 motion-reduce:transition-none"
|
|
133
|
+
/>
|
|
134
|
+
</CollapsibleTrigger>
|
|
60
135
|
<p
|
|
61
|
-
v-if="section.title"
|
|
136
|
+
v-else-if="section.title"
|
|
62
137
|
class="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
|
|
63
138
|
>
|
|
64
139
|
{{ section.title }}
|
|
65
140
|
</p>
|
|
66
141
|
|
|
142
|
+
<CollapsibleContent
|
|
143
|
+
class="flex flex-col gap-0.5 overflow-hidden data-[state=open]:animate-[collapsible-down_260ms_var(--ease-emphasized)] data-[state=closed]:animate-[collapsible-up_200ms_var(--ease-emphasized)] motion-reduce:animate-none"
|
|
144
|
+
>
|
|
67
145
|
<!-- Optional right-click menu per item; passthrough when item.menu is
|
|
68
146
|
absent or empty, so the item keeps the native context menu. -->
|
|
69
147
|
<LpContextMenu
|
|
@@ -113,6 +191,7 @@ const pillTransition = usePillTransition()
|
|
|
113
191
|
</slot>
|
|
114
192
|
</component>
|
|
115
193
|
</LpContextMenu>
|
|
116
|
-
|
|
194
|
+
</CollapsibleContent>
|
|
195
|
+
</CollapsibleRoot>
|
|
117
196
|
</LpScrollArea>
|
|
118
197
|
</template>
|
|
@@ -23,4 +23,22 @@ export interface SidebarSection {
|
|
|
23
23
|
/** Optional heading above the group. */
|
|
24
24
|
title?: string
|
|
25
25
|
items: SidebarItem[]
|
|
26
|
+
/**
|
|
27
|
+
* The heading folds the group away when clicked. Needs a `title` — there is
|
|
28
|
+
* nothing to click otherwise.
|
|
29
|
+
*
|
|
30
|
+
* A long nav is read by scanning headings, not items: past a certain length
|
|
31
|
+
* every item is equally far from the eye, and folding the groups that are
|
|
32
|
+
* not today's work restores that. Which groups start folded is the app's
|
|
33
|
+
* call (see `collapsedIds`), not this component's.
|
|
34
|
+
*/
|
|
35
|
+
collapsible?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Stable key for remembering this group's folded state. Defaults to `title`;
|
|
38
|
+
* set it when the title is translated, so the memory survives a language
|
|
39
|
+
* change.
|
|
40
|
+
*/
|
|
41
|
+
key?: string
|
|
42
|
+
/** Icon for the heading — shown when the group is collapsible. */
|
|
43
|
+
icon?: string
|
|
26
44
|
}
|