@eqtylab/docs 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/chrome/GlobalSearch.module.css +26 -0
- package/dist/runtime/chrome/GlobalSearch.tsx +422 -0
- package/dist/runtime/chrome/Header.astro +105 -0
- package/dist/runtime/chrome/LinkIcon.astro +35 -0
- package/dist/runtime/chrome/NavDrawer.astro +60 -0
- package/dist/runtime/chrome/NavTree.astro +112 -0
- package/dist/runtime/chrome/NotFoundBody.tsx +12 -0
- package/dist/runtime/chrome/PageFooter.astro +80 -0
- package/dist/runtime/chrome/Prose.astro +169 -0
- package/dist/runtime/chrome/Sidebar.astro +21 -0
- package/dist/runtime/chrome/TableOfContents.astro +86 -0
- package/dist/runtime/chrome/ThemeToggle.tsx +85 -0
- package/dist/runtime/chrome/TocElbow.astro +30 -0
- package/dist/runtime/chrome/TocList.astro +43 -0
- package/dist/runtime/components/AlertBridge.astro +16 -0
- package/dist/runtime/components/CodeFence.astro +38 -0
- package/dist/runtime/components/CodeFenceBridge.astro +20 -0
- package/dist/runtime/components/Link.astro +21 -0
- package/dist/runtime/components/TabItem.astro +16 -0
- package/dist/runtime/components/TableBridge.astro +18 -0
- package/dist/runtime/components/Tabs.astro +121 -0
- package/dist/runtime/components/TabsBridge.tsx +41 -0
- package/dist/runtime/components/index.ts +4 -0
- package/dist/runtime/layouts/DocsPage.astro +50 -0
- package/dist/runtime/layouts/DocsShell.astro +92 -0
- package/dist/runtime/lib/mdx-components.ts +49 -0
- package/dist/runtime/lib/nav-data.ts +74 -0
- package/dist/runtime/lib/summary.ts +57 -0
- package/dist/runtime/lib/theme.ts +84 -0
- package/dist/runtime/routes/docs-md.ts +33 -0
- package/dist/runtime/routes/docs.astro +92 -0
- package/dist/runtime/routes/llms-txt.ts +38 -0
- package/dist/runtime/routes/not-found.astro +16 -0
- package/dist/runtime/scripts/eq-copy.ts +27 -0
- package/dist/runtime/scripts/eq-highlight.ts +24 -0
- package/dist/runtime/scripts/eq-nav-drawer.ts +31 -0
- package/dist/runtime/scripts/eq-nav-group.ts +52 -0
- package/dist/runtime/scripts/eq-tabs.ts +114 -0
- package/dist/runtime/scripts/eq-toc.ts +166 -0
- package/dist/runtime/styles/chrome.css +30 -0
- package/dist/runtime/styles/prose.css +102 -0
- package/dist/runtime/styles/theme.css +2 -0
- package/dist/runtime/styles/utilities.css +38 -0
- package/package.json +1 -1
- package/dist/chunk-ATIOPYKE.js +0 -102
- package/dist/chunk-ATIOPYKE.js.map +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
2
|
+
|
|
3
|
+
/** These four are repeated in DocsShell.astro's inline script. Change both. */
|
|
4
|
+
export const STORAGE_KEY = 'eqty-docs-theme';
|
|
5
|
+
export const UPDATE_EVENT = 'eqty-docs-theme-change';
|
|
6
|
+
/** Equality's palette keys on this. */
|
|
7
|
+
export const THEME_ATTRIBUTE = 'data-equality-theme';
|
|
8
|
+
/** ThemeToggle.module.css reads this, so the trigger is correct before React loads. */
|
|
9
|
+
export const PREFERENCE_ATTRIBUTE = 'data-eq-theme-pref';
|
|
10
|
+
|
|
11
|
+
export type ThemePreference = 'light' | 'dark' | 'system';
|
|
12
|
+
|
|
13
|
+
export type ResolvedTheme = 'light' | 'dark';
|
|
14
|
+
|
|
15
|
+
const PREFERENCES: readonly ThemePreference[] = ['dark', 'light', 'system'];
|
|
16
|
+
|
|
17
|
+
function isPreference(value: unknown): value is ThemePreference {
|
|
18
|
+
return typeof value === 'string' && (PREFERENCES as readonly string[]).includes(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readStored(): string | null {
|
|
22
|
+
if (!CONFIG.theme.persist) return window.__eqtyDocsTheme ?? null;
|
|
23
|
+
try {
|
|
24
|
+
return window.localStorage.getItem(STORAGE_KEY);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeStored(preference: ThemePreference): void {
|
|
31
|
+
if (!CONFIG.theme.persist) {
|
|
32
|
+
window.__eqtyDocsTheme = preference;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
window.localStorage.setItem(STORAGE_KEY, preference);
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore a storage failure; the theme still applies for this page.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSystemTheme(): ResolvedTheme {
|
|
43
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getThemePreference(): ThemePreference {
|
|
47
|
+
if (typeof window === 'undefined') return 'system';
|
|
48
|
+
const stored = readStored();
|
|
49
|
+
return isPreference(stored) ? stored : 'system';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveTheme(preference: ThemePreference): ResolvedTheme {
|
|
53
|
+
return preference === 'system' ? getSystemTheme() : preference;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyTheme(preference: ThemePreference): void {
|
|
57
|
+
const resolved = resolveTheme(preference);
|
|
58
|
+
const root = document.documentElement;
|
|
59
|
+
root.setAttribute(THEME_ATTRIBUTE, resolved);
|
|
60
|
+
root.setAttribute(PREFERENCE_ATTRIBUTE, preference);
|
|
61
|
+
// Without this, scrollbars and form controls stay light on a dark page.
|
|
62
|
+
root.style.colorScheme = resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function setThemePreference(preference: ThemePreference): void {
|
|
66
|
+
writeStored(preference);
|
|
67
|
+
applyTheme(preference);
|
|
68
|
+
window.dispatchEvent(new CustomEvent(UPDATE_EVENT, { detail: preference }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function subscribeToThemePreference(listener: () => void): () => void {
|
|
72
|
+
if (typeof window === 'undefined') return () => {};
|
|
73
|
+
|
|
74
|
+
const onStorage = (event: StorageEvent) => {
|
|
75
|
+
if (event.key === STORAGE_KEY) listener();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.addEventListener(UPDATE_EVENT, listener);
|
|
79
|
+
window.addEventListener('storage', onStorage);
|
|
80
|
+
return () => {
|
|
81
|
+
window.removeEventListener(UPDATE_EVENT, listener);
|
|
82
|
+
window.removeEventListener('storage', onStorage);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Markdown twin of every page, for LLM and agent consumption. */
|
|
2
|
+
import { idToPath } from '@eqtylab/docs/paths';
|
|
3
|
+
import type { APIRoute } from 'astro';
|
|
4
|
+
import { getCollection } from 'astro:content';
|
|
5
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
6
|
+
|
|
7
|
+
export async function getStaticPaths() {
|
|
8
|
+
const entries = await getCollection(
|
|
9
|
+
'docs',
|
|
10
|
+
({ data }: { data: { draft?: boolean; noIndex?: boolean } }) => !data.draft && !data.noIndex
|
|
11
|
+
);
|
|
12
|
+
return entries.map((entry) => ({
|
|
13
|
+
// An empty slug would emit a file literally called ".md".
|
|
14
|
+
params: { slug: idToPath(entry.id) || 'index' },
|
|
15
|
+
props: { entry },
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const GET: APIRoute = ({ props }) => {
|
|
20
|
+
const { entry } = props as {
|
|
21
|
+
entry: { body?: string; data: { title: string; description?: string } };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const frontmatter = ['---', `title: ${JSON.stringify(entry.data.title)}`];
|
|
25
|
+
if (entry.data.description) {
|
|
26
|
+
frontmatter.push(`description: ${JSON.stringify(entry.data.description)}`);
|
|
27
|
+
}
|
|
28
|
+
frontmatter.push(`source: ${JSON.stringify(CONFIG.title)}`, '---', '');
|
|
29
|
+
|
|
30
|
+
return new Response(frontmatter.join('\n') + (entry.body ?? ''), {
|
|
31
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render } from 'astro:content';
|
|
3
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
4
|
+
import { docsHref, idToPath, joinPath, normalizePath } from '@eqtylab/docs/paths';
|
|
5
|
+
import DocsPage from '../layouts/DocsPage.astro';
|
|
6
|
+
import { breadcrumbsFor, buildTocTree, docsNav, pathContext, prevNextFor } from '../lib/nav-data.ts';
|
|
7
|
+
import { firstParagraph } from '../lib/summary.ts';
|
|
8
|
+
import { mdxComponents } from '../lib/mdx-components.ts';
|
|
9
|
+
import PageFooter from '../chrome/PageFooter.astro';
|
|
10
|
+
import Prose from '../chrome/Prose.astro';
|
|
11
|
+
|
|
12
|
+
export async function getStaticPaths() {
|
|
13
|
+
const entries = await getCollection('docs', ({ data }: { data: { draft?: boolean } }) =>
|
|
14
|
+
import.meta.env.DEV ? true : !data.draft
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const owned = new Set((CONFIG.ownedByConsumer ?? []).map(normalizePath));
|
|
18
|
+
|
|
19
|
+
return entries
|
|
20
|
+
// Astro does not de-duplicate injected routes against consumer pages.
|
|
21
|
+
.filter((entry) => !owned.has(normalizePath(docsHref(entry.id, pathContext()))))
|
|
22
|
+
.map((entry) => ({
|
|
23
|
+
// `undefined` (not '') is how a rest param matches the empty path.
|
|
24
|
+
params: { slug: idToPath(entry.id) || undefined },
|
|
25
|
+
props: { entry },
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { entry } = Astro.props;
|
|
30
|
+
const { Content, headings } = await render(entry);
|
|
31
|
+
|
|
32
|
+
const tocConfig = entry.data.tableOfContents ?? CONFIG.tableOfContents;
|
|
33
|
+
const showToc = tocConfig !== false;
|
|
34
|
+
const toc = showToc
|
|
35
|
+
? buildTocTree(headings, {
|
|
36
|
+
minLevel: (tocConfig as { minLevel?: number })?.minLevel ?? 2,
|
|
37
|
+
maxLevel: (tocConfig as { maxLevel?: number })?.maxLevel ?? 3,
|
|
38
|
+
})
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
const nav = await docsNav(Astro.url.pathname);
|
|
42
|
+
const splash = entry.data.template === 'splash';
|
|
43
|
+
|
|
44
|
+
// Trail is [group, ..., page]; a length of one means the page has no section above it.
|
|
45
|
+
const trail = breadcrumbsFor(nav, Astro.url.pathname);
|
|
46
|
+
const crumbs = trail.length > 1 ? trail.map((node) => node.label).join(' › ') : undefined;
|
|
47
|
+
|
|
48
|
+
// Ancestors only. `crumbs` above keeps the page as well, because a search result row
|
|
49
|
+
// needs the page name to identify itself. The two diverge on purpose.
|
|
50
|
+
const renderedTrail = trail.slice(0, -1).map((node) => ({ label: node.label, href: node.href }));
|
|
51
|
+
|
|
52
|
+
// Indexed only, never rendered: a page with no `description` still needs a result summary.
|
|
53
|
+
const fallbackSummary = entry.data.description ? undefined : firstParagraph(entry.body);
|
|
54
|
+
|
|
55
|
+
const { prev, next } = CONFIG.footer.showPrevNext ? prevNextFor(nav, Astro.url.pathname) : {};
|
|
56
|
+
|
|
57
|
+
// `editUrl` is a base; the content path is appended. The slice is taken at the
|
|
58
|
+
// consumer's own content directory, which is configurable.
|
|
59
|
+
const contentPath = entry.filePath?.split(`${CONFIG.contentDir}/`).pop();
|
|
60
|
+
const editHref =
|
|
61
|
+
CONFIG.footer.editUrl && contentPath
|
|
62
|
+
? `${CONFIG.footer.editUrl.replace(/\/$/, '')}/${contentPath}`
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
// File-shaped (/x/label.md) beside the directory-shaped page; 'index' must match docs-md.ts.
|
|
66
|
+
const markdownHref = CONFIG.routing.markdownTwins
|
|
67
|
+
? `${joinPath(import.meta.env.BASE_URL, CONFIG.pathPrefix, idToPath(entry.id) || 'index')}.md`
|
|
68
|
+
: undefined;
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
<DocsPage
|
|
72
|
+
title={entry.data.title}
|
|
73
|
+
description={entry.data.description}
|
|
74
|
+
nav={nav}
|
|
75
|
+
toc={toc}
|
|
76
|
+
showToc={showToc}
|
|
77
|
+
splash={splash}
|
|
78
|
+
>
|
|
79
|
+
<Prose
|
|
80
|
+
title={entry.data.title}
|
|
81
|
+
description={entry.data.description}
|
|
82
|
+
deprecated={entry.data.deprecated}
|
|
83
|
+
markdownHref={markdownHref}
|
|
84
|
+
source={entry.body}
|
|
85
|
+
crumbs={crumbs}
|
|
86
|
+
trail={renderedTrail}
|
|
87
|
+
fallbackSummary={fallbackSummary}
|
|
88
|
+
>
|
|
89
|
+
<Content components={mdxComponents} />
|
|
90
|
+
</Prose>
|
|
91
|
+
{!splash && <PageFooter prev={prev} next={next} editHref={editHref} text={CONFIG.footer.text} />}
|
|
92
|
+
</DocsPage>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** An index of every page, in the llms.txt convention. */
|
|
2
|
+
import { docsHref } from '@eqtylab/docs/paths';
|
|
3
|
+
import type { APIRoute } from 'astro';
|
|
4
|
+
import { getCollection } from 'astro:content';
|
|
5
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async (ctx) => {
|
|
8
|
+
const entries = await getCollection(
|
|
9
|
+
'docs',
|
|
10
|
+
({ data }: { data: { draft?: boolean; noIndex?: boolean } }) => !data.draft && !data.noIndex
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const paths = {
|
|
14
|
+
base: import.meta.env.BASE_URL,
|
|
15
|
+
pathPrefix: CONFIG.pathPrefix,
|
|
16
|
+
};
|
|
17
|
+
const origin = (ctx.site ?? new URL(ctx.request.url)).origin;
|
|
18
|
+
|
|
19
|
+
const sorted = [...entries].sort((a, b) => a.data.title.localeCompare(b.data.title));
|
|
20
|
+
|
|
21
|
+
const lines = [
|
|
22
|
+
`# ${CONFIG.title}`,
|
|
23
|
+
'',
|
|
24
|
+
...(CONFIG.description ? [`> ${CONFIG.description}`, ''] : []),
|
|
25
|
+
'## Pages',
|
|
26
|
+
'',
|
|
27
|
+
...sorted.map((entry) => {
|
|
28
|
+
const href = docsHref(entry.id, paths).replace(/\/$/, '');
|
|
29
|
+
const suffix = entry.data.description ? `: ${entry.data.description}` : '';
|
|
30
|
+
return `- [${entry.data.title}](${origin}${href}.md)${suffix}`;
|
|
31
|
+
}),
|
|
32
|
+
'',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
return new Response(lines.join('\n'), {
|
|
36
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
3
|
+
import { withBase } from '@eqtylab/docs/paths';
|
|
4
|
+
import DocsShell from '../layouts/DocsShell.astro';
|
|
5
|
+
import Header from '../chrome/Header.astro';
|
|
6
|
+
import NotFoundBody from '../chrome/NotFoundBody.tsx';
|
|
7
|
+
|
|
8
|
+
const homeHref = withBase(CONFIG.pathPrefix ? `/${CONFIG.pathPrefix}/` : '/', {
|
|
9
|
+
base: import.meta.env.BASE_URL,
|
|
10
|
+
});
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<DocsShell title="Page not found" noIndex>
|
|
14
|
+
<Header />
|
|
15
|
+
<NotFoundBody homeHref={homeHref} client:load />
|
|
16
|
+
</DocsShell>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Copy-to-clipboard for static code fences. One delegated listener, so it survives view transitions. */
|
|
2
|
+
const COPIED_MS = 1600;
|
|
3
|
+
|
|
4
|
+
function findFence(target: EventTarget | null): HTMLElement | null {
|
|
5
|
+
if (!(target instanceof Element)) return null;
|
|
6
|
+
return target.closest<HTMLElement>('[data-eq-copy]');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
document.addEventListener('click', async (event) => {
|
|
10
|
+
const trigger = (event.target as Element | null)?.closest('button');
|
|
11
|
+
if (!trigger) return;
|
|
12
|
+
|
|
13
|
+
const fence = findFence(trigger);
|
|
14
|
+
if (!fence) return;
|
|
15
|
+
|
|
16
|
+
const code = fence.dataset.eqCopy;
|
|
17
|
+
if (!code) return;
|
|
18
|
+
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
try {
|
|
21
|
+
await navigator.clipboard.writeText(code);
|
|
22
|
+
fence.setAttribute('data-copied', '');
|
|
23
|
+
window.setTimeout(() => fence.removeAttribute('data-copied'), COPIED_MS);
|
|
24
|
+
} catch {
|
|
25
|
+
// Clipboard denied; do not show a success state.
|
|
26
|
+
}
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts highlighting for server-rendered code blocks, whose `useEffect` never
|
|
3
|
+
* runs without hydration. A custom element so it survives view transitions.
|
|
4
|
+
*/
|
|
5
|
+
import { scheduleHighlight } from '@eqtylab/equality';
|
|
6
|
+
|
|
7
|
+
class EqHighlight extends HTMLElement {
|
|
8
|
+
// Captured on connect: a detached element no longer knows its tree, and the rescan on removal drops stale ranges.
|
|
9
|
+
#root?: Node;
|
|
10
|
+
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this.#root = this.getRootNode();
|
|
13
|
+
void scheduleHighlight(this.#root);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnectedCallback() {
|
|
17
|
+
void scheduleHighlight(this.#root);
|
|
18
|
+
this.#root = undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!customElements.get('eq-highlight')) {
|
|
23
|
+
customElements.define('eq-highlight', EqHighlight);
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* With no script the Popover API opens and closes the drawer, shuts it on Escape or a
|
|
3
|
+
* click outside, and paints it above everything else. Two things it does not give,
|
|
4
|
+
* both verified in the browser suite rather than assumed:
|
|
5
|
+
*
|
|
6
|
+
* 1. `aria-expanded` on the invoker. No engine adds it, so a screen reader hears a
|
|
7
|
+
* button with no disclosure state.
|
|
8
|
+
* 2. Focus return on dismiss in WebKit. Chromium restores focus to the invoker;
|
|
9
|
+
* desktop Safari, mobile Safari and iPad all leave it on `<body>`, which strands a
|
|
10
|
+
* keyboard user exactly the way `DialogContainer` does. See equality-dialog-repair.
|
|
11
|
+
*
|
|
12
|
+
* Both are reflected off the `toggle` event so they track every dismissal path,
|
|
13
|
+
* rather than being set once and going stale.
|
|
14
|
+
*/
|
|
15
|
+
const drawer = document.getElementById('eq-nav-drawer');
|
|
16
|
+
const trigger = document.querySelector<HTMLElement>('[popovertarget="eq-nav-drawer"]');
|
|
17
|
+
|
|
18
|
+
if (drawer && trigger) {
|
|
19
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
20
|
+
|
|
21
|
+
drawer.addEventListener('toggle', (event) => {
|
|
22
|
+
const open = (event as ToggleEvent).newState === 'open';
|
|
23
|
+
trigger.setAttribute('aria-expanded', String(open));
|
|
24
|
+
|
|
25
|
+
// Only when the engine left focus nowhere. Clicking a link inside the drawer
|
|
26
|
+
// navigates away, and light-dismissing onto another control should keep it.
|
|
27
|
+
if (!open && document.activeElement === document.body) {
|
|
28
|
+
trigger.focus();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animates a navigation group open and closed, on top of `<details>`.
|
|
3
|
+
*
|
|
4
|
+
* Do not swap this for Equality's `MotionCollapsibleContent`. It measures after mount
|
|
5
|
+
* and animates up from zero, so a group that starts open expands on every page load.
|
|
6
|
+
*
|
|
7
|
+
* Duration and easing are that component's, so the motion still matches.
|
|
8
|
+
*/
|
|
9
|
+
const DURATION = 300;
|
|
10
|
+
const EASING = 'ease-in-out';
|
|
11
|
+
|
|
12
|
+
function wire(group: HTMLDetailsElement) {
|
|
13
|
+
const summary = group.querySelector('summary');
|
|
14
|
+
const panel = summary?.nextElementSibling;
|
|
15
|
+
if (!summary || !(panel instanceof HTMLElement)) return;
|
|
16
|
+
|
|
17
|
+
let running: Animation | null = null;
|
|
18
|
+
|
|
19
|
+
summary.addEventListener('click', (event) => {
|
|
20
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
21
|
+
|
|
22
|
+
// The browser would toggle `open` immediately; the close has to outlive that.
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
running?.cancel();
|
|
25
|
+
|
|
26
|
+
const opening = !group.open;
|
|
27
|
+
// Measured while open in both directions, so interrupting mid-animation does not
|
|
28
|
+
// snap to a stale height.
|
|
29
|
+
if (opening) group.open = true;
|
|
30
|
+
const height = panel.scrollHeight;
|
|
31
|
+
const from = opening ? 0 : panel.getBoundingClientRect().height;
|
|
32
|
+
|
|
33
|
+
panel.style.overflow = 'hidden';
|
|
34
|
+
running = panel.animate(
|
|
35
|
+
{
|
|
36
|
+
height: [`${from}px`, `${opening ? height : 0}px`],
|
|
37
|
+
opacity: [opening ? 0 : 1, opening ? 1 : 0],
|
|
38
|
+
},
|
|
39
|
+
{ duration: DURATION, easing: EASING }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
running.onfinish = () => {
|
|
43
|
+
running = null;
|
|
44
|
+
panel.style.removeProperty('overflow');
|
|
45
|
+
if (!opening) group.open = false;
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const group of document.querySelectorAll<HTMLDetailsElement>('details[data-eq-nav-group]')) {
|
|
51
|
+
wire(group);
|
|
52
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab switching for server-rendered `<Tabs>`. Equality renders them with `staticRender`, so
|
|
3
|
+
* every panel is already in the DOM and selecting one is a `data-state` flip - there is no
|
|
4
|
+
* React on the page to do it. One delegated listener, so it survives view transitions.
|
|
5
|
+
*/
|
|
6
|
+
const STORAGE_PREFIX = 'eqty-docs-tabs:';
|
|
7
|
+
|
|
8
|
+
function setOf(node: Element | null): HTMLElement | null {
|
|
9
|
+
return node?.closest<HTMLElement>('[data-eq-tabs]') ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function triggersIn(set: HTMLElement): HTMLElement[] {
|
|
13
|
+
return Array.from(set.querySelectorAll<HTMLElement>('[role="tab"]'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function panelFor(trigger: HTMLElement): HTMLElement | null {
|
|
17
|
+
const id = trigger.getAttribute('aria-controls');
|
|
18
|
+
return id ? document.getElementById(id) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function select(set: HTMLElement, chosen: HTMLElement, focus = false): void {
|
|
22
|
+
for (const trigger of triggersIn(set)) {
|
|
23
|
+
const active = trigger === chosen;
|
|
24
|
+
const state = active ? 'active' : 'inactive';
|
|
25
|
+
trigger.setAttribute('aria-selected', String(active));
|
|
26
|
+
trigger.setAttribute('data-state', state);
|
|
27
|
+
// Roving tabindex: the strip is one tab stop, arrows move within it.
|
|
28
|
+
trigger.tabIndex = active ? 0 : -1;
|
|
29
|
+
panelFor(trigger)?.setAttribute('data-state', state);
|
|
30
|
+
}
|
|
31
|
+
if (focus) chosen.focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function labelOf(trigger: HTMLElement): string {
|
|
35
|
+
return trigger.dataset.eqTabLabel ?? '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function read(key: string): string | null {
|
|
39
|
+
try {
|
|
40
|
+
return window.localStorage.getItem(STORAGE_PREFIX + key);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function write(key: string, label: string): void {
|
|
47
|
+
try {
|
|
48
|
+
window.localStorage.setItem(STORAGE_PREFIX + key, label);
|
|
49
|
+
} catch {
|
|
50
|
+
// Storage denied; syncing still works for this page view.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every other set sharing the key follows. A set without that label is left alone. */
|
|
55
|
+
function sync(key: string, label: string, origin: HTMLElement | null): void {
|
|
56
|
+
for (const set of document.querySelectorAll<HTMLElement>('[data-eq-tabs][data-sync-key]')) {
|
|
57
|
+
if (set === origin || set.dataset.syncKey !== key) continue;
|
|
58
|
+
const match = triggersIn(set).find((trigger) => labelOf(trigger) === label);
|
|
59
|
+
if (match) select(set, match);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function activate(set: HTMLElement, trigger: HTMLElement, focus = false): void {
|
|
64
|
+
select(set, trigger, focus);
|
|
65
|
+
const key = set.dataset.syncKey;
|
|
66
|
+
if (!key) return;
|
|
67
|
+
const label = labelOf(trigger);
|
|
68
|
+
write(key, label);
|
|
69
|
+
sync(key, label, set);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
document.addEventListener('click', (event) => {
|
|
73
|
+
const trigger = (event.target as Element | null)?.closest<HTMLElement>('[role="tab"]');
|
|
74
|
+
const set = setOf(trigger ?? null);
|
|
75
|
+
if (!trigger || !set) return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
activate(set, trigger);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const MOVES: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 };
|
|
81
|
+
|
|
82
|
+
document.addEventListener('keydown', (event) => {
|
|
83
|
+
const trigger = (event.target as Element | null)?.closest<HTMLElement>('[role="tab"]');
|
|
84
|
+
const set = setOf(trigger ?? null);
|
|
85
|
+
if (!trigger || !set) return;
|
|
86
|
+
|
|
87
|
+
const triggers = triggersIn(set);
|
|
88
|
+
const from = triggers.indexOf(trigger);
|
|
89
|
+
if (from < 0) return;
|
|
90
|
+
|
|
91
|
+
let to: number;
|
|
92
|
+
if (event.key in MOVES) {
|
|
93
|
+
to = (from + (MOVES[event.key] as number) + triggers.length) % triggers.length;
|
|
94
|
+
} else if (event.key === 'Home') {
|
|
95
|
+
to = 0;
|
|
96
|
+
} else if (event.key === 'End') {
|
|
97
|
+
to = triggers.length - 1;
|
|
98
|
+
} else {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
activate(set, triggers[to] as HTMLElement, true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Restore remembered choices. Runs after first paint, so a non-default choice visibly settles.
|
|
107
|
+
for (const set of document.querySelectorAll<HTMLElement>('[data-eq-tabs][data-sync-key]')) {
|
|
108
|
+
const key = set.dataset.syncKey;
|
|
109
|
+
if (!key) continue;
|
|
110
|
+
const stored = read(key);
|
|
111
|
+
if (!stored) continue;
|
|
112
|
+
const match = triggersIn(set).find((trigger) => labelOf(trigger) === stored);
|
|
113
|
+
if (match) select(set, match);
|
|
114
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-spy for the table of contents, and the lit segment of its rail.
|
|
3
|
+
*
|
|
4
|
+
* Do not swap this for an IntersectionObserver band. A band leaves nothing active at
|
|
5
|
+
* load, and nothing between two headings.
|
|
6
|
+
*
|
|
7
|
+
* The segment is a dash on a path tracing the whole rail, elbows included, so it
|
|
8
|
+
* travels through corners. Its numbers are path length, not pixels.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Scaling by distance is what makes a corner take visible time. */
|
|
12
|
+
const MS_PER_PX = 8;
|
|
13
|
+
const MIN_MS = 220;
|
|
14
|
+
const MAX_MS = 520;
|
|
15
|
+
const EASE = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
|
16
|
+
|
|
17
|
+
interface Span {
|
|
18
|
+
start: number;
|
|
19
|
+
length: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function measureRail(root: HTMLElement, path: SVGPathElement) {
|
|
23
|
+
const origin = root.getBoundingClientRect();
|
|
24
|
+
const spans = new Map<string, Span>();
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
|
|
28
|
+
const rows = root.querySelectorAll<HTMLElement>('a[data-slug], [data-eq-elbow]');
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const box = row.getBoundingClientRect();
|
|
31
|
+
const top = box.top - origin.top;
|
|
32
|
+
const bottom = box.bottom - origin.top;
|
|
33
|
+
const direction = row.getAttribute('data-eq-elbow');
|
|
34
|
+
|
|
35
|
+
if (direction) {
|
|
36
|
+
const left = box.left - origin.left;
|
|
37
|
+
const right = box.right - origin.left;
|
|
38
|
+
const [from, to] = direction === 'in' ? [left, right] : [right, left];
|
|
39
|
+
const mid = (top + bottom) / 2;
|
|
40
|
+
if (parts.length === 0) parts.push(`M ${from} ${top}`);
|
|
41
|
+
parts.push(`C ${from} ${mid}, ${to} ${mid}, ${to} ${bottom}`);
|
|
42
|
+
} else {
|
|
43
|
+
// The border's centre line, so the lit segment covers the grey one exactly.
|
|
44
|
+
const x = box.left - origin.left + parseFloat(getComputedStyle(row).borderLeftWidth) / 2;
|
|
45
|
+
if (parts.length === 0) parts.push(`M ${x} ${top}`);
|
|
46
|
+
parts.push(`L ${x} ${bottom}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
path.setAttribute('d', parts.join(' '));
|
|
50
|
+
const end = path.getTotalLength();
|
|
51
|
+
const slug = row.dataset.slug;
|
|
52
|
+
if (!direction && slug) spans.set(slug, { start: cursor, length: end - cursor });
|
|
53
|
+
cursor = end;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { spans, total: cursor };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class EqToc extends HTMLElement {
|
|
60
|
+
#teardown?: () => void;
|
|
61
|
+
|
|
62
|
+
connectedCallback() {
|
|
63
|
+
const slugs = (this.dataset.slugs ?? '').split(',').filter(Boolean);
|
|
64
|
+
|
|
65
|
+
const links = new Map<string, HTMLAnchorElement>();
|
|
66
|
+
for (const link of this.querySelectorAll<HTMLAnchorElement>('a[data-slug]')) {
|
|
67
|
+
if (link.dataset.slug) links.set(link.dataset.slug, link);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const targets = slugs
|
|
71
|
+
.map((slug) => document.getElementById(slug))
|
|
72
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
73
|
+
if (targets.length === 0) return;
|
|
74
|
+
|
|
75
|
+
const path = this.querySelector<SVGPathElement>('[data-eq-toc-rail]');
|
|
76
|
+
const rails = this.querySelector<HTMLElement>('[data-eq-toc-rails]');
|
|
77
|
+
const still = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
78
|
+
|
|
79
|
+
let spans = new Map<string, Span>();
|
|
80
|
+
let total = 0;
|
|
81
|
+
let previous: number | null = null;
|
|
82
|
+
|
|
83
|
+
const remeasure = () => {
|
|
84
|
+
if (!path || !rails) return;
|
|
85
|
+
previous = null;
|
|
86
|
+
({ spans, total } = measureRail(rails, path));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const light = (slug: string) => {
|
|
90
|
+
if (!path) return;
|
|
91
|
+
const span = spans.get(slug);
|
|
92
|
+
if (!span) return;
|
|
93
|
+
|
|
94
|
+
if (previous === null || still.matches) {
|
|
95
|
+
// No slide into the first position, and none under reduced motion.
|
|
96
|
+
path.style.transition = 'none';
|
|
97
|
+
} else {
|
|
98
|
+
const ms = Math.min(
|
|
99
|
+
MAX_MS,
|
|
100
|
+
Math.max(MIN_MS, Math.round(Math.abs(span.start - previous) * MS_PER_PX))
|
|
101
|
+
);
|
|
102
|
+
path.style.transition = `stroke-dasharray ${ms}ms ${EASE}, stroke-dashoffset ${ms}ms ${EASE}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
path.style.strokeDasharray = `${span.length} ${total}`;
|
|
106
|
+
path.style.strokeDashoffset = `${-span.start}`;
|
|
107
|
+
if (previous === null) {
|
|
108
|
+
// Commit the jump before the fade, so the segment appears in place.
|
|
109
|
+
void path.getBoundingClientRect();
|
|
110
|
+
path.style.opacity = '1';
|
|
111
|
+
}
|
|
112
|
+
previous = span.start;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let frame = 0;
|
|
116
|
+
const paint = () => {
|
|
117
|
+
frame = 0;
|
|
118
|
+
// Clicking a heading lands it 96px down (scroll-mt-24), so the line sits below.
|
|
119
|
+
const line = window.innerHeight * 0.2;
|
|
120
|
+
let active = targets[0];
|
|
121
|
+
for (const target of targets) {
|
|
122
|
+
if (target.getBoundingClientRect().top <= line) active = target;
|
|
123
|
+
else break;
|
|
124
|
+
}
|
|
125
|
+
for (const [slug, link] of links) {
|
|
126
|
+
if (slug === active.id) {
|
|
127
|
+
link.setAttribute('data-active', '');
|
|
128
|
+
// `location` is the ARIA value for the current place within a set.
|
|
129
|
+
link.setAttribute('aria-current', 'location');
|
|
130
|
+
} else {
|
|
131
|
+
link.removeAttribute('data-active');
|
|
132
|
+
link.removeAttribute('aria-current');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
light(active.id);
|
|
136
|
+
};
|
|
137
|
+
const schedule = () => {
|
|
138
|
+
if (frame === 0) frame = requestAnimationFrame(paint);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onResize = () => {
|
|
142
|
+
remeasure();
|
|
143
|
+
schedule();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
window.addEventListener('scroll', schedule, { passive: true });
|
|
147
|
+
window.addEventListener('resize', onResize, { passive: true });
|
|
148
|
+
remeasure();
|
|
149
|
+
paint();
|
|
150
|
+
|
|
151
|
+
this.#teardown = () => {
|
|
152
|
+
window.removeEventListener('scroll', schedule);
|
|
153
|
+
window.removeEventListener('resize', onResize);
|
|
154
|
+
if (frame !== 0) cancelAnimationFrame(frame);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
disconnectedCallback() {
|
|
159
|
+
this.#teardown?.();
|
|
160
|
+
this.#teardown = undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!customElements.get('eq-toc')) {
|
|
165
|
+
customElements.define('eq-toc', EqToc);
|
|
166
|
+
}
|