Skip to content

Commit

Permalink
Fix hash/anchor navigation synchronization (#607)
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-lee authored Jan 28, 2025
1 parent 752068b commit 8edd608
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 25 deletions.
7 changes: 5 additions & 2 deletions packages/zudoku/src/lib/components/AnchorLink.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import React from "react";
import { Link, type LinkProps, useLocation } from "react-router";
import { useScrollToHash } from "../util/useScrollToAnchor.js";

/**
* Link that scrolls to anchor even if the hash is already set in the URL.
*/
export const AnchorLink = (props: LinkProps) => {
const location = useLocation();
const scrollToHash = useScrollToHash();
const hash = typeof props.to === "string" ? props.to : props.to.hash;

const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
props.onClick?.(event);
if (!hash?.startsWith("#") || hash !== location.hash) return;

event.preventDefault();
document.getElementById(hash.slice(1))?.scrollIntoView();
scrollToHash(hash);
};

return <Link onClick={handleClick} {...props} />;
return <Link {...props} onClick={handleClick} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const ViewportAnchorProvider = ({
const [activeAnchor, setActiveAnchor] = useState("");
const observerRef = useRef<IntersectionObserver | null>(null);
const registeredElements = useRef(new Set<HTMLElement>());
const pendingElements = useRef(new Set<HTMLElement>());

useEffect(() => {
observerRef.current = new IntersectionObserver(
Expand All @@ -81,6 +82,13 @@ export const ViewportAnchorProvider = ({
},
);

// Process any elements that tried to register before observer was ready
pendingElements.current.forEach((element) => {
registeredElements.current.add(element);
observerRef.current?.observe(element);
});
pendingElements.current.clear();

return () => observerRef.current?.disconnect();
}, []);

Expand Down Expand Up @@ -114,14 +122,22 @@ export const ViewportAnchorProvider = ({
const observeFns = useMemo(() => {
return {
observe: (element: HTMLElement | null) => {
if (!element || !observerRef.current) return;
if (!element) return;

if (!observerRef.current) {
pendingElements.current.add(element);
return;
}

registeredElements.current.add(element);
observerRef.current.observe(element);
},
unobserve: (element: HTMLElement | null) => {
if (!element || !observerRef.current) return;
if (!element) return;

pendingElements.current.delete(element);
registeredElements.current.delete(element);
observerRef.current.unobserve(element);
observerRef.current?.unobserve(element);
},
};
}, []);
Expand All @@ -132,8 +148,6 @@ export const ViewportAnchorProvider = ({
);

return (
<ViewportAnchorContext.Provider value={value}>
{children}
</ViewportAnchorContext.Provider>
<ViewportAnchorContext value={value}>{children}</ViewportAnchorContext>
);
};
2 changes: 0 additions & 2 deletions packages/zudoku/src/lib/plugins/openapi/OperationListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export const OperationListItem = ({
level={3}
className="capitalize"
id={`${operation.slug}/request-body`}
registerSidebarAnchor
>
Request Body
</Heading>
Expand All @@ -108,7 +107,6 @@ export const OperationListItem = ({
level={3}
className="capitalize mt-8 pt-8 border-t"
id={`${operation.slug}/responses`}
registerSidebarAnchor
>
Responses
</Heading>
Expand Down
47 changes: 32 additions & 15 deletions packages/zudoku/src/lib/util/useScrollToAnchor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { useLocation } from "react-router";
import { useViewportAnchor } from "../components/context/ViewportAnchorContext.js";
import { DATA_ANCHOR_ATTR } from "../components/navigation/SidebarItem.js";
Expand All @@ -22,32 +22,49 @@ const scrollIntoViewIfNeeded = (
element.scrollIntoView(options);
};

export const useScrollToAnchor = () => {
const location = useLocation();
export const useScrollToHash = () => {
const { setActiveAnchor } = useViewportAnchor();

useEffect(() => {
if (!location.hash) return;

const hash = decodeURIComponent(location.hash.split("/")[0]!.slice(1));

const scrollToElement = () => {
const element = document.getElementById(hash);
const link = document.querySelector(`[${DATA_ANCHOR_ATTR}="${hash}"]`);
const scrollToHash = useCallback(
(hash: string) => {
const cleanHash = hash
.replace(/^#/, "")
// Operation list items might have subdivisions that the sidebar doesn't show.
// The subdivisions are separated by a slash so we need to remove everything before the slash to get the sidebar correct item.
.split("/")
.at(0)!;
const element = document.getElementById(decodeURIComponent(cleanHash));
const link = document.querySelector(
`[${DATA_ANCHOR_ATTR}="${cleanHash}"]`,
);

if (element) {
element.scrollIntoView();
scrollIntoViewIfNeeded(link);
requestIdleCallback(() => setActiveAnchor(hash));
requestIdleCallback(() => setActiveAnchor(cleanHash));
return true;
}

// Scroll didn't happen
return false;
};
},
[setActiveAnchor],
);

return scrollToHash;
};

export const useScrollToAnchor = () => {
const location = useLocation();
const { setActiveAnchor } = useViewportAnchor();
const scrollToHash = useScrollToHash();

useEffect(() => {
if (!location.hash) return;

if (!scrollToElement()) {
if (!scrollToHash(location.hash)) {
const observer = new MutationObserver((_, obs) => {
if (!scrollToElement()) return;
if (!scrollToHash(location.hash)) return;
obs.disconnect();
});

Expand Down

0 comments on commit 8edd608

Please sign in to comment.