@leavepulse/ui 0.19.0 → 0.19.2

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.
@@ -6,7 +6,7 @@ import { _ as _sfc_main$3 } from "./LpButton-5VXXCjwF.js";
6
6
  import { _ as _sfc_main$1 } from "./LpFormField-Bn-ZwC6z.js";
7
7
  import { _ as _sfc_main$4 } from "./LpIcon-CCnX5_2j.js";
8
8
  import { _ as _sfc_main$5 } from "./LpInput-BauZRyzm.js";
9
- import { _ as _sfc_main$7 } from "./LpModal-BJTiTSDJ.js";
9
+ import { _ as _sfc_main$7 } from "./LpModal-g2dGpF7l.js";
10
10
  import { _ as _sfc_main$6 } from "./LpSelect-D5mU4cZO.js";
11
11
  const _hoisted_1 = { class: "flex flex-col gap-3" };
12
12
  const _hoisted_2 = { class: "flex items-stretch gap-2" };
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, openBlock, createBlock, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
- import { _ as _sfc_main$1 } from "./LpModal-BJTiTSDJ.js";
3
+ import { _ as _sfc_main$1 } from "./LpModal-g2dGpF7l.js";
4
4
  const _sfc_main = /* @__PURE__ */ defineComponent({
5
5
  __name: "LpConfirmDialog",
6
6
  props: {
@@ -1,4 +1,4 @@
1
- import { defineComponent, computed, useSlots, openBlock, createBlock, unref, withCtx, createVNode, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
1
+ import { defineComponent, computed, useSlots, ref, watch, onBeforeUnmount, openBlock, createBlock, unref, withCtx, createVNode, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
3
3
  import { CLOSE_ICON } from "../components/dropdown.js";
4
4
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
@@ -13,6 +13,7 @@ const _hoisted_4 = {
13
13
  key: 3,
14
14
  class: "flex shrink-0 justify-end gap-2 p-5 pt-4"
15
15
  };
16
+ const TWEEN_MS = 160;
16
17
  const _sfc_main = /* @__PURE__ */ defineComponent({
17
18
  __name: "LpModal",
18
19
  props: {
@@ -49,6 +50,73 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
49
50
  slots.footer ? "" : "pb-5"
50
51
  ].filter(Boolean).join(" ")
51
52
  );
53
+ const panelRef = ref(null);
54
+ const tweening = ref(false);
55
+ const resizing = ref(false);
56
+ let ro;
57
+ let mo;
58
+ let raf = 0;
59
+ let settleTimer;
60
+ const retune = (el) => {
61
+ const from = el.offsetHeight;
62
+ el.style.height = "";
63
+ el.style.maxHeight = "none";
64
+ const to = el.offsetHeight;
65
+ el.style.maxHeight = "";
66
+ if (to === 0 || to === from) return;
67
+ el.style.height = `${from}px`;
68
+ void el.offsetHeight;
69
+ el.style.height = `${to}px`;
70
+ resizing.value = true;
71
+ clearTimeout(settleTimer);
72
+ settleTimer = setTimeout(() => {
73
+ resizing.value = false;
74
+ el.style.height = "";
75
+ }, TWEEN_MS);
76
+ };
77
+ watch(
78
+ () => props.open,
79
+ (isOpen) => {
80
+ ro?.disconnect();
81
+ mo?.disconnect();
82
+ ro = void 0;
83
+ mo = void 0;
84
+ cancelAnimationFrame(raf);
85
+ clearTimeout(settleTimer);
86
+ tweening.value = false;
87
+ resizing.value = false;
88
+ const el = panelRef.value?.$el;
89
+ if (!isOpen || !(el instanceof HTMLElement)) return;
90
+ el.style.height = `${el.offsetHeight}px`;
91
+ raf = requestAnimationFrame(() => {
92
+ tweening.value = true;
93
+ el.style.height = "";
94
+ });
95
+ const watchChildren = () => {
96
+ if (!ro) return;
97
+ ro.disconnect();
98
+ for (const child of Array.from(el.children)) ro.observe(child);
99
+ };
100
+ if (typeof ResizeObserver !== "undefined") {
101
+ ro = new ResizeObserver(() => retune(el));
102
+ watchChildren();
103
+ }
104
+ if (typeof MutationObserver !== "undefined") {
105
+ mo = new MutationObserver(() => {
106
+ watchChildren();
107
+ retune(el);
108
+ });
109
+ mo.observe(el, { childList: true, subtree: true, characterData: true });
110
+ }
111
+ },
112
+ { flush: "post" }
113
+ );
114
+ onBeforeUnmount(() => {
115
+ ro?.disconnect();
116
+ mo?.disconnect();
117
+ cancelAnimationFrame(raf);
118
+ clearTimeout(settleTimer);
119
+ });
52
120
  return (_ctx, _cache) => {
53
121
  return openBlock(), createBlock(unref(DialogRoot), {
54
122
  open: __props.open,
@@ -60,7 +128,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
60
128
  createVNode(unref(DialogOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]" }),
61
129
  createElementVNode("div", _hoisted_1, [
62
130
  createVNode(unref(DialogContent), mergeProps({
63
- class: ["pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col 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)]", widthClass.value],
131
+ ref_key: "panelRef",
132
+ ref: panelRef,
133
+ 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)]", [widthClass.value, tweening.value ? "transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none" : ""]],
64
134
  style: __props.width ? { width: __props.width } : void 0
65
135
  }, describedByAttrs.value), {
66
136
  default: withCtx(() => [
@@ -105,14 +175,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
105
175
  renderSlot(_ctx.$slots, "default")
106
176
  ], 2)) : (openBlock(), createBlock(_sfc_main$2, {
107
177
  key: 2,
108
- class: "min-h-0 flex-1 text-sm text-ink/90",
178
+ class: normalizeClass(["min-h-0 flex-1 text-sm text-ink/90", resizing.value ? "[&_[data-scrollbarimpl]]:invisible" : ""]),
109
179
  "content-class": bodyPad.value
110
180
  }, {
111
181
  default: withCtx(() => [
112
182
  renderSlot(_ctx.$slots, "default")
113
183
  ]),
114
184
  _: 3
115
- }, 8, ["content-class"])),
185
+ }, 8, ["class", "content-class"])),
116
186
  _ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_4, [
117
187
  renderSlot(_ctx.$slots, "footer")
118
188
  ])) : createCommentVNode("", true)
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAddressInput-CWlfDyKz.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAddressInput-CCtskgfZ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-JNW4rpx3.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-Z9hsfm2D.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -16,15 +16,15 @@ type __VLS_Props = {
16
16
  */
17
17
  fillBody?: boolean;
18
18
  };
19
- declare var __VLS_33: {}, __VLS_52: {}, __VLS_60: {}, __VLS_62: {};
19
+ declare var __VLS_35: {}, __VLS_54: {}, __VLS_62: {}, __VLS_64: {};
20
20
  type __VLS_Slots = {} & {
21
- title?: (props: typeof __VLS_33) => any;
21
+ title?: (props: typeof __VLS_35) => any;
22
22
  } & {
23
- default?: (props: typeof __VLS_52) => any;
23
+ default?: (props: typeof __VLS_54) => any;
24
24
  } & {
25
- default?: (props: typeof __VLS_60) => any;
25
+ default?: (props: typeof __VLS_62) => any;
26
26
  } & {
27
- footer?: (props: typeof __VLS_62) => any;
27
+ footer?: (props: typeof __VLS_64) => any;
28
28
  };
29
29
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
30
30
  "update:open": (value: boolean) => any;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-BJTiTSDJ.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-g2dGpF7l.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -62,10 +62,11 @@ function fileIcon(node, expanded = false) {
62
62
  const ext = dot > 0 ? lower.slice(dot + 1) : "";
63
63
  return EXT_ICONS[ext] ?? "lucide:file";
64
64
  }
65
+ const NAME_COLLATOR = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
65
66
  function sortNodes(nodes) {
66
67
  return [...nodes].sort((a, b) => {
67
68
  if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
68
- return a.name.localeCompare(b.name, void 0, { numeric: true, sensitivity: "base" });
69
+ return NAME_COLLATOR.compare(a.name, b.name);
69
70
  });
70
71
  }
71
72
  function formatSize(bytes) {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-CWlfDyKz.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-CCtskgfZ.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-DFKrd1Ne.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";
@@ -14,7 +14,7 @@ import { _ as _12 } from "./chunks/LpCard-BmrTvWOe.js";
14
14
  import { _ as _13 } from "./chunks/LpCheckbox-BO0zuuPh.js";
15
15
  import { _ as _14 } from "./chunks/LpCodeBlock-CSiOSPs8.js";
16
16
  import { default as default2 } from "./components/LpCommandPalette.vue.js";
17
- import { _ as _15 } from "./chunks/LpConfirmDialog-JNW4rpx3.js";
17
+ import { _ as _15 } from "./chunks/LpConfirmDialog-Z9hsfm2D.js";
18
18
  import { _ as _16 } from "./chunks/LpContextMenu-D0mzXqA7.js";
19
19
  import { _ as _17 } from "./chunks/LpDatePicker-dY2q4dr4.js";
20
20
  import { _ as _18 } from "./chunks/LpDisclosure-DP6yfgfM.js";
@@ -32,7 +32,7 @@ import { _ as _28 } from "./chunks/LpLightbox-CzhFe9wZ.js";
32
32
  import { fileNameOf } from "./components/lightbox.js";
33
33
  import { _ as _29 } from "./chunks/LpLink-DHwOEp4e.js";
34
34
  import { _ as _30 } from "./chunks/LpLogViewer-tmKPNTuU.js";
35
- import { _ as _31 } from "./chunks/LpModal-BJTiTSDJ.js";
35
+ import { _ as _31 } from "./chunks/LpModal-g2dGpF7l.js";
36
36
  import { _ as _32 } from "./chunks/LpNotificationBell-DHze8wYN.js";
37
37
  import { _ as _33 } from "./chunks/LpNumberField-C5ar-OwE.js";
38
38
  import { _ as _34 } from "./chunks/LpOtpInput-DqaT0Z75.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -8,7 +8,8 @@ import {
8
8
  DialogRoot,
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
- import { computed, useSlots } from "vue"
11
+ import type { ComponentPublicInstance } from "vue"
12
+ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
13
  import { CLOSE_ICON } from "./dropdown"
13
14
  import LpIcon from "./LpIcon.vue"
14
15
  import LpScrollArea from "./LpScrollArea.vue"
@@ -70,6 +71,129 @@ const bodyPad = computed(() =>
70
71
  .filter(Boolean)
71
72
  .join(" "),
72
73
  )
74
+
75
+ /*
76
+ * Height is content-derived, so a body that arrives late — a placeholder swapped
77
+ * for the loaded thing — resizes the panel in a single frame, right on top of
78
+ * the open animation. CSS alone can't smooth it: `height: auto` isn't
79
+ * interpolable, and the keyword-interpolation escape hatches
80
+ * (`interpolate-size`, `calc-size()`) are Chromium-only, so they would do
81
+ * nothing in the WebKitGTK launcher. Measuring and pinning a pixel height is
82
+ * what works on every engine.
83
+ *
84
+ * The pin is always the panel's own natural height, so `max-h` still clips and
85
+ * the body keeps `flex-1 min-h-0` — an over-tall panel hands its overflow to the
86
+ * scroll area exactly as before. A panel whose size never moves is pinned once
87
+ * and never transitions.
88
+ */
89
+ // reka renders a real element but exposes it as a component instance, so the
90
+ // panel is reached through $el.
91
+ const panelRef = ref<ComponentPublicInstance | null>(null)
92
+ const tweening = ref(false)
93
+ const resizing = ref(false)
94
+
95
+ /** Kept in step with the `duration-fast` utility on the panel. */
96
+ const TWEEN_MS = 160
97
+
98
+ let ro: ResizeObserver | undefined
99
+ let mo: MutationObserver | undefined
100
+ let raf = 0
101
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
102
+
103
+ // The height is driven imperatively rather than through a style binding. By the
104
+ // time replacement content is in the DOM the panel has already reflowed to its
105
+ // new size, so a bound value would only ever be written once, with no earlier
106
+ // height for the transition to start from. Writing the OLD height, forcing a
107
+ // reflow, then writing the new one gives the transition both ends.
108
+ const retune = (el: HTMLElement) => {
109
+ const from = el.offsetHeight
110
+ el.style.height = ""
111
+ // `max-h` comes from a class, so the panel already reports the clipped figure
112
+ // once the cap bites. Pinning that would leave a short panel sitting over
113
+ // content it can no longer grow to fit, so the natural height is measured with
114
+ // the cap lifted and the clamp is left to CSS afterwards.
115
+ el.style.maxHeight = "none"
116
+ const to = el.offsetHeight
117
+ el.style.maxHeight = ""
118
+ // Nothing moved, so there is nothing to ease — and leaving the height unset
119
+ // keeps the panel free to size itself.
120
+ if (to === 0 || to === from) return
121
+ el.style.height = `${from}px`
122
+ void el.offsetHeight
123
+ el.style.height = `${to}px`
124
+
125
+ // While the panel is still catching up, the body is shorter than the content
126
+ // it already holds, so the scroll area would flash a bar for an overflow that
127
+ // resolves itself the moment the transition lands.
128
+ resizing.value = true
129
+ clearTimeout(settleTimer)
130
+ settleTimer = setTimeout(() => {
131
+ resizing.value = false
132
+ // Released once the panel has arrived. A height left pinned in pixels stops
133
+ // being a starting point and becomes a cap: content that grows afterwards
134
+ // is clipped, and `max-h` can no longer size the panel itself.
135
+ el.style.height = ""
136
+ }, TWEEN_MS)
137
+ }
138
+
139
+ // Keyed off `open` rather than the ref: the panel is portalled and remounts on
140
+ // every open, and the teardown has to run on close, when the ref is already gone.
141
+ watch(
142
+ () => props.open,
143
+ (isOpen) => {
144
+ ro?.disconnect()
145
+ mo?.disconnect()
146
+ ro = undefined
147
+ mo = undefined
148
+ cancelAnimationFrame(raf)
149
+ clearTimeout(settleTimer)
150
+ tweening.value = false
151
+ resizing.value = false
152
+
153
+ const el = panelRef.value?.$el
154
+ if (!isOpen || !(el instanceof HTMLElement)) return
155
+
156
+ el.style.height = `${el.offsetHeight}px`
157
+ // The opening panel must not animate its height in from the pre-content box,
158
+ // so the transition only arms once that first pin has painted. The pin is
159
+ // dropped in the same frame: it exists to give the first `retune` something
160
+ // to start from, and holding it would cap the panel at its opening size.
161
+ raf = requestAnimationFrame(() => {
162
+ tweening.value = true
163
+ el.style.height = ""
164
+ })
165
+
166
+ // Two triggers, since either can move the height alone: the resize observer
167
+ // catches a child growing in place (an image decoding, a list filling in),
168
+ // the mutation observer catches one subtree swapped for another, where every
169
+ // observed box may keep its size. Children are observed, never the panel —
170
+ // that would feed the pin straight back in.
171
+ const watchChildren = () => {
172
+ if (!ro) return
173
+ ro.disconnect()
174
+ for (const child of Array.from(el.children)) ro.observe(child)
175
+ }
176
+ if (typeof ResizeObserver !== "undefined") {
177
+ ro = new ResizeObserver(() => retune(el))
178
+ watchChildren()
179
+ }
180
+ if (typeof MutationObserver !== "undefined") {
181
+ mo = new MutationObserver(() => {
182
+ watchChildren()
183
+ retune(el)
184
+ })
185
+ mo.observe(el, { childList: true, subtree: true, characterData: true })
186
+ }
187
+ },
188
+ { flush: "post" },
189
+ )
190
+
191
+ onBeforeUnmount(() => {
192
+ ro?.disconnect()
193
+ mo?.disconnect()
194
+ cancelAnimationFrame(raf)
195
+ clearTimeout(settleTimer)
196
+ })
73
197
  </script>
74
198
 
75
199
  <template>
@@ -100,8 +224,9 @@ const bodyPad = computed(() =>
100
224
  pass a description keeps the generated id and the link to
101
225
  DialogDescription. -->
102
226
  <DialogContent
103
- class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col 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)]"
104
- :class="widthClass"
227
+ ref="panelRef"
228
+ 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)]"
229
+ :class="[widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
105
230
  :style="width ? { width } : undefined"
106
231
  v-bind="describedByAttrs"
107
232
  >
@@ -135,9 +260,14 @@ const bodyPad = computed(() =>
135
260
  >
136
261
  <slot />
137
262
  </div>
263
+ <!-- While the panel is still growing it is briefly shorter than the body
264
+ it already holds, so the scroll area raises a bar for an overflow
265
+ that resolves itself as the transition lands. Hiding just the bar
266
+ for the length of the tween leaves scrolling itself untouched. -->
138
267
  <LpScrollArea
139
268
  v-else
140
269
  class="min-h-0 flex-1 text-sm text-ink/90"
270
+ :class="resizing ? '[&_[data-scrollbarimpl]]:invisible' : ''"
141
271
  :content-class="bodyPad"
142
272
  >
143
273
  <slot />
@@ -104,11 +104,18 @@ export function fileIcon(node: FileNode, expanded = false): string {
104
104
  return EXT_ICONS[ext] ?? "lucide:file"
105
105
  }
106
106
 
107
+ /*
108
+ * Built once. `String.localeCompare` with options constructs a collator on every
109
+ * call, which on a few thousand nodes costs tens of milliseconds — enough to
110
+ * drop frames while a dialog is animating open.
111
+ */
112
+ const NAME_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
113
+
107
114
  /** Directories first, then case-insensitive by name — the file-manager order. */
108
115
  export function sortNodes(nodes: FileNode[]): FileNode[] {
109
116
  return [...nodes].sort((a, b) => {
110
117
  if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
111
- return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
118
+ return NAME_COLLATOR.compare(a.name, b.name)
112
119
  })
113
120
  }
114
121