@nu-appdev/northwestern-starlight-theme 1.2.0 → 1.3.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +53 -2
  2. package/index.ts +84 -5
  3. package/mermaid.ts +196 -80
  4. package/package.json +19 -4
  5. package/src/components/Expandable.astro +19 -0
  6. package/src/components/Glossary.astro +11 -0
  7. package/src/components/Hero.astro +0 -2
  8. package/src/components/Kbd.astro +147 -0
  9. package/src/components/Property.astro +64 -0
  10. package/src/components/PropertyGroup.astro +13 -0
  11. package/src/components/PropertyTable.astro +54 -0
  12. package/src/components/Term.astro +29 -0
  13. package/src/components/Tooltip.astro +138 -0
  14. package/src/components/glossary-store.ts +12 -0
  15. package/src/components/index.ts +8 -0
  16. package/src/rehype-table-scroll.ts +35 -0
  17. package/src/scripts/mermaid/focus.ts +100 -0
  18. package/src/scripts/mermaid/fullscreen.ts +120 -0
  19. package/src/scripts/mermaid/index.ts +2 -0
  20. package/src/scripts/mermaid/overlay.ts +132 -0
  21. package/src/scripts/mermaid/pan-zoom.ts +285 -0
  22. package/src/scripts/mermaid/render.ts +143 -0
  23. package/src/scripts/mermaid/toolbar.ts +137 -0
  24. package/src/scripts/mermaid/ui.ts +265 -0
  25. package/src/styles/a11y.css +69 -0
  26. package/src/styles/components/blockquotes.css +37 -0
  27. package/src/styles/components/code.css +8 -5
  28. package/src/styles/components/footnotes.css +76 -0
  29. package/src/styles/components/kbd.css +69 -0
  30. package/src/styles/components/mermaid.css +4 -4
  31. package/src/styles/components/property-table.css +569 -0
  32. package/src/styles/components/steps.css +0 -3
  33. package/src/styles/components/tables.css +60 -0
  34. package/src/styles/components/tabs.css +0 -3
  35. package/src/styles/components/tooltip.css +145 -0
  36. package/src/styles/content.css +4 -160
  37. package/src/styles/layers.css +0 -7
  38. package/src/styles/mermaid-toolbar.css +62 -13
  39. package/src/styles/navigation.css +66 -17
  40. package/src/styles/openapi.css +0 -3
  41. package/src/styles/theme.css +0 -4
  42. package/src/styles/typography.css +4 -0
  43. package/src/styles/variables.css +0 -3
  44. package/src/virtual.d.ts +11 -0
  45. package/src/scripts/mermaid-toolbar.ts +0 -659
@@ -101,7 +101,6 @@ const imageWidth = themeConfig.homepage.imageWidth;
101
101
  </div>
102
102
 
103
103
  <style>
104
- /* ---- Base / Centered layout ---- */
105
104
  .hero {
106
105
  display: flex;
107
106
  align-items: center;
@@ -187,7 +186,6 @@ const imageWidth = themeConfig.homepage.imageWidth;
187
186
  }
188
187
  }
189
188
 
