@dogsbay/ui 0.2.0-beta.92 → 0.2.0-beta.94
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/package.json +15 -9
- package/src/alert/CollapsibleAlert.astro +19 -3
- package/src/api-layout/ApiLayout.astro +28 -4
- package/src/code-block/CodeBlock.astro +10 -1
- package/src/code-rich/CodeRich.astro +66 -8
- package/src/code-rich/markers.ts +64 -0
- package/src/content-renderer/ContentRenderer.astro +13 -2
- package/src/docs-layout/DocsLayout.astro +21 -1
- package/src/sidebar/SidebarNavMark.astro +85 -0
- package/src/sidebar/SidebarNavTree.astro +111 -7
- package/src/table/Table.astro +4 -1
package/package.json
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/ui",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.94",
|
|
4
4
|
"description": "Accessible UI components for Astro, inspired by Base UI and shadcn",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./*": "./src/*"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"tailwind-variants": "^0.3.
|
|
10
|
+
"tailwind-variants": "^0.3.1"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^4.1.10"
|
|
11
14
|
},
|
|
12
15
|
"peerDependencies": {
|
|
13
16
|
"astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
14
|
-
"@dogsbay/
|
|
15
|
-
"@dogsbay/
|
|
16
|
-
"@dogsbay/
|
|
17
|
+
"@dogsbay/elements": "0.2.0-beta.94",
|
|
18
|
+
"@dogsbay/icons": "0.2.0-beta.94",
|
|
19
|
+
"@dogsbay/primitives": "0.2.0-beta.94"
|
|
17
20
|
},
|
|
18
21
|
"optionalDependencies": {
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"maplibre-gl": "^5.
|
|
22
|
-
"
|
|
22
|
+
"@shikijs/transformers": "^4.3.1",
|
|
23
|
+
"katex": "^0.16.47",
|
|
24
|
+
"maplibre-gl": "^5.24.0",
|
|
25
|
+
"shiki": "^4.3.1"
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
|
25
28
|
"src",
|
|
@@ -34,5 +37,8 @@
|
|
|
34
37
|
"homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/ui",
|
|
35
38
|
"bugs": {
|
|
36
39
|
"url": "https://github.com/dogsbay/dogsbay/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "vitest run"
|
|
37
43
|
}
|
|
38
44
|
}
|
|
@@ -18,6 +18,11 @@ const wrapper = tv({
|
|
|
18
18
|
variants: {
|
|
19
19
|
variant: {
|
|
20
20
|
default: "border-border",
|
|
21
|
+
// `plain` = a disclosure that is CHROME, not an alert: no tint, no
|
|
22
|
+
// icon, no ARIA role. Used for collapsed context (e.g. the
|
|
23
|
+
// comparison view's unchanged-section folds), where announcing a
|
|
24
|
+
// "note" or a live-region "status" would be a lie.
|
|
25
|
+
plain: "border-border",
|
|
21
26
|
destructive: "border-destructive/50",
|
|
22
27
|
note: "border-note/50",
|
|
23
28
|
abstract: "border-abstract/50",
|
|
@@ -41,6 +46,7 @@ type Variant = VariantProps<typeof wrapper>["variant"];
|
|
|
41
46
|
/** Title bar: stronger tint (~10%) */
|
|
42
47
|
const titleBg: Record<string, string> = {
|
|
43
48
|
default: "bg-muted",
|
|
49
|
+
plain: "bg-transparent",
|
|
44
50
|
destructive: "bg-destructive/10 text-destructive",
|
|
45
51
|
note: "bg-note/10", abstract: "bg-abstract/10", info: "bg-info/10",
|
|
46
52
|
tip: "bg-tip/10", success: "bg-success/10", question: "bg-question/10",
|
|
@@ -51,6 +57,7 @@ const titleBg: Record<string, string> = {
|
|
|
51
57
|
/** Body: subtle tint (~3%) */
|
|
52
58
|
const bodyBg: Record<string, string> = {
|
|
53
59
|
default: "bg-background",
|
|
60
|
+
plain: "bg-transparent",
|
|
54
61
|
destructive: "bg-destructive/3",
|
|
55
62
|
note: "bg-note/3", abstract: "bg-abstract/3", info: "bg-info/3",
|
|
56
63
|
tip: "bg-tip/3", success: "bg-success/3", question: "bg-question/3",
|
|
@@ -79,14 +86,23 @@ interface Props {
|
|
|
79
86
|
const { variant = "note", title, open = false, class: className } = Astro.props;
|
|
80
87
|
const v = variant || "note";
|
|
81
88
|
|
|
82
|
-
|
|
89
|
+
// `plain` is chrome — a disclosure, nothing more. No role at all:
|
|
90
|
+
// role="note"/"status" on collapsed context misinforms assistive tech
|
|
91
|
+
// (and "status" would make it a live region).
|
|
92
|
+
const role = v === "plain" ? undefined : contentVariants.has(v) ? "note" : "status";
|
|
83
93
|
const icon = contentVariants.has(v) ? icons[v] : null;
|
|
84
94
|
---
|
|
85
95
|
|
|
96
|
+
{/*
|
|
97
|
+
The role belongs on the CONTENT, not on <details>. A <details> element
|
|
98
|
+
has an implicit `group` role, and ARIA does not permit overriding it
|
|
99
|
+
with `note`/`status` — axe flags it (aria-allowed-role), which is
|
|
100
|
+
exactly what an accessibility pass found on the comparison site's
|
|
101
|
+
collapsed sections. Moving it to the body keeps the semantics legal.
|
|
102
|
+
*/}
|
|
86
103
|
<details
|
|
87
104
|
class:list={[wrapper({ variant, class: className }), "group/collapsible"]}
|
|
88
105
|
data-variant={variant}
|
|
89
|
-
role={role}
|
|
90
106
|
open={open}
|
|
91
107
|
>
|
|
92
108
|
<summary class:list={[
|
|
@@ -97,7 +113,7 @@ const icon = contentVariants.has(v) ? icons[v] : null;
|
|
|
97
113
|
<span class="flex-1 text-foreground" data-part="title">{title || v.charAt(0).toUpperCase() + v.slice(1)}</span>
|
|
98
114
|
<span class="shrink-0 text-muted-foreground group-open/collapsible:rotate-180 transition-transform duration-200" set:html={chevronSvg} />
|
|
99
115
|
</summary>
|
|
100
|
-
<div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]}>
|
|
116
|
+
<div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]} role={role}>
|
|
101
117
|
<slot />
|
|
102
118
|
</div>
|
|
103
119
|
</details>
|
|
@@ -12,17 +12,41 @@
|
|
|
12
12
|
*/
|
|
13
13
|
interface Props {
|
|
14
14
|
class?: string;
|
|
15
|
+
/**
|
|
16
|
+
* "page" (default): a dedicated API operation page — full-viewport
|
|
17
|
+
* min-height, sticky code rail.
|
|
18
|
+
* "embedded": the layout sits inline among prose (imported MDX docs
|
|
19
|
+
* with several endpoints per page) — natural height, no sticky.
|
|
20
|
+
*/
|
|
21
|
+
variant?: "page" | "embedded";
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const { class: className } = Astro.props;
|
|
24
|
+
const { class: className, variant = "page" } = Astro.props;
|
|
25
|
+
const isPage = variant === "page";
|
|
18
26
|
---
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
{/* dba-api marks an API-reference region: the generated .docs-prose
|
|
29
|
+
typography rules skip everything inside it via :not(.dba-api *), so
|
|
30
|
+
endpoint cards keep their own utility-class typography even when the
|
|
31
|
+
page body is wrapped in the prose article. */}
|
|
32
|
+
<div
|
|
33
|
+
class:list={[
|
|
34
|
+
"dba-api grid grid-cols-1 lg:grid-cols-2",
|
|
35
|
+
isPage ? "lg:min-h-[calc(100vh-3rem)]" : "my-6 rounded-lg border overflow-hidden",
|
|
36
|
+
className,
|
|
37
|
+
]}
|
|
38
|
+
>
|
|
39
|
+
<div class:list={[isPage ? "px-6 py-10" : "p-6", "lg:border-r"]}>
|
|
22
40
|
<slot name="description" />
|
|
23
41
|
</div>
|
|
24
42
|
<div class="bg-[var(--api-panel-bg)] text-[var(--api-panel-fg)]">
|
|
25
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
class:list={[
|
|
45
|
+
isPage
|
|
46
|
+
? "px-6 py-10 lg:sticky lg:top-12 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto"
|
|
47
|
+
: "p-6",
|
|
48
|
+
]}
|
|
49
|
+
>
|
|
26
50
|
<slot name="code" />
|
|
27
51
|
</div>
|
|
28
52
|
</div>
|
|
@@ -42,6 +42,15 @@ const {
|
|
|
42
42
|
...rest
|
|
43
43
|
} = Astro.props;
|
|
44
44
|
|
|
45
|
+
// AsciiDoc/docs language tokens that are not Shiki grammars → the nearest real
|
|
46
|
+
// one, so the block actually highlights (and data-language is meaningful)
|
|
47
|
+
// instead of silently falling back to plaintext. The header still shows the
|
|
48
|
+
// original label (`lang` below), only the highlighter input is aliased. #009.
|
|
49
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
50
|
+
terminal: "bash",
|
|
51
|
+
};
|
|
52
|
+
const shikiLang = LANG_ALIASES[lang.toLowerCase()] ?? lang.toLowerCase();
|
|
53
|
+
|
|
45
54
|
// Show header bar based on showTitle prop:
|
|
46
55
|
// - true: show when there's a title or a declared language (default, original behavior)
|
|
47
56
|
// - false: never show
|
|
@@ -107,7 +116,7 @@ const floatingCopy = copy && !showHeader;
|
|
|
107
116
|
</div>
|
|
108
117
|
)}
|
|
109
118
|
<div class="relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:px-4 [&_pre]:py-3">
|
|
110
|
-
<Code code={code} lang={
|
|
119
|
+
<Code code={code} lang={shikiLang} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
|
|
111
120
|
{floatingCopy && (
|
|
112
121
|
<button
|
|
113
122
|
class:list={[codeBlockCopy(), "absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "./code-rich.css";
|
|
3
|
-
import {
|
|
3
|
+
import { stripMarkersForCopy, stripUnprocessableMarkers } from "./markers";
|
|
4
|
+
import { codeToHtml, bundledLanguages, type BundledLanguage, type BundledTheme } from "shiki";
|
|
4
5
|
import {
|
|
5
6
|
transformerNotationDiff,
|
|
6
7
|
transformerNotationHighlight,
|
|
@@ -44,6 +45,21 @@ interface Props {
|
|
|
44
45
|
wordHighlights?: string;
|
|
45
46
|
/** Shiki theme */
|
|
46
47
|
theme?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Override the copy-button text. Diff-annotated blocks copy the
|
|
50
|
+
* post-change code only — the default (all lines minus annotation
|
|
51
|
+
* comments) would paste removed lines into the reader's editor.
|
|
52
|
+
*/
|
|
53
|
+
copyText?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Diff lines by NUMBER (1-based, e.g. "2,5-6") instead of `[!code]`
|
|
56
|
+
* comment markers. Markers only work in languages whose grammar has
|
|
57
|
+
* comments — in `plaintext` (or any unknown language) Shiki cannot
|
|
58
|
+
* recognize them and they render as literal text. Line numbers are
|
|
59
|
+
* language-independent, so a generated diff never leaks markers.
|
|
60
|
+
*/
|
|
61
|
+
diffAdd?: string;
|
|
62
|
+
diffRemove?: string;
|
|
47
63
|
class?: string;
|
|
48
64
|
[key: string]: unknown;
|
|
49
65
|
}
|
|
@@ -58,10 +74,28 @@ const {
|
|
|
58
74
|
highlights,
|
|
59
75
|
wordHighlights,
|
|
60
76
|
theme = "github-dark-default",
|
|
77
|
+
copyText: copyTextOverride,
|
|
78
|
+
diffAdd,
|
|
79
|
+
diffRemove,
|
|
61
80
|
class: className,
|
|
62
81
|
...rest
|
|
63
82
|
} = Astro.props;
|
|
64
83
|
|
|
84
|
+
// "2,5-6" → Set{2,5,6}
|
|
85
|
+
const parseLineSpec = (spec?: string): Set<number> => {
|
|
86
|
+
const out = new Set<number>();
|
|
87
|
+
for (const part of (spec ?? "").split(",")) {
|
|
88
|
+
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
89
|
+
if (!range) continue;
|
|
90
|
+
const start = Number(range[1]);
|
|
91
|
+
const end = range[2] ? Number(range[2]) : start;
|
|
92
|
+
for (let n = start; n <= end; n++) out.add(n);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
const addLines = parseLineSpec(diffAdd);
|
|
97
|
+
const removeLines = parseLineSpec(diffRemove);
|
|
98
|
+
|
|
65
99
|
// Build transformers list
|
|
66
100
|
const transformers: ShikiTransformer[] = [
|
|
67
101
|
transformerNotationDiff(),
|
|
@@ -71,6 +105,19 @@ const transformers: ShikiTransformer[] = [
|
|
|
71
105
|
transformerNotationWordHighlight(),
|
|
72
106
|
];
|
|
73
107
|
|
|
108
|
+
// Line-number diffs (language-independent — see the prop docs)
|
|
109
|
+
if (addLines.size || removeLines.size) {
|
|
110
|
+
transformers.push({
|
|
111
|
+
name: "line-diff",
|
|
112
|
+
line(node, line) {
|
|
113
|
+
const kind = addLines.has(line) ? "add" : removeLines.has(line) ? "remove" : null;
|
|
114
|
+
if (!kind) return;
|
|
115
|
+
const cls = node.properties.class;
|
|
116
|
+
node.properties.class = `${Array.isArray(cls) ? cls.join(" ") : (cls ?? "")} diff ${kind}`.trim();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
// Meta-based highlights: ```ts {1,3-5}
|
|
75
122
|
if (highlights) {
|
|
76
123
|
transformers.push(transformerMetaHighlight());
|
|
@@ -136,14 +183,25 @@ let metaStr = "";
|
|
|
136
183
|
if (highlights) metaStr += `{${highlights}} `;
|
|
137
184
|
if (wordHighlights) metaStr += `/${wordHighlights}/ `;
|
|
138
185
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
// Copy text: explicit override (diff blocks pass the post-change code)
|
|
187
|
+
// or the block's own code — either way every annotation form is
|
|
188
|
+
// stripped (see ./markers.ts).
|
|
189
|
+
const copyText = stripMarkersForCopy(copyTextOverride ?? code);
|
|
190
|
+
|
|
191
|
+
// Unknown languages (package-install, mdx-ish doc tokens) fall back
|
|
192
|
+
// to plaintext instead of throwing — same behavior as CodeBlock's
|
|
193
|
+
// <Code>. The header still shows the declared language label.
|
|
194
|
+
const requestedLang = lang.toLowerCase();
|
|
195
|
+
const isPlainGrammar =
|
|
196
|
+
!(requestedLang in bundledLanguages) || ["plaintext", "text", "txt", "ansi"].includes(requestedLang);
|
|
197
|
+
const shikiLang = isPlainGrammar ? "plaintext" : requestedLang;
|
|
198
|
+
|
|
199
|
+
// Markers Shiki cannot process would render as literal text — strip
|
|
200
|
+
// exactly those, leave the rest for the notation transformers.
|
|
201
|
+
const highlightCode = stripUnprocessableMarkers(code, isPlainGrammar);
|
|
144
202
|
|
|
145
|
-
const html = await codeToHtml(
|
|
146
|
-
lang:
|
|
203
|
+
const html = await codeToHtml(highlightCode, {
|
|
204
|
+
lang: shikiLang as BundledLanguage,
|
|
147
205
|
themes: {
|
|
148
206
|
light: "github-light-default" as BundledTheme,
|
|
149
207
|
dark: (theme || "github-dark-default") as BundledTheme,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[!code …]` annotation hygiene.
|
|
3
|
+
*
|
|
4
|
+
* Shiki's notation transformers consume these markers — but only the
|
|
5
|
+
* ones they can SEE: a marker is recognized when the grammar tokenizes
|
|
6
|
+
* its comment AND the marker ends the line. Anything else reaches the
|
|
7
|
+
* reader as literal text in the code block. Two real cases from the
|
|
8
|
+
* better-auth corpus:
|
|
9
|
+
*
|
|
10
|
+
* - `package-install` (and every unknown language) falls back to
|
|
11
|
+
* `plaintext`, which has no comment tokens at all;
|
|
12
|
+
* - upstream authors mid-line markers — `// [!code highlight] // check
|
|
13
|
+
* if the user is allowed` — where the marker is not the last thing
|
|
14
|
+
* on the line.
|
|
15
|
+
*
|
|
16
|
+
* These helpers strip exactly what Shiki cannot process, and strip
|
|
17
|
+
* everything from copy text (a marker pasted into an editor is always
|
|
18
|
+
* wrong).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A `[!code …]` annotation carrying its own comment leader. */
|
|
22
|
+
const MARKER = /[ \t]*(?:\/\/|#|--|<!--|\/\*|;)[ \t]*\[!code[^\]]*\](?:[ \t]*(?:-->|\*\/))?/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A bare annotation — upstream also appends one INSIDE an existing
|
|
26
|
+
* comment (`// e.g. "us-east-1" [!code highlight]`), with no leader of
|
|
27
|
+
* its own.
|
|
28
|
+
*/
|
|
29
|
+
const BARE_MARKER = /[ \t]*\[!code[^\]]*\]/g;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove the markers Shiki cannot process, leaving the ones it can for
|
|
33
|
+
* the notation transformers to consume.
|
|
34
|
+
*
|
|
35
|
+
* @param code the block's source
|
|
36
|
+
* @param isPlainGrammar true when highlighting falls back to plaintext
|
|
37
|
+
*/
|
|
38
|
+
export function stripUnprocessableMarkers(code: string, isPlainGrammar: boolean): string {
|
|
39
|
+
return code
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => {
|
|
42
|
+
// No grammar → no comment tokens → Shiki sees nothing to strip.
|
|
43
|
+
if (isPlainGrammar) return line.replace(MARKER, "").replace(BARE_MARKER, "");
|
|
44
|
+
let out = "";
|
|
45
|
+
let last = 0;
|
|
46
|
+
for (const m of line.matchAll(MARKER)) {
|
|
47
|
+
const end = m.index + m[0].length;
|
|
48
|
+
if (line.slice(end).trim() === "") break; // ends the line — Shiki handles it
|
|
49
|
+
out += line.slice(last, m.index);
|
|
50
|
+
last = end;
|
|
51
|
+
}
|
|
52
|
+
return out + line.slice(last);
|
|
53
|
+
})
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strip EVERY annotation form — markers must never reach the clipboard. */
|
|
58
|
+
export function stripMarkersForCopy(code: string): string {
|
|
59
|
+
return code
|
|
60
|
+
.replace(MARKER, "")
|
|
61
|
+
.replace(BARE_MARKER, "")
|
|
62
|
+
.replace(/\s*\/\/\s*![a-z]+.*$/gm, "")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
@@ -177,7 +177,7 @@ function withBaseUrl(src: string | undefined): string {
|
|
|
177
177
|
<Fragment set:html={
|
|
178
178
|
`<h${node.props!.level} id="${node.props!.slug || ''}" class="group scroll-mt-20">` +
|
|
179
179
|
`${node.inline ? inlineToHtml(node.inline) : (node.html?.replace(/<h\d[^>]*>|<\/h\d>/g, '') || node.props!.text || '')}` +
|
|
180
|
-
`<a href="#${node.props!.slug || ''}" class="no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
|
|
180
|
+
`<a href="#${node.props!.slug || ''}" class="heading-anchor no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
|
|
181
181
|
`</h${node.props!.level}>`
|
|
182
182
|
} />
|
|
183
183
|
)}
|
|
@@ -295,7 +295,18 @@ function withBaseUrl(src: string | undefined): string {
|
|
|
295
295
|
{/* ── Table ──────────────────────────────────────────────── */}
|
|
296
296
|
{node.type === "table" && (
|
|
297
297
|
<Table class="my-4">
|
|
298
|
-
{
|
|
298
|
+
{(node.props?.headerRows as string[][] | undefined)?.length ? (
|
|
299
|
+
<TableHeader>
|
|
300
|
+
{(node.props!.headerRows as string[][]).map((row) => (
|
|
301
|
+
<TableRow>{row.map((cell) => <TableHead set:html={cell} />)}</TableRow>
|
|
302
|
+
))}
|
|
303
|
+
</TableHeader>
|
|
304
|
+
) : null}
|
|
305
|
+
<TableBody>
|
|
306
|
+
{((node.props?.bodyRows as string[][] | undefined) ?? []).map((row) => (
|
|
307
|
+
<TableRow>{row.map((cell) => <TableCell set:html={cell} />)}</TableRow>
|
|
308
|
+
))}
|
|
309
|
+
</TableBody>
|
|
299
310
|
</Table>
|
|
300
311
|
)}
|
|
301
312
|
{node.type === "thead" && <TableHeader>{children.map(child => <Astro.self node={child} images={images} icons={icons} />)}</TableHeader>}
|
|
@@ -103,6 +103,17 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
|
103
103
|
document.documentElement.classList.add("dark");
|
|
104
104
|
}
|
|
105
105
|
</script>
|
|
106
|
+
<style is:global>
|
|
107
|
+
/*
|
|
108
|
+
The sticky-chrome contract. The docs header is exactly this tall,
|
|
109
|
+
and anything else that sticks (the comparison toolbar) offsets by
|
|
110
|
+
it — one variable, so a header change can never leave another bar
|
|
111
|
+
overlapping or floating.
|
|
112
|
+
*/
|
|
113
|
+
:root {
|
|
114
|
+
--db-header-height: 3.25rem;
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
106
117
|
<slot name="head" />
|
|
107
118
|
</head>
|
|
108
119
|
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
@@ -131,7 +142,16 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
|
131
142
|
<SidebarRail />
|
|
132
143
|
</Sidebar>
|
|
133
144
|
<SidebarInset>
|
|
134
|
-
|
|
145
|
+
{/*
|
|
146
|
+
The header's height comes from `--db-header-height` (declared on
|
|
147
|
+
:root below) — the CONTRACT that any other sticky chrome sticks
|
|
148
|
+
below, e.g. the comparison toolbar. Both read the same variable,
|
|
149
|
+
so they cannot drift; a magic offset copied into another
|
|
150
|
+
stylesheet would break the moment this padding changed.
|
|
151
|
+
*/}
|
|
152
|
+
<header
|
|
153
|
+
class="sticky top-0 z-40 flex h-[var(--db-header-height)] items-center gap-3 border-b border-border bg-background/95 px-4 backdrop-blur-sm"
|
|
154
|
+
>
|
|
135
155
|
<SidebarTrigger />
|
|
136
156
|
<Separator orientation="vertical" class="h-5" />
|
|
137
157
|
<span class="text-sm font-medium" data-page-title>{title}</span>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A nav state marker (changed / new / removed / moved).
|
|
4
|
+
*
|
|
5
|
+
* Accessibility contract — the reason this is a component and not a
|
|
6
|
+
* coloured dot inlined three times:
|
|
7
|
+
*
|
|
8
|
+
* - **Never colour alone** (WCAG 1.4.1): each kind gets a distinct
|
|
9
|
+
* GLYPH (+ • − →), so it is readable in greyscale and by anyone who
|
|
10
|
+
* does not perceive the hue difference. Colour only reinforces.
|
|
11
|
+
* - **Survives forced-colors** (Windows High Contrast strips
|
|
12
|
+
* background-color): the glyph is real text, so it always renders;
|
|
13
|
+
* `currentColor` keeps it visible when the palette is overridden.
|
|
14
|
+
* - **Announced, not implied**: a visually-hidden word rides along, so
|
|
15
|
+
* the row reads "MySQL, changed". `title` alone is not enough — it is
|
|
16
|
+
* unreliable in screen readers and invisible to touch users.
|
|
17
|
+
* - **aria-hidden on the glyph** so it is not announced twice.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
kind: "added" | "changed" | "removed" | "moved";
|
|
21
|
+
/** Announced text; defaults to the kind (or "contains X" for a subtree). */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** True when this summarizes DESCENDANTS (a collapsed group). */
|
|
24
|
+
subtree?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { kind, label, subtree = false, class: className } = Astro.props;
|
|
29
|
+
|
|
30
|
+
const GLYPH: Record<Props["kind"], string> = {
|
|
31
|
+
added: "+",
|
|
32
|
+
changed: "•",
|
|
33
|
+
removed: "−",
|
|
34
|
+
moved: "→",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const text = label ?? (subtree ? `contains ${kind}` : kind);
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<span
|
|
41
|
+
class:list={["db-nav-mark", className]}
|
|
42
|
+
data-nav-mark={kind}
|
|
43
|
+
data-nav-mark-subtree={subtree ? "" : undefined}
|
|
44
|
+
title={text}
|
|
45
|
+
>
|
|
46
|
+
<span aria-hidden="true">{GLYPH[kind]}</span>
|
|
47
|
+
<span class="sr-only">{text}</span>
|
|
48
|
+
</span>
|
|
49
|
+
|
|
50
|
+
<style is:global>
|
|
51
|
+
.db-nav-mark {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
width: 1rem;
|
|
57
|
+
height: 1rem;
|
|
58
|
+
margin-left: auto; /* right-aligned: labels stay flush-left and scannable */
|
|
59
|
+
border-radius: 99px;
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
/* colour REINFORCES the glyph; it never carries the meaning alone */
|
|
64
|
+
color: var(--color-muted-foreground, currentColor);
|
|
65
|
+
}
|
|
66
|
+
.db-nav-mark[data-nav-mark="added"] { color: var(--color-success, oklch(0.72 0.19 150)); }
|
|
67
|
+
.db-nav-mark[data-nav-mark="changed"] { color: var(--color-warning, oklch(0.8 0.16 85)); }
|
|
68
|
+
.db-nav-mark[data-nav-mark="removed"] { color: var(--color-destructive, oklch(0.64 0.21 25)); }
|
|
69
|
+
.db-nav-mark[data-nav-mark="moved"] { color: var(--color-info, oklch(0.7 0.14 240)); }
|
|
70
|
+
/* A subtree marker is a hint, not a claim about this row — mute it. */
|
|
71
|
+
.db-nav-mark[data-nav-mark-subtree] { opacity: 0.55; }
|
|
72
|
+
|
|
73
|
+
/* Removed pages get a second non-colour signal — but ONLY a page that
|
|
74
|
+
is itself removed. A subtree aggregate is a claim about descendants;
|
|
75
|
+
striking through a surviving group would tell the reader the whole
|
|
76
|
+
section was deleted. */
|
|
77
|
+
[data-nav-mark-row="removed"]:not([data-nav-mark-subtree]) {
|
|
78
|
+
text-decoration: line-through;
|
|
79
|
+
opacity: 0.7;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media (forced-colors: active) {
|
|
83
|
+
.db-nav-mark { color: CanvasText; }
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import SidebarNavMark from "./SidebarNavMark.astro";
|
|
2
3
|
/**
|
|
3
4
|
* Recursive sidebar navigation tree.
|
|
4
5
|
* Renders a data-driven tree with native <details>/<summary> for zero-JS expand/collapse.
|
|
@@ -8,11 +9,18 @@
|
|
|
8
9
|
* <SidebarNavTree items={navItems} currentPath={Astro.url.pathname} />
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
interface NavMark {
|
|
13
|
+
kind: "added" | "changed" | "removed" | "moved";
|
|
14
|
+
label?: string;
|
|
15
|
+
subtree?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
interface NavItem {
|
|
12
19
|
label: string;
|
|
13
20
|
href?: string;
|
|
14
21
|
icon?: string; // Raw SVG string (rendered via set:html)
|
|
15
22
|
children?: NavItem[];
|
|
23
|
+
mark?: NavMark;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
interface Props {
|
|
@@ -24,6 +32,13 @@ interface Props {
|
|
|
24
32
|
|
|
25
33
|
const { items, currentPath, level = 0, class: className } = Astro.props;
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Markers are rendered by SidebarNavMark, which owns the accessibility
|
|
37
|
+
* contract (distinct glyph so colour is never the sole channel,
|
|
38
|
+
* visually-hidden text so the row announces "MySQL, changed",
|
|
39
|
+
* forced-colors survival). See that component.
|
|
40
|
+
*/
|
|
41
|
+
|
|
27
42
|
const normalizedPath = currentPath.replace(/\/$/, "") || "/";
|
|
28
43
|
|
|
29
44
|
function isActive(href: string) {
|
|
@@ -54,24 +69,78 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
54
69
|
const active = item.href ? isActive(item.href) : false;
|
|
55
70
|
const hasChildren = item.children && item.children.length > 0;
|
|
56
71
|
const shouldOpen = hasChildren && hasActiveDescendant(item);
|
|
72
|
+
const open = shouldOpen || active;
|
|
73
|
+
// A branch that is ALSO a page (has an href) is a section landing
|
|
74
|
+
// page; it uses the button-driven disclosure below and needs a
|
|
75
|
+
// stable id linking its toggle button to its submenu.
|
|
76
|
+
const submenuId = item.href
|
|
77
|
+
? `nav-sub-${item.href.replace(/[^a-z0-9]+/gi, "-")}-${level}`
|
|
78
|
+
: undefined;
|
|
57
79
|
|
|
58
80
|
return (
|
|
59
81
|
<li data-sidebar="nav-tree-item">
|
|
60
|
-
{hasChildren ? (
|
|
82
|
+
{hasChildren && item.href ? (
|
|
83
|
+
/*
|
|
84
|
+
Section landing page: APG "disclosure navigation" pattern — a
|
|
85
|
+
real link (navigates) + a SEPARATE toggle <button> (expands),
|
|
86
|
+
as siblings. Nesting a focusable <a> inside the interactive
|
|
87
|
+
<summary> is an axe `nested-interactive` violation, and native
|
|
88
|
+
<details> can't show a header link when collapsed without that
|
|
89
|
+
nesting. Progressive enhancement: the submenu's server state
|
|
90
|
+
(hidden unless open) is correct with no JS — the link still
|
|
91
|
+
works and the active section is expanded — and the inline
|
|
92
|
+
script wires the toggle button when JS is available.
|
|
93
|
+
*/
|
|
94
|
+
<Fragment>
|
|
95
|
+
<div
|
|
96
|
+
class:list={[
|
|
97
|
+
"flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
98
|
+
level === 0 ? "h-8" : "h-7",
|
|
99
|
+
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
|
|
100
|
+
]}
|
|
101
|
+
style={`padding-left: ${paddingLeft};`}
|
|
102
|
+
>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
data-nav-toggle
|
|
106
|
+
aria-controls={submenuId}
|
|
107
|
+
aria-expanded={open ? "true" : "false"}
|
|
108
|
+
aria-label={`Toggle ${item.label} section`}
|
|
109
|
+
class="shrink-0 rounded outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
110
|
+
>
|
|
111
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
|
|
112
|
+
</button>
|
|
113
|
+
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
114
|
+
<a
|
|
115
|
+
href={item.href}
|
|
116
|
+
class="min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
117
|
+
data-nav-href={item.href}
|
|
118
|
+
data-active={active || undefined}
|
|
119
|
+
data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
|
|
120
|
+
>{item.label}</a>
|
|
121
|
+
{item.mark && (
|
|
122
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<div id={submenuId} data-nav-submenu hidden={!open}>
|
|
126
|
+
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
127
|
+
</div>
|
|
128
|
+
</Fragment>
|
|
129
|
+
) : hasChildren ? (
|
|
61
130
|
<details open={shouldOpen || active}>
|
|
62
131
|
<summary
|
|
63
132
|
class:list={[
|
|
64
133
|
"flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
|
|
65
134
|
level === 0 ? "h-8" : "h-7",
|
|
66
|
-
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
|
|
67
135
|
]}
|
|
68
136
|
style={`padding-left: ${paddingLeft};`}
|
|
69
|
-
data-nav-href={item.href || undefined}
|
|
70
|
-
data-active={active || undefined}
|
|
71
137
|
>
|
|
72
138
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
|
|
73
139
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
74
|
-
<span class="truncate">{item.label}</span>
|
|
140
|
+
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
141
|
+
{item.mark && (
|
|
142
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
143
|
+
)}
|
|
75
144
|
</summary>
|
|
76
145
|
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
77
146
|
</details>
|
|
@@ -86,12 +155,22 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
86
155
|
style={`padding-left: ${paddingLeft};`}
|
|
87
156
|
data-active={active || undefined}
|
|
88
157
|
data-nav-href={item.href}
|
|
158
|
+
{/*
|
|
159
|
+
A SUBTREE mark is a claim about descendants, never about
|
|
160
|
+
this row — striking through a surviving group because one
|
|
161
|
+
child was deleted tells the reader the whole section is
|
|
162
|
+
gone (code review, 2026-07-13).
|
|
163
|
+
*/}
|
|
164
|
+
data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
|
|
89
165
|
>
|
|
90
166
|
{/* Spacer to align with chevron in branch items */}
|
|
91
167
|
<span class="size-4 shrink-0" />
|
|
92
168
|
{/* Icon (level 0 only) */}
|
|
93
169
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
94
|
-
<span class="truncate">{item.label}</span>
|
|
170
|
+
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
171
|
+
{item.mark && (
|
|
172
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
173
|
+
)}
|
|
95
174
|
</a>
|
|
96
175
|
)}
|
|
97
176
|
</li>
|
|
@@ -100,7 +179,32 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
100
179
|
</ul>
|
|
101
180
|
|
|
102
181
|
<style is:global>
|
|
103
|
-
details[open] > summary [data-chevron]
|
|
182
|
+
details[open] > summary [data-chevron],
|
|
183
|
+
[data-nav-toggle][aria-expanded="true"] [data-chevron] {
|
|
104
184
|
transform: rotate(90deg);
|
|
105
185
|
}
|
|
106
186
|
</style>
|
|
187
|
+
|
|
188
|
+
<script>
|
|
189
|
+
// Wire the section-landing disclosure toggles (APG disclosure-nav).
|
|
190
|
+
// The link navigates on its own; this button just expands/collapses
|
|
191
|
+
// the sibling submenu. Server-rendered state is already correct, so
|
|
192
|
+
// with no JS the link still works and the active section is open.
|
|
193
|
+
// Idempotent + re-run after view transitions.
|
|
194
|
+
function wireNavToggles() {
|
|
195
|
+
document
|
|
196
|
+
.querySelectorAll<HTMLButtonElement>("[data-nav-toggle]:not([data-wired])")
|
|
197
|
+
.forEach((btn) => {
|
|
198
|
+
btn.dataset.wired = "1";
|
|
199
|
+
const id = btn.getAttribute("aria-controls");
|
|
200
|
+
const submenu = id ? document.getElementById(id) : null;
|
|
201
|
+
btn.addEventListener("click", () => {
|
|
202
|
+
const isOpen = btn.getAttribute("aria-expanded") === "true";
|
|
203
|
+
btn.setAttribute("aria-expanded", String(!isOpen));
|
|
204
|
+
if (submenu) submenu.hidden = isOpen;
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
wireNavToggles();
|
|
209
|
+
document.addEventListener("astro:page-load", wireNavToggles);
|
|
210
|
+
</script>
|
package/src/table/Table.astro
CHANGED
|
@@ -11,14 +11,17 @@ const table = tv({
|
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
class?: string;
|
|
14
|
+
/** Optional table caption, rendered as a `<caption>` (bottom-aligned). */
|
|
15
|
+
caption?: string;
|
|
14
16
|
[key: string]: unknown;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
const { class: className, ...rest } = Astro.props;
|
|
19
|
+
const { class: className, caption, ...rest } = Astro.props;
|
|
18
20
|
---
|
|
19
21
|
|
|
20
22
|
<div class={tableContainer()}>
|
|
21
23
|
<table class={table({ class: className })} {...rest}>
|
|
24
|
+
{caption && <caption class="text-sm text-muted-foreground mt-2">{caption}</caption>}
|
|
22
25
|
<slot />
|
|
23
26
|
</table>
|
|
24
27
|
</div>
|