@leavepulse/ui 0.28.3 → 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.
Files changed (34) hide show
  1. package/dist/chunks/{LpAddressInput-BTqnUXZG.js → LpAddressInput-BoTkszLk.js} +3 -3
  2. package/dist/chunks/{LpAppShell-B5k0J3Cf.js → LpAppShell-CGX0DlIW.js} +11 -6
  3. package/dist/chunks/{LpCard-gu11ZmSt.js → LpCard-DSefTGEH.js} +7 -1
  4. package/dist/chunks/{LpFormField-CesaPWPO.js → LpFormField-DEaakcHe.js} +1 -1
  5. package/dist/chunks/{LpInput-jwqXlAy0.js → LpInput-BXr40nqX.js} +1 -1
  6. package/dist/chunks/{LpPasswordInput-Bmo6q9Kd.js → LpPasswordInput-Bdv6o8ae.js} +1 -1
  7. package/dist/chunks/{LpSelect-Cw8-gL5F.js → LpSelect-ANIVtrpM.js} +1 -1
  8. package/dist/chunks/{LpSidebar-7Vpy2IJc.js → LpSidebar-CWehDCxN.js} +34 -8
  9. package/dist/chunks/LpSidebarNav-5RGNr2D3.js +185 -0
  10. package/dist/chunks/{LpTextarea-1ENb3ZAO.js → LpTextarea-tc8N4UO_.js} +1 -1
  11. package/dist/components/LpAddressInput.vue.js +1 -1
  12. package/dist/components/LpAppShell.vue.d.ts +17 -11
  13. package/dist/components/LpAppShell.vue.js +1 -1
  14. package/dist/components/LpCard.vue.js +1 -1
  15. package/dist/components/LpFormField.vue.js +1 -1
  16. package/dist/components/LpInput.vue.js +1 -1
  17. package/dist/components/LpPasswordInput.vue.js +1 -1
  18. package/dist/components/LpSelect.vue.js +1 -1
  19. package/dist/components/LpSidebar.vue.d.ts +32 -10
  20. package/dist/components/LpSidebar.vue.js +1 -1
  21. package/dist/components/LpSidebarNav.vue.d.ts +11 -2
  22. package/dist/components/LpSidebarNav.vue.js +1 -1
  23. package/dist/components/LpTextarea.vue.js +1 -1
  24. package/dist/components/sidebar.d.ts +18 -0
  25. package/dist/index.js +9 -9
  26. package/package.json +1 -1
  27. package/src/components/LpAppShell.vue +27 -3
  28. package/src/components/LpCard.vue +7 -1
  29. package/src/components/LpFormField.vue +6 -2
  30. package/src/components/LpSelect.vue +0 -0
  31. package/src/components/LpSidebar.vue +67 -1
  32. package/src/components/LpSidebarNav.vue +83 -4
  33. package/src/components/sidebar.ts +18 -0
  34. package/dist/chunks/LpSidebarNav-CFX5NxGs.js +0 -126
