@orbytes/astrolab 0.3.0 → 0.4.0-next.1
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/README.md +109 -64
- package/defaults.mjs +65 -0
- package/dist/core/virtual-module/virtual-routes.js +12 -1
- package/docs/PIN-CONTRACT.md +8 -0
- package/docs/PIN.md +29 -19
- package/index.d.ts +40 -16
- package/index.mjs +32 -10
- package/package.json +6 -2
- package/src/Home.astro +167 -264
- package/src/LabHead.astro +37 -1047
- package/src/chrome/ComponentCard.astro +69 -0
- package/src/chrome/Icon.astro +21 -0
- package/src/chrome/LICENSE-icons +43 -0
- package/src/chrome/Nav.astro +105 -0
- package/src/chrome/Panel.astro +104 -0
- package/src/chrome/Shell.astro +106 -0
- package/src/chrome/Sprite.astro +23 -0
- package/src/chrome/StoryView.astro +251 -0
- package/src/chrome/Tree.astro +83 -0
- package/src/chrome/ViewportControls.astro +98 -0
- package/src/chrome/ViewportStage.astro +32 -0
- package/src/chrome/fonts/OFL.txt +93 -0
- package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/src/chrome/icons.ts +59 -0
- package/src/chrome/marks-client.ts +102 -0
- package/src/chrome/model.ts +142 -0
- package/src/chrome/pins-data.ts +30 -0
- package/src/chrome/shell-client.ts +372 -0
- package/src/chrome/site-data.ts +230 -0
- package/src/chrome/trees.ts +152 -0
- package/src/chrome/viewport-client.ts +579 -0
- package/src/chrome/views/Assets.astro +110 -0
- package/src/chrome/views/Pages.astro +178 -0
- package/src/chrome/views/Placeholder.astro +37 -0
- package/src/chrome/views/Tasks.astro +107 -0
- package/src/core/LICENSE-astrobook +16 -0
- package/src/core/lib/components/home.astro +4 -2
- package/src/core/lib/pages/story.astro +12 -10
- package/src/core/virtual-module/virtual-routes.ts +20 -4
- package/src/pin/board.mjs +389 -175
- package/src/pin/index.mjs +33 -2
- package/src/pin/toolbar.js +1 -1
- package/src/shell/Browse.astro +106 -353
- package/src/shell/Viewport.astro +22 -1315
- package/src/shell/lab-index.ts +23 -14
- package/src/ui/components/app.astro +5 -7
- package/src/ui/components/theme-script.astro +13 -2
- package/src/ui/lab.css +2152 -370
- package/virtual.d.ts +13 -0
- package/src/shell/CardGrid.astro +0 -297
- package/src/ui/components/build-path.ts +0 -13
- package/src/ui/components/build-tree.ts +0 -108
- package/src/ui/components/collapse-duration.ts +0 -28
- package/src/ui/components/compress-terms.ts +0 -10
- package/src/ui/components/dashboard-layout.astro +0 -39
- package/src/ui/components/home.astro +0 -65
- package/src/ui/components/layout.astro +0 -110
- package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
- package/src/ui/components/sidebar-button-search.astro +0 -23
- package/src/ui/components/sidebar-button-theme.astro +0 -9
- package/src/ui/components/sidebar-button.astro +0 -24
- package/src/ui/components/sidebar-resize-handle.astro +0 -74
- package/src/ui/components/sidebar-search-panel.astro +0 -41
- package/src/ui/components/sidebar-search-script.ts +0 -103
- package/src/ui/components/sidebar-title.astro +0 -17
- package/src/ui/components/sidebar-tree-node.astro +0 -143
- package/src/ui/components/sidebar-tree.astro +0 -84
- package/src/ui/components/sidebar.astro +0 -29
- package/src/ui/components/theme-toggle.astro +0 -63
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The status menu's marks — responsive (approved / done) and marked-for-deletion — on the dev
|
|
2
|
+
// server's two mark APIs (../../index.mjs). Both are dev-only middleware: on a staging build the
|
|
3
|
+
// probe fails, the boxes stay disabled and show the state the page was built with, and the menu
|
|
4
|
+
// says why. The box always shows the server's answer, never the click's.
|
|
5
|
+
//
|
|
6
|
+
// GET /__lab/responsive → { done, approved } PUT { done, approved }
|
|
7
|
+
// GET /__lab/cull → { marked } PUT { marked } (400 with offenders if refused)
|
|
8
|
+
const boxes = [...document.querySelectorAll<HTMLInputElement>("input[data-lab-mark]")];
|
|
9
|
+
const note = document.querySelector<HTMLElement>("[data-lab-marks-note]");
|
|
10
|
+
const respPill = document.querySelector<HTMLElement>("[data-lab-resp-pill]");
|
|
11
|
+
const cullPill = document.querySelector<HTMLElement>("[data-lab-cull-pill]");
|
|
12
|
+
|
|
13
|
+
type Resp = { done: string[]; approved: string[] };
|
|
14
|
+
let resp: Resp | null = null;
|
|
15
|
+
let marked: string[] | null = null;
|
|
16
|
+
|
|
17
|
+
const getJson = async (url: string) => {
|
|
18
|
+
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
19
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
20
|
+
return response.json();
|
|
21
|
+
};
|
|
22
|
+
const putJson = async (url: string, body: unknown) => {
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
method: "PUT",
|
|
25
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const data = await response.json().catch(() => ({}));
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const names = Array.isArray(data.offenders) ? data.offenders.map((o: { reason?: string }) => o.reason).join("; ") : "";
|
|
31
|
+
throw new Error(`${data.error || `HTTP ${response.status}`}${names ? ` — ${names}` : ""}`);
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const paint = () => {
|
|
37
|
+
for (const box of boxes) {
|
|
38
|
+
const file = box.dataset.file!;
|
|
39
|
+
const kind = box.dataset.labMark;
|
|
40
|
+
if (kind === "cull") {
|
|
41
|
+
if (marked) {
|
|
42
|
+
box.checked = marked.includes(file);
|
|
43
|
+
box.disabled = Boolean(box.dataset.blocked);
|
|
44
|
+
}
|
|
45
|
+
} else if (resp) {
|
|
46
|
+
box.checked = (kind === "done" ? resp.done : resp.approved).includes(file);
|
|
47
|
+
box.disabled = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const doneBox = boxes.find((b) => b.dataset.labMark === "done");
|
|
51
|
+
if (respPill && doneBox && resp) {
|
|
52
|
+
respPill.textContent = doneBox.checked ? "responsive" : "not responsive";
|
|
53
|
+
respPill.classList.toggle("lab-pill--responsive", doneBox.checked);
|
|
54
|
+
respPill.classList.toggle("lab-pill--unresponsive", !doneBox.checked);
|
|
55
|
+
}
|
|
56
|
+
const cullBox = boxes.find((b) => b.dataset.labMark === "cull");
|
|
57
|
+
if (cullPill) cullPill.hidden = !(cullBox && cullBox.checked);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const say = (text: string) => {
|
|
61
|
+
if (note) note.textContent = text;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (boxes.length) {
|
|
65
|
+
const wantsResp = boxes.some((b) => b.dataset.labMark !== "cull");
|
|
66
|
+
const wantsCull = boxes.some((b) => b.dataset.labMark === "cull");
|
|
67
|
+
Promise.allSettled([
|
|
68
|
+
wantsResp ? getJson("/__lab/responsive").then((d: Resp) => (resp = { done: d.done ?? [], approved: d.approved ?? [] })) : null,
|
|
69
|
+
wantsCull ? getJson("/__lab/cull").then((d: { marked?: string[] }) => (marked = d.marked ?? [])) : null,
|
|
70
|
+
]).then(() => {
|
|
71
|
+
if (resp || marked) say("Saved to your repo as you tick.");
|
|
72
|
+
paint();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const box of boxes) {
|
|
76
|
+
box.addEventListener("change", async () => {
|
|
77
|
+
const file = box.dataset.file!;
|
|
78
|
+
const kind = box.dataset.labMark;
|
|
79
|
+
const want = box.checked;
|
|
80
|
+
box.checked = !want; // the server's answer decides
|
|
81
|
+
try {
|
|
82
|
+
if (kind === "cull" && marked) {
|
|
83
|
+
const next = want ? [...new Set([...marked, file])] : marked.filter((f) => f !== file);
|
|
84
|
+
marked = (await putJson("/__lab/cull", { marked: next })).marked ?? next;
|
|
85
|
+
} else if (resp) {
|
|
86
|
+
const set = new Set(kind === "done" ? resp.done : resp.approved);
|
|
87
|
+
if (want) set.add(file);
|
|
88
|
+
else set.delete(file);
|
|
89
|
+
const body = kind === "done" ? { done: [...set], approved: resp.approved } : { done: resp.done, approved: [...set] };
|
|
90
|
+
const data = await putJson("/__lab/responsive", body);
|
|
91
|
+
resp = { done: data.done ?? body.done, approved: data.approved ?? body.approved };
|
|
92
|
+
}
|
|
93
|
+
say("Saved.");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
say(`Not saved: ${(error as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
paint();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The chrome's data shapes, and the one place that decides what level 1 lists.
|
|
2
|
+
//
|
|
3
|
+
// Server-side only: everything here runs in the .astro frontmatter of a chrome page (dev: per
|
|
4
|
+
// request; staging: once, at build). Nothing is sent to the browser but the HTML it renders.
|
|
5
|
+
/// <reference path="../../virtual.d.ts" />
|
|
6
|
+
import labConfig from "virtual:orbytes-lab/config.mjs";
|
|
7
|
+
import type { IconName } from "./icons";
|
|
8
|
+
import { labBase } from "../shell/lab-index";
|
|
9
|
+
import storyModules from "virtual:astrobook/story-modules.mjs";
|
|
10
|
+
|
|
11
|
+
/** Tiers that hold at least one stories file — an empty tier is not worth a row in level 1. */
|
|
12
|
+
const tiersInUse = new Set(storyModules.map((mod) => mod.directory.split("/")[0] ?? ""));
|
|
13
|
+
|
|
14
|
+
/** One row of a level 2 tree. A node with `children` is a group; one with only `href` is a leaf. */
|
|
15
|
+
export interface TreeNode {
|
|
16
|
+
/** Stable across requests — the key its open/closed state is remembered under. */
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
href?: string;
|
|
20
|
+
icon?: IconName;
|
|
21
|
+
children?: TreeNode[];
|
|
22
|
+
/** This row is the page being shown. Its ancestors are forced open. */
|
|
23
|
+
current?: boolean;
|
|
24
|
+
/** The one-glyph status: live on the site, used by something live, an open task, or muted. */
|
|
25
|
+
dot?: "live" | "used" | "open" | "muted";
|
|
26
|
+
dotTitle?: string;
|
|
27
|
+
count?: number | string;
|
|
28
|
+
/** Extra words the panel search matches, beyond the label. */
|
|
29
|
+
search?: string;
|
|
30
|
+
/** Values the panel filter menu matches against, space-separated (e.g. "live section"). */
|
|
31
|
+
facets?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
title?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Crumb {
|
|
37
|
+
label: string;
|
|
38
|
+
href?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One choice in the level 2 filter menu. `value` is matched against a node's `facets`. */
|
|
42
|
+
export interface FilterOption {
|
|
43
|
+
value: string;
|
|
44
|
+
label: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which level 1 item a page belongs to. Tiers are the consumer's (`tiers` option), so they are
|
|
49
|
+
* keyed by id rather than listed here.
|
|
50
|
+
*/
|
|
51
|
+
export type NavKey =
|
|
52
|
+
| "home"
|
|
53
|
+
| "pages"
|
|
54
|
+
| `tier:${string}`
|
|
55
|
+
| "assets"
|
|
56
|
+
| "tasks-board"
|
|
57
|
+
| "tasks-all"
|
|
58
|
+
| "support"
|
|
59
|
+
| "settings";
|
|
60
|
+
|
|
61
|
+
export interface NavItem {
|
|
62
|
+
key: NavKey;
|
|
63
|
+
label: string;
|
|
64
|
+
href: string;
|
|
65
|
+
icon: IconName;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NavGroup {
|
|
69
|
+
id: string;
|
|
70
|
+
label: string;
|
|
71
|
+
icon: IconName;
|
|
72
|
+
items: NavItem[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The tier's icon: the two conventional tiers get the design's own; anything else a component. */
|
|
76
|
+
const tierIcon = (id: string): IconName =>
|
|
77
|
+
id === labConfig.sectionsTier || id === "sections" ? "sections" : id === "components" ? "components" : "component";
|
|
78
|
+
|
|
79
|
+
/** Where the task views live, or null when the pin board is not running (a build, or `pin: false`). */
|
|
80
|
+
export const tasksBase: string | null = labConfig.tasks ? labConfig.tasks.base : null;
|
|
81
|
+
|
|
82
|
+
export const hrefs = {
|
|
83
|
+
home: labBase || "/",
|
|
84
|
+
pages: `${labBase}/pages`,
|
|
85
|
+
page: (route: string) => `${labBase}/pages/${route === "/" ? "index" : route.replace(/^\/+/, "")}`,
|
|
86
|
+
tier: (id: string) => `${labBase}/browse/${id}`,
|
|
87
|
+
folder: (path: string) => `${labBase}/browse/${path}`,
|
|
88
|
+
assets: `${labBase}/assets`,
|
|
89
|
+
support: `${labBase}/support`,
|
|
90
|
+
settings: `${labBase}/settings`,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const navModel = (): { home: NavItem; groups: NavGroup[]; footer: NavItem[] } => {
|
|
94
|
+
const site: NavGroup = {
|
|
95
|
+
id: "site",
|
|
96
|
+
label: "Site",
|
|
97
|
+
icon: "site",
|
|
98
|
+
items: [
|
|
99
|
+
{ key: "pages", label: "Pages", href: hrefs.pages, icon: "pages" },
|
|
100
|
+
...labConfig.tiers.filter((tier) => tiersInUse.has(tier.id)).map((tier) => ({
|
|
101
|
+
key: `tier:${tier.id}` as NavKey,
|
|
102
|
+
label: tier.label,
|
|
103
|
+
href: hrefs.tier(tier.id),
|
|
104
|
+
icon: tierIcon(tier.id),
|
|
105
|
+
})),
|
|
106
|
+
{ key: "assets", label: "Assets", href: hrefs.assets, icon: "assets" },
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
const groups = [site];
|
|
110
|
+
// Tasks are the pin board, which is dev only — so the group exists only where the board does.
|
|
111
|
+
if (tasksBase) {
|
|
112
|
+
groups.push({
|
|
113
|
+
id: "tasks",
|
|
114
|
+
label: "Tasks",
|
|
115
|
+
icon: "tasks",
|
|
116
|
+
items: [
|
|
117
|
+
{ key: "tasks-board", label: "Kanban Board", href: tasksBase, icon: "board" },
|
|
118
|
+
{ key: "tasks-all", label: "All Tasks", href: `${tasksBase}/all`, icon: "all-tasks" },
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
home: { key: "home", label: "Home", href: hrefs.home, icon: "home" },
|
|
124
|
+
groups,
|
|
125
|
+
footer: [
|
|
126
|
+
{ key: "support", label: "Support", href: hrefs.support, icon: "support" },
|
|
127
|
+
{ key: "settings", label: "Settings", href: hrefs.settings, icon: "settings" },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** The group a nav key sits in, for the breadcrumb ("Site › Pages"). */
|
|
133
|
+
export const crumbsFor = (key: NavKey, extra: Crumb[] = []): Crumb[] => {
|
|
134
|
+
const model = navModel();
|
|
135
|
+
for (const group of model.groups) {
|
|
136
|
+
const item = group.items.find((i) => i.key === key);
|
|
137
|
+
if (item) return [{ label: group.label }, { label: item.label, href: item.href }, ...extra];
|
|
138
|
+
}
|
|
139
|
+
return extra;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export { labBase, labConfig };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Open pin-board tickets per source file, for the "N open pins" pill. Dev only: `labConfig.tasks`
|
|
2
|
+
// is null in every build, and then this returns an empty map without touching the disk.
|
|
3
|
+
import { labConfig } from "./model";
|
|
4
|
+
|
|
5
|
+
export interface PinCounts {
|
|
6
|
+
/** open tickets whose source file ends with the given site-relative path */
|
|
7
|
+
openFor: (file: string | null | undefined) => number;
|
|
8
|
+
total: number;
|
|
9
|
+
review: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const none: PinCounts = { openFor: () => 0, total: 0, review: 0 };
|
|
13
|
+
|
|
14
|
+
export const pinCounts = async (): Promise<PinCounts> => {
|
|
15
|
+
if (!labConfig.tasks) return none;
|
|
16
|
+
try {
|
|
17
|
+
const { collectTickets, OPEN_STATUSES, READY_FOR_REVIEW } = await import("../pin/board.mjs");
|
|
18
|
+
const { tickets } = collectTickets(labConfig.tasks.repoRoot, { backlogDir: labConfig.tasks.backlogDir });
|
|
19
|
+
const open = (tickets as { status: string; source?: string }[]).filter((t) => OPEN_STATUSES.includes(t.status));
|
|
20
|
+
return {
|
|
21
|
+
// A ticket's source is repo-relative and a lab path is site-relative; in a monorepo the site
|
|
22
|
+
// sits below the repo root, so the comparison is on the tail.
|
|
23
|
+
openFor: (file) => (file ? open.filter((t) => t.source === file || t.source?.endsWith(`/${file}`)).length : 0),
|
|
24
|
+
total: open.length,
|
|
25
|
+
review: open.filter((t) => t.status === READY_FOR_REVIEW).length,
|
|
26
|
+
};
|
|
27
|
+
} catch {
|
|
28
|
+
return none;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// The chrome's behaviour — one module, run once per page (./Shell.astro has no client router, so
|
|
2
|
+
// there is no swapped document to re-initialise and no guard against running twice).
|
|
3
|
+
//
|
|
4
|
+
// · the two collapses: level 1 ⇄ icon rail (⌘⇧B), level 2 open ⇄ closed (⌘B), both (⌘.), each
|
|
5
|
+
// remembered in localStorage and applied before paint by the inline script in Shell.astro
|
|
6
|
+
// · the peek: level 2 closed, pointer at the stage's left edge → the panel slides out OVER the
|
|
7
|
+
// stage for as long as the pointer stays on it. Over, not beside: pushing the stage would
|
|
8
|
+
// resize the preview frame and trip its breakpoints
|
|
9
|
+
// · level 1 groups (Site, Tasks) folding, remembered
|
|
10
|
+
// · the tree: closed groups remembered, scroll restored, the current row kept in view
|
|
11
|
+
// · search (⌘K) and the filter menu, applied to the tree AND to any cards in the content that
|
|
12
|
+
// carry the same data-lab-search / data-lab-facets attributes
|
|
13
|
+
// · j / k: the navbar's own previous/next links when the page has them, the tree's rows when not
|
|
14
|
+
// · popover menus placed under the button that opened them
|
|
15
|
+
//
|
|
16
|
+
// Every shortcut asks window.__labIgnoreKey first (../LabHead.astro › THE KEYBOARD GUARD).
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
interface Window {
|
|
20
|
+
__labIgnoreKey?: (event: KeyboardEvent) => boolean;
|
|
21
|
+
labTheme?: { setTheme: (theme: "light" | "dark") => void; getTheme: () => "light" | "dark" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const root = document.documentElement;
|
|
26
|
+
const panel = document.getElementById("lab-l2");
|
|
27
|
+
const page = root.dataset.labPage || "lab";
|
|
28
|
+
|
|
29
|
+
const read = (key: string, store: Storage = localStorage): string | null => {
|
|
30
|
+
try {
|
|
31
|
+
return store.getItem(key);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const write = (key: string, value: string | null, store: Storage = localStorage) => {
|
|
37
|
+
try {
|
|
38
|
+
if (value === null) store.removeItem(key);
|
|
39
|
+
else store.setItem(key, value);
|
|
40
|
+
} catch {
|
|
41
|
+
/* private mode — the state holds for this page and is forgotten after */
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const ignoreKey = (event: KeyboardEvent) => window.__labIgnoreKey?.(event) ?? false;
|
|
45
|
+
|
|
46
|
+
// ---- the two collapses ---------------------------------------------------------------------------
|
|
47
|
+
const setL1 = (rail: boolean) => {
|
|
48
|
+
if (rail) root.dataset.labL1 = "rail";
|
|
49
|
+
else delete root.dataset.labL1;
|
|
50
|
+
write("lab-l1", rail ? "rail" : "open");
|
|
51
|
+
};
|
|
52
|
+
const setL2 = (closed: boolean) => {
|
|
53
|
+
if (!panel) return;
|
|
54
|
+
if (closed) root.dataset.labL2 = "closed";
|
|
55
|
+
else delete root.dataset.labL2;
|
|
56
|
+
delete root.dataset.labPeek;
|
|
57
|
+
write("lab-l2", closed ? "closed" : null);
|
|
58
|
+
};
|
|
59
|
+
const l1Rail = () => root.dataset.labL1 === "rail";
|
|
60
|
+
const l2Closed = () => root.dataset.labL2 === "closed";
|
|
61
|
+
const toggleL1 = () => setL1(!l1Rail());
|
|
62
|
+
const toggleL2 = () => setL2(!l2Closed());
|
|
63
|
+
// Focus mode: both away if either is showing, both back if both are away.
|
|
64
|
+
const toggleFocus = () => {
|
|
65
|
+
const away = l1Rail() && (l2Closed() || !panel);
|
|
66
|
+
setL1(!away);
|
|
67
|
+
setL2(!away);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (const button of document.querySelectorAll<HTMLElement>("[data-lab-toggle]")) {
|
|
71
|
+
button.addEventListener("click", () => (button.dataset.labToggle === "l1" ? toggleL1() : toggleL2()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clicking the level 1 item you are already on toggles level 2, rather than reloading the page you
|
|
75
|
+
// are looking at — the activity-bar behaviour.
|
|
76
|
+
if (panel) {
|
|
77
|
+
for (const link of document.querySelectorAll<HTMLAnchorElement>('.lab-l1 a[aria-current="page"]')) {
|
|
78
|
+
link.addEventListener("click", (event) => {
|
|
79
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
toggleL2();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- the peek -----------------------------------------------------------------------------------
|
|
87
|
+
if (panel) {
|
|
88
|
+
const zone = document.querySelector<HTMLElement>("[data-lab-peek-zone]");
|
|
89
|
+
let openTimer = 0;
|
|
90
|
+
let closeTimer = 0;
|
|
91
|
+
const peek = (on: boolean) => {
|
|
92
|
+
if (on && l2Closed()) root.dataset.labPeek = "";
|
|
93
|
+
else delete root.dataset.labPeek;
|
|
94
|
+
};
|
|
95
|
+
zone?.addEventListener("mouseenter", () => {
|
|
96
|
+
clearTimeout(closeTimer);
|
|
97
|
+
openTimer = window.setTimeout(() => peek(true), 120);
|
|
98
|
+
});
|
|
99
|
+
zone?.addEventListener("mouseleave", () => clearTimeout(openTimer));
|
|
100
|
+
panel.addEventListener("mouseenter", () => clearTimeout(closeTimer));
|
|
101
|
+
panel.addEventListener("mouseleave", () => {
|
|
102
|
+
if (!("labPeek" in root.dataset)) return;
|
|
103
|
+
closeTimer = window.setTimeout(() => peek(false), 250);
|
|
104
|
+
});
|
|
105
|
+
document.addEventListener("keydown", (event) => {
|
|
106
|
+
if (event.key === "Escape" && "labPeek" in root.dataset) peek(false);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- level 1 groups -----------------------------------------------------------------------------
|
|
111
|
+
const GROUPS_KEY = "lab-nav-collapsed";
|
|
112
|
+
const collapsedGroups = new Set<string>((read(GROUPS_KEY) || "").split(",").filter(Boolean));
|
|
113
|
+
for (const group of document.querySelectorAll<HTMLElement>("[data-lab-group]")) {
|
|
114
|
+
const id = group.dataset.labGroup!;
|
|
115
|
+
const button = group.querySelector<HTMLButtonElement>("[data-lab-group-toggle]");
|
|
116
|
+
const apply = (collapsed: boolean) => {
|
|
117
|
+
group.toggleAttribute("data-collapsed", collapsed);
|
|
118
|
+
button?.setAttribute("aria-expanded", String(!collapsed));
|
|
119
|
+
};
|
|
120
|
+
// The group holding the current page is always open — you are in it.
|
|
121
|
+
apply(collapsedGroups.has(id) && !group.hasAttribute("data-lab-holds-current"));
|
|
122
|
+
button?.addEventListener("click", () => {
|
|
123
|
+
// In the rail the header is an icon whose items fly out on hover; a click goes to the first.
|
|
124
|
+
if (l1Rail()) {
|
|
125
|
+
const first = group.querySelector<HTMLAnchorElement>(".lab-nav-group__items a");
|
|
126
|
+
if (first) location.href = first.href;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const collapsed = !group.hasAttribute("data-collapsed");
|
|
130
|
+
apply(collapsed);
|
|
131
|
+
if (collapsed) collapsedGroups.add(id);
|
|
132
|
+
else collapsedGroups.delete(id);
|
|
133
|
+
write(GROUPS_KEY, [...collapsedGroups].join(",") || null);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- theme --------------------------------------------------------------------------------------
|
|
138
|
+
for (const button of document.querySelectorAll<HTMLElement>("[data-lab-theme-toggle]")) {
|
|
139
|
+
button.addEventListener("click", () => {
|
|
140
|
+
const next = window.labTheme?.getTheme() === "dark" ? "light" : "dark";
|
|
141
|
+
window.labTheme?.setTheme(next);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- the tree -----------------------------------------------------------------------------------
|
|
146
|
+
const tree = document.querySelector<HTMLElement>("[data-lab-tree]");
|
|
147
|
+
const body = document.querySelector<HTMLElement>("[data-lab-l2-body]");
|
|
148
|
+
const CLOSED_KEY = "lab-tree-closed";
|
|
149
|
+
const closedIds = new Set<string>((read(CLOSED_KEY) || "").split("\n").filter(Boolean));
|
|
150
|
+
let searching = false;
|
|
151
|
+
|
|
152
|
+
const applyStoredOpen = () => {
|
|
153
|
+
for (const details of tree?.querySelectorAll<HTMLDetailsElement>("details[data-lab-tree-id]") ?? []) {
|
|
154
|
+
const shut = closedIds.has(details.dataset.labTreeId!) && !details.hasAttribute("data-lab-holds-current");
|
|
155
|
+
details.open = !shut;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (tree) {
|
|
160
|
+
applyStoredOpen();
|
|
161
|
+
tree.addEventListener(
|
|
162
|
+
"toggle",
|
|
163
|
+
(event) => {
|
|
164
|
+
const details = event.target as HTMLDetailsElement;
|
|
165
|
+
const id = details.dataset?.labTreeId;
|
|
166
|
+
if (!id || searching) return; // a search opens groups; that is not the reader's choice
|
|
167
|
+
if (details.open) closedIds.delete(id);
|
|
168
|
+
else closedIds.add(id);
|
|
169
|
+
write(CLOSED_KEY, [...closedIds].join("\n") || null);
|
|
170
|
+
},
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (body) {
|
|
176
|
+
const SCROLL_KEY = `lab-l2-scroll:${page}`;
|
|
177
|
+
const saved = Number(read(SCROLL_KEY, sessionStorage));
|
|
178
|
+
if (saved) body.scrollTop = saved;
|
|
179
|
+
body.addEventListener("scrollend", () => write(SCROLL_KEY, String(body.scrollTop), sessionStorage));
|
|
180
|
+
const current = tree?.querySelector<HTMLElement>('[aria-current="page"]');
|
|
181
|
+
current?.scrollIntoView({ block: "nearest" });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- search and filter --------------------------------------------------------------------------
|
|
185
|
+
const search = document.querySelector<HTMLInputElement>("[data-lab-tree-search]");
|
|
186
|
+
const filterButton = document.querySelector<HTMLElement>("[data-lab-filter-btn]");
|
|
187
|
+
const FILTER_KEY = `lab-filter:${page}`;
|
|
188
|
+
let facet = read(FILTER_KEY, sessionStorage) || "";
|
|
189
|
+
|
|
190
|
+
const matches = (el: HTMLElement, terms: string[]) => {
|
|
191
|
+
const hay = el.dataset.labSearch || "";
|
|
192
|
+
if (!terms.every((term) => hay.includes(term))) return false;
|
|
193
|
+
if (!facet) return true;
|
|
194
|
+
return (el.dataset.labFacets || "").split(/\s+/).includes(facet);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Show a tree node when it, or anything beneath it, matches. Returns whether it is shown. */
|
|
198
|
+
const filterNode = (li: HTMLElement, terms: string[]): boolean => {
|
|
199
|
+
const children = [...li.querySelectorAll<HTMLElement>(":scope > details > ul > .lab-tree__node")];
|
|
200
|
+
let shown: boolean;
|
|
201
|
+
if (children.length) {
|
|
202
|
+
const any = children.map((child) => filterNode(child, terms)).some(Boolean);
|
|
203
|
+
// A group whose own name matches shows everything under it (unless a facet is narrowing).
|
|
204
|
+
shown = any || (!facet && terms.length > 0 && matches(li, terms));
|
|
205
|
+
if (shown && !any) for (const child of children) showAll(child);
|
|
206
|
+
const details = li.querySelector<HTMLDetailsElement>(":scope > details");
|
|
207
|
+
if (details && (terms.length || facet) && any) details.open = true;
|
|
208
|
+
} else {
|
|
209
|
+
shown = matches(li, terms);
|
|
210
|
+
}
|
|
211
|
+
li.toggleAttribute("data-lab-hidden", !shown);
|
|
212
|
+
return shown;
|
|
213
|
+
};
|
|
214
|
+
const showAll = (li: HTMLElement) => {
|
|
215
|
+
li.removeAttribute("data-lab-hidden");
|
|
216
|
+
for (const child of li.querySelectorAll<HTMLElement>(".lab-tree__node")) child.removeAttribute("data-lab-hidden");
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const applyFilter = () => {
|
|
220
|
+
const text = (search?.value || "").trim().toLowerCase();
|
|
221
|
+
const terms = text ? text.split(/\s+/) : [];
|
|
222
|
+
searching = terms.length > 0 || Boolean(facet);
|
|
223
|
+
if (tree) {
|
|
224
|
+
if (!searching) applyStoredOpen();
|
|
225
|
+
for (const li of tree.querySelectorAll<HTMLElement>(":scope > .lab-tree__node")) filterNode(li, terms);
|
|
226
|
+
let empty = tree.parentElement?.querySelector<HTMLElement>("[data-lab-tree-none]");
|
|
227
|
+
const anyShown = tree.querySelector(":scope > .lab-tree__node:not([data-lab-hidden])");
|
|
228
|
+
if (!anyShown && tree.children.length) {
|
|
229
|
+
if (!empty) {
|
|
230
|
+
empty = document.createElement("p");
|
|
231
|
+
empty.className = "lab-tree__empty";
|
|
232
|
+
empty.dataset.labTreeNone = "";
|
|
233
|
+
empty.textContent = "Nothing matches.";
|
|
234
|
+
tree.after(empty);
|
|
235
|
+
}
|
|
236
|
+
} else empty?.remove();
|
|
237
|
+
}
|
|
238
|
+
// The content's cards follow the same search and filter, so one box narrows both.
|
|
239
|
+
for (const card of document.querySelectorAll<HTMLElement>(".lab-content [data-lab-search]")) {
|
|
240
|
+
card.toggleAttribute("data-lab-hidden", !matches(card, terms));
|
|
241
|
+
}
|
|
242
|
+
for (const section of document.querySelectorAll<HTMLElement>(".lab-content [data-lab-filter-section]")) {
|
|
243
|
+
const any = section.querySelector("[data-lab-search]:not([data-lab-hidden])");
|
|
244
|
+
section.hidden = !any;
|
|
245
|
+
}
|
|
246
|
+
filterButton?.setAttribute("aria-pressed", String(Boolean(facet)));
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
search?.addEventListener("input", applyFilter);
|
|
250
|
+
search?.addEventListener("keydown", (event) => {
|
|
251
|
+
if (event.key !== "Escape") return;
|
|
252
|
+
if (search.value) {
|
|
253
|
+
search.value = "";
|
|
254
|
+
applyFilter();
|
|
255
|
+
} else search.blur();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const filterItems = [...document.querySelectorAll<HTMLElement>("[data-lab-filter]")];
|
|
259
|
+
const markFilter = () => {
|
|
260
|
+
for (const item of filterItems) item.setAttribute("aria-checked", String(item.dataset.labFilter === facet));
|
|
261
|
+
};
|
|
262
|
+
if (filterItems.length && !filterItems.some((item) => item.dataset.labFilter === facet)) facet = "";
|
|
263
|
+
for (const item of filterItems) {
|
|
264
|
+
item.addEventListener("click", () => {
|
|
265
|
+
facet = item.dataset.labFilter || "";
|
|
266
|
+
write(FILTER_KEY, facet || null, sessionStorage);
|
|
267
|
+
markFilter();
|
|
268
|
+
applyFilter();
|
|
269
|
+
item.closest<HTMLElement>("[popover]")?.hidePopover?.();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
markFilter();
|
|
273
|
+
if (facet) applyFilter();
|
|
274
|
+
|
|
275
|
+
// ---- keys ---------------------------------------------------------------------------------------
|
|
276
|
+
const stepLinks = () => {
|
|
277
|
+
// A page with its own previous/next (a story or a page view) steps through those.
|
|
278
|
+
const prev = document.querySelector<HTMLAnchorElement>('a[data-lab-step="prev"]');
|
|
279
|
+
const next = document.querySelector<HTMLAnchorElement>('a[data-lab-step="next"]');
|
|
280
|
+
if (prev || next || document.querySelector("[data-lab-step]")) return { prev, next };
|
|
281
|
+
// Otherwise the tree's visible leaves.
|
|
282
|
+
const leaves = [...(tree?.querySelectorAll<HTMLAnchorElement>("a[data-lab-leaf]") ?? [])].filter(
|
|
283
|
+
(a) => a.offsetParent !== null,
|
|
284
|
+
);
|
|
285
|
+
const at = leaves.findIndex((a) => a.getAttribute("aria-current") === "page");
|
|
286
|
+
return {
|
|
287
|
+
prev: at > 0 ? leaves[at - 1] : at === -1 ? leaves[leaves.length - 1] : null,
|
|
288
|
+
next: at === -1 ? leaves[0] : leaves[at + 1] ?? null,
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
document.addEventListener("keydown", (event) => {
|
|
293
|
+
const key = event.key.toLowerCase();
|
|
294
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
295
|
+
|
|
296
|
+
// ⌘K stays live inside the lab's own search box, where it means "select what is there".
|
|
297
|
+
if (mod && key === "k") {
|
|
298
|
+
if (event.target !== search && ignoreKey(event)) return;
|
|
299
|
+
if (!search) return;
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
if (l2Closed()) setL2(false);
|
|
302
|
+
search.focus();
|
|
303
|
+
search.select();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (ignoreKey(event)) return;
|
|
307
|
+
if (mod && key === "b") {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
if (event.shiftKey) toggleL1();
|
|
310
|
+
else toggleL2();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (mod && key === ".") {
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
toggleFocus();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (mod || event.altKey || event.shiftKey || event.isComposing) return;
|
|
319
|
+
if (key !== "j" && key !== "k") return;
|
|
320
|
+
const { prev, next } = stepLinks();
|
|
321
|
+
const target = key === "j" ? next : prev;
|
|
322
|
+
if (!target) return;
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
target.click();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---- popover menus ------------------------------------------------------------------------------
|
|
328
|
+
// Placed under the button that opened them. CSS anchor positioning would do this with no script,
|
|
329
|
+
// but it is not in every browser yet; this is twelve lines.
|
|
330
|
+
for (const menu of document.querySelectorAll<HTMLElement>(".lab-menu[popover]")) {
|
|
331
|
+
menu.addEventListener("toggle", (event) => {
|
|
332
|
+
if ((event as ToggleEvent).newState !== "open") return;
|
|
333
|
+
const invoker = document.querySelector<HTMLElement>(`[popovertarget="${menu.id}"]`);
|
|
334
|
+
if (!invoker) return;
|
|
335
|
+
const box = invoker.getBoundingClientRect();
|
|
336
|
+
const width = menu.offsetWidth;
|
|
337
|
+
const alignEnd = menu.dataset.labAlign === "end";
|
|
338
|
+
let left = alignEnd ? box.right - width : box.left;
|
|
339
|
+
left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
|
|
340
|
+
let top = box.bottom + 4;
|
|
341
|
+
if (top + menu.offsetHeight > window.innerHeight - 8) top = Math.max(8, box.top - menu.offsetHeight - 4);
|
|
342
|
+
menu.style.left = `${left}px`;
|
|
343
|
+
menu.style.top = `${top}px`;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- card thumbnails ----------------------------------------------------------------------------
|
|
348
|
+
// Each thumbnail is the real bare render in an <iframe> sized to a virtual viewport
|
|
349
|
+
// (data-frame-w × data-frame-h), scaled down to the card. One observer for all of them.
|
|
350
|
+
const thumbs = document.querySelectorAll<HTMLElement>("[data-frame-w]");
|
|
351
|
+
if (thumbs.length) {
|
|
352
|
+
const fit = (thumb: HTMLElement) => {
|
|
353
|
+
const frame = thumb.querySelector<HTMLElement>("iframe");
|
|
354
|
+
if (!frame) return;
|
|
355
|
+
const w = Number(thumb.dataset.frameW) || 1440;
|
|
356
|
+
const h = Number(thumb.dataset.frameH) || 900;
|
|
357
|
+
const scale = thumb.clientWidth / w;
|
|
358
|
+
frame.style.width = `${w}px`;
|
|
359
|
+
frame.style.height = `${h}px`;
|
|
360
|
+
frame.style.transform = `scale(${scale})`;
|
|
361
|
+
thumb.style.aspectRatio = `${w} / ${h}`;
|
|
362
|
+
};
|
|
363
|
+
const observer = new ResizeObserver((entries) => {
|
|
364
|
+
for (const entry of entries) fit(entry.target as HTMLElement);
|
|
365
|
+
});
|
|
366
|
+
for (const thumb of thumbs) {
|
|
367
|
+
fit(thumb);
|
|
368
|
+
observer.observe(thumb);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export {};
|