Navigation menu in Preferences (#2304)

This commit is contained in:
Alex Andreev authored and GitHub committed 2021-03-15 08:57:19 -04:00
1 parent 8e1583a59c
commit 5c373a886c
20 files changed
+838 -254

No files matched your search

@@ -0,0 +1,186 @@
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render, waitFor } from "@testing-library/react";
import { ScrollSpy } from "../scroll-spy";
import { RecursiveTreeView } from "../../tree-view";
const observe = jest.fn();
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: jest.fn().mockImplementation(() => ({
observe,
unobserve: jest.fn(),
})),
});
describe("<ScrollSpy/>", () => {
it("renders w/o errors", () => {
const { container } = render(<ScrollSpy render={() => (
<div>
<section id="application">
<h1>Application</h1>
</section>
</div>
)}/>);
expect(container).toBeInstanceOf(HTMLElement);
});
it("calls intersection observer", () => {
render(<ScrollSpy render={() => (
<div>
<section id="application">
<h1>Application</h1>
</section>
</div>
)}/>);
expect(observe).toHaveBeenCalled();
});
it("renders dataTree component", async () => {
const { queryByTestId } = render(<ScrollSpy render={dataTree => (
<div>
<nav>
<RecursiveTreeView data={dataTree}/>
</nav>
<section id="application">
<h1>Application</h1>
</section>
</div>
)}/>);
await waitFor(() => {
expect(queryByTestId("TreeView")).toBeInTheDocument();
});
});
it("throws if no sections founded", () => {
// Prevent writing to stderr during this render.
const err = console.error;
console.error = jest.fn();
expect(() => render(<ScrollSpy render={() => (
<div>
Content
</div>
)}/>)).toThrow();
// Restore writing to stderr.
console.error = err;
});
});
describe("<TreeView/> dataTree inside <ScrollSpy/>", () => {
it("contains links to all sections", async () => {
const { queryByTitle } = render(<ScrollSpy render={dataTree => (
<div>
<nav>
<RecursiveTreeView data={dataTree}/>
</nav>
<section id="application">
<h1>Application</h1>
<section id="appearance">
<h2>Appearance</h2>
</section>
<section id="theme">
<h2>Theme</h2>
<div>description</div>
</section>
</section>
</div>
)}/>);
await waitFor(() => {
expect(queryByTitle("Application")).toBeInTheDocument();
expect(queryByTitle("Appearance")).toBeInTheDocument();
expect(queryByTitle("Theme")).toBeInTheDocument();
});
});
it("not showing links to sections without id", async () => {
const { queryByTitle } = render(<ScrollSpy render={dataTree => (
<div>
<nav>
<RecursiveTreeView data={dataTree}/>
</nav>
<section id="application">
<h1>Application</h1>
<section>
<h2>Kubectl</h2>
</section>
<section id="appearance">
<h2>Appearance</h2>
</section>
</section>
</div>
)}/>);
await waitFor(() => {
expect(queryByTitle("Application")).toBeInTheDocument();
expect(queryByTitle("Appearance")).toBeInTheDocument();
expect(queryByTitle("Kubectl")).not.toBeInTheDocument();
});
});
it("expands parent sections", async () => {
const { queryByTitle } = render(<ScrollSpy render={dataTree => (
<div>
<nav>
<RecursiveTreeView data={dataTree}/>
</nav>
<section id="application">
<h1>Application</h1>
<section id="appearance">
<h2>Appearance</h2>
</section>
<section id="theme">
<h2>Theme</h2>
<div>description</div>
</section>
</section>
<section id="Kubernetes">
<h1>Kubernetes</h1>
<section id="kubectl">
<h2>Kubectl</h2>
</section>
</section>
</div>
)}/>);
await waitFor(() => {
expect(queryByTitle("Application")).toHaveAttribute("aria-expanded");
expect(queryByTitle("Kubernetes")).toHaveAttribute("aria-expanded");
});
// console.log(prettyDOM());
});
it("skips sections without headings", async () => {
const { queryByTitle } = render(<ScrollSpy render={dataTree => (
<div>
<nav>
<RecursiveTreeView data={dataTree}/>
</nav>
<section id="application">
<h1>Application</h1>
<section id="appearance">
<p>Appearance</p>
</section>
<section id="theme">
<h2>Theme</h2>
</section>
</section>
</div>
)}/>);
await waitFor(() => {
expect(queryByTitle("Application")).toBeInTheDocument();
expect(queryByTitle("appearance")).not.toBeInTheDocument();
expect(queryByTitle("Appearance")).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,96 @@
import { observer } from "mobx-react";
import React, { useEffect, useRef, useState } from "react";
import { useMutationObserver } from "../../hooks";
import { NavigationTree } from "../tree-view";
interface Props extends React.DOMAttributes<HTMLElement> {
render: (data: NavigationTree[]) => JSX.Element
htmlFor?: string // Id of the element to put observers on
rootMargin?: string // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
}
export const ScrollSpy = observer(({ render, htmlFor, rootMargin = "0px 0px -100% 0px" }: Props) => {
const parent = useRef<HTMLDivElement>();
const sections = useRef<NodeListOf<HTMLElement>>();
const [tree, setTree] = useState<NavigationTree[]>([]);
const [activeElementId, setActiveElementId] = useState("");
const setSections = () => {
sections.current = parent.current.querySelectorAll("section");
if (!sections.current.length) {
throw new Error("No <section/> tag founded! Content should be placed inside <section></section> elements to activate navigation.");
}
};
const getSectionsParentElement = () => {
return sections.current?.[0].parentElement;
};
const updateNavigation = () => {
setTree(getNavigation(getSectionsParentElement()));
};
const getNavigation = (element: Element) => {
const sections = element.querySelectorAll(":scope > section"); // Searching only direct children of an element. Impossible without :scope
const children: NavigationTree[] = [];
sections.forEach(section => {
const id = section.getAttribute("id");
const parentId = section.parentElement.id;
const name = section.querySelector("h1, h2, h3, h4, h5, h6")?.textContent;
const selected = id === activeElementId;
if (!name || !id) {
return;
}
children.push({
id,
parentId,
name,
selected,
children: getNavigation(section)
});
});
return children;
};
const handleIntersect = ([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
setActiveElementId(entry.target.closest("section[id]").id);
}
};
const observeSections = () => {
const options: IntersectionObserverInit = {
root: document.getElementById(htmlFor) || getSectionsParentElement(),
rootMargin
};
sections.current.forEach((section) => {
const observer = new IntersectionObserver(handleIntersect, options);
const target = section.querySelector("section") || section;
observer.observe(target);
});
};
useEffect(() => {
setSections();
observeSections();
}, []);
useEffect(() => {
updateNavigation();
}, [activeElementId]);
useMutationObserver(getSectionsParentElement(), updateNavigation);
return (
<div className="ScrollSpy" ref={parent}>
{render(tree)}
</div>
);
});