Skip to content
Merged
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
50 changes: 50 additions & 0 deletions src/components/InfoCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from "react";
import clsx from "clsx";
import { Info } from "lucide-react";

type InfoCalloutProps = {
children: React.ReactNode;
className?: string;
};

/**
* A note or definition set apart from the surrounding prose: blue frame, pale
* blue fill, and a leading ⓘ.
*
* The resource pages have two tiers of box. Plain reference cards get a blue
* frame on white and no icon; this one adds the tint and the icon and is
* reserved for content the reader should treat as an aside — a caution, or a
* definition they may need before the surrounding text makes sense. Keeping
* the icon rare is the point: put it on every "allowed values" box and
* fourteen of them read as fourteen warnings.
*
* Callers supply their own heading, because the cases genuinely differ — a
* caution wants a bold lead-in inline with its prose, a definition wants a
* real `<h3>`, and a footnote wants neither.
*
* This is deliberately not built on `AdditionalInfoBox`: that component wraps
* its content in a `<section className="mt-8">`, whose fixed margin would
* break the definition cards' grid alignment, and it is shared with the
* pathway detail page, which must not pick up this styling.
*/
const InfoCallout: React.FC<InfoCalloutProps> = ({ children, className }) => (
<div
className={clsx(
"rounded-lg border border-rmiblue-200 bg-rmiblue-100/30 p-5",
className,
)}
>
<div className="flex gap-3">
<Info
size={18}
aria-hidden="true"
className="mt-1 flex-none text-rmiblue-800"
/>
<div className="min-w-0 flex-1 text-rmiblue-800 leading-7">
{children}
</div>
</div>
</div>
);

export default InfoCallout;
131 changes: 130 additions & 1 deletion src/components/OnPageIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { render, screen, fireEvent, within, act } from "@testing-library/react";
import OnPageIndex from "./OnPageIndex";
import { MockIntersectionObserver } from "../test/mockIntersectionObserver";

/** Harness: a container with three real headings, plus the index itself. */
/** Harness: a container with three real headings, plus the index itself.
* The second section has sub-headings, so the nesting behavior can be
* exercised alongside the flat cases. */
const Harness: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
return (
Expand All @@ -13,6 +15,8 @@ const Harness: React.FC = () => {
<div ref={containerRef}>
<h2 id="first">First section</h2>
<h2 id="second">Second section</h2>
<h3 id="second-a">Second A</h3>
<h3 id="second-b">Second B</h3>
<h2 id="third">Third section</h2>
</div>
</>
Expand All @@ -22,6 +26,9 @@ const Harness: React.FC = () => {
const getActiveLink = () =>
screen.getAllByRole("link").find((link) => link.getAttribute("aria-current"));

const linkTexts = () =>
screen.getAllByRole("link").map((link) => link.textContent);

describe("OnPageIndex", () => {
// `location`/`history` persist across tests within this file (jsdom is
// shared per test file, not per test), so start each test from a clean,
Expand Down Expand Up @@ -161,3 +168,125 @@ describe("OnPageIndex", () => {
expect(getActiveLink()?.textContent).toBe("First section");
});
});

describe("OnPageIndex — nested sub-headings (#955)", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/");
});

it("hides a section's sub-entries while another section is in view", () => {
render(<Harness />);
// "First section" is active on load and has no sub-headings of its own.
expect(linkTexts()).not.toContain("Second A");
expect(linkTexts()).not.toContain("Second B");
});

it("reveals a section's sub-entries when it becomes the active one", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];

act(() => {
observer.trigger(document.getElementById("second")!, true);
});

expect(linkTexts()).toContain("Second A");
expect(linkTexts()).toContain("Second B");
});

it("hides them again once a different section takes over", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];

act(() => {
observer.trigger(document.getElementById("second")!, true);
});
expect(linkTexts()).toContain("Second A");

act(() => {
observer.trigger(document.getElementById("second")!, false);
observer.trigger(document.getElementById("third")!, true);
});

expect(getActiveLink()?.textContent).toBe("Third section");
expect(linkTexts()).not.toContain("Second A");
});

it("keeps the parent section expanded when a sub-heading is the one in view", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];

act(() => {
observer.trigger(document.getElementById("second-b")!, true);
});

expect(getActiveLink()?.textContent).toBe("Second B");
// Its sibling stays listed — the whole section's sub-entries are shown.
expect(linkTexts()).toContain("Second A");
});

it("activates the topmost heading when several are inside the trigger band at once", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];

// Reported out of document order, as a real IntersectionObserver may.
act(() => {
observer.trigger(document.getElementById("second-b")!, true);
observer.trigger(document.getElementById("second")!, true);
observer.trigger(document.getElementById("second-a")!, true);
});

expect(getActiveLink()?.textContent).toBe("Second section");

// As the section scrolls past, the next heading down takes over.
act(() => {
observer.trigger(document.getElementById("second")!, false);
});
expect(getActiveLink()?.textContent).toBe("Second A");
});

it("keeps the last active entry when no heading is inside the band", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];

act(() => {
observer.trigger(document.getElementById("third")!, true);
});
act(() => {
observer.trigger(document.getElementById("third")!, false);
});

expect(getActiveLink()?.textContent).toBe("Third section");
});

it("scrolls to a sub-heading and updates the hash when its entry is clicked", () => {
render(<Harness />);
const observer = MockIntersectionObserver.instances[0];
act(() => {
observer.trigger(document.getElementById("second")!, true);
});

const scrollIntoView = vi.fn();
document.getElementById("second-b")!.scrollIntoView = scrollIntoView;
const replaceState = vi.spyOn(window.history, "replaceState");

fireEvent.click(screen.getByRole("link", { name: "Second B" }));

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "start",
});
expect(replaceState).toHaveBeenCalledWith(null, "", "#second-b");
expect(getActiveLink()?.textContent).toBe("Second B");
});

it("deep link: a URL pointing at a sub-heading lands on it and expands its parent", () => {
window.history.replaceState(null, "", "#second-a");
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");

render(<Harness />);

expect(getActiveLink()?.textContent).toBe("Second A");
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" });
expect(linkTexts()).toContain("Second B");
});
});
Loading
Loading