@specglass/theme-default 0.0.2
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/dist/__tests__/code-tabs.test.d.ts +2 -0
- package/dist/__tests__/code-tabs.test.d.ts.map +1 -0
- package/dist/__tests__/code-tabs.test.js +219 -0
- package/dist/__tests__/code-tabs.test.js.map +1 -0
- package/dist/__tests__/copy-button.test.d.ts +2 -0
- package/dist/__tests__/copy-button.test.d.ts.map +1 -0
- package/dist/__tests__/copy-button.test.js +116 -0
- package/dist/__tests__/copy-button.test.js.map +1 -0
- package/dist/__tests__/search-palette.test.d.ts +2 -0
- package/dist/__tests__/search-palette.test.d.ts.map +1 -0
- package/dist/__tests__/search-palette.test.js +71 -0
- package/dist/__tests__/search-palette.test.js.map +1 -0
- package/dist/__tests__/shiki.test.d.ts +2 -0
- package/dist/__tests__/shiki.test.d.ts.map +1 -0
- package/dist/__tests__/shiki.test.js +37 -0
- package/dist/__tests__/shiki.test.js.map +1 -0
- package/dist/__tests__/theme-css.test.d.ts +2 -0
- package/dist/__tests__/theme-css.test.d.ts.map +1 -0
- package/dist/__tests__/theme-css.test.js +124 -0
- package/dist/__tests__/theme-css.test.js.map +1 -0
- package/dist/__tests__/theme-helpers.test.d.ts +2 -0
- package/dist/__tests__/theme-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/theme-helpers.test.js +81 -0
- package/dist/__tests__/theme-helpers.test.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/islands/CodeTabs.d.ts +21 -0
- package/dist/islands/CodeTabs.d.ts.map +1 -0
- package/dist/islands/CodeTabs.js +125 -0
- package/dist/islands/CodeTabs.js.map +1 -0
- package/dist/islands/CopyButton.d.ts +16 -0
- package/dist/islands/CopyButton.d.ts.map +1 -0
- package/dist/islands/CopyButton.js +54 -0
- package/dist/islands/CopyButton.js.map +1 -0
- package/dist/islands/SearchPalette.d.ts +2 -0
- package/dist/islands/SearchPalette.d.ts.map +1 -0
- package/dist/islands/SearchPalette.js +109 -0
- package/dist/islands/SearchPalette.js.map +1 -0
- package/dist/islands/SearchResults.d.ts +2 -0
- package/dist/islands/SearchResults.d.ts.map +1 -0
- package/dist/islands/SearchResults.js +130 -0
- package/dist/islands/SearchResults.js.map +1 -0
- package/dist/islands/ThemeToggle.d.ts +12 -0
- package/dist/islands/ThemeToggle.d.ts.map +1 -0
- package/dist/islands/ThemeToggle.js +43 -0
- package/dist/islands/ThemeToggle.js.map +1 -0
- package/dist/layouts/DocPage.test.d.ts +2 -0
- package/dist/layouts/DocPage.test.d.ts.map +1 -0
- package/dist/layouts/DocPage.test.js +165 -0
- package/dist/layouts/DocPage.test.js.map +1 -0
- package/dist/lib/utils.d.ts +10 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/scripts/code-block-enhancer.d.ts +16 -0
- package/dist/scripts/code-block-enhancer.d.ts.map +1 -0
- package/dist/scripts/code-block-enhancer.js +55 -0
- package/dist/scripts/code-block-enhancer.js.map +1 -0
- package/dist/ui/command.d.ts +87 -0
- package/dist/ui/command.d.ts.map +1 -0
- package/dist/ui/command.js +28 -0
- package/dist/ui/command.js.map +1 -0
- package/dist/ui/dialog.d.ts +20 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +22 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/utils/parse-highlight-range.d.ts +12 -0
- package/dist/utils/parse-highlight-range.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.js +40 -0
- package/dist/utils/parse-highlight-range.js.map +1 -0
- package/dist/utils/parse-highlight-range.test.d.ts +2 -0
- package/dist/utils/parse-highlight-range.test.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.test.js +32 -0
- package/dist/utils/parse-highlight-range.test.js.map +1 -0
- package/dist/utils/schema-renderer.d.ts +38 -0
- package/dist/utils/schema-renderer.d.ts.map +1 -0
- package/dist/utils/schema-renderer.js +115 -0
- package/dist/utils/schema-renderer.js.map +1 -0
- package/dist/utils/schema-renderer.test.d.ts +2 -0
- package/dist/utils/schema-renderer.test.d.ts.map +1 -0
- package/dist/utils/schema-renderer.test.js +219 -0
- package/dist/utils/schema-renderer.test.js.map +1 -0
- package/dist/utils/shiki.d.ts +20 -0
- package/dist/utils/shiki.d.ts.map +1 -0
- package/dist/utils/shiki.js +84 -0
- package/dist/utils/shiki.js.map +1 -0
- package/dist/utils/sidebar-helpers.d.ts +10 -0
- package/dist/utils/sidebar-helpers.d.ts.map +1 -0
- package/dist/utils/sidebar-helpers.js +14 -0
- package/dist/utils/sidebar-helpers.js.map +1 -0
- package/dist/utils/theme-css.d.ts +21 -0
- package/dist/utils/theme-css.d.ts.map +1 -0
- package/dist/utils/theme-css.js +77 -0
- package/dist/utils/theme-css.js.map +1 -0
- package/dist/utils/theme-helpers.d.ts +28 -0
- package/dist/utils/theme-helpers.d.ts.map +1 -0
- package/dist/utils/theme-helpers.js +55 -0
- package/dist/utils/theme-helpers.js.map +1 -0
- package/dist/utils/toc-helpers.d.ts +12 -0
- package/dist/utils/toc-helpers.d.ts.map +1 -0
- package/dist/utils/toc-helpers.js +9 -0
- package/dist/utils/toc-helpers.js.map +1 -0
- package/package.json +68 -0
- package/src/components/ApiAuth.astro +116 -0
- package/src/components/ApiEndpoint.astro +75 -0
- package/src/components/ApiNavigation.astro +110 -0
- package/src/components/ApiParameters.astro +204 -0
- package/src/components/ApiResponse.astro +144 -0
- package/src/components/Callout.astro +54 -0
- package/src/components/Card.astro +46 -0
- package/src/components/CodeBlock.astro +142 -0
- package/src/components/CodeBlockGroup.astro +196 -0
- package/src/components/CodeTabs.astro +53 -0
- package/src/components/Footer.astro +41 -0
- package/src/components/Header.astro +80 -0
- package/src/components/Sidebar.astro +117 -0
- package/src/components/TabItem.astro +24 -0
- package/src/components/TableOfContents.astro +111 -0
- package/src/components/Tabs.astro +185 -0
- package/src/islands/CodeTabs.tsx +212 -0
- package/src/islands/CopyButton.tsx +101 -0
- package/src/islands/SearchPalette.tsx +307 -0
- package/src/islands/SearchResults.tsx +301 -0
- package/src/islands/ThemeToggle.tsx +107 -0
- package/src/layouts/ApiReferencePage.astro +239 -0
- package/src/layouts/DocPage.astro +199 -0
- package/src/layouts/DocPage.test.ts +183 -0
- package/src/layouts/LandingPage.astro +143 -0
- package/src/lib/utils.ts +13 -0
- package/src/styles/global.css +241 -0
- package/src/utils/parse-highlight-range.test.ts +40 -0
- package/src/utils/parse-highlight-range.ts +41 -0
- package/src/utils/schema-renderer.test.ts +269 -0
- package/src/utils/schema-renderer.ts +152 -0
- package/src/utils/shiki.ts +99 -0
- package/src/utils/sidebar-helpers.ts +24 -0
- package/src/utils/theme-css.ts +101 -0
- package/src/utils/theme-helpers.ts +59 -0
- package/src/utils/toc-helpers.ts +11 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TableOfContents — Right-side navigation based on page headings.
|
|
4
|
+
*
|
|
5
|
+
* Renders h2 and h3 headings as a nested list with anchor links.
|
|
6
|
+
* Includes an inline scroll-spy script to highlight the currently
|
|
7
|
+
* visible section.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Props {
|
|
11
|
+
headings: Array<{ depth: number; slug: string; text: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { headings } = Astro.props;
|
|
15
|
+
|
|
16
|
+
// Filter to h2 and h3 only
|
|
17
|
+
const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
tocHeadings.length > 0 && (
|
|
22
|
+
<nav aria-label="Table of contents">
|
|
23
|
+
<h2 class="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
24
|
+
On this page
|
|
25
|
+
</h2>
|
|
26
|
+
<ul class="list-none p-0 m-0 space-y-0.5" id="toc-list">
|
|
27
|
+
{tocHeadings.map((heading) => (
|
|
28
|
+
<li>
|
|
29
|
+
<a
|
|
30
|
+
href={`#${heading.slug}`}
|
|
31
|
+
class:list={[
|
|
32
|
+
"block text-sm py-1 no-underline transition-colors duration-150",
|
|
33
|
+
heading.depth === 3 ? "pl-4" : "pl-0",
|
|
34
|
+
"text-text-muted hover:text-text",
|
|
35
|
+
]}
|
|
36
|
+
data-toc-link
|
|
37
|
+
data-toc-id={heading.slug}
|
|
38
|
+
>
|
|
39
|
+
{heading.text}
|
|
40
|
+
</a>
|
|
41
|
+
</li>
|
|
42
|
+
))}
|
|
43
|
+
</ul>
|
|
44
|
+
</nav>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
<script>
|
|
49
|
+
function initScrollSpy() {
|
|
50
|
+
const tocLinks = document.querySelectorAll("[data-toc-link]");
|
|
51
|
+
if (tocLinks.length === 0) return;
|
|
52
|
+
|
|
53
|
+
const headingIds = Array.from(tocLinks).map((link) => (link as HTMLElement).dataset.tocId!);
|
|
54
|
+
|
|
55
|
+
const headingElements = headingIds
|
|
56
|
+
.map((id) => document.getElementById(id))
|
|
57
|
+
.filter(Boolean) as HTMLElement[];
|
|
58
|
+
|
|
59
|
+
if (headingElements.length === 0) return;
|
|
60
|
+
|
|
61
|
+
let activeId = headingIds[0];
|
|
62
|
+
|
|
63
|
+
function setActive(id: string) {
|
|
64
|
+
if (id === activeId) return;
|
|
65
|
+
activeId = id;
|
|
66
|
+
tocLinks.forEach((link) => {
|
|
67
|
+
const linkId = (link as HTMLElement).dataset.tocId;
|
|
68
|
+
if (linkId === id) {
|
|
69
|
+
link.classList.add("text-primary", "font-medium");
|
|
70
|
+
link.classList.remove("text-text-muted");
|
|
71
|
+
} else {
|
|
72
|
+
link.classList.remove("text-primary", "font-medium");
|
|
73
|
+
link.classList.add("text-text-muted");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const observer = new IntersectionObserver(
|
|
79
|
+
(entries) => {
|
|
80
|
+
// Find the first visible heading
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry.isIntersecting) {
|
|
83
|
+
setActive(entry.target.id);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
rootMargin: "-80px 0px -70% 0px",
|
|
90
|
+
threshold: 0,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
headingElements.forEach((el) => observer.observe(el));
|
|
95
|
+
|
|
96
|
+
// Set initial active state
|
|
97
|
+
setActive(headingIds[0]);
|
|
98
|
+
|
|
99
|
+
// Cleanup on page transition
|
|
100
|
+
document.addEventListener(
|
|
101
|
+
"astro:before-swap",
|
|
102
|
+
() => {
|
|
103
|
+
observer.disconnect();
|
|
104
|
+
},
|
|
105
|
+
{ once: true },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
initScrollSpy();
|
|
110
|
+
document.addEventListener("astro:after-swap", initScrollSpy);
|
|
111
|
+
</script>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Tabs component — renders tabbed content panels with accessible tab controls.
|
|
4
|
+
*
|
|
5
|
+
* Children must be <TabItem> components. The first tab is active by default.
|
|
6
|
+
* Uses inline <script> for tab switching — NOT a React island.
|
|
7
|
+
*
|
|
8
|
+
* Accessible: role="tablist", role="tab", role="tabpanel", aria-selected,
|
|
9
|
+
* aria-controls, arrow key navigation.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <Tabs>
|
|
13
|
+
* <TabItem label="npm">npm install foo</TabItem>
|
|
14
|
+
* <TabItem label="yarn">yarn add foo</TabItem>
|
|
15
|
+
* </Tabs>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<div class="tabs-container" data-tabs-id={tabGroupId}>
|
|
22
|
+
<div role="tablist" class="tabs-header" aria-label="Tab options">
|
|
23
|
+
<!-- Tab buttons are generated client-side from TabItem children -->
|
|
24
|
+
</div>
|
|
25
|
+
<div class="tabs-content">
|
|
26
|
+
<slot />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<style is:global>
|
|
31
|
+
/*
|
|
32
|
+
* Must be is:global — tab buttons are created at runtime by <script>,
|
|
33
|
+
* so Astro's scoping data attribute is not present on them.
|
|
34
|
+
*/
|
|
35
|
+
.tabs-container {
|
|
36
|
+
margin: 1.5rem 0;
|
|
37
|
+
border: 1px solid var(--tabs-border, #e5e7eb);
|
|
38
|
+
border-radius: 0.5rem;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.tabs-header {
|
|
43
|
+
display: flex;
|
|
44
|
+
border-bottom: 1px solid var(--tabs-border, #e5e7eb);
|
|
45
|
+
background-color: var(--tabs-header-bg, #f9fafb);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.tabs-header [role="tab"] {
|
|
49
|
+
padding: 0.625rem 1rem;
|
|
50
|
+
border: none;
|
|
51
|
+
background: none;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
font-size: 0.875rem;
|
|
54
|
+
font-weight: 500;
|
|
55
|
+
color: var(--tabs-text, #6b7280);
|
|
56
|
+
border-bottom: 2px solid transparent;
|
|
57
|
+
transition:
|
|
58
|
+
color 0.15s,
|
|
59
|
+
border-color 0.15s;
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.tabs-header [role="tab"]:hover {
|
|
64
|
+
color: var(--tabs-text-hover, #374151);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.tabs-header [role="tab"][aria-selected="true"] {
|
|
68
|
+
color: var(--tabs-text-active, #111827);
|
|
69
|
+
border-bottom-color: var(--tabs-active-border, #3b82f6);
|
|
70
|
+
background-color: var(--tabs-active-bg, transparent);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.tabs-header [role="tab"]:focus-visible {
|
|
74
|
+
outline: 2px solid var(--tabs-focus-ring, #3b82f6);
|
|
75
|
+
outline-offset: -2px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tabs-content {
|
|
79
|
+
padding: 1rem 1.25rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.tabs-content .tab-item {
|
|
83
|
+
display: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.tabs-content .tab-item.active {
|
|
87
|
+
display: block;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Dark mode — remap CSS vars to dark surface colors */
|
|
91
|
+
.dark .tabs-container {
|
|
92
|
+
--tabs-border: oklch(0.303 0.034 264.376);
|
|
93
|
+
--tabs-header-bg: oklch(0.179 0.04 264.376);
|
|
94
|
+
--tabs-text: oklch(0.708 0.014 264.364);
|
|
95
|
+
--tabs-text-hover: oklch(0.85 0.01 264);
|
|
96
|
+
--tabs-text-active: oklch(0.95 0.005 264);
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
99
|
+
|
|
100
|
+
<script>
|
|
101
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
102
|
+
document.querySelectorAll<HTMLDivElement>("[data-tabs-id]").forEach((container) => {
|
|
103
|
+
const tablist = container.querySelector<HTMLDivElement>('[role="tablist"]');
|
|
104
|
+
const contentArea = container.querySelector<HTMLDivElement>(".tabs-content");
|
|
105
|
+
if (!tablist || !contentArea) return;
|
|
106
|
+
|
|
107
|
+
const tabItems = contentArea.querySelectorAll<HTMLDivElement>(".tab-item");
|
|
108
|
+
if (tabItems.length === 0) return;
|
|
109
|
+
|
|
110
|
+
const groupId = container.dataset.tabsId ?? "tabs";
|
|
111
|
+
|
|
112
|
+
// Build tab buttons from TabItem children
|
|
113
|
+
tabItems.forEach((item, index) => {
|
|
114
|
+
const label = item.dataset.tabLabel ?? `Tab ${index + 1}`;
|
|
115
|
+
const tabId = `${groupId}-tab-${index}`;
|
|
116
|
+
const panelId = `${groupId}-panel-${index}`;
|
|
117
|
+
|
|
118
|
+
// Create tab button
|
|
119
|
+
const button = document.createElement("button");
|
|
120
|
+
button.role = "tab";
|
|
121
|
+
button.id = tabId;
|
|
122
|
+
button.setAttribute("aria-controls", panelId);
|
|
123
|
+
button.setAttribute("aria-selected", index === 0 ? "true" : "false");
|
|
124
|
+
button.tabIndex = index === 0 ? 0 : -1;
|
|
125
|
+
button.textContent = label;
|
|
126
|
+
tablist.appendChild(button);
|
|
127
|
+
|
|
128
|
+
// Set up panel
|
|
129
|
+
item.id = panelId;
|
|
130
|
+
item.setAttribute("role", "tabpanel");
|
|
131
|
+
item.setAttribute("aria-labelledby", tabId);
|
|
132
|
+
item.tabIndex = 0;
|
|
133
|
+
|
|
134
|
+
if (index === 0) {
|
|
135
|
+
item.classList.add("active");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Tab switching
|
|
140
|
+
const tabs = tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]');
|
|
141
|
+
|
|
142
|
+
function activateTab(index: number) {
|
|
143
|
+
tabs.forEach((tab, i) => {
|
|
144
|
+
const isActive = i === index;
|
|
145
|
+
tab.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
146
|
+
tab.tabIndex = isActive ? 0 : -1;
|
|
147
|
+
});
|
|
148
|
+
tabItems.forEach((item, i) => {
|
|
149
|
+
item.classList.toggle("active", i === index);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
tabs.forEach((tab, index) => {
|
|
154
|
+
tab.addEventListener("click", () => {
|
|
155
|
+
activateTab(index);
|
|
156
|
+
tab.focus();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Keyboard navigation
|
|
161
|
+
tablist.addEventListener("keydown", (e) => {
|
|
162
|
+
const currentIndex = Array.from(tabs).findIndex((t) => t === document.activeElement);
|
|
163
|
+
if (currentIndex === -1) return;
|
|
164
|
+
|
|
165
|
+
let nextIndex: number | null = null;
|
|
166
|
+
|
|
167
|
+
if (e.key === "ArrowRight") {
|
|
168
|
+
nextIndex = (currentIndex + 1) % tabs.length;
|
|
169
|
+
} else if (e.key === "ArrowLeft") {
|
|
170
|
+
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
171
|
+
} else if (e.key === "Home") {
|
|
172
|
+
nextIndex = 0;
|
|
173
|
+
} else if (e.key === "End") {
|
|
174
|
+
nextIndex = tabs.length - 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (nextIndex !== null) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
activateTab(nextIndex);
|
|
180
|
+
tabs[nextIndex].focus();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
</script>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CodeTabs — React island for SDK/language code tabs with persistent preference.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Tab switching with keyboard navigation (ArrowLeft/Right, Home/End)
|
|
9
|
+
* - localStorage persistence of preferred language
|
|
10
|
+
* - Cross-group sync via CustomEvent broadcast
|
|
11
|
+
* - Configurable syncKey for independent tab groups
|
|
12
|
+
*
|
|
13
|
+
* Designed for `client:idle` hydration in Astro islands.
|
|
14
|
+
*
|
|
15
|
+
* @see architecture.md FR32 (SDK tab switching), FR33 (persistent preference)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const LANGUAGE_CHANGE_EVENT = "ndocs-language-change";
|
|
23
|
+
|
|
24
|
+
function getStorageKey(syncKey: string): string {
|
|
25
|
+
return `ndocs-preferred-${syncKey}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** A single tab definition */
|
|
33
|
+
interface CodeTab {
|
|
34
|
+
/** Display label (e.g., "Python", "Node.js", "cURL") */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Language key for sync/persistence (e.g., "python", "nodejs", "curl") */
|
|
37
|
+
language: string;
|
|
38
|
+
/** Pre-rendered HTML content (from Shiki or MDX) */
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CodeTabsProps {
|
|
43
|
+
/** Array of tab definitions */
|
|
44
|
+
tabs: CodeTab[];
|
|
45
|
+
/** Sync key — all CodeTabs with same key share selection. Default: "sdk-language" */
|
|
46
|
+
syncKey?: string;
|
|
47
|
+
/** Custom ARIA label for the tablist. Default: "SDK language tabs" */
|
|
48
|
+
ariaLabel?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Hook — useCodeTabs
|
|
53
|
+
// SYNC: If changing this hook, update code-tabs.test.ts mirror copy
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function useCodeTabs(languages: string[], syncKey: string) {
|
|
57
|
+
const storageKey = getStorageKey(syncKey);
|
|
58
|
+
|
|
59
|
+
// Always start at index 0 — SSR-safe. Stored preference is restored in useEffect
|
|
60
|
+
// to avoid hydration mismatch (server has no localStorage → renders index 0,
|
|
61
|
+
// but client reads a different index → React can't reconcile → multiple panels visible).
|
|
62
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
63
|
+
const isInternalChange = useRef(false);
|
|
64
|
+
|
|
65
|
+
// Restore stored preference after hydration
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
try {
|
|
68
|
+
const stored = localStorage.getItem(storageKey);
|
|
69
|
+
if (stored) {
|
|
70
|
+
const idx = languages.indexOf(stored);
|
|
71
|
+
if (idx !== -1 && idx !== 0) {
|
|
72
|
+
setActiveIndex(idx);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// localStorage unavailable
|
|
77
|
+
}
|
|
78
|
+
}, [storageKey, languages]);
|
|
79
|
+
|
|
80
|
+
const setTab = useCallback(
|
|
81
|
+
(index: number) => {
|
|
82
|
+
if (index < 0 || index >= languages.length) return;
|
|
83
|
+
setActiveIndex(index);
|
|
84
|
+
const language = languages[index];
|
|
85
|
+
if (language) {
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(storageKey, language);
|
|
88
|
+
} catch {
|
|
89
|
+
// localStorage unavailable
|
|
90
|
+
}
|
|
91
|
+
isInternalChange.current = true;
|
|
92
|
+
window.dispatchEvent(
|
|
93
|
+
new CustomEvent(LANGUAGE_CHANGE_EVENT, {
|
|
94
|
+
detail: { syncKey, language },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
isInternalChange.current = false;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
[languages, storageKey, syncKey],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Listen for external language change events (from other CodeTabs instances)
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const handler = (e: Event) => {
|
|
106
|
+
if (isInternalChange.current) return;
|
|
107
|
+
const detail = (e as CustomEvent).detail;
|
|
108
|
+
if (detail?.syncKey === syncKey) {
|
|
109
|
+
const idx = languages.indexOf(detail.language);
|
|
110
|
+
if (idx !== -1) {
|
|
111
|
+
setActiveIndex(idx);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
window.addEventListener(LANGUAGE_CHANGE_EVENT, handler);
|
|
116
|
+
return () => window.removeEventListener(LANGUAGE_CHANGE_EVENT, handler);
|
|
117
|
+
}, [languages, syncKey]);
|
|
118
|
+
|
|
119
|
+
return { activeIndex, setTab };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Component
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function CodeTabs({ tabs, syncKey = "sdk-language", ariaLabel = "SDK language tabs" }: CodeTabsProps) {
|
|
127
|
+
// Memoize languages to prevent useEffect re-subscription on every render (H1 fix)
|
|
128
|
+
const languageKey = tabs.map((t) => t.language).join(",");
|
|
129
|
+
const languages = useMemo(() => tabs.map((t) => t.language), [languageKey]);
|
|
130
|
+
const { activeIndex, setTab } = useCodeTabs(languages, syncKey);
|
|
131
|
+
|
|
132
|
+
// Stable unique ID per instance via React's useId (HMR-safe)
|
|
133
|
+
const groupId = useId();
|
|
134
|
+
|
|
135
|
+
// Keyboard navigation
|
|
136
|
+
const handleKeyDown = useCallback(
|
|
137
|
+
(e: React.KeyboardEvent) => {
|
|
138
|
+
let nextIndex: number | null = null;
|
|
139
|
+
|
|
140
|
+
if (e.key === "ArrowRight") {
|
|
141
|
+
nextIndex = (activeIndex + 1) % tabs.length;
|
|
142
|
+
} else if (e.key === "ArrowLeft") {
|
|
143
|
+
nextIndex = (activeIndex - 1 + tabs.length) % tabs.length;
|
|
144
|
+
} else if (e.key === "Home") {
|
|
145
|
+
nextIndex = 0;
|
|
146
|
+
} else if (e.key === "End") {
|
|
147
|
+
nextIndex = tabs.length - 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (nextIndex !== null) {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
setTab(nextIndex);
|
|
153
|
+
// Focus the newly active tab button
|
|
154
|
+
const btn = document.getElementById(`${groupId}-tab-${nextIndex}`);
|
|
155
|
+
btn?.focus();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
[activeIndex, tabs.length, setTab, groupId],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (tabs.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="my-6 border border-border rounded-lg overflow-hidden">
|
|
165
|
+
{/* Tab bar */}
|
|
166
|
+
<div
|
|
167
|
+
role="tablist"
|
|
168
|
+
aria-label={ariaLabel}
|
|
169
|
+
className="flex border-b border-border bg-surface-raised"
|
|
170
|
+
onKeyDown={handleKeyDown}
|
|
171
|
+
>
|
|
172
|
+
{tabs.map((tab, i) => (
|
|
173
|
+
<button
|
|
174
|
+
key={tab.language}
|
|
175
|
+
id={`${groupId}-tab-${i}`}
|
|
176
|
+
role="tab"
|
|
177
|
+
aria-selected={i === activeIndex}
|
|
178
|
+
aria-controls={`${groupId}-panel-${i}`}
|
|
179
|
+
tabIndex={i === activeIndex ? 0 : -1}
|
|
180
|
+
className={cn(
|
|
181
|
+
"px-4 py-2 border-b-2 border-transparent text-[0.8125rem] font-medium font-mono",
|
|
182
|
+
"text-text-muted cursor-pointer whitespace-nowrap bg-transparent",
|
|
183
|
+
"transition-colors duration-150 hover:text-text",
|
|
184
|
+
"focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2",
|
|
185
|
+
i === activeIndex && "text-text border-b-primary",
|
|
186
|
+
)}
|
|
187
|
+
onClick={() => setTab(i)}
|
|
188
|
+
>
|
|
189
|
+
{tab.label}
|
|
190
|
+
</button>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Panels */}
|
|
195
|
+
{tabs.map((tab, i) => (
|
|
196
|
+
<div
|
|
197
|
+
key={tab.language}
|
|
198
|
+
id={`${groupId}-panel-${i}`}
|
|
199
|
+
role="tabpanel"
|
|
200
|
+
aria-labelledby={`${groupId}-tab-${i}`}
|
|
201
|
+
tabIndex={0}
|
|
202
|
+
className="code-tabs-panel"
|
|
203
|
+
style={{ display: i === activeIndex ? undefined : "none" }}
|
|
204
|
+
dangerouslySetInnerHTML={{ __html: tab.content }}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { CodeTabs };
|
|
212
|
+
export type { CodeTab, CodeTabsProps };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CopyButton island — copies code to clipboard with visual feedback.
|
|
3
|
+
*
|
|
4
|
+
* Renders a small button with a copy icon that, on click, copies the
|
|
5
|
+
* provided `code` string to the clipboard. Shows a checkmark for 2s
|
|
6
|
+
* after a successful copy, then reverts to the copy icon.
|
|
7
|
+
*
|
|
8
|
+
* Architecture: React island in `islands/` — hydrated with `client:idle`.
|
|
9
|
+
* Uses inline SVGs (no icon library) following the ThemeToggle pattern.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useCallback, useRef } from "react";
|
|
13
|
+
|
|
14
|
+
export interface CopyButtonProps {
|
|
15
|
+
/** The raw code text to copy to clipboard */
|
|
16
|
+
code: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CopyButton({ code }: CopyButtonProps) {
|
|
20
|
+
const [copied, setCopied] = useState(false);
|
|
21
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
|
+
|
|
23
|
+
const handleCopy = useCallback(() => {
|
|
24
|
+
// Clear any existing timeout to avoid stale state
|
|
25
|
+
if (timeoutRef.current) {
|
|
26
|
+
clearTimeout(timeoutRef.current);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
30
|
+
setCopied(true);
|
|
31
|
+
timeoutRef.current = setTimeout(() => {
|
|
32
|
+
setCopied(false);
|
|
33
|
+
timeoutRef.current = null;
|
|
34
|
+
}, 2000);
|
|
35
|
+
}).catch(() => {
|
|
36
|
+
// Clipboard API unavailable — silent fail for MVP
|
|
37
|
+
console.warn("[CopyButton] Clipboard API unavailable");
|
|
38
|
+
});
|
|
39
|
+
}, [code]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={handleCopy}
|
|
45
|
+
aria-label={copied ? "Copied!" : "Copy code"}
|
|
46
|
+
title={copied ? "Copied!" : "Copy code"}
|
|
47
|
+
className="sg-copy-btn"
|
|
48
|
+
style={{
|
|
49
|
+
position: "absolute",
|
|
50
|
+
top: "0.5rem",
|
|
51
|
+
right: "0.5rem",
|
|
52
|
+
display: "flex",
|
|
53
|
+
alignItems: "center",
|
|
54
|
+
justifyContent: "center",
|
|
55
|
+
width: "2rem",
|
|
56
|
+
height: "2rem",
|
|
57
|
+
borderRadius: "0.375rem",
|
|
58
|
+
border: "1px solid var(--color-border, #e5e7eb)",
|
|
59
|
+
backgroundColor: "var(--color-surface, #ffffff)",
|
|
60
|
+
color: "var(--color-text-muted, #6b7280)",
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
opacity: copied ? 1 : 0,
|
|
63
|
+
transition: "opacity 150ms ease, background-color 150ms ease, color 150ms ease",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{copied ? (
|
|
67
|
+
/* Checkmark icon */
|
|
68
|
+
<svg
|
|
69
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
70
|
+
width="16"
|
|
71
|
+
height="16"
|
|
72
|
+
viewBox="0 0 24 24"
|
|
73
|
+
fill="none"
|
|
74
|
+
stroke="currentColor"
|
|
75
|
+
strokeWidth="2"
|
|
76
|
+
strokeLinecap="round"
|
|
77
|
+
strokeLinejoin="round"
|
|
78
|
+
style={{ color: "#22c55e" }}
|
|
79
|
+
>
|
|
80
|
+
<polyline points="20 6 9 17 4 12" />
|
|
81
|
+
</svg>
|
|
82
|
+
) : (
|
|
83
|
+
/* Copy icon */
|
|
84
|
+
<svg
|
|
85
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
86
|
+
width="16"
|
|
87
|
+
height="16"
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
strokeWidth="2"
|
|
92
|
+
strokeLinecap="round"
|
|
93
|
+
strokeLinejoin="round"
|
|
94
|
+
>
|
|
95
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
96
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
97
|
+
</svg>
|
|
98
|
+
)}
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|