From 4ca629f47147b78323730c16abb9373157fc3a0c Mon Sep 17 00:00:00 2001 From: Kirill Vasiltsov <38713361+jlkiri@users.noreply.github.com> Date: Sat, 21 Mar 2020 02:32:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(www):=20useActiveHash=20hook=20for=20highl?= =?UTF-8?q?ighting=20links=20in=20Docs'=E2=80=A6=20(#21762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create useActiveHash hook * Change hashlist to idlist * Remove console.log * Hook cleanup * Style link based on hash * Add traversal option * Move hooks to its own file * Improve hook * Add comments, remove console.log * Revert hash remove logic * Remove unused colors * Revert "Remove unused colors" This reverts commit 8160ad8618b8de61c2d64764831607c0b3ecccfc. * Remove unused colors * Refactor getHeadingIds * Switch to getElementById * Handle cases where url is absent * Do not update URL hash * Disable active link behavior on mobile * Handle mobile with media query * Move function * Rename toc to items, add depth prop * Highlight subheadings * Add tests * update recursion so tableOfContentsDepth is respected and something is always highlighted in ToC Co-authored-by: Kyle Gill --- www/package.json | 1 + .../components/__tests__/table-of-contents.js | 221 ++++++++++++++++++ www/src/components/docs-table-of-contents.js | 144 ++++++++---- www/src/hooks/use-active-hash.js | 30 +++ www/src/templates/template-docs-markdown.js | 6 +- 5 files changed, 351 insertions(+), 51 deletions(-) create mode 100644 www/src/components/__tests__/table-of-contents.js create mode 100644 www/src/hooks/use-active-hash.js diff --git a/www/package.json b/www/package.json index a1ce7396a76de..b52ed6f97d609 100644 --- a/www/package.json +++ b/www/package.json @@ -149,6 +149,7 @@ "lingui:build": "yarn lingui:extract && yarn lingui:compile" }, "devDependencies": { + "@testing-library/jest-dom": "^5.1.1", "@lingui/cli": "^2.9.1", "@lingui/macro": "^2.9.1", "@testing-library/react": "^8.0.9", diff --git a/www/src/components/__tests__/table-of-contents.js b/www/src/components/__tests__/table-of-contents.js new file mode 100644 index 0000000000000..58a7afd2f0c67 --- /dev/null +++ b/www/src/components/__tests__/table-of-contents.js @@ -0,0 +1,221 @@ +import React from "react" +import { render } from "@testing-library/react" +import "@testing-library/jest-dom/extend-expect" +import { ThemeProvider } from "theme-ui" + +import theme from "../../../src/gatsby-plugin-theme-ui" +import TableOfContents from "../docs-table-of-contents" + +const tableOfContentsNoUrl = { + location: { + pathname: "", + }, + depth: 2, + items: [ + { + title: "API commands", + items: [ + { + url: "#new", + title: "new", + items: [ + { + title: "Arguments", + }, + { + url: "#examples", + title: "Examples", + }, + ], + }, + ], + }, + ], +} + +const tableOfContentsSimple = { + location: { + pathname: "", + }, + depth: null, + items: [ + { + url: "#how-to-use-gatsby-cli", + title: "How to use gatsby-cli", + }, + ], +} + +const tableOfContentsDeep = { + location: { + pathname: "", + }, + depth: 2, + items: [ + { + url: "#how-to-use-gatsby-cli", + title: "How to use gatsby-cli", + }, + { + url: "#api-commands", + title: "API commands", + items: [ + { + url: "#new", + title: "new", + items: [ + { + url: "#arguments", + title: "Arguments", + }, + { + url: "#examples", + title: "Examples", + }, + ], + }, + { + url: "#develop", + title: "develop", + items: [ + { + url: "#options", + title: "Options", + }, + { + url: "#preview-changes-on-other-devices", + title: "Preview changes on other devices", + }, + ], + }, + { + url: "#build", + title: "build", + items: [ + { + url: "#options-1", + title: "Options", + }, + ], + }, + { + url: "#serve", + title: "serve", + items: [ + { + url: "#options-2", + title: "Options", + }, + ], + }, + { + url: "#info", + title: "info", + items: [ + { + url: "#options-3", + title: "Options", + }, + ], + }, + { + url: "#clean", + title: "clean", + }, + { + url: "#plugin", + title: "plugin", + items: [ + { + url: "#docs", + title: "docs", + }, + ], + }, + { + url: "#repl", + title: "Repl", + }, + { + url: "#disabling-colored-output", + title: "Disabling colored output", + }, + ], + }, + { + url: "#how-to-change-your-default-package-manager-for-your-next-project", + title: + "How to change your default package manager for your next project?", + }, + ], +} + +Object.defineProperty(window, "IntersectionObserver", { + writable: true, + value: jest.fn().mockImplementation(() => { + return { + observe: jest.fn(), + unobserve: jest.fn(), + } + }), +}) + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + addListener: jest.fn(), + removeListener: jest.fn(), + })), +}) + +const testHeadingsRecursively = (getByTestId, items, depth) => { + if (depth === 0) return + + for (const item of items) { + if (item.url) { + expect(getByTestId(item.url)).toHaveTextContent(item.title) + } + + if (item.items) { + testHeadingsRecursively(getByTestId, item.items, depth - 1) + } + } +} + +test("Table of contents (depth == 0)", () => { + const { items, depth, location } = tableOfContentsSimple + const { getByTestId } = render( + + + + ) + + for (const item of items) { + if (item.url) { + expect(getByTestId(item.url)).toHaveTextContent(item.title) + } + } +}) + +test("Table of contents (depth >= 1)", () => { + const { items, depth, location } = tableOfContentsDeep + const { getByTestId } = render( + + + + ) + + testHeadingsRecursively(getByTestId, items, depth - 1) +}) + +test("Table of contents (missing URLs)", () => { + const { items, depth, location } = tableOfContentsNoUrl + const { getByTestId } = render( + + + + ) + + testHeadingsRecursively(getByTestId, items, depth - 1) +}) diff --git a/www/src/components/docs-table-of-contents.js b/www/src/components/docs-table-of-contents.js index 474556edde705..16f19caf47e6f 100644 --- a/www/src/components/docs-table-of-contents.js +++ b/www/src/components/docs-table-of-contents.js @@ -3,9 +3,41 @@ import { jsx } from "theme-ui" import Link from "./localized-link" import { Trans } from "@lingui/macro" import { - colors, mediaQueries, + breakpoints, } from "gatsby-design-tokens/dist/theme-gatsbyjs-org" +import { useEffect, useState } from "react" +import { useActiveHash } from "../hooks/use-active-hash" + +const getHeadingIds = ( + toc, + traverseFullDepth = true, + depth, + recursionDepth = 1 +) => { + const idList = [] + const hashToId = str => str.slice(1) + + if (toc) { + for (const item of toc) { + // Sometimes url does not exist on item. See #19851 + if (item.url) { + idList.push(hashToId(item.url)) + } + + // Only traverse sub-items if specified (they are not displayed in ToC) + // recursion depth should only go up to 6 headings deep and may come in as + // undefined if not set in the tableOfContentsDepth frontmatter field + if (item.items && traverseFullDepth && recursionDepth < (depth || 6)) { + idList.push( + ...getHeadingIds(item.items, true, depth, recursionDepth + 1) + ) + } + } + } + + return idList +} function isUnderDepthLimit(depth, maxDepth) { if (maxDepth === null) { @@ -18,55 +50,72 @@ function isUnderDepthLimit(depth, maxDepth) { // depth and maxDepth are used to figure out how many bullets deep to render in the ToC sidebar, if no // max depth is set via the tableOfContentsDepth field in the frontmatter, all headings will be rendered -function createItems(items, location, depth, maxDepth) { +function createItems(items, location, depth, maxDepth, activeHash, isDesktop) { return ( items && - items.map((item, index) => ( -
  • - {item.url && ( - - `all ${t.transition.speed.fast} ${t.transition.curve.default}`, - ":hover": { - color: `link.color`, - borderBottom: t => `1px solid ${t.colors.link.hoverBorder}`, + items.map((item, index) => { + const isActive = isDesktop && item.url === `#${activeHash}` + return ( +
  • + {item.url && ( + + isActive + ? `1px solid ${t.colors.link.hoverBorder}` + : `none`, + transition: t => + `all ${t.transition.speed.fast} ${t.transition.curve.default}`, + ":hover": { + color: `link.color`, + borderBottom: t => `1px solid ${t.colors.link.hoverBorder}`, + }, }, - }, - }} - getProps={({ href, location }) => - location && location.href && location.href.includes(href) - ? { - style: { - color: colors.link.color, - borderBottom: `1px solid ${colors.link.hoverBorder}`, - }, - } - : null - } - to={location.pathname + item.url} - > - {item.title} - - )} - {item.items && isUnderDepthLimit(depth, maxDepth) && ( - - )} -
  • - )) + }} + to={location.pathname + item.url} + > + {item.title} + + )} + {item.items && isUnderDepthLimit(depth, maxDepth) && ( + + )} + + ) + }) ) } -function TableOfContents({ page, location }) { - return page.tableOfContents.items ? ( +function TableOfContents({ items, depth, location }) { + const [isDesktop, setIsDesktop] = useState(false) + const activeHash = useActiveHash(getHeadingIds(items, true, depth)) + + useEffect(() => { + const isDesktopQuery = window.matchMedia(`(min-width: ${breakpoints[4]})`) // 1200px + setIsDesktop(isDesktopQuery.matches) + + const updateIsDesktop = e => setIsDesktop(e.matches) + isDesktopQuery.addListener(updateIsDesktop) + return () => isDesktopQuery.removeListener(updateIsDesktop) + }, []) + + return items ? ( ) : null diff --git a/www/src/hooks/use-active-hash.js b/www/src/hooks/use-active-hash.js new file mode 100644 index 0000000000000..0d6176e13cd73 --- /dev/null +++ b/www/src/hooks/use-active-hash.js @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react" + +export const useActiveHash = (itemIds, rootMargin = undefined) => { + const [activeHash, setActiveHash] = useState(``) + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + setActiveHash(entry.target.id) + } + }) + }, + { rootMargin: rootMargin || `0% 0% -80% 0%` } + ) + + itemIds.forEach(id => { + observer.observe(document.getElementById(id)) + }) + + return () => { + itemIds.forEach(id => { + observer.unobserve(document.getElementById(id)) + }) + } + }, []) + + return activeHash +} diff --git a/www/src/templates/template-docs-markdown.js b/www/src/templates/template-docs-markdown.js index b49881d6090a6..3609bd218dfcd 100644 --- a/www/src/templates/template-docs-markdown.js +++ b/www/src/templates/template-docs-markdown.js @@ -96,7 +96,11 @@ function DocsTemplate({ data, location, pageContext: { next, prev } }) { }, }} > - + )}