@leavepulse/ui 0.34.0 → 0.36.0

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 (55) hide show
  1. package/dist/assets/{LpTable-Ckoi05Am.css → LpTable-DfSwPCJ-.css} +21 -8
  2. package/dist/chunks/{LpAddressInput-FZXfljWV.js → LpAddressInput-D4t8WFD-.js} +1 -1
  3. package/dist/chunks/{LpConfirmDialog-CQBVaLpo.js → LpConfirmDialog-CxbizvSB.js} +1 -1
  4. package/dist/chunks/LpModal-7LvuAA7U.js +224 -0
  5. package/dist/chunks/LpModalNav-DBMGlq_x.js +166 -0
  6. package/dist/component-names.d.ts +1 -1
  7. package/dist/components/LpActionBar.vue.d.ts +1 -1
  8. package/dist/components/LpAddressInput.vue.d.ts +3 -3
  9. package/dist/components/LpAddressInput.vue.js +1 -1
  10. package/dist/components/LpAppShell.vue.d.ts +4 -1
  11. package/dist/components/LpAutocomplete.vue.d.ts +1 -1
  12. package/dist/components/LpBadge.vue.d.ts +2 -2
  13. package/dist/components/LpCodeBlock.vue.d.ts +1 -1
  14. package/dist/components/LpColorPicker.vue.d.ts +9 -4
  15. package/dist/components/LpConfirmDialog.vue.js +1 -1
  16. package/dist/components/LpDropzone.vue.d.ts +4 -4
  17. package/dist/components/LpFileTree.vue.d.ts +4 -4
  18. package/dist/components/LpFormField.vue.d.ts +1 -1
  19. package/dist/components/LpLaneNode.vue.d.ts +11 -0
  20. package/dist/components/LpLightbox.vue.d.ts +2 -2
  21. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  22. package/dist/components/LpMapPicker.vue.d.ts +1 -1
  23. package/dist/components/LpMediaGallery.vue.d.ts +1 -1
  24. package/dist/components/LpModal.vue.d.ts +9 -5
  25. package/dist/components/LpModal.vue.js +1 -1
  26. package/dist/components/LpModalNav.vue.d.ts +63 -0
  27. package/dist/components/LpModalNav.vue.js +4 -0
  28. package/dist/components/LpPie.vue.d.ts +22 -4
  29. package/dist/components/LpPopover.vue.d.ts +1 -1
  30. package/dist/components/LpProgress.vue.d.ts +2 -2
  31. package/dist/components/LpRollingText.vue.d.ts +3 -3
  32. package/dist/components/LpSelect.vue.d.ts +6 -6
  33. package/dist/components/LpShift.vue.d.ts +1 -1
  34. package/dist/components/LpShortcutsDialog.vue.d.ts +1 -1
  35. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  36. package/dist/components/LpSidebar.vue.d.ts +7 -1
  37. package/dist/components/LpSidebarNav.vue.d.ts +2 -2
  38. package/dist/components/LpSlider.vue.d.ts +2 -2
  39. package/dist/components/LpSparkline.vue.d.ts +2 -2
  40. package/dist/components/LpTable.vue.d.ts +9 -9
  41. package/dist/components/LpTable.vue.js +7 -4
  42. package/dist/components/LpTabs.vue.d.ts +1 -1
  43. package/dist/components/LpThemeSwitcher.vue.d.ts +1 -1
  44. package/dist/components/LpTilt.vue.d.ts +1 -1
  45. package/dist/components/LpTooltip.vue.d.ts +1 -1
  46. package/dist/components/LpTopologyCanvas.vue.d.ts +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +75 -73
  49. package/package.json +1 -1
  50. package/src/component-names.ts +1 -0
  51. package/src/components/LpModal.vue +69 -1
  52. package/src/components/LpModalNav.vue +202 -0
  53. package/src/components/LpTable.vue +22 -2
  54. package/src/index.ts +1 -0
  55. package/dist/chunks/LpModal-DAqXzsm-.js +0 -192
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-FZXfljWV.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-D4t8WFD-.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
@@ -19,7 +19,7 @@ import { _ as _15 } from "./chunks/LpCodeBlock-Bo0B4TFR.js";
19
19
  import { _ as _16 } from "./chunks/LpColorPicker-CFC6iaNK.js";