@@ -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 __VLS_27: {
18
+ declare var __VLS_63: {
13
19
  item: SidebarItem;
14
20
  active: boolean;
15
21
  };
16
22
  type __VLS_Slots = {} & {
17
- item?: (props: typeof __VLS_27) => any;
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;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebarNav-CFX5NxGs.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebarNav-5RGNr2D3.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTextarea-1ENb3ZAO.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTextarea-tc8N4UO_.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -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,16 +1,16 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-BTqnUXZG.js";
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-B5k0J3Cf.js";
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-gu11ZmSt.js";
13
+ import { _ as _12 } from "./chunks/LpCard-DSefTGEH.js";
14
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";
@@ -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-CesaPWPO.js";
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-jwqXlAy0.js";
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-Bmo6q9Kd.js";
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-Cw8-gL5F.js";
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-7Vpy2IJc.js";
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-1ENb3ZAO.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.28.3",
3
+ "version": "0.28.4",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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
- <LpShift axis="width" class="ml-auto flex shrink-0 items-center gap-2">
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
- base: "rounded-card border bg-surface-raised break-words",
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
@@ -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
- <div class="flex flex-col gap-1.5">
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
- <div v-for="(section, si) in groups" :key="si" class="flex flex-col gap-0.5">
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
- </div>
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
  }
@@ -1,126 +0,0 @@
1
- import { defineComponent, openBlock, createElementBlock, createElementVNode, createCommentVNode, Fragment, renderList, createBlock, withCtx, toDisplayString, resolveDynamicComponent, normalizeClass, unref, renderSlot, createTextVNode } from "vue";
2
- import { Motion } from "motion-v";
3
- import { usePillTransition } from "../composables/usePillTransition.js";
4
- import { _ as _sfc_main$4 } from "./LpBadge-BPodVcJa.js";
5
- import { _ as _sfc_main$2 } from "./LpContextMenu-D0mzXqA7.js";
6
- import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
7
- import { _ as _sfc_main$1 } from "./LpScrollArea-D8xyKXcF.js";
8
- const _hoisted_1 = {
9
- key: 0,
10
- class: "flex min-h-0 flex-1 flex-col gap-4"
11
- };
12
- const _hoisted_2 = {
13
- key: 0,
14
- class: "flex items-center gap-3 px-1"
15
- };
16
- const _hoisted_3 = { class: "space-y-1" };
17
- const _hoisted_4 = {
18
- key: 0,
19
- class: "px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
20
- };
21
- const _hoisted_5 = { class: "relative z-10 min-w-0 flex-1 truncate text-left" };
22
- const _sfc_main = /* @__PURE__ */ defineComponent({
23
- __name: "LpSidebarNav",
24
- props: {
25
- groups: {},
26
- pillId: {},
27
- itemActive: {},
28
- loading: { type: Boolean },
29
- skeletonRows: { default: 6 },
30
- skeletonHeader: { type: Boolean, default: true }
31
- },
32
- emits: ["activate"],
33
- setup(__props, { emit: __emit }) {
34
- const emit = __emit;
35
- const pillTransition = usePillTransition();
36
- return (_ctx, _cache) => {
37
- return __props.loading ? (openBlock(), createElementBlock("div", _hoisted_1, [
38
- __props.skeletonHeader ? (openBlock(), createElementBlock("div", _hoisted_2, [..._cache[0] || (_cache[0] = [
39
- createElementVNode("div", { class: "size-10 shrink-0 animate-pulse rounded-pill bg-surface-soft" }, null, -1),
40
- createElementVNode("div", { class: "flex-1 space-y-2" }, [
41
- createElementVNode("div", { class: "h-3.5 w-24 animate-pulse rounded bg-surface-soft" }),
42
- createElementVNode("div", { class: "h-3 w-16 animate-pulse rounded bg-surface-soft/60" })
43
- ], -1)
44
- ])])) : createCommentVNode("", true),
45
- createElementVNode("div", _hoisted_3, [
46
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.skeletonRows, (n) => {
47
- return openBlock(), createElementBlock("div", {
48
- key: n,
49
- class: "h-9 animate-pulse rounded-control bg-surface-soft"
50
- });
51
- }), 128))
52
- ])
53
- ])) : (openBlock(), createBlock(_sfc_main$1, {
54
- key: 1,
55
- class: "min-h-0 flex-1",
56
- "content-class": "flex min-h-full flex-col gap-4"
57
- }, {
58
- default: withCtx(() => [
59
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.groups, (section, si) => {
60
- return openBlock(), createElementBlock("div", {
61
- key: si,
62
- class: "flex flex-col gap-0.5"
63
- }, [
64
- section.title ? (openBlock(), createElementBlock("p", _hoisted_4, toDisplayString(section.title), 1)) : createCommentVNode("", true),
65
- (openBlock(true), createElementBlock(Fragment, null, renderList(section.items, (item) => {
66
- return openBlock(), createBlock(_sfc_main$2, {
67
- key: item.id,
68
- items: item.menu ?? []
69
- }, {
70
- default: withCtx(() => [
71
- (openBlock(), createBlock(resolveDynamicComponent(item.href && !item.disabled ? "a" : "button"), {
72
- href: item.href && !item.disabled ? item.href : void 0,
73
- type: item.href ? void 0 : "button",
74
- disabled: item.href ? void 0 : item.disabled,
75
- "aria-current": __props.itemActive(item) ? "page" : void 0,
76
- "aria-disabled": item.disabled || void 0,
77
- class: normalizeClass(["group/item relative flex items-center gap-3 rounded-control px-3 py-2 text-sm font-medium outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", item.disabled ? "cursor-not-allowed text-muted/50" : __props.itemActive(item) ? "text-ink" : "text-muted hover:text-ink"]),
78
- onClick: ($event) => emit("activate", item)
79
- }, {
80
- default: withCtx(() => [
81
- __props.itemActive(item) ? (openBlock(), createBlock(unref(Motion), {
82
- key: 0,
83
- "layout-id": __props.pillId,
84
- transition: unref(pillTransition),
85
- class: "absolute inset-0 z-0 rounded-control bg-brand-soft"
86
- }, null, 8, ["layout-id", "transition"])) : createCommentVNode("", true),
87
- renderSlot(_ctx.$slots, "item", {
88
- item,
89
- active: __props.itemActive(item)
90
- }, () => [
91
- item.icon ? (openBlock(), createBlock(_sfc_main$3, {
92
- key: 0,
93
- name: item.icon,
94
- size: 17,
95
- class: normalizeClass(["relative z-10 shrink-0 transition-colors", __props.itemActive(item) ? "text-brand" : ""])
96
- }, null, 8, ["name", "class"])) : createCommentVNode("", true),
97
- createElementVNode("span", _hoisted_5, toDisplayString(item.label), 1),
98
- item.badge != null ? (openBlock(), createBlock(_sfc_main$4, {
99
- key: 1,
100
- tone: __props.itemActive(item) ? "brand" : "neutral",
101
- class: "relative z-10 shrink-0"
102
- }, {
103
- default: withCtx(() => [
104
- createTextVNode(toDisplayString(item.badge), 1)
105
- ]),
106
- _: 2
107
- }, 1032, ["tone"])) : createCommentVNode("", true)
108
- ])
109
- ]),
110
- _: 2
111
- }, 1032, ["href", "type", "disabled", "aria-current", "aria-disabled", "class", "onClick"]))
112
- ]),
113
- _: 2
114
- }, 1032, ["items"]);
115
- }), 128))
116
- ]);
117
- }), 128))
118
- ]),
119
- _: 3
120
- }));
121
- };
122
- }
123
- });
124
- export {
125
- _sfc_main as _
126
- };