@leavepulse/ui 0.72.8 → 0.73.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.
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, computed, useModel, ref, useId, onMounted, watch, openBlock, createElementBlock, mergeProps, unref, createElementVNode, withModifiers, toDisplayString, createVNode, createSlots, withCtx, renderSlot, normalizeProps, guardReactiveProps, normalizeClass, withDirectives, vShow, createBlock, Teleport, createCommentVNode, mergeModels } from "vue";
2
2
  import { useLabels } from "../composables/useLpConfig.js";
3
+ import { useScrollMemory } from "../composables/scrollMemory.js";
3
4
  import { _ as _sfc_main$6 } from "./LpBottomNav-CbNQQPuL.js";
4
5
  import { _ as _sfc_main$2 } from "./LpButton-CFb44IjC.js";
5
6
  import { _ as _sfc_main$3 } from "./LpIcon--5WSb3jJ.js";
@@ -22,6 +23,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
22
23
  autoCollapseAfter: {},
23
24
  modelValue: {},
24
25
  path: {},
26
+ hash: {},
25
27
  isActive: {},
26
28
  title: {},
27
29
  mobileBreakpoint: { default: "lg" },
@@ -88,9 +90,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
88
90
  () => props.path,
89
91
  () => {
90
92
  if (drawerOpen.value) drawerOpen.value = false;
91
- scrollArea.value?.viewportEl?.scrollTo({ top: 0, left: 0, behavior: "instant" });
92
93
  }
93
94
  );
95
+ useScrollMemory(
96
+ () => bleeding.value ? null : scrollArea.value?.viewportEl,
97
+ () => props.path,
98
+ () => props.hash
99
+ );
94
100
  function onSelect(item) {
95
101
  emit("select", item);
96
102
  }
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, computed, ref, useId, onMounted, watch, onBeforeUnmount, openBlock, createElementBlock, createElementVNode, toDisplayString, Fragment, renderList, normalizeStyle, normalizeClass, createBlock, unref, createCommentVNode, createTextVNode } from "vue";
2
2
  import { Motion } from "motion-v";
3
3
  import { useLabels } from "../composables/useLpConfig.js";
4
- import { scrollParent, atScrollEnd } from "../composables/scroll.js";
4
+ import { scrollParent, scrollToElement, scrollMarginTop, atScrollEnd } from "../composables/scroll.js";
5
5
  import { usePillTransition } from "../composables/usePillTransition.js";
6
6
  const _hoisted_1 = ["aria-label"];
7
7
  const _hoisted_2 = { class: "mb-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted" };
@@ -103,7 +103,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
103
103
  ev.preventDefault();
104
104
  activeId.value = id;
105
105
  suppressSpyUntilScrollEnds(el);
106
- el.scrollIntoView({ behavior: "smooth", block: "start" });
106
+ scrollToElement(el, { block: "start", behavior: "smooth", margin: scrollMarginTop(el) });
107
107
  history.replaceState(history.state, "", `#${id}`);
108
108
  }
109
109
  onMounted(setup);
