diff --git a/.changeset/six-phones-joke.md b/.changeset/six-phones-joke.md new file mode 100644 index 0000000000..ad5d4aae06 --- /dev/null +++ b/.changeset/six-phones-joke.md @@ -0,0 +1,5 @@ +--- +"@patternfly/pfe-core": patch +--- + +`ScrollSpyController`: respond to hashchange events diff --git a/core/pfe-core/controllers/scroll-spy-controller.ts b/core/pfe-core/controllers/scroll-spy-controller.ts index f55e89bfa6..5edd597f7a 100644 --- a/core/pfe-core/controllers/scroll-spy-controller.ts +++ b/core/pfe-core/controllers/scroll-spy-controller.ts @@ -1,4 +1,4 @@ -import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import { isServer, type ReactiveController, type ReactiveControllerHost } from 'lit'; export interface ScrollSpyControllerOptions extends IntersectionObserverInit { /** @@ -18,11 +18,13 @@ export interface ScrollSpyControllerOptions extends IntersectionObserverInit { * @default the host's root node */ rootNode?: Node; + /** * function to call on link children to get their URL hash (i.e. id to scroll to) * @default el => el.getAttribute('href'); */ getHash?: (el: Element) => string | null; + /** * Optional callback for when an intersection occurs */ @@ -33,16 +35,24 @@ export class ScrollSpyController implements ReactiveController { static #instances = new Set; static { - addEventListener('scroll', () => { - if (Math.round(window.innerHeight + window.scrollY) >= document.body.scrollHeight) { + if (!isServer) { + addEventListener('scroll', () => { + if (Math.round(window.innerHeight + window.scrollY) >= document.body.scrollHeight) { + this.#instances.forEach(ssc => { + ssc.#setActive(ssc.#linkChildren.at(-1)); + }); + } + }, { passive: true }); + addEventListener('hashchange', () => { this.#instances.forEach(ssc => { - ssc.#setActive(ssc.#linkChildren.at(-1)); + ssc.#activateHash(); }); - } - }, { passive: true }); + }); + } } #tagNames: string[]; + #activeAttribute: string; #io?: IntersectionObserver; @@ -57,17 +67,28 @@ export class ScrollSpyController implements ReactiveController { #intersected = false; #root: ScrollSpyControllerOptions['root']; + #rootMargin?: string; + #threshold: number | number[]; - #intersectingElements: Element[] = []; + + #intersectingTargets = new Set(); + + #linkTargetMap = new Map(); #getRootNode: () => Node | null; + #getHash: (el: Element) => string | null; + #onIntersection?: () => void; get #linkChildren(): Element[] { - return Array.from(this.host.querySelectorAll(this.#tagNames.join(','))) - .filter(this.#getHash); + if (isServer) { + return []; + } else { + return Array.from(this.host.querySelectorAll(this.#tagNames.join(','))) + .filter(this.#getHash); + } } get root(): Element | Document | null | undefined { @@ -132,12 +153,16 @@ export class ScrollSpyController implements ReactiveController { if (rootNode instanceof Document || rootNode instanceof ShadowRoot) { const { rootMargin, threshold, root } = this; this.#io = new IntersectionObserver(r => this.#onIo(r), { root, rootMargin, threshold }); - this.#linkChildren - .map(x => this.#getHash(x)) - .filter((x): x is string => !!x) - .map(x => rootNode.getElementById(x.replace('#', ''))) - .filter((x): x is HTMLElement => !!x) - .forEach(target => this.#io?.observe(target)); + for (const link of this.#linkChildren) { + const id = this.#getHash(link)?.replace('#', ''); + if (id) { + const target = document.getElementById(id); + if (target) { + this.#io?.observe(target); + this.#linkTargetMap.set(link, target); + } + } + } } } @@ -155,6 +180,17 @@ export class ScrollSpyController implements ReactiveController { } } + async #activateHash() { + const links = this.#linkChildren; + const { hash } = location; + if (!hash) { + this.setActive(links.at(0) ?? null); + } else { + await this.#nextIntersection(); + this.setActive(links.find(x => this.#getHash(x) === hash) ?? null); + } + } + async #nextIntersection() { this.#intersected = false; // safeguard the loop @@ -178,13 +214,15 @@ export class ScrollSpyController implements ReactiveController { this.#setActive(last ?? this.#linkChildren.at(0)); } this.#intersected = true; - this.#intersectingElements = - entries - .filter(x => x.isIntersecting) - .map(x => x.target); + this.#intersectingTargets.clear(); + for (const entry of entries) { + if (entry.isIntersecting) { + this.#intersectingTargets.add(entry.target); + } + } if (this.#initializing) { const ints = entries?.filter(x => x.isIntersecting) ?? []; - if (this.#intersectingElements) { + if (this.#intersectingTargets.size > 0) { const [{ target = null } = {}] = ints; const { id } = target ?? {}; if (id) {