Skip to content

Commit a2f3254

Browse files
feat(core): scroll-spy-controller hashchange (#2900)
* feat(core): scroll-spy-controller hashchange * style: whitespace * fix(core): add isServer to scrollspy controller Co-authored-by: Steven Spriggs <[email protected]> * fix: oops * docs: changeset --------- Co-authored-by: Steven Spriggs <[email protected]>
1 parent 0277045 commit a2f3254

File tree

2 files changed

+63
-20
lines changed

2 files changed

+63
-20
lines changed

.changeset/six-phones-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@patternfly/pfe-core": patch
3+
---
4+
5+
`ScrollSpyController`: respond to hashchange events

core/pfe-core/controllers/scroll-spy-controller.ts

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactiveController, ReactiveControllerHost } from 'lit';
1+
import { isServer, type ReactiveController, type ReactiveControllerHost } from 'lit';
22

33
export interface ScrollSpyControllerOptions extends IntersectionObserverInit {
44
/**
@@ -18,11 +18,13 @@ export interface ScrollSpyControllerOptions extends IntersectionObserverInit {
1818
* @default the host's root node
1919
*/
2020
rootNode?: Node;
21+
2122
/**
2223
* function to call on link children to get their URL hash (i.e. id to scroll to)
2324
* @default el => el.getAttribute('href');
2425
*/
2526
getHash?: (el: Element) => string | null;
27+
2628
/**
2729
* Optional callback for when an intersection occurs
2830
*/
@@ -33,16 +35,24 @@ export class ScrollSpyController implements ReactiveController {
3335
static #instances = new Set<ScrollSpyController>;
3436

3537
static {
36-
addEventListener('scroll', () => {
37-
if (Math.round(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
38+
if (!isServer) {
39+
addEventListener('scroll', () => {
40+
if (Math.round(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
41+
this.#instances.forEach(ssc => {
42+
ssc.#setActive(ssc.#linkChildren.at(-1));
43+
});
44+
}
45+
}, { passive: true });
46+
addEventListener('hashchange', () => {
3847
this.#instances.forEach(ssc => {
39-
ssc.#setActive(ssc.#linkChildren.at(-1));
48+
ssc.#activateHash();
4049
});
41-
}
42-
}, { passive: true });
50+
});
51+
}
4352
}
4453

4554
#tagNames: string[];
55+
4656
#activeAttribute: string;
4757

4858
#io?: IntersectionObserver;
@@ -57,17 +67,28 @@ export class ScrollSpyController implements ReactiveController {
5767
#intersected = false;
5868

5969
#root: ScrollSpyControllerOptions['root'];
70+
6071
#rootMargin?: string;
72+
6173
#threshold: number | number[];
62-
#intersectingElements: Element[] = [];
74+
75+
#intersectingTargets = new Set<Element>();
76+
77+
#linkTargetMap = new Map<Element, Element | null>();
6378

6479
#getRootNode: () => Node | null;
80+
6581
#getHash: (el: Element) => string | null;
82+
6683
#onIntersection?: () => void;
6784

6885
get #linkChildren(): Element[] {
69-
return Array.from(this.host.querySelectorAll(this.#tagNames.join(',')))
70-
.filter(this.#getHash);
86+
if (isServer) {
87+
return [];
88+
} else {
89+
return Array.from(this.host.querySelectorAll(this.#tagNames.join(',')))
90+
.filter(this.#getHash);
91+
}
7192
}
7293

7394
get root(): Element | Document | null | undefined {
@@ -132,12 +153,16 @@ export class ScrollSpyController implements ReactiveController {
132153
if (rootNode instanceof Document || rootNode instanceof ShadowRoot) {
133154
const { rootMargin, threshold, root } = this;
134155
this.#io = new IntersectionObserver(r => this.#onIo(r), { root, rootMargin, threshold });
135-
this.#linkChildren
136-
.map(x => this.#getHash(x))
137-
.filter((x): x is string => !!x)
138-
.map(x => rootNode.getElementById(x.replace('#', '')))
139-
.filter((x): x is HTMLElement => !!x)
140-
.forEach(target => this.#io?.observe(target));
156+
for (const link of this.#linkChildren) {
157+
const id = this.#getHash(link)?.replace('#', '');
158+
if (id) {
159+
const target = document.getElementById(id);
160+
if (target) {
161+
this.#io?.observe(target);
162+
this.#linkTargetMap.set(link, target);
163+
}
164+
}
165+
}
141166
}
142167
}
143168

@@ -155,6 +180,17 @@ export class ScrollSpyController implements ReactiveController {
155180
}
156181
}
157182

183+
async #activateHash() {
184+
const links = this.#linkChildren;
185+
const { hash } = location;
186+
if (!hash) {
187+
this.setActive(links.at(0) ?? null);
188+
} else {
189+
await this.#nextIntersection();
190+
this.setActive(links.find(x => this.#getHash(x) === hash) ?? null);
191+
}
192+
}
193+
158194
async #nextIntersection() {
159195
this.#intersected = false;
160196
// safeguard the loop
@@ -178,13 +214,15 @@ export class ScrollSpyController implements ReactiveController {
178214
this.#setActive(last ?? this.#linkChildren.at(0));
179215
}
180216
this.#intersected = true;
181-
this.#intersectingElements =
182-
entries
183-
.filter(x => x.isIntersecting)
184-
.map(x => x.target);
217+
this.#intersectingTargets.clear();
218+
for (const entry of entries) {
219+
if (entry.isIntersecting) {
220+
this.#intersectingTargets.add(entry.target);
221+
}
222+
}
185223
if (this.#initializing) {
186224
const ints = entries?.filter(x => x.isIntersecting) ?? [];
187-
if (this.#intersectingElements) {
225+
if (this.#intersectingTargets.size > 0) {
188226
const [{ target = null } = {}] = ints;
189227
const { id } = target ?? {};
190228
if (id) {

0 commit comments

Comments
 (0)