@dogsbay/ui 0.2.0-beta.93 → 0.2.0-beta.95
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-rich/CodeRich.astro +66 -8
- package/src/code-rich/markers.ts +64 -0
- package/src/docs-layout/DocsLayout.astro +21 -1
- package/src/sidebar/SidebarNavMark.astro +85 -0
- package/src/sidebar/SidebarNavTree.astro +33 -1
- 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.95",
|
|
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/icons": "0.2.0-beta.
|
|
16
|
-
"@dogsbay/
|
|
17
|
+
"@dogsbay/elements": "0.2.0-beta.95",
|
|
18
|
+
"@dogsbay/icons": "0.2.0-beta.95",
|
|
19
|
+
"@dogsbay/primitives": "0.2.0-beta.95"
|
|
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>
|
|
@@ -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
|
+
}
|
|
@@ -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) {
|
|
@@ -101,7 +116,11 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
101
116
|
class="min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
102
117
|
data-nav-href={item.href}
|
|
103
118
|
data-active={active || undefined}
|
|
119
|
+
data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
|
|
104
120
|
>{item.label}</a>
|
|
121
|
+
{item.mark && (
|
|
122
|
+
<SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
|
|
123
|
+
)}
|
|
105
124
|
</div>
|
|
106
125
|
<div id={submenuId} data-nav-submenu hidden={!open}>
|
|
107
126
|
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
@@ -119,6 +138,9 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
119
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>
|
|
120
139
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
121
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
|
+
)}
|
|
122
144
|
</summary>
|
|
123
145
|
<Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
|
|
124
146
|
</details>
|
|
@@ -133,12 +155,22 @@ const paddingLeft = `${8 + level * 12}px`;
|
|
|
133
155
|
style={`padding-left: ${paddingLeft};`}
|
|
134
156
|
data-active={active || undefined}
|
|
135
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}
|
|
136
165
|
>
|
|
137
166
|
{/* Spacer to align with chevron in branch items */}
|
|
138
167
|
<span class="size-4 shrink-0" />
|
|
139
168
|
{/* Icon (level 0 only) */}
|
|
140
169
|
{item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
|
|
141
|
-
<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
|
+
)}
|
|
142
174
|
</a>
|
|
143
175
|
)}
|
|
144
176
|
</li>
|
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>
|