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
44 changes: 44 additions & 0 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,50 @@ main {
line-height: 1;
text-transform: uppercase;
}
/*
* The copy-link button next to a group's chart count (GroupPermalink). A bare
* icon button with the info icon's muted-to-foreground hover treatment; the
* `--copied` state swaps in an accent-colored checkmark as confirmation.
*/
.group-permalink {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
flex-shrink: 0;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.group-permalink:hover,
.group-permalink:focus-visible {
color: var(--fg);
background: var(--border);
}
.group-permalink--copied,
.group-permalink--copied:hover,
.group-permalink--copied:focus-visible {
color: var(--accent);
}
/* Screen-reader-only text (the permalink button's copied announcement). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/*
* The small ⓘ next to a group title that surfaces the editorial blurb on
* hover and on focus. The icon is keyboard-focusable. In the JS-free server
Expand Down
15 changes: 13 additions & 2 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Footer } from '@/components/Footer';
import { GroupNav } from '@/components/GroupNav';
import { GroupSection } from '@/components/GroupSection';
import { Header } from '@/components/Header';
import { groupAnchors } from '@/lib/anchor';
import { parseFilterCsv, singleSearchParam } from '@/lib/chart-format';
import { cachedFilterUniverse, cachedGroups } from '@/lib/data-cache';

Expand Down Expand Up @@ -42,23 +43,33 @@ export default async function Home({
const initialFormats = parseFilterCsv(singleSearchParam(params.format));

const [groups, universe] = await Promise.all([cachedGroups(), cachedFilterUniverse()]);
// Human-readable permalink anchors, one per section in render order (see
// lib/anchor.ts for why these are not the opaque API slugs).
const anchors = groupAnchors(groups.map((group) => group.name));
let nextIndex = 0;
return (
<>
<Header universe={universe} initialEngines={initialEngines} initialFormats={initialFormats} />
<GroupNav groups={groups.map((group) => ({ name: group.name, slug: group.slug }))} />
<GroupNav
groups={groups.map((group, i) => ({
name: group.name,
slug: group.slug,
anchor: anchors[i],
}))}
/>
<main>
{groups.length === 0 ? (
<p className="empty">No data ingested yet.</p>
) : (
<>
{groups.map((group) => {
{groups.map((group, i) => {
const startIndex = nextIndex;
nextIndex += group.charts.length;
return (
<GroupSection
key={group.slug}
group={group}
anchor={anchors[i]}
startIndex={startIndex}
universe={universe}
/>
Expand Down
143 changes: 138 additions & 5 deletions web/components/GroupNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@

// @vitest-environment jsdom

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { GroupNav, jumpToGroup, type GroupNavItem } from '@/components/GroupNav';
import {
GroupNav,
jumpToGroup,
jumpToLocationHash,
type GroupNavItem,
} from '@/components/GroupNav';

const GROUPS: GroupNavItem[] = [
{ name: 'Random Access', slug: 'random_access.abc' },
{ name: 'PolarSignals Profiling', slug: 'qmg.polar' },
{ name: 'Random Access', slug: 'random_access.abc', anchor: 'random-access' },
{ name: 'PolarSignals Profiling', slug: 'qmg.polar', anchor: 'polarsignals-profiling' },
];

describe('GroupNav markup', () => {
Expand Down Expand Up @@ -38,10 +45,11 @@ describe('jumpToGroup', () => {

function seedSections(): void {
document.body.innerHTML = `
<section data-group-slug="qmg.polar">
<div id="group-nav-panel"></div>
<section id="polarsignals-profiling" data-group-slug="qmg.polar">
<details class="group-disclosure"><summary>PolarSignals</summary></details>
</section>
<section data-group-slug="random_access.abc">
<section id="random-access" data-group-slug="random_access.abc">
<details class="group-disclosure"><summary>Random Access</summary></details>
</section>`;
}
Expand Down Expand Up @@ -74,4 +82,129 @@ describe('jumpToGroup', () => {
expect(jumpToGroup('does-not-exist', document)).toBe(false);
expect(scrollSpy).not.toHaveBeenCalled();
});

describe('jumpToLocationHash', () => {
afterEach(() => {
window.history.replaceState(null, '', '/');
});

it('expands and scrolls to the group whose anchor id the fragment names', () => {
seedSections();
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
window.history.replaceState(null, '', '#random-access');

expect(jumpToLocationHash(document)).toBe(true);
const disclosure = document
.querySelector('#random-access')
?.querySelector<HTMLDetailsElement>('details.group-disclosure');
expect(disclosure?.open).toBe(true);
expect(scrollSpy).toHaveBeenCalledTimes(1);
});

it('falls back to the opaque-slug match for legacy fragments, decoding them', () => {
seedSections();
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
// Links copied before anchors became readable carried the API slug,
// percent-encoded by GroupPermalink; the jump decodes symmetrically.
window.history.replaceState(null, '', `#${encodeURIComponent('random_access.abc')}`);

expect(jumpToLocationHash(document)).toBe(true);
const disclosure = document
.querySelector('[data-group-slug="random_access.abc"]')
?.querySelector<HTMLDetailsElement>('details.group-disclosure');
expect(disclosure?.open).toBe(true);
expect(scrollSpy).toHaveBeenCalledTimes(1);
});

it('is a no-op for an empty or unknown fragment', () => {
seedSections();
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;

expect(jumpToLocationHash(document)).toBe(false);
window.history.replaceState(null, '', '#does-not-exist');
expect(jumpToLocationHash(document)).toBe(false);
expect(scrollSpy).not.toHaveBeenCalled();
});

it('ignores fragments naming a non-group element id', () => {
seedSections();
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
window.history.replaceState(null, '', '#group-nav-panel');

expect(jumpToLocationHash(document)).toBe(false);
expect(scrollSpy).not.toHaveBeenCalled();
});
});
});

describe('GroupNav jump-menu clicks', () => {
let container: HTMLElement;
let root: Root | null = null;

afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
document.body.innerHTML = '';
window.history.replaceState(null, '', '/');
});

it('expands the group, mirrors its anchor into the URL, and closes the panel', () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
document.body.innerHTML = `
<section id="random-access" data-group-slug="random_access.abc">
<details class="group-disclosure"><summary>Random Access</summary></details>
</section>`;
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<GroupNav groups={GROUPS} />);
});

const toggle = container.querySelector<HTMLButtonElement>('.group-nav-toggle');
act(() => {
toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(container.querySelector('.group-nav-panel--open')).not.toBeNull();

const link = Array.from(container.querySelectorAll<HTMLButtonElement>('.group-nav-link')).find(
(b) => b.textContent === 'Random Access',
);
act(() => {
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});

expect(window.location.hash).toBe('#random-access');
const disclosure = document
.querySelector('#random-access')
?.querySelector<HTMLDetailsElement>('details.group-disclosure');
expect(disclosure?.open).toBe(true);
expect(scrollSpy).toHaveBeenCalledTimes(1);
expect(container.querySelector('.group-nav-panel--open')).toBeNull();
});

it('does not touch the URL when the jump target is missing', () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// No sections seeded: every jump misses.
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<GroupNav groups={GROUPS} />);
});

const link = container.querySelector<HTMLButtonElement>('.group-nav-link');
act(() => {
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});

expect(window.location.hash).toBe('');
});
});
74 changes: 69 additions & 5 deletions web/components/GroupNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import { useEffect, useRef, useState } from 'react';

/**
* A group entry for the jump menu: its display name and the `data-group-slug`
* carried by its landing-page `<section>` (see [`GroupSection`]).
* A group entry for the jump menu: its display name, the `data-group-slug`
* carried by its landing-page `<section>` (see [`GroupSection`]), and the
* readable permalink anchor (`lib/anchor.ts`) that section carries as its
* `id` — mirrored into the address bar when the entry is clicked.
*/
export interface GroupNavItem {
name: string;
slug: string;
anchor: string;
}

/**
Expand All @@ -33,19 +36,61 @@ export function jumpToGroup(slug: string, doc: Document = document): boolean {
if (section === undefined) {
return false;
}
expandAndScroll(section, doc);
return true;
}

/** Open a group section's disclosure and scroll it into view (the shared tail
* of [`jumpToGroup`] and [`jumpToLocationHash`]). */
function expandAndScroll(section: HTMLElement, doc: Document): void {
const disclosure = section.querySelector<HTMLDetailsElement>('details.group-disclosure');
if (disclosure !== null) {
disclosure.open = true;
}
const reduceMotion = doc.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
section.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
return true;
}

/**
* Expand and scroll to the group named by the window's URL fragment, if any.
*
* This is the landing half of the group-permalink flow: [`GroupPermalink`]
* copies `/#<anchor>` (percent-encoded, decoded symmetrically here), and this
* turns that fragment back into an opened, scrolled-to group — the browser's
* native anchor scroll cannot open the `<details>` disclosure on its own.
*
* The fragment resolves against the section `id` (the readable anchor from
* `lib/anchor.ts`), guarded to group sections so a stray fragment naming some
* other element's id (e.g. `#group-nav-panel`) is not treated as a group.
* Fragments carrying the opaque API slug — links copied before anchors became
* readable — fall back to the [`jumpToGroup`] slug match. An unknown or absent
* fragment is a no-op.
*
* Exported so the fragment handling is unit-testable without rendering.
*/
export function jumpToLocationHash(doc: Document = document): boolean {
const hash = doc.defaultView?.location.hash ?? '';
if (!hash.startsWith('#') || hash.length === 1) {
return false;
}
const fragment = decodeURIComponent(hash.slice(1));
const section = doc.getElementById(fragment);
if (section !== null && section.dataset.groupSlug !== undefined) {
expandAndScroll(section, doc);
return true;
}
return jumpToGroup(fragment, doc);
}

/**
* A left-side "Jump to group" menu: a fixed toggle button that opens a panel
* listing every group, each a button that expands and scrolls to that group's
* section (via [`jumpToGroup`]) and then closes the panel.
* section (via [`jumpToGroup`]), mirrors the group's permalink anchor into
* the address bar, and then closes the panel.
*
* Also hosts the hash-jump effect: on mount and on every later `hashchange`,
* [`jumpToLocationHash`] expands and scrolls to the group a `/#<slug>`
* permalink points at.
*
* Toggle-driven (not hover) so it works on touch and keyboard, mirroring the
* header's hamburger nav ([`Header`]): `aria-expanded` / `aria-controls` on the
Expand All @@ -57,6 +102,18 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) {
const panelRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);

// Land group permalinks: expand and scroll to the fragment's group on mount
// (the copied-link entry path) and on later `hashchange` (back/forward
// between fragments, or an in-page anchor click).
useEffect(() => {
jumpToLocationHash();
const onHashChange = (): void => {
jumpToLocationHash();
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);

// Close on outside click and Escape while open (the header nav's pattern).
useEffect(() => {
if (!open) {
Expand Down Expand Up @@ -115,7 +172,14 @@ export function GroupNav({ groups }: { groups: GroupNavItem[] }) {
className="group-nav-link"
type="button"
onClick={() => {
jumpToGroup(group.slug);
// Mirror the group's anchor into the address bar so a jump
// leaves a shareable URL, exactly like the header copy-link
// button. replaceState (not `location.hash =`) avoids firing
// `hashchange`, which would re-trigger the hash-jump effect
// and double-scroll.
if (jumpToGroup(group.slug)) {
window.history.replaceState(null, '', `#${encodeURIComponent(group.anchor)}`);
}
setOpen(false);
}}
>
Expand Down
Loading
Loading