Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const SiteHeader = () => {
<Flex
as="ul"
width="full"
gap="1"
justifyContent="flex-end"
alignItems="center"
>
Expand Down
6 changes: 6 additions & 0 deletions apps/designmanual-frontend/app/routes/_base/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { LeftSidebar } from "~/routes/_base/left-sidebar/LeftSidebar";
import { getClient } from "~/utils/sanity/client";

import { useStickymenu } from "../_base/content-menu/utils";
import TableOfContent from "./table-of-contents/TableOfContents";
import { useHeadings } from "./table-of-contents/useHeadings";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const slug = params.slug;
Expand All @@ -29,6 +31,8 @@ export default function BaseLayout() {
const navigate = useNavigate();
const location = useLocation();

const { contentRef, headings } = useHeadings();

const { asideRef, forceFixed, fixedRect } = useStickymenu();

const TOP = "11.25rem";
Expand Down Expand Up @@ -76,6 +80,7 @@ export default function BaseLayout() {
<Flex
id="content"
justifyContent="space-between"
ref={contentRef}
gap={8}
marginX={{ base: "4", md: "8" }}
overflow="visible"
Expand Down Expand Up @@ -114,6 +119,7 @@ export default function BaseLayout() {
>
<Outlet />
</Box>
<TableOfContent headings={headings} />
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Box, BoxProps, Heading } from "@vygruppen/spor-react";
import { useLocation } from "react-router";

import { MenuItem } from "~/routes/_base/content-menu/MenuItem";

import { useScrollSpy } from "./useScrollSpy";

export type HeadingLevelType = "h2" | "h3" | "h4" | "h5" | "h6";
export type HeadingType = {
id: string;
text: string;
level: HeadingLevelType;
};

type TableOfContentProps = BoxProps & {
headings: HeadingType[];
};

/** A table of content of the current page */
function TableOfContent(props: TableOfContentProps) {
const { headings, ...rest } = props;
const activeId = useScrollSpy(
headings.map(({ id }) => `[id="${id}"]`),
{
rootMargin: "0% 0% -24% 0%",
},
);

const hasHeadings = headings.length > 1;

const location = useLocation();

const pathSegments = location.pathname.split("/").filter(Boolean);

if (pathSegments.length < 2 || pathSegments.includes("identitet"))
return null; // Do not show TOC on top-level pages or identity pages

return (
<Box
as="nav"
aria-labelledby="toc-title"
display={["none", null, null, "none", "flex"]}
visibility={hasHeadings ? "visible" : "hidden"}
flexDirection="column"
transform={hasHeadings ? "translateY(0)" : "translateY(10px)"}
transitionDuration="fast"
transitionProperty="common"
paddingY={8}
paddingX={5}
alignSelf="start"
fontSize="sm"
maxHeight="calc(100vh - 8rem)"
minWidth="20rem"
position="sticky"
top="7.5rem"
overflowY="auto"
{...rest}
>
<Heading as="h2" id="toc-title" variant="sm" fontWeight="bold">
On this page
</Heading>
<Box
as="ol"
padding={0}
marginLeft="0"
marginTop="4"
listStyleType="none"
>
{headings.map(({ id, text, level }) => (
<Box
key={id}
as="li"
title={text}
marginLeft={
Number(level.slice(1)) > 2 ? Number(level.slice(1)) : undefined
}
>
<MenuItem
title={text}
url={`#${id}`}
aria-current={id === activeId ? "location" : undefined}
isActive={id === activeId}
>
{text}
</MenuItem>
</Box>
))}
</Box>
</Box>
);
}

export default TableOfContent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router";

import { HeadingLevelType, HeadingType } from "./TableOfContents";

/**
* Returns a list of all headings inside of whatever the ref is placed on.
*/
export const useHeadings = () => {
const contentRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const [headings, setHeadings] = useState<HeadingType[]>([]);
useEffect(() => {
const id = setTimeout(() => {
const headingList: HeadingType[] = [];
const container = contentRef.current;
if (container) {
for (const element of container.querySelectorAll("h2, h3, h4")) {
if (!element.id) {
continue;
}
headingList.push({
id: element.id,
text: element.textContent || "",
level: element.tagName.toLowerCase() as HeadingLevelType,
});
}
}
setHeadings(headingList);
}, 16);
return () => clearTimeout(id);
}, [location]);
return { headings, contentRef };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from "react";

/**
* Accepts a list of selectors, and returns the active ID.
*
* ```tsx
* const activeId = useScrollSpy(['#foo', '#bar']);
* ```
*
* You can also specify any intersection observer options as a second argument:
*
* ```tsx
* const activeId = useScrollSpy(['#foo', '#bar'], {
* rootMargin: '0% 0% -24% 0%',
* });
* ```
*
* Adapted from the Chakra UI docs
* @see https://github.com/chakra-ui/chakra-ui-docs/blob/main/src/hooks/use-scrollspy.ts
*/
export function useScrollSpy(
selectors: string[],
options?: IntersectionObserverInit,
) {
const [activeId, setActiveId] = React.useState<string>();
const observer = React.useRef<IntersectionObserver | null>(null);
React.useEffect(() => {
const elements = selectors.map((selector) =>
document.querySelector(selector),
);
observer.current?.disconnect();
observer.current = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry?.isIntersecting) {
setActiveId(entry.target.getAttribute("id") || undefined);
}
}
}, options);
for (const element of elements) {
if (element) {
observer.current?.observe(element);
}
}
return () => observer.current?.disconnect();
}, [selectors, options]);

return activeId;
}