@opentf/web-docs 0.1.0 → 0.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/build/blog-posts-plugin.js +127 -0
- package/build/docs-nav-plugin.js +17 -10
- package/build/feed.js +136 -0
- package/build/index.js +5 -0
- package/build/last-updated-plugin.js +98 -0
- package/build/last-updated.js +45 -0
- package/build/llms.js +156 -0
- package/build/pagefind.js +58 -0
- package/build/reading-time.js +16 -0
- package/components/BlogLayout.jsx +77 -0
- package/components/Breadcrumbs.jsx +30 -27
- package/components/Callout.jsx +7 -7
- package/components/Card.jsx +21 -0
- package/components/Cards.jsx +11 -0
- package/components/CodeBlock.jsx +39 -0
- package/components/DocsLayout.jsx +66 -6
- package/components/LastUpdated.jsx +22 -0
- package/components/NavIcon.jsx +34 -0
- package/components/Navbar.jsx +34 -22
- package/components/NavbarLink.jsx +33 -0
- package/components/Pagination.jsx +37 -33
- package/components/PostBanner.jsx +25 -0
- package/components/PostCard.jsx +19 -0
- package/components/PostList.jsx +17 -0
- package/components/PostMeta.jsx +28 -0
- package/components/ReadingTime.jsx +6 -0
- package/components/Search.jsx +211 -0
- package/components/SearchTrigger.jsx +14 -3
- package/components/Sidebar.jsx +111 -9
- package/components/SidebarNode.jsx +1 -0
- package/components/SidebarToggle.jsx +47 -0
- package/components/Steps.jsx +14 -0
- package/components/Tabs.jsx +4 -2
- package/components/ThemeToggle.jsx +150 -25
- package/components/Tooltip.jsx +19 -0
- package/components/format.js +11 -0
- package/config.js +28 -2
- package/index.js +29 -2
- package/nav-virtual.js +6 -4
- package/package.json +15 -1
- package/posts-virtual.js +17 -0
- package/theme/index.css +1034 -58
- package/updated-virtual.js +10 -0
- package/components/CodeGroup.jsx +0 -28
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// One post in the blog index: a link to the post with its meta + title + excerpt.
|
|
2
|
+
import { Link } from "@opentf/web";
|
|
3
|
+
import PostMeta from "./PostMeta.jsx";
|
|
4
|
+
|
|
5
|
+
export default function PostCard(props) {
|
|
6
|
+
const post = props.post;
|
|
7
|
+
return (
|
|
8
|
+
<Link href={post.path} class={post.cover ? "otfw-post-card otfw-has-cover" : "otfw-post-card"}>
|
|
9
|
+
{post.cover ? (
|
|
10
|
+
<img class="otfw-post-card-cover" src={post.cover} alt={post.title} loading="lazy" />
|
|
11
|
+
) : null}
|
|
12
|
+
<div class="otfw-post-card-body">
|
|
13
|
+
<PostMeta post={post} />
|
|
14
|
+
<div class="otfw-post-card-title">{post.title}</div>
|
|
15
|
+
{post.description ? <p class="otfw-post-card-desc">{post.description}</p> : null}
|
|
16
|
+
</div>
|
|
17
|
+
</Link>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The blog index listing: renders a PostCard per post. Pass the list from
|
|
2
|
+
// `@opentf/web-docs/posts`:
|
|
3
|
+
//
|
|
4
|
+
// import { posts } from "@opentf/web-docs/posts";
|
|
5
|
+
// <PostList posts={posts} />
|
|
6
|
+
import PostCard from "./PostCard.jsx";
|
|
7
|
+
|
|
8
|
+
export default function PostList(props) {
|
|
9
|
+
const posts = props.posts || [];
|
|
10
|
+
return (
|
|
11
|
+
<div class="otfw-post-list">
|
|
12
|
+
{posts.length
|
|
13
|
+
? posts.map((post) => <PostCard post={post} />)
|
|
14
|
+
: <p class="otfw-post-empty">No posts yet.</p>}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A post's metadata row: date · author · reading time. Pass a post record (from
|
|
2
|
+
// `@opentf/web-docs/posts`) as `post`, or the fields directly. Missing fields are
|
|
3
|
+
// omitted. Used in both the post banner and the index cards.
|
|
4
|
+
import ReadingTime from "./ReadingTime.jsx";
|
|
5
|
+
import { formatDate } from "./format.js";
|
|
6
|
+
|
|
7
|
+
export default function PostMeta(props) {
|
|
8
|
+
const post = props.post || props;
|
|
9
|
+
return (
|
|
10
|
+
<div class="otfw-post-meta">
|
|
11
|
+
{post.author ? (
|
|
12
|
+
<span class="otfw-post-author">
|
|
13
|
+
{post.authorAvatar ? (
|
|
14
|
+
<img class="otfw-post-avatar" src={post.authorAvatar} alt={post.author} loading="lazy" />
|
|
15
|
+
) : null}
|
|
16
|
+
<span class="otfw-post-author-name">{post.author}</span>
|
|
17
|
+
{post.authorRole ? <span class="otfw-post-author-role">{post.authorRole}</span> : null}
|
|
18
|
+
</span>
|
|
19
|
+
) : null}
|
|
20
|
+
{post.date ? (
|
|
21
|
+
<time class="otfw-post-date" datetime={String(post.date)}>
|
|
22
|
+
{formatDate(post.date)}
|
|
23
|
+
</time>
|
|
24
|
+
) : null}
|
|
25
|
+
{post.readingTime ? <ReadingTime minutes={post.readingTime} /> : null}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Reading-time label, e.g. "4 min read". `minutes` is precomputed at build time by
|
|
2
|
+
// the blog posts plugin (see build/reading-time.js).
|
|
3
|
+
export default function ReadingTime(props) {
|
|
4
|
+
const minutes = props.minutes ?? 1;
|
|
5
|
+
return <span class="otfw-reading-time">{minutes} min read</span>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Pagefind-backed search modal (Phase 2). The navbar's `SearchTrigger` and the global
|
|
2
|
+
// ⌘K / Ctrl+K shortcut open it via `window.__otfwOpenSearch`, which this component
|
|
3
|
+
// installs on mount. The static index is generated at build time (`otfw build --ssg`
|
|
4
|
+
// with `docs.search.provider === "pagefind"`) and lives at `/pagefind/`; we load its
|
|
5
|
+
// runtime on first open so pages without a search never pay for it.
|
|
6
|
+
//
|
|
7
|
+
// Note: results only exist against a built site (`dist/`). In dev there is no index,
|
|
8
|
+
// so the modal opens but reports no results — expected.
|
|
9
|
+
import { onMount, router, RawHtml, Portal } from "@opentf/web";
|
|
10
|
+
|
|
11
|
+
let pagefindPromise = null;
|
|
12
|
+
function loadPagefind() {
|
|
13
|
+
if (!pagefindPromise) {
|
|
14
|
+
// A non-literal specifier keeps the bundler from trying to resolve the index at
|
|
15
|
+
// build time (it doesn't exist yet) — it stays a runtime import of the static file.
|
|
16
|
+
const path = "/pagefind/pagefind.js";
|
|
17
|
+
pagefindPromise = import(/* @vite-ignore */ path).then(async (pf) => {
|
|
18
|
+
await pf.init?.();
|
|
19
|
+
return pf;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return pagefindPromise;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function Search() {
|
|
26
|
+
let open = $state(false);
|
|
27
|
+
let query = $state("");
|
|
28
|
+
let results = $state([]);
|
|
29
|
+
let active = $state(0);
|
|
30
|
+
let loading = $state(false);
|
|
31
|
+
const inputRef = $ref();
|
|
32
|
+
|
|
33
|
+
let timer;
|
|
34
|
+
let token = 0;
|
|
35
|
+
|
|
36
|
+
async function runSearch(q) {
|
|
37
|
+
const mine = ++token;
|
|
38
|
+
if (!q.trim()) {
|
|
39
|
+
results = [];
|
|
40
|
+
loading = false;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
loading = true;
|
|
44
|
+
try {
|
|
45
|
+
const pf = await loadPagefind();
|
|
46
|
+
const search = await pf.search(q);
|
|
47
|
+
const top = (search.results || []).slice(0, 6);
|
|
48
|
+
const data = await Promise.all(top.map((r) => r.data()));
|
|
49
|
+
if (mine !== token) return; // a newer query superseded this one
|
|
50
|
+
// Flatten to heading-anchored sub-results so each row deep-links to the matched
|
|
51
|
+
// section (`/page/#heading`) rather than the page top. Pagefind builds these from
|
|
52
|
+
// the body's headings; a page with none falls back to a single page-level row.
|
|
53
|
+
const flat = [];
|
|
54
|
+
for (const d of data) {
|
|
55
|
+
const crumb = d.meta && d.meta.breadcrumb ? d.meta.breadcrumb.replace(/\s*\/\s*/g, " › ") : "";
|
|
56
|
+
// The page/post title — always shown as the result heading so you can tell which
|
|
57
|
+
// page (or blog post) a hit belongs to, even across same-named sections.
|
|
58
|
+
const pageTitle = (d.meta && d.meta.title) || d.url;
|
|
59
|
+
const subs =
|
|
60
|
+
d.sub_results && d.sub_results.length
|
|
61
|
+
? d.sub_results
|
|
62
|
+
: [{ title: pageTitle, url: d.url, excerpt: d.excerpt }];
|
|
63
|
+
for (const s of subs.slice(0, 4)) {
|
|
64
|
+
flat.push({
|
|
65
|
+
url: s.url,
|
|
66
|
+
title: pageTitle,
|
|
67
|
+
// The matched section heading, when it differs from the page title.
|
|
68
|
+
section: s.title && s.title !== pageTitle ? s.title : "",
|
|
69
|
+
crumb,
|
|
70
|
+
excerpt: s.excerpt,
|
|
71
|
+
});
|
|
72
|
+
if (flat.length >= 10) break;
|
|
73
|
+
}
|
|
74
|
+
if (flat.length >= 10) break;
|
|
75
|
+
}
|
|
76
|
+
results = flat;
|
|
77
|
+
active = 0;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (mine === token) results = [];
|
|
80
|
+
} finally {
|
|
81
|
+
if (mine === token) loading = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$effect(() => {
|
|
86
|
+
const q = query;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
timer = setTimeout(() => runSearch(q), 150);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const focusInput = () => {
|
|
92
|
+
const el = inputRef;
|
|
93
|
+
if (el && typeof requestAnimationFrame !== "undefined") requestAnimationFrame(() => el.focus());
|
|
94
|
+
};
|
|
95
|
+
const openModal = () => {
|
|
96
|
+
open = true;
|
|
97
|
+
focusInput();
|
|
98
|
+
};
|
|
99
|
+
const close = () => {
|
|
100
|
+
open = false;
|
|
101
|
+
query = "";
|
|
102
|
+
results = [];
|
|
103
|
+
active = 0;
|
|
104
|
+
};
|
|
105
|
+
const go = (url) => {
|
|
106
|
+
close();
|
|
107
|
+
router.push(url);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
onMount(() => {
|
|
111
|
+
if (typeof window === "undefined") return;
|
|
112
|
+
window.__otfwOpenSearch = openModal;
|
|
113
|
+
const onKey = (e) => {
|
|
114
|
+
const k = e.key;
|
|
115
|
+
if ((e.metaKey || e.ctrlKey) && k.toLowerCase() === "k") {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
openModal();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!open) return;
|
|
121
|
+
if (k === "Escape") close();
|
|
122
|
+
else if (k === "ArrowDown") {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
active = results.length ? (active + 1) % results.length : 0;
|
|
125
|
+
} else if (k === "ArrowUp") {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
active = results.length ? (active - 1 + results.length) % results.length : 0;
|
|
128
|
+
} else if (k === "Enter" && results[active]) {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
go(results[active].url);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
window.addEventListener("keydown", onKey);
|
|
134
|
+
return () => {
|
|
135
|
+
window.removeEventListener("keydown", onKey);
|
|
136
|
+
if (window.__otfwOpenSearch === openModal) delete window.__otfwOpenSearch;
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Portal to <body>: the navbar (our render parent) sets `backdrop-filter`, which
|
|
141
|
+
// establishes a containing block for fixed-position descendants — so without this the
|
|
142
|
+
// `position: fixed` overlay would collapse to the navbar's box instead of the viewport.
|
|
143
|
+
return (
|
|
144
|
+
<Portal>
|
|
145
|
+
<div
|
|
146
|
+
class={open ? "otfw-search-modal is-open" : "otfw-search-modal"}
|
|
147
|
+
onclick={(e) => {
|
|
148
|
+
if (e.target === e.currentTarget) close();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div class="otfw-search-panel" role="dialog" aria-label="Search docs">
|
|
152
|
+
<div class="otfw-search-field">
|
|
153
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
154
|
+
<circle cx="11" cy="11" r="7" />
|
|
155
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke-linecap="round" />
|
|
156
|
+
</svg>
|
|
157
|
+
<input
|
|
158
|
+
ref={inputRef}
|
|
159
|
+
class="otfw-search-input-field"
|
|
160
|
+
type="search"
|
|
161
|
+
placeholder="Search docs…"
|
|
162
|
+
aria-label="Search docs"
|
|
163
|
+
value={query}
|
|
164
|
+
oninput={(e) => (query = e.target.value)}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<ul class="otfw-search-results">
|
|
168
|
+
{results.map((r, i) => (
|
|
169
|
+
<li>
|
|
170
|
+
<a
|
|
171
|
+
href={r.url}
|
|
172
|
+
class={i === active ? "otfw-search-result is-active" : "otfw-search-result"}
|
|
173
|
+
onclick={(e) => {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
go(r.url);
|
|
176
|
+
}}
|
|
177
|
+
onmouseenter={() => (active = i)}
|
|
178
|
+
>
|
|
179
|
+
{r.crumb ? <span class="otfw-search-result-crumb">{r.crumb}</span> : null}
|
|
180
|
+
<span class="otfw-search-result-title">{r.title}</span>
|
|
181
|
+
{r.section ? <span class="otfw-search-result-section">{r.section}</span> : null}
|
|
182
|
+
<span class="otfw-search-result-excerpt">
|
|
183
|
+
<RawHtml html={r.excerpt} />
|
|
184
|
+
</span>
|
|
185
|
+
</a>
|
|
186
|
+
</li>
|
|
187
|
+
))}
|
|
188
|
+
</ul>
|
|
189
|
+
{query.trim() && !loading && results.length === 0 ? (
|
|
190
|
+
<div class="otfw-search-empty">No results for “{query}”.</div>
|
|
191
|
+
) : null}
|
|
192
|
+
<div class="otfw-search-hint">
|
|
193
|
+
<span class="otfw-search-hint-item">
|
|
194
|
+
<kbd>↑</kbd>
|
|
195
|
+
<kbd>↓</kbd>
|
|
196
|
+
navigate
|
|
197
|
+
</span>
|
|
198
|
+
<span class="otfw-search-hint-item">
|
|
199
|
+
<kbd>↵</kbd>
|
|
200
|
+
select
|
|
201
|
+
</span>
|
|
202
|
+
<span class="otfw-search-hint-item">
|
|
203
|
+
<kbd>esc</kbd>
|
|
204
|
+
close
|
|
205
|
+
</span>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</Portal>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// Search box trigger in the navbar
|
|
2
|
-
//
|
|
1
|
+
// Search box trigger in the navbar. Calls the global opener that the Pagefind-backed
|
|
2
|
+
// <Search> modal installs on `window` (the ⌘K / Ctrl+K shortcut opens it too). Shows
|
|
3
|
+
// the shortcut as a hint; the modifier resolves to ⌘ on Apple platforms, Ctrl
|
|
4
|
+
// elsewhere, on mount.
|
|
5
|
+
|
|
6
|
+
import { onMount } from "@opentf/web";
|
|
3
7
|
|
|
4
8
|
export default function SearchTrigger() {
|
|
9
|
+
let mod = $state("Ctrl");
|
|
10
|
+
|
|
11
|
+
onMount(() => {
|
|
12
|
+
const ua = navigator.platform || navigator.userAgent || "";
|
|
13
|
+
if (/Mac|iPhone|iPad|iPod/.test(ua)) mod = "⌘";
|
|
14
|
+
});
|
|
15
|
+
|
|
5
16
|
const open = () => {
|
|
6
17
|
if (typeof window !== "undefined" && typeof window.__otfwOpenSearch === "function") {
|
|
7
18
|
window.__otfwOpenSearch();
|
|
@@ -15,7 +26,7 @@ export default function SearchTrigger() {
|
|
|
15
26
|
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke-linecap="round" />
|
|
16
27
|
</svg>
|
|
17
28
|
<span class="otfw-search-label">Search</span>
|
|
18
|
-
<kbd class="otfw-search-kbd"
|
|
29
|
+
<kbd class="otfw-search-kbd">{() => `${mod} K`}</kbd>
|
|
19
30
|
</button>
|
|
20
31
|
);
|
|
21
32
|
}
|
package/components/Sidebar.jsx
CHANGED
|
@@ -1,21 +1,123 @@
|
|
|
1
1
|
// Documentation sidebar — renders the build-time nav tree (from
|
|
2
2
|
// `@opentf/web-docs/nav`). Static content (crawlable with JS off); the active link
|
|
3
3
|
// updates reactively in SidebarNode.
|
|
4
|
+
//
|
|
5
|
+
// On desktop it is the sticky left column of the docs grid. On mobile (< 768px) the
|
|
6
|
+
// same element becomes an off-canvas drawer: hidden off-screen, slid in over a
|
|
7
|
+
// backdrop. The drawer is opened by the navbar hamburger, which lives in a different
|
|
8
|
+
// component (and, on the website, a different layout), so the two are coupled the same
|
|
9
|
+
// way Search/SearchTrigger are — through a global toggle installed here:
|
|
10
|
+
//
|
|
11
|
+
// • `window.__otfwToggleSidebar()` / `__otfwCloseSidebar()` — called by the burger.
|
|
12
|
+
// • `document.documentElement[data-otfw-has-sidebar]` — set while a Sidebar is
|
|
13
|
+
// mounted, so the burger shows only on pages that actually have one.
|
|
14
|
+
// • a `otfw:sidebar` CustomEvent (`{ open }`) — lets the burger mirror `aria-expanded`.
|
|
15
|
+
//
|
|
16
|
+
// The drawer closes on navigation, Escape, backdrop click, or a viewport resize up to
|
|
17
|
+
// the desktop breakpoint, and locks body scroll while open.
|
|
18
|
+
|
|
19
|
+
import { onMount, router } from "@opentf/web";
|
|
4
20
|
|
|
5
21
|
import SidebarNode from "./SidebarNode.jsx";
|
|
22
|
+
import NavbarLink from "./NavbarLink.jsx";
|
|
23
|
+
|
|
24
|
+
const DESKTOP_QUERY = "(min-width: 768px)";
|
|
25
|
+
|
|
26
|
+
// How many Sidebars are mounted right now. On SPA navigation a new section's Sidebar can
|
|
27
|
+
// mount *before* the old one unmounts, so the burger's `data-otfw-has-sidebar` flag must
|
|
28
|
+
// be reference-counted — otherwise the departing instance's cleanup would clear the flag
|
|
29
|
+
// the arriving instance just set, and the burger would vanish after navigating.
|
|
30
|
+
let liveSidebars = 0;
|
|
6
31
|
|
|
7
32
|
export default function Sidebar(props) {
|
|
8
33
|
const nav = props.nav || [];
|
|
34
|
+
// Top-level site links (config.nav). On desktop they live in the navbar; on mobile the
|
|
35
|
+
// navbar hides them and the drawer surfaces them above the section tree, so the whole
|
|
36
|
+
// nav is reachable from one place.
|
|
37
|
+
const navLinks = (props.config && props.config.nav) || [];
|
|
38
|
+
let open = $state(false);
|
|
39
|
+
|
|
40
|
+
const close = () => (open = false);
|
|
41
|
+
// Stable per-instance toggle so cleanup can tell whether the global still points at us.
|
|
42
|
+
const toggle = () => (open = !open);
|
|
43
|
+
|
|
44
|
+
onMount(() => {
|
|
45
|
+
const root = document.documentElement;
|
|
46
|
+
liveSidebars += 1;
|
|
47
|
+
root.setAttribute("data-otfw-has-sidebar", "");
|
|
48
|
+
|
|
49
|
+
// The burger (navbar) drives the drawer through these globals — same decoupling as
|
|
50
|
+
// the Search modal's `__otfwOpenSearch`. The latest-mounted Sidebar owns them.
|
|
51
|
+
window.__otfwToggleSidebar = toggle;
|
|
52
|
+
window.__otfwCloseSidebar = close;
|
|
53
|
+
|
|
54
|
+
const onKey = (e) => e.key === "Escape" && close();
|
|
55
|
+
window.addEventListener("keydown", onKey);
|
|
56
|
+
|
|
57
|
+
// If the viewport grows to desktop while the drawer is open, drop the drawer state
|
|
58
|
+
// (CSS turns the element back into the sticky column; this releases the scroll lock
|
|
59
|
+
// and resyncs the burger).
|
|
60
|
+
const mql = window.matchMedia(DESKTOP_QUERY);
|
|
61
|
+
const onDesktop = (e) => e.matches && close();
|
|
62
|
+
mql.addEventListener?.("change", onDesktop);
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
window.removeEventListener("keydown", onKey);
|
|
66
|
+
mql.removeEventListener?.("change", onDesktop);
|
|
67
|
+
document.body.style.removeProperty("overflow");
|
|
68
|
+
root.removeAttribute("data-otfw-sidebar-open");
|
|
69
|
+
// Only drop the flag/globals if we're the last Sidebar and still the owner — an
|
|
70
|
+
// arriving instance may already have taken over during navigation.
|
|
71
|
+
liveSidebars -= 1;
|
|
72
|
+
if (liveSidebars <= 0) {
|
|
73
|
+
liveSidebars = 0;
|
|
74
|
+
root.removeAttribute("data-otfw-has-sidebar");
|
|
75
|
+
}
|
|
76
|
+
if (window.__otfwToggleSidebar === toggle) delete window.__otfwToggleSidebar;
|
|
77
|
+
if (window.__otfwCloseSidebar === close) delete window.__otfwCloseSidebar;
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Reflect open state: lock body scroll, mark the root (for styling), and notify the
|
|
82
|
+
// burger so it can mirror `aria-expanded`. Reading `open` (not writing it) keeps this
|
|
83
|
+
// effect free of the route/resize effects.
|
|
84
|
+
$effect(() => {
|
|
85
|
+
const isOpen = open;
|
|
86
|
+
if (typeof document === "undefined") return;
|
|
87
|
+
document.documentElement.toggleAttribute("data-otfw-sidebar-open", isOpen);
|
|
88
|
+
document.body.style.overflow = isOpen ? "hidden" : "";
|
|
89
|
+
window.dispatchEvent(new CustomEvent("otfw:sidebar", { detail: { open: isOpen } }));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Close on navigation — clicking a drawer link should dismiss it.
|
|
93
|
+
$effect(() => {
|
|
94
|
+
router.pathname; // subscribe
|
|
95
|
+
close();
|
|
96
|
+
});
|
|
9
97
|
|
|
10
98
|
return (
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
99
|
+
<div class="otfw-sidebar-shell">
|
|
100
|
+
<div
|
|
101
|
+
class={open ? "otfw-sidebar-backdrop is-open" : "otfw-sidebar-backdrop"}
|
|
102
|
+
onclick={close}
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
></div>
|
|
105
|
+
<aside id="otfw-sidebar" class={open ? "otfw-sidebar is-open" : "otfw-sidebar"}>
|
|
106
|
+
{navLinks.length ? (
|
|
107
|
+
<nav class="otfw-drawer-links" aria-label="Site">
|
|
108
|
+
{navLinks.map((link) => (
|
|
109
|
+
<NavbarLink link={link} />
|
|
110
|
+
))}
|
|
111
|
+
</nav>
|
|
112
|
+
) : null}
|
|
113
|
+
<nav class="otfw-sidebar-nav" aria-label="Documentation">
|
|
114
|
+
<ul class="otfw-sidebar-list">
|
|
115
|
+
{nav.map((item) => (
|
|
116
|
+
<SidebarNode item={item} />
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</nav>
|
|
120
|
+
</aside>
|
|
121
|
+
</div>
|
|
20
122
|
);
|
|
21
123
|
}
|
|
@@ -14,6 +14,7 @@ export default function SidebarNode(props) {
|
|
|
14
14
|
href={item.path}
|
|
15
15
|
class={router.pathname === item.path ? "otfw-sidebar-link otfw-active" : "otfw-sidebar-link"}
|
|
16
16
|
>
|
|
17
|
+
<span class="otfw-sidebar-dot" aria-hidden="true"></span>
|
|
17
18
|
{item.title}
|
|
18
19
|
</Link>
|
|
19
20
|
) : (
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Hamburger button in the navbar that opens the mobile sidebar drawer. Like
|
|
2
|
+
// SearchTrigger, it is decoupled from the thing it controls: the <Sidebar> installs
|
|
3
|
+
// `window.__otfwToggleSidebar` and emits an `otfw:sidebar` event, so this button works
|
|
4
|
+
// even when the navbar and the sidebar live in different layouts (the website renders
|
|
5
|
+
// the navbar at its root, the sidebar only inside the docs/api sections).
|
|
6
|
+
//
|
|
7
|
+
// The button is hidden by default and revealed by CSS only on small screens and only
|
|
8
|
+
// while a Sidebar is mounted (`:root[data-otfw-has-sidebar]`), so it never shows on a
|
|
9
|
+
// page without a drawer (e.g. the marketing home).
|
|
10
|
+
|
|
11
|
+
import { onMount } from "@opentf/web";
|
|
12
|
+
|
|
13
|
+
export default function SidebarToggle() {
|
|
14
|
+
let expanded = $state(false);
|
|
15
|
+
|
|
16
|
+
onMount(() => {
|
|
17
|
+
// Mirror the drawer's real state so aria-expanded stays correct however it was
|
|
18
|
+
// toggled (button, backdrop, Escape, navigation, resize).
|
|
19
|
+
const onState = (e) => (expanded = !!(e.detail && e.detail.open));
|
|
20
|
+
window.addEventListener("otfw:sidebar", onState);
|
|
21
|
+
return () => window.removeEventListener("otfw:sidebar", onState);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const toggle = () => window.__otfwToggleSidebar?.();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
class="otfw-navbar-icon otfw-navbar-burger"
|
|
29
|
+
onclick={toggle}
|
|
30
|
+
aria-label="Toggle navigation"
|
|
31
|
+
aria-controls="otfw-sidebar"
|
|
32
|
+
aria-expanded={expanded ? "true" : "false"}
|
|
33
|
+
>
|
|
34
|
+
{() =>
|
|
35
|
+
expanded ? (
|
|
36
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
37
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
38
|
+
</svg>
|
|
39
|
+
) : (
|
|
40
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
41
|
+
<path d="M3 6h18M3 12h18M3 18h18" />
|
|
42
|
+
</svg>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Numbered, vertically-connected steps for walkthroughs. Each child heading
|
|
2
|
+
// (`<h3>`/`<h4>`) becomes a numbered step via a CSS counter, so it composes with
|
|
3
|
+
// plain Markdown inside MDX:
|
|
4
|
+
//
|
|
5
|
+
// <Steps>
|
|
6
|
+
// ### Install
|
|
7
|
+
// …
|
|
8
|
+
// ### Run the dev server
|
|
9
|
+
// …
|
|
10
|
+
// </Steps>
|
|
11
|
+
|
|
12
|
+
export default function Steps(props) {
|
|
13
|
+
return <div class="otfw-steps">{props.children}</div>;
|
|
14
|
+
}
|
package/components/Tabs.jsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Generic tabbed panel. `tabs` is an array of `{ label, content }`.
|
|
1
|
+
// Generic tabbed panel. `tabs` is an array of `{ label, content }`. `content` is
|
|
2
|
+
// rendered as-is: a string is plain text, a node is the node. For a code panel with a
|
|
3
|
+
// copy button, pass a `CodeBlock`: `{ label, content: <CodeBlock code="…" /> }`.
|
|
2
4
|
//
|
|
3
|
-
// <Tabs tabs={[{ label: "npm", content:
|
|
5
|
+
// <Tabs tabs={[{ label: "npm", content: <CodeBlock code="npm i …" /> }, …]} />
|
|
4
6
|
|
|
5
7
|
export default function Tabs(props) {
|
|
6
8
|
let active = $state(0);
|