@leavepulse/ui 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ import { MaybeRefOrGetter } from 'vue';
2
+ export interface UseSettledOptions {
3
+ /** Override the wait. Defaults to the kit's overlay animation duration. */
4
+ delay?: MaybeRefOrGetter<number>;
5
+ }
6
+ /**
7
+ * Returns a ref that flips true once `open` has been truthy for the length of
8
+ * the overlay's opening animation. Gate expensive content on it:
9
+ *
10
+ * ```ts
11
+ * const settled = useSettled(() => isOpen.value)
12
+ * ```
13
+ * ```vue
14
+ * <RowList v-if="settled" :rows="rows" />
15
+ * <RowSkeletons v-else />
16
+ * ```
17
+ *
18
+ * Anything truthy counts as open, so an id can be passed instead of a boolean.
19
+ * That is the useful form when the same panel is reused for different subjects:
20
+ * the wait restarts when the id changes, because the panel is mounting a whole
21
+ * new body without ever having closed.
22
+ *
23
+ * With reduced motion there is no animation to protect, so it resolves on the
24
+ * next tick rather than making the user wait for a delay that buys nothing.
25
+ */
26
+ export declare function useSettled(open: MaybeRefOrGetter<unknown>, options?: UseSettledOptions): import('vue').Ref<boolean, boolean>;
@@ -0,0 +1,35 @@
1
+ import { ref, watch, toValue, onScopeDispose } from "vue";
2
+ const SETTLE_MS = 260;
3
+ function useSettled(open, options = {}) {
4
+ const settled = ref(false);
5
+ let timer;
6
+ function clear() {
7
+ if (timer !== void 0) {
8
+ clearTimeout(timer);
9
+ timer = void 0;
10
+ }
11
+ }
12
+ watch(
13
+ () => toValue(open),
14
+ (value) => {
15
+ clear();
16
+ if (!value) {
17
+ settled.value = false;
18
+ return;
19
+ }
20
+ settled.value = false;
21
+ const reduced = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
22
+ const wait = reduced ? 0 : toValue(options.delay) ?? SETTLE_MS;
23
+ timer = setTimeout(() => {
24
+ settled.value = true;
25
+ timer = void 0;
26
+ }, wait);
27
+ },
28
+ { immediate: true }
29
+ );
30
+ onScopeDispose(clear);
31
+ return settled;
32
+ }
33
+ export {
34
+ useSettled
35
+ };
package/dist/index.d.ts CHANGED
@@ -109,6 +109,8 @@ export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from './theme/config'
109
109
  export type { UiConfig } from './theme/config';
110
110
  export { useInputFilter } from './composables/useInputFilter';
111
111
  export type { UseInputFilterOptions } from './composables/useInputFilter';
112
+ export { useSettled } from './composables/useSettled';
113
+ export type { UseSettledOptions } from './composables/useSettled';
112
114
  export { useTilt } from './composables/useTilt';
113
115
  export type { UseTilt, UseTiltOptions } from './composables/useTilt';
114
116
  export { useZoomPan } from './composables/useZoomPan';
package/dist/index.js CHANGED
@@ -70,6 +70,7 @@ import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, remove
70
70
  import { blockTitle, defineBlocks } from "./layout/registry.js";
71
71
  import { useLayout } from "./layout/useLayout.js";
72
72
  import { useInputFilter } from "./composables/useInputFilter.js";
73
+ import { useSettled } from "./composables/useSettled.js";
73
74
  import { useTilt } from "./composables/useTilt.js";
74
75
  import { useZoomPan } from "./composables/useZoomPan.js";
75
76
  import { useToast } from "./composables/useToast.js";
