@nu-appdev/northwestern-starlight-theme 1.1.1 → 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.
- package/CHANGELOG.md +73 -2
- package/index.ts +84 -5
- package/mermaid.ts +196 -80
- package/package.json +19 -4
- package/src/components/Expandable.astro +19 -0
- package/src/components/Glossary.astro +11 -0
- package/src/components/Hero.astro +0 -2
- package/src/components/Kbd.astro +147 -0
- package/src/components/Property.astro +64 -0
- package/src/components/PropertyGroup.astro +13 -0
- package/src/components/PropertyTable.astro +54 -0
- package/src/components/Term.astro +29 -0
- package/src/components/Tooltip.astro +138 -0
- package/src/components/glossary-store.ts +12 -0
- package/src/components/index.ts +8 -0
- package/src/rehype-table-scroll.ts +35 -0
- package/src/scripts/mermaid/focus.ts +100 -0
- package/src/scripts/mermaid/fullscreen.ts +120 -0
- package/src/scripts/mermaid/index.ts +2 -0
- package/src/scripts/mermaid/overlay.ts +132 -0
- package/src/scripts/mermaid/pan-zoom.ts +285 -0
- package/src/scripts/mermaid/render.ts +143 -0
- package/src/scripts/mermaid/toolbar.ts +137 -0
- package/src/scripts/mermaid/ui.ts +265 -0
- package/src/styles/a11y.css +69 -0
- package/src/styles/components/blockquotes.css +37 -0
- package/src/styles/components/code.css +8 -5
- package/src/styles/components/footnotes.css +76 -0
- package/src/styles/components/kbd.css +69 -0
- package/src/styles/components/mermaid.css +4 -4
- package/src/styles/components/property-table.css +569 -0
- package/src/styles/components/steps.css +0 -3
- package/src/styles/components/tables.css +60 -0
- package/src/styles/components/tabs.css +0 -3
- package/src/styles/components/tooltip.css +145 -0
- package/src/styles/content.css +9 -94
- package/src/styles/layers.css +0 -7
- package/src/styles/mermaid-toolbar.css +91 -14
- package/src/styles/navigation.css +72 -16
- package/src/styles/openapi.css +0 -3
- package/src/styles/theme.css +0 -4
- package/src/styles/typography.css +4 -0
- package/src/styles/variables.css +0 -3
- package/src/virtual.d.ts +11 -0
- package/src/scripts/mermaid-toolbar.ts +0 -589
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Label for the toggle. */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Whether to start expanded. */
|
|
6
|
+
open?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { title = "Show properties", open = false } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<details class="nu-expandable" open={open || undefined}>
|
|
13
|
+
<summary class="nu-expandable__toggle">{title}</summary>
|
|
14
|
+
<div class="nu-expandable__content">
|
|
15
|
+
<div class="nu-expandable__nested">
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</details>
|
|
@@ -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,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
|
+
}
|