@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.
- package/dist/chunks/{LpAppShell-CPCG0I-c.js → LpAppShell-rr5kqiK3.js} +7 -1
- package/dist/chunks/{LpTableOfContents-CraicEs4.js → LpTableOfContents-BN5zvpnx.js} +2 -2
- package/dist/components/LpAppShell.vue.d.ts +20 -0
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpTableOfContents.vue.js +1 -1
- package/dist/composables/scroll.d.ts +2 -0
- package/dist/composables/scroll.js +4 -0
- package/dist/composables/scrollMemory.d.ts +1 -0
- package/dist/composables/scrollMemory.js +111 -0
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/src/components/LpAppShell.vue +18 -2
- package/src/components/LpTableOfContents.vue +4 -2
- package/src/composables/scroll.ts +5 -0
- package/src/composables/scrollMemory.ts +145 -0
|
@@ -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
|
|
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
|
*/
|
|
@@ -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-
|
|
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-
|
|
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
|
@@ -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
|
-
|
|
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
|
+
}
|