@ulu/frontend-vue 0.5.14 → 0.5.16
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/components/systems/scroll-anchors/UluScrollAnchors.vue.d.ts.map +1 -1
- package/dist/components/systems/scroll-anchors/UluScrollAnchors.vue.js +18 -10
- package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.d.ts +8 -2
- package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.d.ts.map +1 -1
- package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.js +92 -47
- package/dist/components/systems/scroll-anchors/useScrollAnchors.d.ts.map +1 -1
- package/dist/components/systems/scroll-anchors/useScrollAnchors.js +113 -70
- package/lib/components/systems/scroll-anchors/UluScrollAnchors.vue +9 -1
- package/lib/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue +95 -16
- package/lib/components/systems/scroll-anchors/_scroll-anchors-nav-animated.scss +12 -1
- package/lib/components/systems/scroll-anchors/useScrollAnchors.js +195 -54
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UluScrollAnchors.vue.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/UluScrollAnchors.vue"],"names":[],"mappings":"AAKA;
|
|
1
|
+
{"version":3,"file":"UluScrollAnchors.vue.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/UluScrollAnchors.vue"],"names":[],"mappings":"AAKA;wBAqMqB,uBAAuB,CAAC,OAAO,eAAe,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAC;;6BAEtE,CAAC,EAAE,CAAC;;;AAbjC;;;;;;;mBAUG"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref as s, provide as
|
|
1
|
+
import { ref as s, provide as l, computed as p, createElementBlock as f, openBlock as m, renderSlot as d } from "vue";
|
|
2
2
|
import { useScrollAnchors as h } from "./useScrollAnchors.js";
|
|
3
3
|
const A = {
|
|
4
4
|
__name: "UluScrollAnchors",
|
|
@@ -33,20 +33,28 @@ const A = {
|
|
|
33
33
|
/**
|
|
34
34
|
* Enable debug logging for the IntersectionObserver
|
|
35
35
|
*/
|
|
36
|
-
debug: Boolean
|
|
36
|
+
debug: Boolean,
|
|
37
|
+
/**
|
|
38
|
+
* If true, the last section will deactivate when scrolling past its bounding box.
|
|
39
|
+
* By default, the last section remains active until the user scrolls back up.
|
|
40
|
+
*/
|
|
41
|
+
deactivateLastItem: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: !1
|
|
44
|
+
}
|
|
37
45
|
},
|
|
38
46
|
emits: ["section-change"],
|
|
39
|
-
setup(
|
|
40
|
-
const
|
|
41
|
-
return h({ sections: e, props:
|
|
47
|
+
setup(c, { emit: r }) {
|
|
48
|
+
const a = c, u = r, e = s([]), n = s(null);
|
|
49
|
+
return h({ sections: e, props: a, emit: u, componentElRef: n }), l("uluScrollAnchorsSections", p(() => e.value)), l("uluScrollAnchorsRegister", (o) => {
|
|
42
50
|
e.value.push(o);
|
|
43
|
-
}),
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
}), (o,
|
|
51
|
+
}), l("uluScrollAnchorsUnregister", (o) => {
|
|
52
|
+
const t = e.value.findIndex((i) => i.id === o);
|
|
53
|
+
t > -1 && e.value.splice(t, 1);
|
|
54
|
+
}), (o, t) => (m(), f("div", {
|
|
47
55
|
class: "scroll-anchors",
|
|
48
56
|
ref_key: "componentEl",
|
|
49
|
-
ref:
|
|
57
|
+
ref: n
|
|
50
58
|
}, [
|
|
51
59
|
d(o.$slots, "default")
|
|
52
60
|
], 512));
|
|
@@ -6,6 +6,9 @@ type __VLS_WithTemplateSlots<T, S> = T & (new () => {
|
|
|
6
6
|
declare const __VLS_component: import('vue').DefineComponent<{}, {
|
|
7
7
|
element: string;
|
|
8
8
|
railWidth: number;
|
|
9
|
+
trimRailToCenters: boolean;
|
|
10
|
+
railStartOffset: number;
|
|
11
|
+
railEndOffset: number;
|
|
9
12
|
indicatorWidth: number;
|
|
10
13
|
indicatorHeight: number;
|
|
11
14
|
indicatorAlignment: string;
|
|
@@ -13,13 +16,16 @@ declare const __VLS_component: import('vue').DefineComponent<{}, {
|
|
|
13
16
|
$props: {
|
|
14
17
|
readonly element?: string | undefined;
|
|
15
18
|
readonly railWidth?: number | undefined;
|
|
19
|
+
readonly trimRailToCenters?: boolean | undefined;
|
|
20
|
+
readonly railStartOffset?: number | undefined;
|
|
21
|
+
readonly railEndOffset?: number | undefined;
|
|
16
22
|
readonly indicatorWidth?: number | undefined;
|
|
17
23
|
readonly indicatorHeight?: number | undefined;
|
|
18
24
|
readonly indicatorAlignment?: string | undefined;
|
|
19
25
|
readonly indicatorAlignmentOffset?: number | undefined;
|
|
20
26
|
};
|
|
21
27
|
}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {
|
|
22
|
-
|
|
28
|
+
listRef: HTMLUListElement;
|
|
23
29
|
}, any>;
|
|
24
30
|
type __VLS_TemplateResult = {
|
|
25
31
|
attrs: Partial<{}>;
|
|
@@ -30,7 +36,7 @@ type __VLS_TemplateResult = {
|
|
|
30
36
|
}): any;
|
|
31
37
|
};
|
|
32
38
|
refs: {
|
|
33
|
-
|
|
39
|
+
listRef: HTMLUListElement;
|
|
34
40
|
};
|
|
35
41
|
rootEl: any;
|
|
36
42
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UluScrollAnchorsNavAnimated.vue.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"UluScrollAnchorsNavAnimated.vue.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue"],"names":[],"mappings":"AAqCA;wBA4eqB,uBAAuB,CAAC,OAAO,eAAe,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAC;;6BAEtE,CAAC,EAAE,CAAC;;;AAXjC;;;;;;;;;;;;;;;;;;;;;;;QAQG"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ref as
|
|
2
|
-
import { runAfterFramePaint as
|
|
3
|
-
import { useScrollAnchorSections as
|
|
4
|
-
const
|
|
1
|
+
import { ref as c, onMounted as $, onBeforeUnmount as w, computed as N, watch as C, createBlock as z, createCommentVNode as B, unref as p, openBlock as y, resolveDynamicComponent as M, normalizeStyle as g, withCtx as T, createElementVNode as _, createElementBlock as k, Fragment as W, renderList as E, normalizeClass as S, renderSlot as H, createTextVNode as I, toDisplayString as V } from "vue";
|
|
2
|
+
import { runAfterFramePaint as D } from "@ulu/utils/browser/performance.js";
|
|
3
|
+
import { useScrollAnchorSections as F } from "./useScrollAnchorSections.js";
|
|
4
|
+
const L = ["href"], j = {
|
|
5
5
|
__name: "UluScrollAnchorsNavAnimated",
|
|
6
6
|
props: {
|
|
7
7
|
/**
|
|
@@ -18,6 +18,26 @@ const E = { class: "scroll-anchors-nav-animated__rail" }, F = ["href"], P = {
|
|
|
18
18
|
type: Number,
|
|
19
19
|
default: 3
|
|
20
20
|
},
|
|
21
|
+
/**
|
|
22
|
+
* Dynamically trims the rail to span exactly from the center of the first indicator to the center of the last indicator. Disabled by default
|
|
23
|
+
*/
|
|
24
|
+
trimRailToCenters: {
|
|
25
|
+
type: Boolean
|
|
26
|
+
},
|
|
27
|
+
/**
|
|
28
|
+
* Pixel offset for the start (top) of the dynamic rail.
|
|
29
|
+
*/
|
|
30
|
+
railStartOffset: {
|
|
31
|
+
type: Number,
|
|
32
|
+
default: 0
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* Pixel offset for the end (bottom) of the dynamic rail.
|
|
36
|
+
*/
|
|
37
|
+
railEndOffset: {
|
|
38
|
+
type: Number,
|
|
39
|
+
default: 0
|
|
40
|
+
},
|
|
21
41
|
/**
|
|
22
42
|
* The width of the indicator, defaults to railWidth
|
|
23
43
|
*/
|
|
@@ -48,71 +68,96 @@ const E = { class: "scroll-anchors-nav-animated__rail" }, F = ["href"], P = {
|
|
|
48
68
|
default: 0
|
|
49
69
|
}
|
|
50
70
|
},
|
|
51
|
-
setup(
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
setup(u) {
|
|
72
|
+
const l = u, a = F(), f = c(null), x = c({}), d = c(!1), h = c(0);
|
|
73
|
+
let s = null;
|
|
74
|
+
$(() => {
|
|
75
|
+
f.value && (s = new ResizeObserver(() => {
|
|
76
|
+
h.value++;
|
|
77
|
+
}), s.observe(f.value));
|
|
78
|
+
}), w(() => {
|
|
79
|
+
s && (s.disconnect(), s = null);
|
|
80
|
+
});
|
|
81
|
+
function v(e) {
|
|
82
|
+
const t = x.value[e];
|
|
83
|
+
if (!t) return null;
|
|
84
|
+
const { offsetTop: n, offsetHeight: r } = t, o = l.indicatorHeight != null, R = l.indicatorWidth ?? l.railWidth, b = o ? l.indicatorHeight : r;
|
|
85
|
+
let m = n;
|
|
86
|
+
return l.indicatorAlignment === "center" && (m = n + r / 2 - b / 2), m += l.indicatorAlignmentOffset, { y: m, height: b, width: R };
|
|
87
|
+
}
|
|
88
|
+
const i = N(() => {
|
|
89
|
+
if (h.value, !a || !a.value || !a.value.length)
|
|
57
90
|
return !1;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
91
|
+
const e = a.value.findIndex((t) => t.active);
|
|
92
|
+
return e === -1 ? !1 : v(e) || !1;
|
|
93
|
+
}), A = N(() => {
|
|
94
|
+
if (h.value, !l.trimRailToCenters) return {};
|
|
95
|
+
if (!a || !a.value || a.value.length < 1) return {};
|
|
96
|
+
const e = v(0), t = v(a.value.length - 1);
|
|
97
|
+
if (!e || !t) return {};
|
|
98
|
+
let n = e.y + e.height / 2, r = t.y + t.height / 2;
|
|
99
|
+
n += l.railStartOffset, r += l.railEndOffset;
|
|
100
|
+
const o = Math.max(0, r - n);
|
|
101
|
+
return {
|
|
102
|
+
"--ulu-sa-nav-rail-top": `${n}px`,
|
|
103
|
+
"--ulu-sa-nav-rail-height": `${o}px`
|
|
104
|
+
};
|
|
63
105
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
C(i, (e) => {
|
|
107
|
+
e && !d.value && D(() => {
|
|
108
|
+
d.value = !0;
|
|
67
109
|
});
|
|
68
110
|
});
|
|
69
|
-
function
|
|
70
|
-
|
|
111
|
+
function O(e, t) {
|
|
112
|
+
t && (x.value[e] = t);
|
|
71
113
|
}
|
|
72
|
-
return (
|
|
114
|
+
return (e, t) => p(a) && p(a).length ? (y(), z(M(u.element), {
|
|
73
115
|
key: 0,
|
|
74
116
|
class: "scroll-anchors__nav scroll-anchors__nav--animated scroll-anchors-nav-animated",
|
|
75
|
-
style:
|
|
117
|
+
style: g({ "--ulu-sa-nav-rail-width": `${u.railWidth}px` })
|
|
76
118
|
}, {
|
|
77
|
-
default:
|
|
78
|
-
|
|
79
|
-
|
|
119
|
+
default: T(() => [
|
|
120
|
+
_("ul", {
|
|
121
|
+
class: "scroll-anchors-nav-animated__rail",
|
|
122
|
+
ref_key: "listRef",
|
|
123
|
+
ref: f,
|
|
124
|
+
style: g(A.value)
|
|
125
|
+
}, [
|
|
126
|
+
(y(!0), k(W, null, E(p(a), (n, r) => (y(), k("li", {
|
|
80
127
|
key: r,
|
|
81
|
-
class:
|
|
128
|
+
class: S({ "is-active": n.active })
|
|
82
129
|
}, [
|
|
83
|
-
|
|
84
|
-
class:
|
|
130
|
+
_("a", {
|
|
131
|
+
class: S({ "is-active": n.active }),
|
|
85
132
|
ref_for: !0,
|
|
86
|
-
ref: (
|
|
87
|
-
href: `#${
|
|
133
|
+
ref: (o) => O(r, o),
|
|
134
|
+
href: `#${n.titleId}`
|
|
88
135
|
}, [
|
|
89
|
-
|
|
90
|
-
item:
|
|
136
|
+
H(e.$slots, "default", {
|
|
137
|
+
item: n,
|
|
91
138
|
index: r
|
|
92
139
|
}, () => [
|
|
93
|
-
|
|
140
|
+
I(V(n.title), 1)
|
|
94
141
|
])
|
|
95
|
-
], 10,
|
|
142
|
+
], 10, L)
|
|
96
143
|
], 2))), 128))
|
|
97
|
-
]),
|
|
98
|
-
|
|
99
|
-
class:
|
|
100
|
-
"scroll-anchors-nav-animated__indicator--can-transition":
|
|
144
|
+
], 4),
|
|
145
|
+
_("div", {
|
|
146
|
+
class: S(["scroll-anchors-nav-animated__indicator", {
|
|
147
|
+
"scroll-anchors-nav-animated__indicator--can-transition": d.value
|
|
101
148
|
}]),
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
height: `${e.value ? e.value.height : 0}px`,
|
|
108
|
-
width: `${e.value ? e.value.width : 0}px`
|
|
149
|
+
style: g({
|
|
150
|
+
opacity: i.value ? "1" : "0",
|
|
151
|
+
transform: `translateY(${i.value ? i.value.y : 0}px)`,
|
|
152
|
+
height: `${i.value ? i.value.height : 0}px`,
|
|
153
|
+
width: `${i.value ? i.value.width : 0}px`
|
|
109
154
|
})
|
|
110
155
|
}, null, 6)
|
|
111
156
|
]),
|
|
112
157
|
_: 3
|
|
113
|
-
}, 8, ["style"])) :
|
|
158
|
+
}, 8, ["style"])) : B("", !0);
|
|
114
159
|
}
|
|
115
160
|
};
|
|
116
161
|
export {
|
|
117
|
-
|
|
162
|
+
j as default
|
|
118
163
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollAnchors.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/useScrollAnchors.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useScrollAnchors.d.ts","sourceRoot":"","sources":["../../../../lib/components/systems/scroll-anchors/useScrollAnchors.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AACH,4EAFW;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,WAAW;IAAC,cAAc,EAAE,MAAM,CAAA;CAAC,QAsTnF"}
|
|
@@ -1,95 +1,138 @@
|
|
|
1
|
-
import { onMounted as
|
|
2
|
-
import { getScrollParent as
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { onMounted as P, onUnmounted as U, watch as Y, nextTick as L } from "vue";
|
|
2
|
+
import { getScrollParent as M } from "@ulu/utils/browser/dom.js";
|
|
3
|
+
import { debounce as j } from "@ulu/utils/performance.js";
|
|
4
|
+
function J({ sections: r, props: t, emit: T, componentElRef: m }) {
|
|
5
|
+
let a = null, O = 0, k = "down", d = null, x = !0;
|
|
6
|
+
function g(e) {
|
|
7
|
+
return r.value.findIndex(({ element: n }) => e === n);
|
|
7
8
|
}
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
function B(e, n, s = "down") {
|
|
10
|
+
if (!e) return;
|
|
11
|
+
const v = s === "down" ? "forward" : "reverse";
|
|
12
|
+
n ? (e.active = !0, e.inactiveFrom = null, e.activeFrom = v) : (e.active && (e.inactiveFrom = v, e.activeFrom = null), e.active = !1);
|
|
13
|
+
}
|
|
14
|
+
function $(e = null, n = "down") {
|
|
15
|
+
r.value.forEach((s) => {
|
|
16
|
+
s !== e && B(s, !1, n);
|
|
11
17
|
});
|
|
12
18
|
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
function h(e, n) {
|
|
20
|
+
e && !e.active && (t.debug && console.log("Activate:", e.title), L(() => {
|
|
21
|
+
$(e, n), B(e, !0, n), T("section-change", { section: e, sections: r.value, active: !0 });
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function I(e, n) {
|
|
25
|
+
const s = r.value.find((v) => v.active);
|
|
26
|
+
s && (t.debug && n && console.log(n, s.title), L(() => {
|
|
27
|
+
$(null, e), T("section-change", { section: s, sections: r.value, active: !1 });
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
function y() {
|
|
31
|
+
let e = null;
|
|
32
|
+
return t.observerOptions && t.observerOptions.root !== void 0 ? e = t.observerOptions.root : m.value && (e = M(m.value), e === document.scrollingElement && (e = null)), e || window;
|
|
33
|
+
}
|
|
34
|
+
const A = j(() => {
|
|
35
|
+
t.debug && console.log("New Observer (debounced/check)"), a && (a.disconnect(), a = null), E(), w();
|
|
36
|
+
}, 100);
|
|
37
|
+
function C() {
|
|
38
|
+
D(), d = y(), d && d.addEventListener("scroll", A, { passive: !0 });
|
|
39
|
+
}
|
|
40
|
+
function D() {
|
|
41
|
+
d && (d.removeEventListener("scroll", A), d = null);
|
|
42
|
+
}
|
|
43
|
+
function E() {
|
|
44
|
+
x = !0;
|
|
45
|
+
const e = (u) => {
|
|
46
|
+
const { root: R } = a, S = R ? R.scrollTop : document.documentElement.scrollTop || window.scrollY;
|
|
47
|
+
let i = k;
|
|
48
|
+
S > O ? i = "down" : S < O && (i = "up"), t.debug && (console.groupCollapsed(`Scroll: ${O} -> ${S} (${i})`), console.table(u.map((o) => ({
|
|
49
|
+
el: o.target.id || o.target.tagName,
|
|
50
|
+
int: o.isIntersecting,
|
|
51
|
+
ratio: o.intersectionRatio.toFixed(2)
|
|
52
|
+
})))), O = S, k = i;
|
|
53
|
+
const b = u.filter((o) => o.isIntersecting);
|
|
54
|
+
if (b.length > 0) {
|
|
55
|
+
b.sort((c, f) => g(c.target) - g(f.target));
|
|
56
|
+
const o = i === "down" ? b[b.length - 1] : b[0];
|
|
57
|
+
t.debug && console.log("Target:", o.target.id || o.target.tagName);
|
|
58
|
+
const l = r.value[g(o.target)];
|
|
59
|
+
h(l, i);
|
|
60
|
+
} else if (x) {
|
|
61
|
+
t.debug && console.log("Fallback: bounds");
|
|
62
|
+
let o = -1;
|
|
63
|
+
if (u.forEach((l) => {
|
|
64
|
+
const c = l.rootBounds ? l.rootBounds.top : 0;
|
|
65
|
+
if (l.boundingClientRect.top <= c + 1) {
|
|
66
|
+
const f = g(l.target);
|
|
67
|
+
f > o && (o = f);
|
|
68
|
+
}
|
|
69
|
+
}), o > -1) {
|
|
70
|
+
const l = o === r.value.length - 1, c = r.value[o];
|
|
71
|
+
if (l && t.deactivateLastItem) {
|
|
72
|
+
const f = u.find((H) => H.target === c.element), F = f.rootBounds ? f.rootBounds.bottom : window.innerHeight;
|
|
73
|
+
f && f.boundingClientRect.bottom < F ? I(i, "Deactivate (last):") : h(c, i);
|
|
74
|
+
} else
|
|
75
|
+
h(c, i);
|
|
76
|
+
} else if (t.debug && console.log("Fallback: top"), !t.firstItemActive)
|
|
77
|
+
I(i, "Deactivate (top):");
|
|
78
|
+
else {
|
|
79
|
+
const l = r.value[0];
|
|
80
|
+
h(l, i);
|
|
81
|
+
}
|
|
33
82
|
} else {
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
const
|
|
40
|
-
(
|
|
41
|
-
w(null, s), h("section-change", { section: n, sections: i.value, active: !1 });
|
|
42
|
-
}));
|
|
83
|
+
t.debug && console.log("Check edges");
|
|
84
|
+
const o = r.value.find((l) => l.active);
|
|
85
|
+
if (o) {
|
|
86
|
+
const l = u.find((c) => c.target === o.element);
|
|
87
|
+
if (l && !l.isIntersecting) {
|
|
88
|
+
const c = g(l.target), f = c === 0, F = c === r.value.length - 1;
|
|
89
|
+
(f && i === "up" && !t.firstItemActive || F && i === "down" && t.deactivateLastItem) && I(i, "Deactivate (edge):");
|
|
43
90
|
}
|
|
44
91
|
}
|
|
45
92
|
}
|
|
46
|
-
|
|
93
|
+
x = !1, t.debug && console.groupEnd();
|
|
47
94
|
};
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
let
|
|
95
|
+
let n = null;
|
|
96
|
+
t.observerOptions && t.observerOptions.root !== void 0 ? n = t.observerOptions.root : m.value && (n = M(m.value), n === document.scrollingElement && (n = null));
|
|
97
|
+
let s = {
|
|
51
98
|
rootMargin: "-25% 0px -55% 0px",
|
|
52
99
|
threshold: 0
|
|
53
100
|
};
|
|
54
|
-
if (
|
|
55
|
-
const
|
|
56
|
-
|
|
101
|
+
if (t.snapOffset !== !1 && t.snapOffset !== void 0) {
|
|
102
|
+
const u = t.snapOffset === !0 ? 20 : Number(t.snapOffset);
|
|
103
|
+
s.rootMargin = `-${u}% 0px -${99 - u}% 0px`;
|
|
57
104
|
}
|
|
58
|
-
const
|
|
59
|
-
...
|
|
60
|
-
...
|
|
61
|
-
root:
|
|
105
|
+
const v = {
|
|
106
|
+
...s,
|
|
107
|
+
...t.observerOptions || {},
|
|
108
|
+
root: n
|
|
62
109
|
};
|
|
63
|
-
|
|
110
|
+
a = new IntersectionObserver(e, v);
|
|
64
111
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
function w() {
|
|
113
|
+
a && (a.disconnect(), r.value.forEach(({ element: e }) => {
|
|
114
|
+
e && a.observe(e);
|
|
68
115
|
}));
|
|
69
116
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
117
|
+
function N() {
|
|
118
|
+
a && (a.disconnect(), a = null);
|
|
72
119
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
x();
|
|
81
|
-
}), F(() => i.value.length, () => {
|
|
82
|
-
O(() => {
|
|
83
|
-
b();
|
|
120
|
+
P(() => {
|
|
121
|
+
E(), w(), C();
|
|
122
|
+
}), U(() => {
|
|
123
|
+
N(), D(), A.cancel();
|
|
124
|
+
}), Y(() => r.value.length, () => {
|
|
125
|
+
L(() => {
|
|
126
|
+
w();
|
|
84
127
|
});
|
|
85
|
-
}),
|
|
86
|
-
() => [
|
|
128
|
+
}), Y(
|
|
129
|
+
() => [t.snapOffset, t.observerOptions],
|
|
87
130
|
() => {
|
|
88
|
-
|
|
131
|
+
N(), E(), w(), C();
|
|
89
132
|
},
|
|
90
133
|
{ deep: !0 }
|
|
91
134
|
);
|
|
92
135
|
}
|
|
93
136
|
export {
|
|
94
|
-
|
|
137
|
+
J as useScrollAnchors
|
|
95
138
|
};
|
|
@@ -39,7 +39,15 @@
|
|
|
39
39
|
/**
|
|
40
40
|
* Enable debug logging for the IntersectionObserver
|
|
41
41
|
*/
|
|
42
|
-
debug: Boolean
|
|
42
|
+
debug: Boolean,
|
|
43
|
+
/**
|
|
44
|
+
* If true, the last section will deactivate when scrolling past its bounding box.
|
|
45
|
+
* By default, the last section remains active until the user scrolls back up.
|
|
46
|
+
*/
|
|
47
|
+
deactivateLastItem: {
|
|
48
|
+
type: Boolean,
|
|
49
|
+
default: false
|
|
50
|
+
}
|
|
43
51
|
});
|
|
44
52
|
|
|
45
53
|
const emit = defineEmits(["section-change"]);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
class="scroll-anchors__nav scroll-anchors__nav--animated scroll-anchors-nav-animated"
|
|
6
6
|
:style="{ '--ulu-sa-nav-rail-width': `${ railWidth }px` }"
|
|
7
7
|
>
|
|
8
|
-
<ul class="scroll-anchors-nav-animated__rail">
|
|
8
|
+
<ul class="scroll-anchors-nav-animated__rail" ref="listRef" :style="railStyles">
|
|
9
9
|
<li
|
|
10
10
|
v-for="(item, index) in sections" :key="index"
|
|
11
11
|
:class="{ 'is-active' : item.active }"
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
:class="{
|
|
27
27
|
'scroll-anchors-nav-animated__indicator--can-transition' : indicatorAnimReady
|
|
28
28
|
}"
|
|
29
|
-
ref="indicator"
|
|
30
29
|
:style="{
|
|
31
30
|
opacity: indicatorStyles ? '1' : '0',
|
|
32
31
|
transform: `translateY(${ indicatorStyles ? indicatorStyles.y : 0 }px)`,
|
|
@@ -38,9 +37,9 @@
|
|
|
38
37
|
</template>
|
|
39
38
|
|
|
40
39
|
<script setup>
|
|
41
|
-
import { ref, computed, watch } from
|
|
40
|
+
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
|
42
41
|
import { runAfterFramePaint } from "@ulu/utils/browser/performance.js";
|
|
43
|
-
import { useScrollAnchorSections } from
|
|
42
|
+
import { useScrollAnchorSections } from "./useScrollAnchorSections.js";
|
|
44
43
|
|
|
45
44
|
const props = defineProps({
|
|
46
45
|
/**
|
|
@@ -57,6 +56,26 @@
|
|
|
57
56
|
type: Number,
|
|
58
57
|
default: 3
|
|
59
58
|
},
|
|
59
|
+
/**
|
|
60
|
+
* Dynamically trims the rail to span exactly from the center of the first indicator to the center of the last indicator. Disabled by default
|
|
61
|
+
*/
|
|
62
|
+
trimRailToCenters: {
|
|
63
|
+
type: Boolean
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Pixel offset for the start (top) of the dynamic rail.
|
|
67
|
+
*/
|
|
68
|
+
railStartOffset: {
|
|
69
|
+
type: Number,
|
|
70
|
+
default: 0
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* Pixel offset for the end (bottom) of the dynamic rail.
|
|
74
|
+
*/
|
|
75
|
+
railEndOffset: {
|
|
76
|
+
type: Number,
|
|
77
|
+
default: 0
|
|
78
|
+
},
|
|
60
79
|
/**
|
|
61
80
|
* The width of the indicator, defaults to railWidth
|
|
62
81
|
*/
|
|
@@ -76,7 +95,7 @@
|
|
|
76
95
|
*/
|
|
77
96
|
indicatorAlignment: {
|
|
78
97
|
type: String,
|
|
79
|
-
default:
|
|
98
|
+
default: "center" // options: center, top
|
|
80
99
|
},
|
|
81
100
|
/**
|
|
82
101
|
* Pixel offset for the indicator's vertical alignment
|
|
@@ -87,22 +106,42 @@
|
|
|
87
106
|
}
|
|
88
107
|
});
|
|
89
108
|
|
|
109
|
+
// State from the scroll anchor system
|
|
90
110
|
const sections = useScrollAnchorSections();
|
|
91
111
|
|
|
112
|
+
// Template refs for the list and individual links
|
|
113
|
+
const listRef = ref(null);
|
|
92
114
|
const linkRefs = ref({});
|
|
115
|
+
|
|
116
|
+
// Flag to enable CSS transitions only after initial placement
|
|
93
117
|
const indicatorAnimReady = ref(false);
|
|
94
|
-
const indicator = ref(null);
|
|
95
118
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
// Resize observer to recalculate metrics on layout shifts
|
|
120
|
+
const resizeTrigger = ref(0);
|
|
121
|
+
let resizeObserver = null;
|
|
122
|
+
|
|
123
|
+
onMounted(() => {
|
|
124
|
+
if (listRef.value) {
|
|
125
|
+
resizeObserver = new ResizeObserver(() => {
|
|
126
|
+
resizeTrigger.value++;
|
|
127
|
+
});
|
|
128
|
+
resizeObserver.observe(listRef.value);
|
|
99
129
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
onBeforeUnmount(() => {
|
|
133
|
+
if (resizeObserver) {
|
|
134
|
+
resizeObserver.disconnect();
|
|
135
|
+
resizeObserver = null;
|
|
103
136
|
}
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Helper to calculate the target position/size of the indicator for a specific item
|
|
141
|
+
*/
|
|
142
|
+
function getIndicatorMetrics(index) {
|
|
143
|
+
const link = linkRefs.value[index];
|
|
144
|
+
if (!link) return null;
|
|
106
145
|
|
|
107
146
|
const { offsetTop, offsetHeight } = link;
|
|
108
147
|
const isStatic = props.indicatorHeight != null;
|
|
@@ -110,15 +149,54 @@
|
|
|
110
149
|
const height = isStatic ? props.indicatorHeight : offsetHeight;
|
|
111
150
|
|
|
112
151
|
let y = offsetTop; // Default to 'top' alignment
|
|
113
|
-
if (props.indicatorAlignment ===
|
|
152
|
+
if (props.indicatorAlignment === "center") {
|
|
114
153
|
y = offsetTop + (offsetHeight / 2) - (height / 2);
|
|
115
154
|
}
|
|
116
155
|
|
|
117
156
|
y += props.indicatorAlignmentOffset;
|
|
118
157
|
|
|
119
158
|
return { y, height, width };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Active indicator styles
|
|
162
|
+
const indicatorStyles = computed(() => {
|
|
163
|
+
resizeTrigger.value; // Re-evaluate on resize
|
|
164
|
+
if (!sections || !sections.value || !sections.value.length) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const activeIndex = sections.value.findIndex(s => s.active);
|
|
168
|
+
if (activeIndex === -1) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return getIndicatorMetrics(activeIndex) || false;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Background rail styles (trimmed to start/end of indicators)
|
|
175
|
+
const railStyles = computed(() => {
|
|
176
|
+
resizeTrigger.value; // Re-evaluate on resize
|
|
177
|
+
if (!props.trimRailToCenters) return {};
|
|
178
|
+
if (!sections || !sections.value || sections.value.length < 1) return {};
|
|
179
|
+
|
|
180
|
+
const firstMetrics = getIndicatorMetrics(0);
|
|
181
|
+
const lastMetrics = getIndicatorMetrics(sections.value.length - 1);
|
|
182
|
+
|
|
183
|
+
if (!firstMetrics || !lastMetrics) return {};
|
|
184
|
+
|
|
185
|
+
let top = firstMetrics.y + (firstMetrics.height / 2);
|
|
186
|
+
let bottom = lastMetrics.y + (lastMetrics.height / 2);
|
|
187
|
+
|
|
188
|
+
top += props.railStartOffset;
|
|
189
|
+
bottom += props.railEndOffset;
|
|
190
|
+
|
|
191
|
+
const height = Math.max(0, bottom - top);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"--ulu-sa-nav-rail-top": `${top}px`,
|
|
195
|
+
"--ulu-sa-nav-rail-height": `${height}px`
|
|
196
|
+
};
|
|
120
197
|
});
|
|
121
198
|
|
|
199
|
+
// Allow transition after initial styles are applied
|
|
122
200
|
watch(indicatorStyles, (val) => {
|
|
123
201
|
if (val && !indicatorAnimReady.value) {
|
|
124
202
|
runAfterFramePaint(() => {
|
|
@@ -127,9 +205,10 @@
|
|
|
127
205
|
}
|
|
128
206
|
});
|
|
129
207
|
|
|
208
|
+
// Helper to store link template refs
|
|
130
209
|
function addLinkRef(index, el) {
|
|
131
210
|
if (el) {
|
|
132
211
|
linkRefs.value[index] = el;
|
|
133
212
|
}
|
|
134
213
|
}
|
|
135
|
-
</script>
|
|
214
|
+
</script>
|
|
@@ -49,8 +49,18 @@ $config: (
|
|
|
49
49
|
position: relative;
|
|
50
50
|
}
|
|
51
51
|
#{ $prefix }__rail {
|
|
52
|
-
border-left: var(--ulu-sa-nav-rail-width, 3px) solid color.get(get("rail-border-color"));
|
|
53
52
|
padding-left: get("rail-padding");
|
|
53
|
+
|
|
54
|
+
&::before {
|
|
55
|
+
content: "";
|
|
56
|
+
position: absolute;
|
|
57
|
+
left: 0;
|
|
58
|
+
top: var(--ulu-sa-nav-rail-top, 0);
|
|
59
|
+
height: var(--ulu-sa-nav-rail-height, 100%);
|
|
60
|
+
width: var(--ulu-sa-nav-rail-width, 3px);
|
|
61
|
+
background-color: color.get(get("rail-border-color"));
|
|
62
|
+
z-index: 0;
|
|
63
|
+
}
|
|
54
64
|
}
|
|
55
65
|
#{ $prefix }__indicator {
|
|
56
66
|
position: absolute;
|
|
@@ -58,6 +68,7 @@ $config: (
|
|
|
58
68
|
left: 0;
|
|
59
69
|
background-color: color.get(get("indicator-color"));
|
|
60
70
|
clip-path: get("indicator-clip-path");
|
|
71
|
+
z-index: 1;
|
|
61
72
|
}
|
|
62
73
|
#{ $prefix }__indicator--can-transition {
|
|
63
74
|
transition-property: height, transform, width;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { onMounted, onUnmounted, nextTick, watch } from "vue";
|
|
2
2
|
import { getScrollParent } from "@ulu/utils/browser/dom.js";
|
|
3
|
+
import { debounce } from "@ulu/utils/performance.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The main composable that contains the core "engine" for the Scroll Anchors system.
|
|
@@ -10,96 +11,236 @@ import { getScrollParent } from "@ulu/utils/browser/dom.js";
|
|
|
10
11
|
*/
|
|
11
12
|
export function useScrollAnchors({ sections, props, emit, componentElRef }) {
|
|
12
13
|
let observer = null;
|
|
14
|
+
let lastScrollY = 0;
|
|
15
|
+
let lastScrollDirection = 'down';
|
|
16
|
+
let scrollRoot = null;
|
|
17
|
+
let isObserverRefresh = true;
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Helper to quickly get the index of a section by its DOM element
|
|
21
|
+
*/
|
|
14
22
|
function getSectionIndex(el) {
|
|
15
23
|
return sections.value.findIndex(({ element }) => el === element);
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Standardizes state assignments (active, forward/reverse animations) for a specific section
|
|
28
|
+
*/
|
|
29
|
+
function setSectionState(section, isActive, scrollDirection = 'down') {
|
|
30
|
+
if (!section) return;
|
|
31
|
+
const direction = scrollDirection === 'down' ? 'forward' : 'reverse';
|
|
32
|
+
if (isActive) {
|
|
33
|
+
section.active = true;
|
|
34
|
+
section.inactiveFrom = null;
|
|
35
|
+
section.activeFrom = direction;
|
|
36
|
+
} else {
|
|
37
|
+
if (section.active) {
|
|
38
|
+
section.inactiveFrom = direction;
|
|
39
|
+
section.activeFrom = null;
|
|
40
|
+
}
|
|
41
|
+
section.active = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Loops through all sections and forces them to inactive, optionally skipping one target
|
|
47
|
+
*/
|
|
18
48
|
function removeActive(except = null, scrollDirection = 'down') {
|
|
19
49
|
sections.value.forEach(s => {
|
|
20
50
|
if (s !== except) {
|
|
21
|
-
|
|
22
|
-
s.inactiveFrom = scrollDirection === 'down' ? 'forward' : 'reverse';
|
|
23
|
-
s.activeFrom = null;
|
|
24
|
-
}
|
|
25
|
-
s.active = false;
|
|
51
|
+
setSectionState(s, false, scrollDirection);
|
|
26
52
|
}
|
|
27
53
|
});
|
|
28
54
|
}
|
|
29
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Safe wrapper to clear other sections, make a new target active, and emit the change event
|
|
58
|
+
*/
|
|
59
|
+
function setSectionActive(section, scrollDirection) {
|
|
60
|
+
if (section && !section.active) {
|
|
61
|
+
if (props.debug) console.log("Activate:", section.title);
|
|
62
|
+
nextTick(() => {
|
|
63
|
+
removeActive(section, scrollDirection);
|
|
64
|
+
setSectionState(section, true, scrollDirection);
|
|
65
|
+
emit("section-change", { section, sections: sections.value, active: true });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clears the currently active item and emits a deactivation event (used at top/bottom scroll edges)
|
|
72
|
+
*/
|
|
73
|
+
function deactivateAll(scrollDirection, debugMsg) {
|
|
74
|
+
const activeSection = sections.value.find(s => s.active);
|
|
75
|
+
if (activeSection) {
|
|
76
|
+
if (props.debug && debugMsg) console.log(debugMsg, activeSection.title);
|
|
77
|
+
nextTick(() => {
|
|
78
|
+
removeActive(null, scrollDirection);
|
|
79
|
+
emit("section-change", { section: activeSection, sections: sections.value, active: false });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves the scrolling container element based on options, DOM hierarchy, or defaults to window
|
|
86
|
+
*/
|
|
87
|
+
function getScrollRoot() {
|
|
88
|
+
let root = null;
|
|
89
|
+
if (props.observerOptions && props.observerOptions.root !== undefined) {
|
|
90
|
+
root = props.observerOptions.root;
|
|
91
|
+
} else if (componentElRef.value) {
|
|
92
|
+
root = getScrollParent(componentElRef.value);
|
|
93
|
+
if (root === document.scrollingElement) {
|
|
94
|
+
root = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return root || window;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Debounced callback attached to the scroll listener.
|
|
102
|
+
* Rebuilding the observer ensures a fresh set of entries representing the true layout when scrolling stops.
|
|
103
|
+
*/
|
|
104
|
+
const refreshObserver = debounce(() => {
|
|
105
|
+
if (props.debug) console.log("New Observer (debounced/check)");
|
|
106
|
+
if (observer) {
|
|
107
|
+
observer.disconnect();
|
|
108
|
+
observer = null;
|
|
109
|
+
}
|
|
110
|
+
createObserver();
|
|
111
|
+
observeItems();
|
|
112
|
+
}, 100);
|
|
113
|
+
|
|
114
|
+
function setupScrollListener() {
|
|
115
|
+
removeScrollListener();
|
|
116
|
+
scrollRoot = getScrollRoot();
|
|
117
|
+
if (scrollRoot) {
|
|
118
|
+
scrollRoot.addEventListener('scroll', refreshObserver, { passive: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function removeScrollListener() {
|
|
123
|
+
if (scrollRoot) {
|
|
124
|
+
scrollRoot.removeEventListener('scroll', refreshObserver);
|
|
125
|
+
scrollRoot = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Primary logic core. Creates the IntersectionObserver and handles state changes.
|
|
131
|
+
*/
|
|
30
132
|
function createObserver() {
|
|
31
|
-
|
|
32
|
-
let isInitialObservation = true;
|
|
133
|
+
isObserverRefresh = true;
|
|
33
134
|
|
|
34
135
|
const onObserve = (entries) => {
|
|
35
136
|
const { root } = observer;
|
|
36
137
|
const currentScrollY = root ? root.scrollTop : document.documentElement.scrollTop || window.scrollY;
|
|
37
138
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
139
|
+
// Determine the direction of the scroll
|
|
140
|
+
let scrollDirection = lastScrollDirection;
|
|
141
|
+
if (currentScrollY > lastScrollY) {
|
|
142
|
+
scrollDirection = 'down';
|
|
143
|
+
} else if (currentScrollY < lastScrollY) {
|
|
144
|
+
scrollDirection = 'up';
|
|
43
145
|
}
|
|
44
146
|
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
147
|
+
if (props.debug) {
|
|
148
|
+
console.groupCollapsed(`Scroll: ${lastScrollY} -> ${currentScrollY} (${scrollDirection})`);
|
|
149
|
+
console.table(entries.map(e => ({
|
|
150
|
+
el: e.target.id || e.target.tagName,
|
|
151
|
+
int: e.isIntersecting,
|
|
152
|
+
ratio: e.intersectionRatio.toFixed(2)
|
|
153
|
+
})));
|
|
51
154
|
}
|
|
52
|
-
isInitialObservation = false;
|
|
53
155
|
|
|
54
|
-
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up';
|
|
55
156
|
lastScrollY = currentScrollY;
|
|
56
|
-
|
|
157
|
+
lastScrollDirection = scrollDirection;
|
|
57
158
|
|
|
58
159
|
const intersectingEntries = entries.filter(entry => entry.isIntersecting);
|
|
59
|
-
if (props.debug) console.log("Intersecting entries:", intersectingEntries.map(e => e.target));
|
|
60
160
|
|
|
161
|
+
// Strategy 1: Standard intersection detection
|
|
61
162
|
if (intersectingEntries.length > 0) {
|
|
62
163
|
intersectingEntries.sort((a, b) => getSectionIndex(a.target) - getSectionIndex(b.target));
|
|
63
164
|
|
|
165
|
+
// Choose the most appropriate intersecting entry based on scroll direction
|
|
64
166
|
const targetEntry = scrollDirection === 'down'
|
|
65
167
|
? intersectingEntries[intersectingEntries.length - 1]
|
|
66
168
|
: intersectingEntries[0];
|
|
67
|
-
|
|
169
|
+
|
|
170
|
+
if (props.debug) console.log("Target:", targetEntry.target.id || targetEntry.target.tagName);
|
|
68
171
|
|
|
69
172
|
const sectionToActivate = sections.value[getSectionIndex(targetEntry.target)];
|
|
70
|
-
|
|
71
|
-
if (sectionToActivate && !sectionToActivate.active) {
|
|
72
|
-
if (props.debug) console.log("Activating section:", sectionToActivate.title);
|
|
73
|
-
nextTick(() => {
|
|
74
|
-
removeActive(sectionToActivate, scrollDirection);
|
|
75
|
-
sectionToActivate.active = true;
|
|
76
|
-
sectionToActivate.inactiveFrom = null;
|
|
77
|
-
sectionToActivate.activeFrom = scrollDirection === 'down' ? 'forward' : 'reverse';
|
|
78
|
-
emit("section-change", { section: sectionToActivate, sections: sections.value, active: true });
|
|
79
|
-
});
|
|
80
|
-
}
|
|
173
|
+
setSectionActive(sectionToActivate, scrollDirection);
|
|
81
174
|
} else {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
175
|
+
// Strategy 2: Absolute positioning fallback (fired automatically on fresh observer load)
|
|
176
|
+
// Used to instantly catch sections we skipped entirely during warp-speed scrolling.
|
|
177
|
+
if (isObserverRefresh) {
|
|
178
|
+
if (props.debug) console.log("Fallback: bounds");
|
|
179
|
+
|
|
180
|
+
let highestAboveIndex = -1;
|
|
181
|
+
entries.forEach(entry => {
|
|
182
|
+
const rootTop = entry.rootBounds ? entry.rootBounds.top : 0;
|
|
183
|
+
// +1 buffer for fractional pixels, check if element is above the reading zone
|
|
184
|
+
if (entry.boundingClientRect.top <= rootTop + 1) {
|
|
185
|
+
const idx = getSectionIndex(entry.target);
|
|
186
|
+
if (idx > highestAboveIndex) {
|
|
187
|
+
highestAboveIndex = idx;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Activate the last section that is above the reading zone viewport
|
|
193
|
+
if (highestAboveIndex > -1) {
|
|
194
|
+
const isLast = highestAboveIndex === sections.value.length - 1;
|
|
195
|
+
const targetSection = sections.value[highestAboveIndex];
|
|
196
|
+
|
|
197
|
+
// Edge case: User scrolled past the bottom of the very last section into a footer
|
|
198
|
+
if (isLast && props.deactivateLastItem) {
|
|
199
|
+
const lastEntry = entries.find(e => e.target === targetSection.element);
|
|
200
|
+
const rootBottom = lastEntry.rootBounds ? lastEntry.rootBounds.bottom : window.innerHeight;
|
|
201
|
+
if (lastEntry && lastEntry.boundingClientRect.bottom < rootBottom) {
|
|
202
|
+
deactivateAll(scrollDirection, "Deactivate (last):");
|
|
203
|
+
} else {
|
|
204
|
+
setSectionActive(targetSection, scrollDirection);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
setSectionActive(targetSection, scrollDirection);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// No elements are above the reading zone viewport, we are above the first item
|
|
211
|
+
if (props.debug) console.log("Fallback: top");
|
|
212
|
+
if (!props.firstItemActive) {
|
|
213
|
+
deactivateAll(scrollDirection, "Deactivate (top):");
|
|
214
|
+
} else {
|
|
215
|
+
const firstSection = sections.value[0];
|
|
216
|
+
setSectionActive(firstSection, scrollDirection);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
// Strategy 3: Real-time edge deactivation
|
|
221
|
+
// Fires as user slowly scrolls out of the bounds of the first or last items
|
|
222
|
+
if (props.debug) console.log("Check edges");
|
|
223
|
+
const activeSection = sections.value.find(s => s.active);
|
|
224
|
+
if (activeSection) {
|
|
225
|
+
const entryForActive = entries.find(e => e.target === activeSection.element);
|
|
226
|
+
if (entryForActive && !entryForActive.isIntersecting) {
|
|
227
|
+
const index = getSectionIndex(entryForActive.target);
|
|
228
|
+
const isFirst = index === 0;
|
|
229
|
+
const isLast = index === sections.value.length - 1;
|
|
230
|
+
if ((isFirst && scrollDirection === 'up' && !props.firstItemActive) || (isLast && scrollDirection === 'down' && props.deactivateLastItem)) {
|
|
231
|
+
deactivateAll(scrollDirection, "Deactivate (edge):");
|
|
232
|
+
}
|
|
96
233
|
}
|
|
97
234
|
}
|
|
98
235
|
}
|
|
99
236
|
}
|
|
237
|
+
|
|
238
|
+
// Observer refresh cycle completed
|
|
239
|
+
isObserverRefresh = false;
|
|
100
240
|
if (props.debug) console.groupEnd();
|
|
101
241
|
};
|
|
102
242
|
|
|
243
|
+
// Calculate options and margins for the reading zone
|
|
103
244
|
let root = null;
|
|
104
245
|
if (props.observerOptions && props.observerOptions.root !== undefined) {
|
|
105
246
|
root = props.observerOptions.root;
|
|
@@ -147,32 +288,32 @@ export function useScrollAnchors({ sections, props, emit, componentElRef }) {
|
|
|
147
288
|
}
|
|
148
289
|
|
|
149
290
|
onMounted(() => {
|
|
150
|
-
if (props.firstItemActive && sections.value.length > 0) {
|
|
151
|
-
const first = sections.value[0];
|
|
152
|
-
if (first) {
|
|
153
|
-
first.active = true;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
291
|
createObserver();
|
|
157
292
|
observeItems();
|
|
293
|
+
setupScrollListener();
|
|
158
294
|
});
|
|
159
295
|
|
|
160
296
|
onUnmounted(() => {
|
|
161
297
|
destroyObserver();
|
|
298
|
+
removeScrollListener();
|
|
299
|
+
refreshObserver.cancel();
|
|
162
300
|
});
|
|
163
301
|
|
|
302
|
+
// Re-observe items dynamically if the section list grows/shrinks
|
|
164
303
|
watch(() => sections.value.length, () => {
|
|
165
304
|
nextTick(() => {
|
|
166
305
|
observeItems();
|
|
167
306
|
});
|
|
168
307
|
});
|
|
169
308
|
|
|
309
|
+
// Hot swap the observer if the developer changes options or snap settings
|
|
170
310
|
watch(
|
|
171
311
|
() => [props.snapOffset, props.observerOptions],
|
|
172
312
|
() => {
|
|
173
313
|
destroyObserver();
|
|
174
314
|
createObserver();
|
|
175
315
|
observeItems();
|
|
316
|
+
setupScrollListener();
|
|
176
317
|
},
|
|
177
318
|
{ deep: true }
|
|
178
319
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulu/frontend-vue",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
4
4
|
"description": "A modular, tree-shakeable Vue 3 component library for the Ulu Frontend theming system, plus general utilities for Vue development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
|
66
66
|
"@headlessui/vue": "^1.7.23",
|
|
67
67
|
"@portabletext/vue": "^1.0.14",
|
|
68
|
-
"@ulu/frontend": "^0.
|
|
68
|
+
"@ulu/frontend": "^0.5.0",
|
|
69
69
|
"@ulu/utils": "^0.0.34",
|
|
70
70
|
"@unhead/vue": "^2.0.11",
|
|
71
71
|
"fuse.js": "^6.6.2",
|