@@ -229,6 +230,7 @@ export {
229
230
  unproject,
230
231
  useInputFilter,
231
232
  useLayout,
233
+ useSettled,
232
234
  u as useTheme,
233
235
  useTilt,
234
236
  useToast,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.19.2",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -0,0 +1,91 @@
1
+ import { onScopeDispose, ref, watch, type MaybeRefOrGetter, toValue } from "vue"
2
+
3
+ /*
4
+ * "Has the opening animation had the frame to itself yet?"
5
+ *
6
+ * A panel that mounts something expensive — a few hundred rows, each carrying
7
+ * its own components — spends the opening frame building DOM instead of
8
+ * animating, and the overlay snaps into place instead of easing in. The fix is
9
+ * always the same shape: hold the expensive part back, let the animation run,
10
+ * put the content up right after. That reads as the panel filling in rather
11
+ * than the window stuttering.
12
+ *
13
+ * Consumers were writing this as a bare `setTimeout(260)` with the duration
14
+ * copied from the kit's `--duration-medium` — a number that has to be kept in
15
+ * step by hand, in every caller, forever. This owns it instead.
16
+ *
17
+ * The flag goes false as soon as `open` does, so reopening starts from the top
18
+ * rather than inheriting the previous pass's answer.
19
+ */
20
+
21
+ /** Overlay animations run at `--duration-medium`; the margin covers the frame
22
+ * the browser spends scheduling them. Kept here so callers don't guess. */
23
+ const SETTLE_MS = 260
24
+
25
+ export interface UseSettledOptions {
26
+ /** Override the wait. Defaults to the kit's overlay animation duration. */
27
+ delay?: MaybeRefOrGetter<number>
28
+ }
29
+
30
+ /**
31
+ * Returns a ref that flips true once `open` has been truthy for the length of
32
+ * the overlay's opening animation. Gate expensive content on it:
33
+ *
34
+ * ```ts
35
+ * const settled = useSettled(() => isOpen.value)
36
+ * ```
37
+ * ```vue
38
+ * <RowList v-if="settled" :rows="rows" />
39
+ * <RowSkeletons v-else />
40
+ * ```
41
+ *
42
+ * Anything truthy counts as open, so an id can be passed instead of a boolean.
43
+ * That is the useful form when the same panel is reused for different subjects:
44
+ * the wait restarts when the id changes, because the panel is mounting a whole
45
+ * new body without ever having closed.
46
+ *
47
+ * With reduced motion there is no animation to protect, so it resolves on the
48
+ * next tick rather than making the user wait for a delay that buys nothing.
49
+ */
50
+ export function useSettled(
51
+ open: MaybeRefOrGetter<unknown>,
52
+ options: UseSettledOptions = {},
53
+ ) {
54
+ const settled = ref(false)
55
+ let timer: ReturnType<typeof setTimeout> | undefined
56
+
57
+ function clear() {
58
+ if (timer !== undefined) {
59
+ clearTimeout(timer)
60
+ timer = undefined
61
+ }
62
+ }
63
+
64
+ watch(
65
+ () => toValue(open),
66
+ (value) => {
67
+ clear()
68
+ if (!value) {
69
+ settled.value = false
70
+ return
71
+ }
72
+ // Restarted on the value, not on open/closed: a panel handed a different
73
+ // id stays open while its whole body is replaced, and that second mount
74
+ // is as expensive as the first.
75
+ settled.value = false
76
+ const reduced =
77
+ typeof window !== "undefined" &&
78
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
79
+ const wait = reduced ? 0 : (toValue(options.delay) ?? SETTLE_MS)
80
+ timer = setTimeout(() => {
81
+ settled.value = true
82
+ timer = undefined
83
+ }, wait)
84
+ },
85
+ { immediate: true },
86
+ )
87
+
88
+ onScopeDispose(clear)
89
+
90
+ return settled
91
+ }
package/src/index.ts CHANGED
@@ -184,6 +184,8 @@ export type { UiConfig } from "./theme/config"
184
184
 
185
185
  export { useInputFilter } from "./composables/useInputFilter"
186
186
  export type { UseInputFilterOptions } from "./composables/useInputFilter"
187
+ export { useSettled } from "./composables/useSettled"
188
+ export type { UseSettledOptions } from "./composables/useSettled"
187
189
  export { useTilt } from "./composables/useTilt"
188
190
  export type { UseTilt, UseTiltOptions } from "./composables/useTilt"
189
191
  export { useZoomPan } from "./composables/useZoomPan"