190
- /* ---- Split layout ---- */
191
189
  .hero--split {
192
190
  display: grid;
193
191
  grid-template-columns: 1fr;
@@ -0,0 +1,147 @@
1
+ ---
2
+ import "../styles/components/kbd.css";
3
+ import Tooltip from "./Tooltip.astro";
4
+
5
+ interface Props {
6
+ /** Explicit array of key names. Overrides children parsing. */
7
+ keys?: string[];
8
+ /** Tooltip description shown on hover. Wraps the Kbd in a Tooltip when set. */
9
+ tip?: string;
10
+ }
11
+
12
+ /** Map semantic key names to display symbols (Mac). */
13
+ const KEY_SYMBOLS_MAC: Record<string, string> = {
14
+ mod: "⌘",
15
+ command: "⌘",
16
+ cmd: "⌘",
17
+ meta: "⌘",
18
+ control: "⌃",
19
+ ctrl: "⌃",
20
+ option: "⌥",
21
+ alt: "⌥",
22
+ shift: "⇧",
23
+ enter: "↵",
24
+ return: "↵",
25
+ backspace: "⌫",
26
+ delete: "⌦",
27
+ escape: "Esc",
28
+ esc: "Esc",
29
+ tab: "⇥",
30
+ capslock: "⇪",
31
+ space: "␣",
32
+ up: "↑",
33
+ down: "↓",
34
+ left: "←",
35
+ right: "→",
36
+ pageup: "⇞",
37
+ pagedown: "⇟",
38
+ home: "↖",
39
+ end: "↘",
40
+ };
41
+
42
+ /** Map semantic key names to display symbols (Windows/Linux). */
43
+ const KEY_SYMBOLS_OTHER: Record<string, string> = {
44
+ ...KEY_SYMBOLS_MAC,
45
+ mod: "Ctrl",
46
+ command: "Ctrl",
47
+ cmd: "Ctrl",
48
+ meta: "Win",
49
+ control: "Ctrl",
50
+ ctrl: "Ctrl",
51
+ option: "Alt",
52
+ alt: "Alt",
53
+ };
54
+
55
+ /** Keys that differ between platforms. */
56
+ const PLATFORM_KEYS = new Set(["mod", "command", "cmd", "meta", "control", "ctrl", "option", "alt"]);
57
+
58
+ /** Canonical modifier ordering: Ctrl, Meta/Cmd, Alt/Option, Shift, then others. */
59
+ const MODIFIER_ORDER_MAC = ["⌃", "⌘", "⌥", "⇧"];
60
+ const MODIFIER_ORDER_OTHER = ["Ctrl", "Win", "Alt", "Shift"];
61
+
62
+ function resolveKeys(rawKeys: string[], symbols: Record<string, string>): string[] {
63
+ return rawKeys.map((k) => {
64
+ const lower = k.trim().toLowerCase();
65
+ return symbols[lower] ?? k.trim();
66
+ });
67
+ }
68
+
69
+ function sortKeys(keys: string[], order: string[]): string[] {
70
+ return [...keys].sort((a, b) => {
71
+ const ai = order.indexOf(a);
72
+ const bi = order.indexOf(b);
73
+ if (ai !== -1 && bi !== -1) return ai - bi;
74
+ if (ai !== -1) return -1;
75
+ if (bi !== -1) return 1;
76
+ return 0;
77
+ });
78
+ }
79
+
80
+ const { keys: keysProp, tip: kbdTip } = Astro.props;
81
+
82
+ let rawSlot = "";
83
+ if (!keysProp && Astro.slots.has("default")) {
84
+ rawSlot = await Astro.slots.render("default").then((s) => s.replace(/<[^>]*>/g, "").trim());
85
+ }
86
+
87
+ const rawKeys =
88
+ keysProp ??
89
+ rawSlot
90
+ .split("+")
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ const hasPlatformKeys = rawKeys.some((k) => PLATFORM_KEYS.has(k.toLowerCase()));
94
+
95
+ const macKeys = sortKeys(resolveKeys(rawKeys, KEY_SYMBOLS_MAC), MODIFIER_ORDER_MAC);
96
+ const otherKeys = sortKeys(resolveKeys(rawKeys, KEY_SYMBOLS_OTHER), MODIFIER_ORDER_OTHER);
97
+
98
+ const ariaLabel = rawKeys.join(" + ");
99
+ ---
100
+
101
+ {() => {
102
+ const kbdElement = (
103
+ <kbd class="nu-kbd" aria-label={ariaLabel}>
104
+ {hasPlatformKeys ? (
105
+ <>
106
+ <span class="nu-kbd__mac">
107
+ {macKeys.map((key, i) => (
108
+ <>
109
+ {i > 0 && <span class="nu-kbd__sep" aria-hidden="true">+</span>}
110
+ <kbd class="nu-kbd__key">{key}</kbd>
111
+ </>
112
+ ))}
113
+ </span>
114
+ <span class="nu-kbd__other">
115
+ {otherKeys.map((key, i) => (
116
+ <>
117
+ {i > 0 && <span class="nu-kbd__sep" aria-hidden="true">+</span>}
118
+ <kbd class="nu-kbd__key">{key}</kbd>
119
+ </>
120
+ ))}
121
+ </span>
122
+ </>
123
+ ) : (
124
+ macKeys.map((key, i) => (
125
+ <>
126
+ {i > 0 && <span class="nu-kbd__sep" aria-hidden="true">+</span>}
127
+ <kbd class="nu-kbd__key">{key}</kbd>
128
+ </>
129
+ ))
130
+ )}
131
+ </kbd>
132
+ );
133
+
134
+ return kbdTip
135
+ ? <Tooltip tip={kbdTip} underline={false}>{kbdElement}</Tooltip>
136
+ : kbdElement;
137
+ }}
138
+
139
+ <script>
140
+ function detectPlatform() {
141
+ const isMac = navigator.userAgentData
142
+ ? navigator.userAgentData.platform === "macOS"
143
+ : /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
144
+ document.documentElement.setAttribute("data-platform", isMac ? "mac" : "other");
145
+ }
146
+ detectPlatform();
147
+ </script>
@@ -0,0 +1,64 @@
1
+ ---
2
+ interface Props {
3
+ /** Property name (displayed in monospace). */
4
+ name: string;
5
+ /** Type annotation (displayed in monospace, supports union syntax). */
6
+ type?: string;
7
+ /** URL to link the type annotation to (e.g. a type definition or external docs). */
8
+ typeHref?: string;
9
+ /** Default value (displayed in monospace). */
10
+ default?: string;
11
+ /** Mark as required. Displays a "Required" badge. */
12
+ required?: boolean;
13
+ /** Mark as deprecated. Strikes through the name and mutes the row. */
14
+ deprecated?: boolean;
15
+ /** Version this property was added in. Displays a small badge. */
16
+ since?: string;
17
+ }
18
+
19
+ if (!globalThis.__nuPropertySlugs) {
20
+ globalThis.__nuPropertySlugs = { page: "", counts: new Map<string, number>() };
21
+ }
22
+ const pageSlugState = globalThis.__nuPropertySlugs;
23
+ const currentPage = Astro.url.pathname;
24
+ if (pageSlugState.page !== currentPage) {
25
+ pageSlugState.page = currentPage;
26
+ pageSlugState.counts.clear();
27
+ }
28
+
29
+ const { name, type, typeHref, default: defaultValue, required, deprecated, since } = Astro.props;
30
+ const hasInlineBadges = required || since || deprecated;
31
+ const rawSlug = name
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, "-")
34
+ .replace(/^-|-$/g, "");
35
+ const baseSlug = `prop-${rawSlug || "key"}`;
36
+ const count = (pageSlugState.counts.get(baseSlug) ?? 0) + 1;
37
+ pageSlugState.counts.set(baseSlug, count);
38
+ const slug = count === 1 ? baseSlug : `${baseSlug}-${count}`;
39
+ ---
40
+
41
+ <div class:list={["nu-property", { "nu-property--required": required, "nu-property--deprecated": deprecated }]} role="listitem" id={slug}>
42
+ <div class="nu-property__head">
43
+ <span class="nu-property__prop-col">
44
+ <a class="nu-property__anchor" href={`#${slug}`} aria-label={`Link to ${name}`}>#</a><code class="nu-property__name">{name}</code>
45
+ {hasInlineBadges && (
46
+ <span class="nu-property__badges">
47
+ {required && <span class="nu-property__badge nu-property__badge--required" aria-label="required">Required</span>}
48
+ {since && <span class="nu-property__badge nu-property__badge--since">v{since}</span>}
49
+ {deprecated && <span class="nu-property__badge nu-property__badge--deprecated" aria-label="deprecated">Deprecated</span>}
50
+ </span>
51
+ )}
52
+ </span>
53
+ {type && typeHref
54
+ ? <a class="nu-property__type-link" href={typeHref}><code class="nu-property__type">{type}</code></a>
55
+ : type && <code class="nu-property__type">{type}</code>
56
+ }
57
+ <span class="nu-property__default-col">
58
+ {defaultValue && <span class="nu-property__badge nu-property__badge--default"><code>{defaultValue}</code></span>}
59
+ </span>
60
+ </div>
61
+ <div class="nu-property__description">
62
+ <slot />
63
+ </div>
64
+ </div>
@@ -0,0 +1,13 @@
1
+ ---
2
+ interface Props {
3
+ /** Group heading text. */
4
+ title: string;
5
+ }
6
+
7
+ const { title } = Astro.props;
8
+ ---
9
+
10
+ <div class="nu-property-group">
11
+ <div class="nu-property-group__title">{title}</div>
12
+ <slot />
13
+ </div>
@@ -0,0 +1,54 @@
1
+ ---
2
+ import "../styles/components/property-table.css";
3
+
4
+ type ColumnShorthand = "name" | "type" | "default";
5
+ type ColumnConfig = { key: ColumnShorthand; label: string };
6
+ type ColumnDef = ColumnShorthand | ColumnConfig;
7
+
8
+ interface Props {
9
+ /** Optional heading above the table. */
10
+ title?: string;
11
+ /** Layout variant. "list" (default) shows stacked rows. "table" shows a columnar grid. */
12
+ variant?: "list" | "table";
13
+ /** Columns to display in table variant. Array of field keys or { key, label } objects. */
14
+ columns?: ColumnDef[];
15
+ /** Show "Optional" label on non-required properties. */
16
+ showOptional?: boolean;
17
+ }
18
+
19
+ const DEFAULT_LABELS: Record<ColumnShorthand, string> = {
20
+ name: "Prop",
21
+ type: "Type",
22
+ default: "Default",
23
+ };
24
+
25
+ const { title, variant = "list", columns: columnsProp, showOptional = false } = Astro.props;
26
+ const isTable = variant === "table";
27
+
28
+ const resolvedColumns = isTable
29
+ ? (columnsProp ?? ["name", "type", "default"]).map((col) =>
30
+ typeof col === "string" ? { key: col, label: DEFAULT_LABELS[col] } : col,
31
+ )
32
+ : [];
33
+
34
+ const columnCount = resolvedColumns.length;
35
+ const columnKeys = resolvedColumns.map((c) => c.key);
36
+ ---
37
+
38
+ <div
39
+ class:list={["nu-property-table", { "nu-property-table--table": isTable, "nu-property-table--show-optional": showOptional }]}
40
+ style={isTable ? `--nu-pt-columns: ${columnCount};` : undefined}
41
+ data-columns={isTable ? columnKeys.join(",") : undefined}
42
+ >
43
+ {title && <div class="nu-property-table__header">{title}</div>}
44
+ {isTable && (
45
+ <div class="nu-property-table__columns" aria-hidden="true">
46
+ {resolvedColumns.map((col) => (
47
+ <span>{col.label}</span>
48
+ ))}
49
+ </div>
50
+ )}
51
+ <div class="nu-property-table__body not-content" role="list">
52
+ <slot />
53
+ </div>
54
+ </div>
@@ -0,0 +1,29 @@
1
+ ---
2
+ import { getTerm } from "./glossary-store.ts";
3
+ import Tooltip from "./Tooltip.astro";
4
+
5
+ interface Props {
6
+ /** Key matching a Glossary entry. */
7
+ of: string;
8
+ /** Preferred position. */
9
+ side?: "top" | "bottom";
10
+ /** Render with a dotted underline indicator. */
11
+ underline?: boolean;
12
+ /** Semantic color variant. */
13
+ variant?: "note" | "tip" | "caution" | "danger";
14
+ /** Custom background color. */
15
+ color?: string;
16
+ }
17
+
18
+ const { of: key, side, underline, variant, color } = Astro.props;
19
+ const tip = getTerm(key);
20
+ const hasSlot = Astro.slots.has("default");
21
+
22
+ if (!tip) {
23
+ throw new Error(`[Term] No glossary entry found for "${key}". Add it to a <Glossary> component above this <Term>.`);
24
+ }
25
+ ---
26
+
27
+ <Tooltip tip={tip} side={side} underline={underline} variant={variant} color={color}>
28
+ {hasSlot ? <slot /> : key}
29
+ </Tooltip>
@@ -0,0 +1,138 @@
1
+ ---
2
+ import { darken, isDark, lighten, toHex } from "khroma";
3
+ import "../styles/components/tooltip.css";
4
+
5
+ type TooltipVariant = "note" | "tip" | "caution" | "danger";
6
+
7
+ interface Props {
8
+ /** Definition text shown in the tooltip. Ignored when the content slot is used. */
9
+ tip?: string;
10
+ /** Preferred position. */
11
+ side?: "top" | "bottom";
12
+ /** Render the trigger with a dotted underline indicator. */
13
+ underline?: boolean;
14
+ /** Semantic variant matching Aside types. Sets background, text, and border colors. */
15
+ variant?: TooltipVariant;
16
+ /** Custom background color. Overrides variant. Text color is auto-computed for WCAG contrast. */
17
+ color?: string;
18
+ }
19
+
20
+ /** Maps to --nu-color-* tokens in variables.css */
21
+ const VARIANT_COLORS: Record<TooltipVariant, string> = {
22
+ note: "#5091cd", // --nu-color-info
23
+ tip: "#008656", // --nu-color-success
24
+ caution: "#ffc520", // --nu-color-warning
25
+ danger: "#ef553f", // --nu-color-danger
26
+ };
27
+
28
+ const { tip, side = "top", underline = true, variant, color } = Astro.props;
29
+ const id = `nu-tip-${Math.random().toString(36).slice(2, 9)}`;
30
+
31
+ const hasTipSlot = Astro.slots.has("tip");
32
+
33
+ const resolvedColor = color ?? (variant ? VARIANT_COLORS[variant] : undefined);
34
+
35
+ let colorStyles: string | undefined;
36
+ if (resolvedColor) {
37
+ const bg = toHex(resolvedColor);
38
+ const textColor = isDark(bg) ? "#fff" : "#1a1a1a";
39
+ const borderColor = isDark(bg) ? toHex(lighten(bg, 12)) : toHex(darken(bg, 12));
40
+ colorStyles = `--nu-tip-bg: ${bg}; --nu-tip-text: ${textColor}; --nu-tip-border: ${borderColor}; --nu-tip-arrow: ${bg};`;
41
+ }
42
+ ---
43
+
44
+ <span
45
+ class:list={["nu-tooltip", `nu-tooltip--${side}`, { "nu-tooltip--underline": underline, "nu-tooltip--custom": !!resolvedColor }]}
46
+ data-tooltip-id={id}
47
+ style={colorStyles}
48
+ >
49
+ <span class="nu-tooltip__trigger" tabindex="0" aria-describedby={id}><slot /></span>
50
+ <span class="nu-tooltip__content" role="tooltip" id={id} popover="manual">
51
+ {hasTipSlot ? <slot name="tip" /> : tip}
52
+ <span class="nu-tooltip__arrow"></span>
53
+ </span>
54
+ </span>
55
+
56
+ <script>
57
+ const initialized = new WeakSet<HTMLElement>();
58
+
59
+ function initTooltips() {
60
+ document.querySelectorAll<HTMLElement>(".nu-tooltip").forEach((wrapper) => {
61
+ if (initialized.has(wrapper)) return;
62
+ initialized.add(wrapper);
63
+
64
+ const trigger = wrapper.querySelector<HTMLElement>(".nu-tooltip__trigger");
65
+ const content = wrapper.querySelector<HTMLElement>(".nu-tooltip__content");
66
+ if (!trigger || !content) return;
67
+
68
+ let isVisible = false;
69
+ const hasPopover = "showPopover" in content;
70
+
71
+ function onScroll() {
72
+ if (isVisible) position();
73
+ }
74
+
75
+ function show() {
76
+ if (isVisible) return;
77
+ isVisible = true;
78
+ if (hasPopover) {
79
+ content!.showPopover();
80
+ } else {
81
+ content!.style.position = "fixed";
82
+ content!.style.opacity = "1";
83
+ content!.style.visibility = "visible";
84
+ content!.style.pointerEvents = "auto";
85
+ }
86
+ position();
87
+ window.addEventListener("scroll", onScroll, { passive: true });
88
+ }
89
+
90
+ function hide() {
91
+ if (!isVisible) return;
92
+ isVisible = false;
93
+ if (hasPopover) {
94
+ content!.hidePopover();
95
+ } else {
96
+ content!.style.opacity = "0";
97
+ content!.style.visibility = "hidden";
98
+ content!.style.pointerEvents = "none";
99
+ }
100
+ window.removeEventListener("scroll", onScroll);
101
+ }
102
+
103
+ function position() {
104
+ const rect = trigger!.getBoundingClientRect();
105
+ const side = wrapper.classList.contains("nu-tooltip--bottom") ? "bottom" : "top";
106
+ const gap = 8;
107
+
108
+ content!.style.left = `${rect.left + rect.width / 2}px`;
109
+
110
+ if (side === "top") {
111
+ content!.style.top = `${rect.top - gap}px`;
112
+ content!.style.transform = "translate(-50%, -100%)";
113
+ } else {
114
+ content!.style.top = `${rect.bottom + gap}px`;
115
+ content!.style.transform = "translate(-50%, 0)";
116
+ }
117
+ }
118
+
119
+ wrapper.addEventListener("mouseenter", show);
120
+ wrapper.addEventListener("mouseleave", hide);
121
+ trigger.addEventListener("focus", show);
122
+ trigger.addEventListener("blur", hide);
123
+
124
+ content.addEventListener("mouseenter", () => { isVisible = true; });
125
+ content.addEventListener("mouseleave", hide);
126
+ });
127
+
128
+ }
129
+
130
+ document.addEventListener("keydown", (e) => {
131
+ if (e.key === "Escape") {
132
+ (document.activeElement as HTMLElement)?.blur();
133
+ }
134
+ });
135
+
136
+ initTooltips();
137
+ document.addEventListener("astro:after-swap", initTooltips);
138
+ </script>
@@ -0,0 +1,12 @@
1
+ /** Module-level glossary store. Populated by `<Glossary/>`, read by Term during the same Astro build pass. */
2
+ const terms = new Map<string, string>();
3
+
4
+ export function setTerms(entries: Record<string, string>): void {
5
+ for (const [key, value] of Object.entries(entries)) {
6
+ terms.set(key, value);
7
+ }
8
+ }
9
+
10
+ export function getTerm(key: string): string | undefined {
11
+ return terms.get(key);
12
+ }
@@ -0,0 +1,8 @@
1
+ export { default as Expandable } from "./Expandable.astro";
2
+ export { default as Glossary } from "./Glossary.astro";
3
+ export { default as Kbd } from "./Kbd.astro";
4
+ export { default as Property } from "./Property.astro";
5
+ export { default as PropertyGroup } from "./PropertyGroup.astro";
6
+ export { default as PropertyTable } from "./PropertyTable.astro";
7
+ export { default as Term } from "./Term.astro";
8
+ export { default as Tooltip } from "./Tooltip.astro";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Rehype plugin that wraps `<table>` elements in a scrollable container at build time.
3
+ *
4
+ * Inserts a `<div class="nu-table-scroll">` around each table with `tabindex="0"`,
5
+ * `role="region"`, and `aria-label` for keyboard scrollability. Runs during the
6
+ * Astro build so the HTML arrives with the wrapper already in place.
7
+ */
8
+ import type { Element, Nodes, Root } from "hast";
9
+
10
+ function walk(node: Nodes) {
11
+ if (!("children" in node)) return;
12
+ for (let i = 0; i < node.children.length; i++) {
13
+ const child = node.children[i];
14
+ if (child.type === "element" && (child as Element).tagName === "table") {
15
+ const wrapper: Element = {
16
+ type: "element",
17
+ tagName: "div",
18
+ properties: {
19
+ className: ["nu-table-scroll"],
20
+ tabindex: 0,
21
+ role: "region",
22
+ ariaLabel: "Scrollable table",
23
+ },
24
+ children: [child as Element],
25
+ };
26
+ node.children[i] = wrapper;
27
+ } else {
28
+ walk(child as Nodes);
29
+ }
30
+ }
31
+ }
32
+
33
+ export default function rehypeTableScroll() {
34
+ return (tree: Root) => walk(tree);
35
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Focus management and keyboard shortcut handler for the fullscreen overlay.
3
+ *
4
+ * Implements WCAG-compliant focus trapping (Tab/Shift+Tab cycle within the dialog),
5
+ * zoom/pan keyboard shortcuts, and modality-aware initial focus placement.
6
+ */
7
+
8
+ import type { PanZoomController } from "./pan-zoom";
9
+
10
+ /** Configuration for {@link installKeyboardAndFocus}. */
11
+ export interface KeyboardFocusConfig {
12
+ /** The dialog overlay element (focus trap boundary). */
13
+ overlay: HTMLElement;
14
+ /** The controls bar containing focusable buttons. */
15
+ controls: HTMLElement;
16
+ /** Pan/zoom controller for keyboard zoom and arrow-key panning. */
17
+ panZoom: PanZoomController;
18
+ /** Callback to close the overlay (triggered by Escape). */
19
+ onClose: () => void;
20
+ /** Whether the overlay was opened via keyboard (Enter/Space on the trigger button). */
21
+ openedViaKeyboard: boolean;
22
+ }
23
+
24
+ const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
25
+ const KEYBOARD_ZOOM_FACTOR = 1.2;
26
+ const ARROW_PAN_DISTANCE = 50;
27
+
28
+ /** Map of keyboard shortcuts to their handler functions. */
29
+ type ShortcutHandler = (pz: PanZoomController, close: () => void) => void;
30
+
31
+ const KEYBOARD_SHORTCUTS: Record<string, ShortcutHandler> = {
32
+ Escape: (_pz, close) => close(),
33
+ "+": (pz) => pz.zoomTo(pz.scale * KEYBOARD_ZOOM_FACTOR),
34
+ "=": (pz) => pz.zoomTo(pz.scale * KEYBOARD_ZOOM_FACTOR),
35
+ "-": (pz) => pz.zoomTo(pz.scale / KEYBOARD_ZOOM_FACTOR),
36
+ _: (pz) => pz.zoomTo(pz.scale / KEYBOARD_ZOOM_FACTOR),
37
+ "0": (pz) => pz.resetView(),
38
+ ArrowUp: (pz) => pz.panBy(0, ARROW_PAN_DISTANCE),
39
+ ArrowDown: (pz) => pz.panBy(0, -ARROW_PAN_DISTANCE),
40
+ ArrowLeft: (pz) => pz.panBy(ARROW_PAN_DISTANCE, 0),
41
+ ArrowRight: (pz) => pz.panBy(-ARROW_PAN_DISTANCE, 0),
42
+ };
43
+
44
+ /**
45
+ * Install keyboard event handlers and set initial focus.
46
+ *
47
+ * Focus placement depends on how the overlay was opened:
48
+ * - **Keyboard** (Enter/Space): focuses the first control button with a visible focus ring.
49
+ * - **Mouse** (click): focuses the overlay container itself (invisible, but allows Tab to work).
50
+ *
51
+ * @returns A cleanup function that removes all installed listeners.
52
+ */
53
+ export function installKeyboardAndFocus({
54
+ overlay,
55
+ controls,
56
+ panZoom,
57
+ onClose,
58
+ openedViaKeyboard,
59
+ }: KeyboardFocusConfig): () => void {
60
+ function handleKeydown(event: KeyboardEvent): void {
61
+ if (event.key === "Tab") {
62
+ trapFocus(overlay, event);
63
+ return;
64
+ }
65
+
66
+ const handler = KEYBOARD_SHORTCUTS[event.key];
67
+ if (handler) {
68
+ handler(panZoom, onClose);
69
+ event.preventDefault();
70
+ }
71
+ }
72
+
73
+ window.addEventListener("keydown", handleKeydown);
74
+
75
+ if (openedViaKeyboard) {
76
+ controls.querySelector<HTMLElement>(".nu-mermaid-btn")?.focus();
77
+ } else {
78
+ overlay.setAttribute("tabindex", "-1");
79
+ overlay.focus();
80
+ }
81
+
82
+ return () => window.removeEventListener("keydown", handleKeydown);
83
+ }
84
+
85
+ /** Cycle Tab/Shift+Tab focus within the overlay boundary. */
86
+ function trapFocus(boundary: HTMLElement, event: KeyboardEvent): void {
87
+ const focusableElements = boundary.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
88
+ if (!focusableElements.length) return;
89
+
90
+ const firstElement = focusableElements[0];
91
+ const lastElement = focusableElements[focusableElements.length - 1];
92
+
93
+ if (event.shiftKey && document.activeElement === firstElement) {
94
+ event.preventDefault();
95
+ lastElement.focus();
96
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
97
+ event.preventDefault();
98
+ firstElement.focus();
99
+ }
100
+ }