@@ -16,8 +16,18 @@ declare const __VLS_export: __VLS_WithSlots<import("vue").DefineComponent<{
16
16
  * The current route path. Enables the built-in prefix-match active test and
17
17
  * the derived page title without the host wiring its own `isActive`. Pass
18
18
  * `route.path`. Ignored when a custom `isActive` is provided.
19
+ *
20
+ * A new path also places the page in the shell's scroll region: back and
21
+ * forward return to where it was left, a `hash` lands on that anchor
22
+ * (honouring its `scroll-margin-top`), anything else starts at the top.
19
23
  */
20
24
  path?: string;
25
+ /**
26
+ * The current route hash (`route.hash`). With `path` it lands a page on
27
+ * its anchor; a new hash on the same path glides there, after a dialog
28
+ * that started the jump has closed.
29
+ */
30
+ hash?: string;
21
31
  /**
22
32
  * Custom active test (router-driven nav). Overrides `path`/`modelValue`.
23
33
  */
@@ -97,8 +107,18 @@ declare const __VLS_export: __VLS_WithSlots<import("vue").DefineComponent<{
97
107
  * The current route path. Enables the built-in prefix-match active test and
98
108
  * the derived page title without the host wiring its own `isActive`. Pass
99
109
  * `route.path`. Ignored when a custom `isActive` is provided.
110
+ *
111
+ * A new path also places the page in the shell's scroll region: back and
112
+ * forward return to where it was left, a `hash` lands on that anchor
113
+ * (honouring its `scroll-margin-top`), anything else starts at the top.
100
114
  */
101
115
  path?: string;
116
+ /**
117
+ * The current route hash (`route.hash`). With `path` it lands a page on
118
+ * its anchor; a new hash on the same path glides there, after a dialog
119
+ * that started the jump has closed.
120
+ */
121
+ hash?: string;
102
122
  /**
103
123
  * Custom active test (router-driven nav). Overrides `path`/`modelValue`.
104
124
  */
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-CPCG0I-c.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-rr5kqiK3.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTableOfContents-CraicEs4.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTableOfContents-BN5zvpnx.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -19,4 +19,6 @@ export interface ScrollToElementOptions {
19
19
  margin?: number;
20
20
  }
21
21
  /** Scroll the nearest scroller, and only it, so that `el` is in view. */
22
+ /** The element's own `scroll-margin-top`, the gap it asks to keep above it when scrolled to. */
23
+ export declare function scrollMarginTop(el: Element): number;
22
24
  export declare function scrollToElement(el: Element | null | undefined, { block, behavior, margin }?: ScrollToElementOptions): void;
@@ -15,6 +15,9 @@ function atScrollEnd(el, slack = 2) {
15
15
  if (!box) return true;
16
16
  return box.scrollTop + box.clientHeight >= box.scrollHeight - slack;
17
17
  }
18
+ function scrollMarginTop(el) {
19
+ return Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
20
+ }
18
21
  function scrollToElement(el, { block = "nearest", behavior = "auto", margin = 0 } = {}) {
19
22
  if (!el) return;
20
23
  const doc = el.ownerDocument;
@@ -33,6 +36,7 @@ function scrollToElement(el, { block = "nearest", behavior = "auto", margin = 0
33
36
  }
34
37
  export {
35
38
  atScrollEnd,
39
+ scrollMarginTop,
36
40
  scrollParent,
37
41
  scrollToElement
38
42
  };
@@ -0,0 +1 @@
1
+ export declare function useScrollMemory(viewport: () => HTMLElement | null | undefined, path: () => string | undefined, hash: () => string | undefined): void;
@@ -0,0 +1,111 @@
1
+ import { watch, nextTick, onMounted, onBeforeUnmount } from "vue";
2
+ import { scrollMarginTop } from "./scroll.js";
3
+ const SETTLE_MS = 1500;
4
+ const DIALOG_WAIT_MS = 2e3;
5
+ const SETTLE_STOP = ["wheel", "touchstart", "keydown", "pointerdown"];
6
+ const positions = /* @__PURE__ */ new Map();
7
+ function addressKey() {
8
+ return `${location.pathname}${location.search}`;
9
+ }
10
+ function useScrollMemory(viewport, path, hash) {
11
+ if (typeof window === "undefined") return;
12
+ let current = addressKey();
13
+ let shown = path();
14
+ let travelled = false;
15
+ let stopHold;
16
+ let pending = 0;
17
+ const onPop = () => {
18
+ travelled = true;
19
+ };
20
+ window.addEventListener("popstate", onPop);
21
+ function anchor(value) {
22
+ if (!value || value === "#") return null;
23
+ const id = decodeURIComponent(value.replace(/^#/, ""));
24
+ return document.getElementById(id);
25
+ }
26
+ function reach(value, behavior) {
27
+ const view = viewport();
28
+ const target = anchor(value);
29
+ if (!view || !target || !view.contains(target)) return false;
30
+ const top = target.getBoundingClientRect().top - view.getBoundingClientRect().top + view.scrollTop - scrollMarginTop(target);
31
+ view.scrollTo({ top: Math.max(0, top), behavior });
32
+ return true;
33
+ }
34
+ function hold(land) {
35
+ stopHold?.();
36
+ const view = viewport();
37
+ const content = view?.firstElementChild;
38
+ land();
39
+ if (!content) return;
40
+ const watcher = new ResizeObserver(land);
41
+ const timer = window.setTimeout(() => stop(), SETTLE_MS);
42
+ const stop = () => {
43
+ watcher.disconnect();
44
+ window.clearTimeout(timer);
45
+ for (const name of SETTLE_STOP) window.removeEventListener(name, stop, true);
46
+ if (stopHold === stop) stopHold = void 0;
47
+ };
48
+ for (const name of SETTLE_STOP) window.addEventListener(name, stop, { capture: true, passive: true });
49
+ watcher.observe(content);
50
+ stopHold = stop;
51
+ }
52
+ function arrive() {
53
+ const back = travelled;
54
+ travelled = false;
55
+ current = addressKey();
56
+ shown = path();
57
+ const view = viewport();
58
+ if (!view) return;
59
+ const saved = positions.get(current);
60
+ if (back && saved !== void 0) {
61
+ hold(() => view.scrollTo({ top: saved, behavior: "instant" }));
62
+ return;
63
+ }
64
+ if (hash()) {
65
+ hold(() => {
66
+ if (!reach(hash(), "instant")) view.scrollTo({ top: 0, behavior: "instant" });
67
+ });
68
+ return;
69
+ }
70
+ stopHold?.();
71
+ view.scrollTo({ top: 0, left: 0, behavior: "instant" });
72
+ }
73
+ function afterDialogs(run) {
74
+ const since = performance.now();
75
+ const mine = ++pending;
76
+ const tick = () => {
77
+ if (mine !== pending) return;
78
+ const locked = document.querySelector('[role="dialog"]') || getComputedStyle(document.body).overflow === "hidden";
79
+ if (locked && performance.now() - since < DIALOG_WAIT_MS) requestAnimationFrame(tick);
80
+ else run();
81
+ };
82
+ requestAnimationFrame(tick);
83
+ }
84
+ watch(
85
+ path,
86
+ () => {
87
+ const view = viewport();
88
+ if (view) positions.set(current, view.scrollTop);
89
+ void nextTick(() => requestAnimationFrame(arrive));
90
+ },
91
+ { flush: "pre" }
92
+ );
93
+ watch(hash, (next, previous) => {
94
+ if (next === previous) return;
95
+ if (path() !== shown) return;
96
+ afterDialogs(() => {
97
+ travelled = false;
98
+ if (!reach(next, "smooth")) viewport()?.scrollTo({ top: 0, behavior: "smooth" });
99
+ });
100
+ });
101
+ onMounted(() => {
102
+ if (hash()) requestAnimationFrame(() => hold(() => void reach(hash(), "instant")));
103
+ });
104
+ onBeforeUnmount(() => {
105
+ stopHold?.();
106
+ window.removeEventListener("popstate", onPop);
107
+ });
108
+ }
109
+ export {
110
+ useScrollMemory
111
+ };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { _ as _3 } from "./chunks/LpMapPicker-DRGgh89B.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BP1ld7-S.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-n5XPAZfv.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-CPCG0I-c.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-rr5kqiK3.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DOu7FN5P.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-Cg6Az3wz.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-wUYLN4U9.js";
@@ -99,7 +99,7 @@ import { _ as _71 } from "./chunks/LpSwitch-BnXd1llc.js";
99
99
  import { default as default12 } from "./components/LpTable.vue.js";
100
100
  import { compareValues } from "./components/tableSort.js";
101
101
  import { resolveRowMenu } from "./components/tableRowMenu.js";
102
- import { _ as _72 } from "./chunks/LpTableOfContents-CraicEs4.js";
102
+ import { _ as _72 } from "./chunks/LpTableOfContents-BN5zvpnx.js";
103
103
  import { default as default13 } from "./components/LpTabs.vue.js";
104
104
  import { _ as _73 } from "./chunks/LpBottomNav-CbNQQPuL.js";
105
105
  import { _ as _74 } from "./chunks/LpTagsInput-C-mDG648.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.72.8",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -33,6 +33,7 @@ export default { inheritAttrs: false }
33
33
  */
34
34
  import { computed, onMounted, ref, useId, watch } from "vue"
35
35
  import { useLabels } from "../composables/useLpConfig"
36
+ import { useScrollMemory } from "../composables/scrollMemory"
36
37
  import LpBottomNav from "./LpBottomNav.vue"
37
38
  import LpButton from "./LpButton.vue"
38
39
  import LpIcon from "./LpIcon.vue"
@@ -59,8 +60,18 @@ const props = withDefaults(
59
60
  * The current route path. Enables the built-in prefix-match active test and
60
61
  * the derived page title without the host wiring its own `isActive`. Pass
61
62
  * `route.path`. Ignored when a custom `isActive` is provided.
63
+ *
64
+ * A new path also places the page in the shell's scroll region: back and
65
+ * forward return to where it was left, a `hash` lands on that anchor
66
+ * (honouring its `scroll-margin-top`), anything else starts at the top.
62
67
  */
63
68
  path?: string
69
+ /**
70
+ * The current route hash (`route.hash`). With `path` it lands a page on
71
+ * its anchor; a new hash on the same path glides there, after a dialog
72
+ * that started the jump has closed.
73
+ */
74
+ hash?: string
64
75
  /**
65
76
  * Custom active test (router-driven nav). Overrides `path`/`modelValue`.
66
77
  */
@@ -177,7 +188,8 @@ const text = useLabels(
177
188
  /*
178
189
  * The page scrolls inside the shell's own region, not the window, so the
179
190
  * router's scroll behaviour never reaches it: without this a route change from
180
- * a scrolled page landed mid-way down the next one.
191
+ * a scrolled page landed mid-way down the next one, and back never returned.
192
+ * A full-bleed page owns its scrolling, so the shell leaves it alone.
181
193
  */
182
194
  const scrollArea = ref<{ viewportEl: HTMLElement | null } | null>(null)
183
195
  const bleedId = `lp-shell-bleed-${useId()}`
@@ -195,9 +207,13 @@ watch(
195
207
  () => props.path,
196
208
  () => {
197
209
  if (drawerOpen.value) drawerOpen.value = false
198
- scrollArea.value?.viewportEl?.scrollTo({ top: 0, left: 0, behavior: "instant" })
199
210
  },
200
211
  )
212
+ useScrollMemory(
213
+ () => (bleeding.value ? null : scrollArea.value?.viewportEl),
214
+ () => props.path,
215
+ () => props.hash,
216
+ )
201
217
 
202
218
  function onSelect(item: SidebarItem) {
203
219
  emit("select", item)
@@ -12,7 +12,7 @@
12
12
  import { Motion } from "motion-v"
13
13
  import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
14
14
  import { useLabels } from "../composables/useLpConfig"
15
- import { atScrollEnd, scrollParent } from "../composables/scroll"
15
+ import { atScrollEnd, scrollMarginTop, scrollParent, scrollToElement } from "../composables/scroll"
16
16
  import { usePillTransition } from "../composables/usePillTransition"
17
17
 
18
18
  export interface TocLink {
@@ -170,7 +170,9 @@ function onClick(id: string, ev: MouseEvent) {
170
170
  ev.preventDefault()
171
171
  activeId.value = id // mark immediately so the bar jumps to the clicked item
172
172
  suppressSpyUntilScrollEnds(el)
173
- el.scrollIntoView({ behavior: "smooth", block: "start" })
173
+ // Only the nearest scrolling box moves: scrollIntoView also shifted every
174
+ // overflow-hidden ancestor, the shell itself among them.
175
+ scrollToElement(el, { block: "start", behavior: "smooth", margin: scrollMarginTop(el) })
174
176
  // Keep the router's own state: replacing it with null broke scroll
175
177
  // restoration and back/forward bookkeeping in vue-router apps.
176
178
  history.replaceState(history.state, "", `#${id}`)
@@ -46,6 +46,11 @@ export interface ScrollToElementOptions {
46
46
  }
47
47
 
48
48
  /** Scroll the nearest scroller, and only it, so that `el` is in view. */
49
+ /** The element's own `scroll-margin-top`, the gap it asks to keep above it when scrolled to. */
50
+ export function scrollMarginTop(el: Element): number {
51
+ return Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0
52
+ }
53
+
49
54
  export function scrollToElement(
50
55
  el: Element | null | undefined,
51
56
  { block = "nearest", behavior = "auto", margin = 0 }: ScrollToElementOptions = {},
@@ -0,0 +1,145 @@
1
+ import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
2
+ import { scrollMarginTop } from "./scroll"
3
+
4
+ /*
5
+ * Where a page lands when the shell's own scroll region changes page. The
6
+ * region is not the window, so a router's scrollBehavior never reaches it, and
7
+ * the shell has to answer the questions a browser answers for a plain site:
8
+ *
9
+ * - back and forward return to where the page was left;
10
+ * - an address with a hash lands on that anchor, and stays on it while the
11
+ * page is still growing (a lazily loaded body, images), until the reader
12
+ * scrolls or a moment has passed;
13
+ * - a new hash on the same page glides there, once a dialog that started the
14
+ * jump (a palette, a menu) has let go of the page;
15
+ * - anything else starts at the top.
16
+ *
17
+ * The router is not needed: what changed is read from the shell's `path` and
18
+ * `hash` props, history position from `popstate`, and positions are keyed by
19
+ * the address the browser shows.
20
+ */
21
+
22
+ const SETTLE_MS = 1500
23
+ const DIALOG_WAIT_MS = 2000
24
+ const SETTLE_STOP = ["wheel", "touchstart", "keydown", "pointerdown"] as const
25
+
26
+ const positions = new Map<string, number>()
27
+
28
+ function addressKey(): string {
29
+ return `${location.pathname}${location.search}`
30
+ }
31
+
32
+ export function useScrollMemory(
33
+ viewport: () => HTMLElement | null | undefined,
34
+ path: () => string | undefined,
35
+ hash: () => string | undefined,
36
+ ): void {
37
+ if (typeof window === "undefined") return
38
+ let current = addressKey()
39
+ let shown = path()
40
+ let travelled = false
41
+ let stopHold: (() => void) | undefined
42
+ let pending = 0
43
+
44
+ const onPop = () => {
45
+ travelled = true
46
+ }
47
+ window.addEventListener("popstate", onPop)
48
+
49
+ function anchor(value: string | undefined): HTMLElement | null {
50
+ if (!value || value === "#") return null
51
+ const id = decodeURIComponent(value.replace(/^#/, ""))
52
+ return document.getElementById(id)
53
+ }
54
+
55
+ function reach(value: string | undefined, behavior: ScrollBehavior): boolean {
56
+ const view = viewport()
57
+ const target = anchor(value)
58
+ if (!view || !target || !view.contains(target)) return false
59
+ const top = target.getBoundingClientRect().top - view.getBoundingClientRect().top + view.scrollTop - scrollMarginTop(target)
60
+ view.scrollTo({ top: Math.max(0, top), behavior })
61
+ return true
62
+ }
63
+
64
+ // Re-applies a landing while the page below it keeps changing size.
65
+ function hold(land: () => void) {
66
+ stopHold?.()
67
+ const view = viewport()
68
+ const content = view?.firstElementChild
69
+ land()
70
+ if (!content) return
71
+ const watcher = new ResizeObserver(land)
72
+ const timer = window.setTimeout(() => stop(), SETTLE_MS)
73
+ const stop = () => {
74
+ watcher.disconnect()
75
+ window.clearTimeout(timer)
76
+ for (const name of SETTLE_STOP) window.removeEventListener(name, stop, true)
77
+ if (stopHold === stop) stopHold = undefined
78
+ }
79
+ for (const name of SETTLE_STOP) window.addEventListener(name, stop, { capture: true, passive: true })
80
+ watcher.observe(content)
81
+ stopHold = stop
82
+ }
83
+
84
+ function arrive() {
85
+ const back = travelled
86
+ travelled = false
87
+ current = addressKey()
88
+ shown = path()
89
+ const view = viewport()
90
+ if (!view) return
91
+ const saved = positions.get(current)
92
+ if (back && saved !== undefined) {
93
+ hold(() => view.scrollTo({ top: saved, behavior: "instant" }))
94
+ return
95
+ }
96
+ if (hash()) {
97
+ hold(() => {
98
+ if (!reach(hash(), "instant")) view.scrollTo({ top: 0, behavior: "instant" })
99
+ })
100
+ return
101
+ }
102
+ stopHold?.()
103
+ view.scrollTo({ top: 0, left: 0, behavior: "instant" })
104
+ }
105
+
106
+ function afterDialogs(run: () => void) {
107
+ const since = performance.now()
108
+ const mine = ++pending
109
+ const tick = () => {
110
+ if (mine !== pending) return
111
+ const locked = document.querySelector('[role="dialog"]') || getComputedStyle(document.body).overflow === "hidden"
112
+ if (locked && performance.now() - since < DIALOG_WAIT_MS) requestAnimationFrame(tick)
113
+ else run()
114
+ }
115
+ requestAnimationFrame(tick)
116
+ }
117
+
118
+ watch(
119
+ path,
120
+ () => {
121
+ const view = viewport()
122
+ if (view) positions.set(current, view.scrollTop)
123
+ void nextTick(() => requestAnimationFrame(arrive))
124
+ },
125
+ { flush: "pre" },
126
+ )
127
+
128
+ watch(hash, (next, previous) => {
129
+ if (next === previous) return
130
+ if (path() !== shown) return
131
+ afterDialogs(() => {
132
+ travelled = false
133
+ if (!reach(next, "smooth")) viewport()?.scrollTo({ top: 0, behavior: "smooth" })
134
+ })
135
+ })
136
+
137
+ onMounted(() => {
138
+ if (hash()) requestAnimationFrame(() => hold(() => void reach(hash(), "instant")))
139
+ })
140
+
141
+ onBeforeUnmount(() => {
142
+ stopHold?.()
143
+ window.removeEventListener("popstate", onPop)
144
+ })
145
+ }