20
20
  import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
21
21
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
- import { _ as _17 } from "./chunks/LpConfirmDialog-CQBVaLpo.js";
22
+ import { _ as _17 } from "./chunks/LpConfirmDialog-CxbizvSB.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-6JFXllj5.js";
25
25
  import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
@@ -40,46 +40,47 @@ import { fileNameOf } from "./components/lightbox.js";
40
40
  import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-y53ZqSVq.js";
42
42
  import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
- import { _ as _34 } from "./chunks/LpModal-DAqXzsm-.js";
44
- import { _ as _35 } from "./chunks/LpNotificationBell-lHePf89W.js";
45
- import { _ as _36 } from "./chunks/LpNumberField-CWuX08iX.js";
46
- import { _ as _37 } from "./chunks/LpNumberFlow-amWYaeuA.js";
47
- import { _ as _38 } from "./chunks/LpOtpInput-y2v-pmiN.js";
48
- import { _ as _39 } from "./chunks/LpPagination-5FxjmmB5.js";
49
- import { _ as _40 } from "./chunks/LpPasswordInput--sjYCkLS.js";
50
- import { _ as _41 } from "./chunks/LpPie-c2-q4DJu.js";
51
- import { _ as _42 } from "./chunks/LpPhoneInput-BDSKVl_z.js";
43
+ import { _ as _34 } from "./chunks/LpModal-7LvuAA7U.js";
44
+ import { _ as _35 } from "./chunks/LpModalNav-DBMGlq_x.js";
45
+ import { _ as _36 } from "./chunks/LpNotificationBell-lHePf89W.js";
46
+ import { _ as _37 } from "./chunks/LpNumberField-CWuX08iX.js";
47
+ import { _ as _38 } from "./chunks/LpNumberFlow-amWYaeuA.js";
48
+ import { _ as _39 } from "./chunks/LpOtpInput-y2v-pmiN.js";
49
+ import { _ as _40 } from "./chunks/LpPagination-5FxjmmB5.js";
50
+ import { _ as _41 } from "./chunks/LpPasswordInput--sjYCkLS.js";
51
+ import { _ as _42 } from "./chunks/LpPie-c2-q4DJu.js";
52
+ import { _ as _43 } from "./chunks/LpPhoneInput-BDSKVl_z.js";
52
53
  import { flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
53
- import { _ as _43 } from "./chunks/LpPopover-8268UmlC.js";
54
- import { _ as _44 } from "./chunks/LpProgress-Btq5W4Ut.js";
55
- import { _ as _45 } from "./chunks/LpRadio-B5AfqTpZ.js";
56
- import { _ as _46 } from "./chunks/LpRadioGroup-xPIuVDO_.js";
57
- import { _ as _47 } from "./chunks/LpRollingText-CM5gkSeF.js";
58
- import { _ as _48 } from "./chunks/LpScrollArea-D8xyKXcF.js";
59
- import { _ as _49 } from "./chunks/LpSegmented-fV0Sk92p.js";
60
- import { _ as _50 } from "./chunks/LpSection-aM6J_u51.js";
61
- import { _ as _51 } from "./chunks/LpSelect-ANIVtrpM.js";
62
- import { _ as _52 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
63
- import { _ as _53 } from "./chunks/LpShift-DXNsauQZ.js";
54
+ import { _ as _44 } from "./chunks/LpPopover-8268UmlC.js";
55
+ import { _ as _45 } from "./chunks/LpProgress-Btq5W4Ut.js";
56
+ import { _ as _46 } from "./chunks/LpRadio-B5AfqTpZ.js";
57
+ import { _ as _47 } from "./chunks/LpRadioGroup-xPIuVDO_.js";
58
+ import { _ as _48 } from "./chunks/LpRollingText-CM5gkSeF.js";
59
+ import { _ as _49 } from "./chunks/LpScrollArea-D8xyKXcF.js";
60
+ import { _ as _50 } from "./chunks/LpSegmented-fV0Sk92p.js";
61
+ import { _ as _51 } from "./chunks/LpSection-aM6J_u51.js";
62
+ import { _ as _52 } from "./chunks/LpSelect-ANIVtrpM.js";
63
+ import { _ as _53 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
64
+ import { _ as _54 } from "./chunks/LpShift-DXNsauQZ.js";
64
65
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
65
- import { _ as _54 } from "./chunks/LpSidebar-DcYavim2.js";
66
- import { _ as _55 } from "./chunks/LpSkeleton-CTI-vBgW.js";
67
- import { _ as _56 } from "./chunks/LpSlider-BwNIBkAK.js";
68
- import { _ as _57 } from "./chunks/LpSparkline-C6xQ2dwv.js";
69
- import { _ as _58 } from "./chunks/LpStat-CQaW8R--.js";
70
- import { _ as _59 } from "./chunks/LpStepper-Bk-XaQYU.js";
71
- import { _ as _60 } from "./chunks/LpSwitch-xgnRU2k0.js";
66
+ import { _ as _55 } from "./chunks/LpSidebar-DcYavim2.js";
67
+ import { _ as _56 } from "./chunks/LpSkeleton-CTI-vBgW.js";
68
+ import { _ as _57 } from "./chunks/LpSlider-BwNIBkAK.js";
69
+ import { _ as _58 } from "./chunks/LpSparkline-C6xQ2dwv.js";
70
+ import { _ as _59 } from "./chunks/LpStat-CQaW8R--.js";
71
+ import { _ as _60 } from "./chunks/LpStepper-Bk-XaQYU.js";
72
+ import { _ as _61 } from "./chunks/LpSwitch-xgnRU2k0.js";
72
73
  import { default as default7 } from "./components/LpTable.vue.js";
73
- import { _ as _61 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
74
- import { _ as _62 } from "./chunks/LpTabs-Cefe_lys.js";
75
- import { _ as _63 } from "./chunks/LpTextarea-t7F6gv8E.js";
74
+ import { _ as _62 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
75
+ import { _ as _63 } from "./chunks/LpTabs-Cefe_lys.js";
76
+ import { _ as _64 } from "./chunks/LpTextarea-t7F6gv8E.js";
76
77
  import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpThemeSwitcher-r1te9nB2.js";
77
- import { D, _ as _64, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-r1te9nB2.js";
78
- import { _ as _65 } from "./chunks/LpTilt-DuyKbpkA.js";
79
- import { _ as _66 } from "./chunks/LpToaster-gDB1EaU3.js";
80
- import { _ as _67 } from "./chunks/LpTooltip-hCLPAqMG.js";
81
- import { _ as _68 } from "./chunks/LpTopologyCanvas-Cm1L39uL.js";
82
- import { _ as _69 } from "./chunks/LpUptimeBar-DvAMno0S.js";
78
+ import { D, _ as _65, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-r1te9nB2.js";
79
+ import { _ as _66 } from "./chunks/LpTilt-DuyKbpkA.js";
80
+ import { _ as _67 } from "./chunks/LpToaster-gDB1EaU3.js";
81
+ import { _ as _68 } from "./chunks/LpTooltip-hCLPAqMG.js";
82
+ import { _ as _69 } from "./chunks/LpTopologyCanvas-Cm1L39uL.js";
83
+ import { _ as _70 } from "./chunks/LpUptimeBar-DvAMno0S.js";
83
84
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
84
85
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
85
86
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -169,43 +170,44 @@ export {
169
170
  _3 as LpMapPicker,
170
171
  _33 as LpMediaGallery,
171
172
  _34 as LpModal,
172
- _35 as LpNotificationBell,
173
- _36 as LpNumberField,
174
- _37 as LpNumberFlow,
175
- _38 as LpOtpInput,
176
- _39 as LpPagination,
177
- _40 as LpPasswordInput,
178
- _42 as LpPhoneInput,
179
- _41 as LpPie,
180
- _43 as LpPopover,
181
- _44 as LpProgress,
182
- _45 as LpRadio,
183
- _46 as LpRadioGroup,
184
- _47 as LpRollingText,
185
- _48 as LpScrollArea,
186
- _50 as LpSection,
187
- _49 as LpSegmented,
188
- _51 as LpSelect,
189
- _52 as LpServiceNode,
190
- _53 as LpShift,
173
+ _35 as LpModalNav,
174
+ _36 as LpNotificationBell,
175
+ _37 as LpNumberField,
176
+ _38 as LpNumberFlow,
177
+ _39 as LpOtpInput,
178
+ _40 as LpPagination,
179
+ _41 as LpPasswordInput,
180
+ _43 as LpPhoneInput,
181
+ _42 as LpPie,
182
+ _44 as LpPopover,
183
+ _45 as LpProgress,
184
+ _46 as LpRadio,
185
+ _47 as LpRadioGroup,
186
+ _48 as LpRollingText,
187
+ _49 as LpScrollArea,
188
+ _51 as LpSection,
189
+ _50 as LpSegmented,
190
+ _52 as LpSelect,
191
+ _53 as LpServiceNode,
192
+ _54 as LpShift,
191
193
  default6 as LpShortcutsDialog,
192
- _54 as LpSidebar,
193
- _55 as LpSkeleton,
194
- _56 as LpSlider,
195
- _57 as LpSparkline,
196
- _58 as LpStat,
197
- _59 as LpStepper,
198
- _60 as LpSwitch,
194
+ _55 as LpSidebar,
195
+ _56 as LpSkeleton,
196
+ _57 as LpSlider,
197
+ _58 as LpSparkline,
198
+ _59 as LpStat,
199
+ _60 as LpStepper,
200
+ _61 as LpSwitch,
199
201
  default7 as LpTable,
200
- _61 as LpTableOfContents,
201
- _62 as LpTabs,
202
- _63 as LpTextarea,
203
- _64 as LpThemeSwitcher,
204
- _65 as LpTilt,
205
- _66 as LpToaster,
206
- _67 as LpTooltip,
207
- _68 as LpTopologyCanvas,
208
- _69 as LpUptimeBar,
202
+ _62 as LpTableOfContents,
203
+ _63 as LpTabs,
204
+ _64 as LpTextarea,
205
+ _65 as LpThemeSwitcher,
206
+ _66 as LpTilt,
207
+ _67 as LpToaster,
208
+ _68 as LpTooltip,
209
+ _69 as LpTopologyCanvas,
210
+ _70 as LpUptimeBar,
209
211
  N as NO_BACKDROP_ATTR,
210
212
  OSM_TILES,
211
213
  T as THEME_COOKIE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -43,6 +43,7 @@ export const COMPONENT_NAMES = [
43
43
  "LpMapPicker",
44
44
  "LpMediaGallery",
45
45
  "LpModal",
46
+ "LpModalNav",
46
47
  "LpNotificationBell",
47
48
  "LpNumberField",
48
49
  "LpNumberFlow",
@@ -72,6 +72,20 @@ defineEmits<{ (e: "update:open", value: boolean): void }>()
72
72
  // Spread rather than a plain `:aria-describedby` binding: with a description
73
73
  // the key must be absent entirely so reka-ui's own generated id survives, and
74
74
  // only the description-less case overrides it away.
75
+ /*
76
+ * A click on the aside lands outside DialogContent, and reka reads anything
77
+ * outside it as a click on the scrim — the dialog closed under the pointer the
78
+ * moment you picked a section. The aside marks itself, and a click that starts
79
+ * inside one keeps the dialog open. Without an aside nothing carries the
80
+ * attribute and every click behaves exactly as before.
81
+ */
82
+ const onInteractOutside = (event: Event) => {
83
+ const target = event.target
84
+ if (target instanceof Element && target.closest("[data-lp-modal-aside]")) {
85
+ event.preventDefault()
86
+ }
87
+ }
88
+
75
89
  const describedByAttrs = computed(() =>
76
90
  props.description ? {} : { "aria-describedby": undefined },
77
91
  )
@@ -93,6 +107,9 @@ const widthClass = computed(() => {
93
107
  // inset horizontally; top only when there's no header, bottom only when there's
94
108
  // no footer (header/footer own those edges).
95
109
  const slots = useSlots()
110
+
111
+ // Dialog and aside only form a pair when something fills the slot.
112
+ const hasAside = computed(() => Boolean(slots.aside || slots.asideEnd))
96
113
  // The body clips overflow and focus rings are drawn outside their control, so a
97
114
  // field flush against the top edge lost its ring; pt-1 clears the ring's width.
98
115
  /*
@@ -225,10 +242,50 @@ watch(
225
242
  this wrapper: `max-h-full` on a centred flex child resolves against a
226
243
  box it is free to overflow, so a tall body stopped handing its
227
244
  overflow to the scroll area below and simply ran off-screen. -->
245
+ <!-- A companion panel standing OUTSIDE the dialog, in the same centring
246
+ row: a section list, a preview, a picker. Inside, it would take the
247
+ width the body already wants; measured against the viewport it would
248
+ have to be re-measured on every resize.
249
+
250
+ The row keeps `items-center` until something fills the slot, so a
251
+ dialog without one centres exactly as it always has. With an aside
252
+ the two align to the top of the row instead, and because the row
253
+ collapses to its content the taller of the pair sets that top edge —
254
+ so the aside may be shorter than the dialog, and the dialog is never
255
+ shorter than the aside. Neither is stretched: `stretch` here would
256
+ pull every dialog to the full height of its own ceiling.
257
+
258
+ Height, width, the gap to the dialog and whether it appears at all
259
+ are the caller's, and deliberately so: a utility class written in
260
+ this file is not in the consuming app's Tailwind scan and would never
261
+ be generated. The gap in particular has to go with the aside — kept
262
+ here, it survives an aside hidden at a breakpoint this file knows
263
+ nothing about, and pushes the dialog off centre by its own width. -->
228
264
  <div
229
265
  class="fixed inset-0 flex items-center justify-center pointer-events-none"
230
266
  :style="{ zIndex: layer.panel }"
231
267
  >
268
+ <!-- The dialog and its aside stand in a row of their own, and that row is
269
+ what the wrapper centres. Aligning them on the full-screen wrapper
270
+ instead would pin them to the top of the WINDOW, and the dialog would
271
+ be free to end up shorter than the aside beside it.
272
+ `items-start` inside the row: the row collapses to the taller of the
273
+ two, both hang from its top edge, and neither is stretched. -->
274
+ <div
275
+ class="flex items-start"
276
+ :style="hasAside ? { maxHeight: 'min(90vh, calc(100dvh - 2rem))' } : undefined"
277
+ >
278
+ <!-- `pointer-events-auto` because the wrapper lets clicks through to the
279
+ overlay, and the aside is not the overlay. `data-lp-modal-aside` is
280
+ what tells `interact-outside` a click here is not a click on the
281
+ scrim — without it the dialog closes under the pointer. -->
282
+ <aside
283
+ v-if="$slots.aside"
284
+ data-lp-modal-aside
285
+ class="pointer-events-auto flex min-h-0 flex-col"
286
+ >
287
+ <slot name="aside" />
288
+ </aside>
232
289
  <!-- Without a description reka-ui warns on every open, and the opt-out it
233
290
  checks for is an ABSENT `aria-describedby` — despite the message
234
291
  naming the string "undefined" (a leftover from Radix, where
@@ -240,9 +297,11 @@ watch(
240
297
  ref="panelRef"
241
298
  class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
242
299
  :class="[widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
243
- :style="width ? { width } : undefined"
300
+ :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
244
301
  v-bind="describedByAttrs"
245
302
  @open-auto-focus="onOpenAutoFocus"
303
+ @interact-outside="onInteractOutside"
304
+ @pointer-down-outside="onInteractOutside"
246
305
  >
247
306
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
248
307
  <div class="flex flex-col gap-1">
@@ -294,6 +353,15 @@ watch(
294
353
  <slot name="footer" />
295
354
  </footer>
296
355
  </DialogContent>
356
+
357
+ <aside
358
+ v-if="$slots.asideEnd"
359
+ data-lp-modal-aside
360
+ class="pointer-events-auto flex min-h-0 flex-col"
361
+ >
362
+ <slot name="asideEnd" />
363
+ </aside>
364
+ </div>
297
365
  </div>
298
366
  </DialogPortal>
299
367
  </DialogRoot>
@@ -0,0 +1,202 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A dialog whose sections stand BESIDE it rather than inside it — settings, a
4
+ * profile with tabs' worth of detail, a wizard you can skip around in. Put the
5
+ * list inside the panel and it takes the width the content already wants; a row
6
+ * of tabs across the top runs out of room at about five.
7
+ *
8
+ * This is LpModal's `aside` slot plus LpSidebar, wired together: the pair
9
+ * shares one centring row, so the list lines up with the dialog at any window
10
+ * width and neither has to measure the other. What is added on top is the part
11
+ * that only matters here — below `mobileBreakpoint` there is no room beside the
12
+ * dialog at all, so the rail folds into a drawer and the header grows the
13
+ * button that opens it. LpSidebar leaves that button to the app, and without
14
+ * one the sections are simply unreachable on a phone.
15
+ */
16
+ import { computed, ref } from "vue"
17
+ import LpButton from "./LpButton.vue"
18
+ import LpIcon from "./LpIcon.vue"
19
+ import LpModal from "./LpModal.vue"
20
+ import LpSidebar from "./LpSidebar.vue"
21
+ import type { SidebarItem, SidebarSection } from "./sidebar"
22
+
23
+ export type { SidebarItem, SidebarSection } from "./sidebar"
24
+
25
+ type Breakpoint = "sm" | "md" | "lg" | "xl"
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ open?: boolean
30
+ /** Active section id (v-model). */
31
+ modelValue?: string
32
+ /** Flat sections, or grouped ones — the shapes LpSidebar already takes. */
33
+ items?: SidebarItem[]
34
+ sections?: SidebarSection[]
35
+ title?: string
36
+ description?: string
37
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
38
+ width?: string
39
+ /** Width of the nav rail (any CSS length). */
40
+ navWidth?: string
41
+ /** Put a filter above the nav — see LpSidebar's `searchable`. */
42
+ searchable?: boolean
43
+ searchPlaceholder?: string
44
+ /** Skeleton rows in place of the nav while the sections load. */
45
+ loading?: boolean
46
+ /** Breakpoint below which the rail becomes a drawer. */
47
+ mobileBreakpoint?: Breakpoint
48
+ /** Label for the button that opens that drawer. */
49
+ navLabel?: string
50
+ /** Stand the rail to the right of the dialog instead of the left. */
51
+ navSide?: "start" | "end"
52
+ /**
53
+ * Let the body fill the height instead of scrolling itself. Off by default,
54
+ * as in a plain modal: it existed here to make the rail a full-height
55
+ * column inside the body, and the rail is no longer in the body.
56
+ */
57
+ fillBody?: boolean
58
+ }>(),
59
+ {
60
+ size: "2xl",
61
+ navWidth: "13rem",
62
+ mobileBreakpoint: "lg",
63
+ navLabel: "Sections",
64
+ navSide: "start",
65
+ },
66
+ )
67
+
68
+ const emit = defineEmits<{
69
+ (e: "update:open", value: boolean): void
70
+ (e: "update:modelValue", value: string): void
71
+ }>()
72
+
73
+ const navOpen = ref(false)
74
+
75
+ const active = computed({
76
+ get: () => props.modelValue ?? "",
77
+ set: (v: string) => emit("update:modelValue", v),
78
+ })
79
+
80
+ const hasNav = computed(() => Boolean(props.items?.length || props.sections?.length))
81
+
82
+ /*
83
+ * Both halves of the responsive switch are spelled out rather than built from
84
+ * the prop: a class assembled at runtime is in nobody's Tailwind scan and would
85
+ * never be generated. The rail hides exactly where the button appears.
86
+ *
87
+ * `responsive` on LpSidebar hides its own rail below the breakpoint and draws
88
+ * the drawer as a SIBLING of it, so wrapping the whole thing in a `hidden`
89
+ * element takes the drawer with it — and the sections are then unreachable at
90
+ * exactly the width where the drawer is the only way to them. What is left here
91
+ * is the gap, which has to disappear along with the rail: an aside that is
92
+ * empty but still spaced pushes the dialog off centre.
93
+ */
94
+ const RAIL_GAP: Record<Breakpoint, string> = {
95
+ sm: "sm:mr-3",
96
+ md: "md:mr-3",
97
+ lg: "lg:mr-3",
98
+ xl: "xl:mr-3",
99
+ }
100
+ const RAIL_GAP_END: Record<Breakpoint, string> = {
101
+ sm: "sm:ml-3",
102
+ md: "md:ml-3",
103
+ lg: "lg:ml-3",
104
+ xl: "xl:ml-3",
105
+ }
106
+ const TRIGGER_VISIBILITY: Record<Breakpoint, string> = {
107
+ sm: "sm:hidden",
108
+ md: "md:hidden",
109
+ lg: "lg:hidden",
110
+ xl: "xl:hidden",
111
+ }
112
+ const railClass = computed(() =>
113
+ (props.navSide === "end" ? RAIL_GAP_END : RAIL_GAP)[props.mobileBreakpoint],
114
+ )
115
+ const triggerClass = computed(() => TRIGGER_VISIBILITY[props.mobileBreakpoint])
116
+
117
+ /*
118
+ * The rail carries the dialog's own ceiling, so a long list scrolls with the
119
+ * dialog instead of running past the bottom of the window. Inline because it
120
+ * mirrors LpModal's max-height, which is not a class this file can put in the
121
+ * consuming app's scan.
122
+ */
123
+ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
124
+ </script>
125
+
126
+ <template>
127
+ <LpModal
128
+ :open="open"
129
+ :title="title"
130
+ :description="description"
131
+ :size="size"
132
+ :width="width"
133
+ :fill-body="fillBody"
134
+ @update:open="emit('update:open', $event)"
135
+ >
136
+ <!-- The gap sits on a wrapper rather than on LpSidebar itself: LpSidebar
137
+ splits its `class` between its root and the inner `nav`, so a margin
138
+ given to it lands on both. -->
139
+ <template v-if="hasNav && navSide === 'start'" #aside>
140
+ <div :class="railClass">
141
+ <LpSidebar
142
+ v-model="active"
143
+ v-model:open="navOpen"
144
+ variant="panel"
145
+ :items="items"
146
+ :sections="sections"
147
+ :width="navWidth"
148
+ :searchable="searchable"
149
+ :search-placeholder="searchPlaceholder"
150
+ :loading="loading"
151
+ responsive
152
+ :mobile-breakpoint="mobileBreakpoint"
153
+ :style="railStyle"
154
+ class="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
155
+ />
156
+ </div>
157
+ </template>
158
+
159
+ <template v-if="hasNav && navSide === 'end'" #asideEnd>
160
+ <div :class="railClass">
161
+ <LpSidebar
162
+ v-model="active"
163
+ v-model:open="navOpen"
164
+ variant="panel"
165
+ :items="items"
166
+ :sections="sections"
167
+ :width="navWidth"
168
+ :searchable="searchable"
169
+ :search-placeholder="searchPlaceholder"
170
+ :loading="loading"
171
+ responsive
172
+ :mobile-breakpoint="mobileBreakpoint"
173
+ :style="railStyle"
174
+ class="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
175
+ />
176
+ </div>
177
+ </template>
178
+
179
+ <template #title>
180
+ <div class="flex items-center gap-2">
181
+ <LpButton
182
+ v-if="hasNav"
183
+ variant="ghost"
184
+ size="sm"
185
+ square
186
+ :aria-label="navLabel"
187
+ :class="triggerClass"
188
+ @click="navOpen = true"
189
+ >
190
+ <LpIcon name="lucide:menu" :size="18" />
191
+ </LpButton>
192
+ <slot name="title">{{ title }}</slot>
193
+ </div>
194
+ </template>
195
+
196
+ <slot />
197
+
198
+ <template v-if="$slots.footer" #footer>
199
+ <slot name="footer" />
200
+ </template>
201
+ </LpModal>
202
+ </template>
@@ -451,7 +451,14 @@ const barInsetTop = computed(() =>
451
451
  this is the fallback rather than a competitor. -->
452
452
  <LpContextMenu :items="contextMenu" always>
453
453
  <table class="w-full border-collapse text-sm">
454
- <thead ref="headEl" :class="stickyHeader ? 'sticky top-0 z-10' : ''">
454
+ <!-- The background belongs on `thead` itself, not only on its row:
455
+ `border-collapse` collapses the borders, and with a sticky header the
456
+ rows show through that seam — a band of body text rides across the
457
+ header on every scroll. -->
458
+ <thead
459
+ ref="headEl"
460
+ :class="stickyHeader ? 'sticky top-0 z-10 bg-surface-soft' : ''"
461
+ >
455
462
  <tr class="border-b border-line bg-surface-soft">
456
463
  <th v-if="selectable" class="w-px px-4 py-3">
457
464
  <LpCheckbox
@@ -522,7 +529,7 @@ const barInsetTop = computed(() =>
522
529
  name="lp-row"
523
530
  :ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
524
531
  >
525
- <tr v-if="displayRows.length === 0" key="lp-table-empty">
532
+ <tr v-if="displayRows.length === 0" key="lp-table-empty" class="lp-row-empty">
526
533
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
527
534
  <LpIcon :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
528
535
  <div>{{ emptyLabel }}</div>
@@ -594,6 +601,19 @@ const barInsetTop = computed(() =>
594
601
  transform: translateX(-0.5rem);
595
602
  }
596
603
 
604
+ /*
605
+ * The empty-state row leaves at once, without the fade. It and the first real
606
+ * rows would otherwise share the table for the length of the animation, and a
607
+ * row of blank height sits above them — the table jumps once on every load.
608
+ *
609
+ * `position: absolute` is what a list would use here, but a positioned `<tr>`
610
+ * drops out of the table layout entirely. Nothing sits below this row to be
611
+ * dragged around, so simply not animating its exit costs nothing.
612
+ */
613
+ .lp-row-empty.lp-row-leave-active {
614
+ display: none;
615
+ }
616
+
597
617
  /*
598
618
  * The leaving row stays in the flow. Taking it out with `position: absolute` is
599
619
  * the usual trick for a list, but a `<tr>` positioned that way loses the table's
package/src/index.ts CHANGED
@@ -105,6 +105,7 @@ export { default as LpLogViewer } from "./components/LpLogViewer.vue"
105
105
  export type { LogLevel, LogLine } from "./components/LpLogViewer.vue"
106
106
  export { default as LpMediaGallery } from "./components/LpMediaGallery.vue"
107
107
  export { default as LpModal } from "./components/LpModal.vue"
108
+ export { default as LpModalNav } from "./components/LpModalNav.vue"
108
109
  export { default as LpNotificationBell } from "./components/LpNotificationBell.vue"
109
110
  export type {
110
111
  NotificationItem,