@opentf/web-docs 0.1.0 → 0.2.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.
Files changed (43) hide show
  1. package/build/blog-posts-plugin.js +127 -0
  2. package/build/docs-nav-plugin.js +17 -10
  3. package/build/feed.js +78 -0
  4. package/build/index.js +4 -0
  5. package/build/last-updated-plugin.js +98 -0
  6. package/build/last-updated.js +45 -0
  7. package/build/pagefind.js +58 -0
  8. package/build/reading-time.js +16 -0
  9. package/components/BlogLayout.jsx +77 -0
  10. package/components/Breadcrumbs.jsx +30 -27
  11. package/components/Callout.jsx +7 -7
  12. package/components/Card.jsx +21 -0
  13. package/components/Cards.jsx +11 -0
  14. package/components/CodeBlock.jsx +39 -0
  15. package/components/DocsLayout.jsx +66 -6
  16. package/components/LastUpdated.jsx +22 -0
  17. package/components/NavIcon.jsx +34 -0
  18. package/components/Navbar.jsx +34 -22
  19. package/components/NavbarLink.jsx +33 -0
  20. package/components/Pagination.jsx +37 -33
  21. package/components/PostBanner.jsx +25 -0
  22. package/components/PostCard.jsx +19 -0
  23. package/components/PostList.jsx +17 -0
  24. package/components/PostMeta.jsx +28 -0
  25. package/components/ReadingTime.jsx +6 -0
  26. package/components/Search.jsx +211 -0
  27. package/components/SearchTrigger.jsx +14 -3
  28. package/components/Sidebar.jsx +111 -9
  29. package/components/SidebarNode.jsx +1 -0
  30. package/components/SidebarToggle.jsx +47 -0
  31. package/components/Steps.jsx +14 -0
  32. package/components/Tabs.jsx +4 -2
  33. package/components/ThemeToggle.jsx +150 -25
  34. package/components/Tooltip.jsx +19 -0
  35. package/components/format.js +11 -0
  36. package/config.js +25 -1
  37. package/index.js +29 -2
  38. package/nav-virtual.js +6 -4
  39. package/package.json +15 -1
  40. package/posts-virtual.js +17 -0
  41. package/theme/index.css +1034 -58
  42. package/updated-virtual.js +9 -0
  43. package/components/CodeGroup.jsx +0 -28
@@ -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 (⌘K). Inert in Phase 1 it calls a global
2
- // opener that the Phase 2 Pagefind-backed <Search> modal installs on `window`.
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">⌘K</kbd>
29
+ <kbd class="otfw-search-kbd">{() => `${mod} K`}</kbd>
19
30
  </button>
20
31
  );
21
32
  }
@@ -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
- <aside class="otfw-sidebar">
12
- <nav class="otfw-sidebar-nav" aria-label="Documentation">
13
- <ul class="otfw-sidebar-list">
14
- {nav.map((item) => (
15
- <SidebarNode item={item} />
16
- ))}
17
- </ul>
18
- </nav>
19
- </aside>
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
+ }
@@ -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);
@@ -1,39 +1,164 @@
1
- // Light/dark toggle. Sets `data-theme` on <html>, persists to localStorage, and
2
- // respects the OS preference on first load. Pair with the no-flash inline script in
3
- // the docs shell index.html so the initial paint already has the right theme.
1
+ // Theme switcher: a subtle ghost icon button that opens a Light / Dark / System menu.
2
+ //
3
+ // The chosen *mode* is persisted to localStorage("theme") as "light" | "dark" |
4
+ // "system" (default "system"). The applied theme is the resolved value — in System
5
+ // mode it follows the OS and live-updates when the OS preference flips. The trigger
6
+ // shows the mode's icon; in System mode it shows the monitor icon plus a small badge of
7
+ // the currently resolved icon (sun/moon), so you can see both the choice and the result.
8
+ //
9
+ // Pair with the no-flash inline script in index.html, which must resolve "system"/none
10
+ // to a concrete theme on first paint.
4
11
 
5
12
  import { onMount } from "@opentf/web";
6
13
 
14
+ import Tooltip from "./Tooltip.jsx";
15
+
16
+ const STORAGE_KEY = "theme";
17
+ const prefersDark = () =>
18
+ typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
19
+
7
20
  export default function ThemeToggle() {
8
- let theme = $state("light");
21
+ let mode = $state("system"); // "light" | "dark" | "system"
22
+ let resolved = $state("light"); // "light" | "dark"
23
+ let openMenu = $state(false);
9
24
 
10
- onMount(() => {
11
- const saved =
12
- localStorage.getItem("theme") ||
13
- (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
14
- theme = saved;
15
- document.documentElement.setAttribute("data-theme", theme);
16
- });
25
+ const rootRef = $ref();
17
26
 
18
- const toggle = () => {
19
- theme = theme === "light" ? "dark" : "light";
20
- document.documentElement.setAttribute("data-theme", theme);
21
- localStorage.setItem("theme", theme);
27
+ const resolve = (m) => (m === "system" ? (prefersDark() ? "dark" : "light") : m);
28
+
29
+ const apply = (m) => {
30
+ resolved = resolve(m);
31
+ if (typeof document !== "undefined") {
32
+ document.documentElement.setAttribute("data-theme", resolved);
33
+ }
34
+ };
35
+
36
+ const setMode = (m) => {
37
+ mode = m;
38
+ openMenu = false;
39
+ try {
40
+ localStorage.setItem(STORAGE_KEY, m);
41
+ } catch {}
42
+ apply(m);
22
43
  };
23
44
 
45
+ onMount(() => {
46
+ const saved = (() => {
47
+ try {
48
+ return localStorage.getItem(STORAGE_KEY);
49
+ } catch {
50
+ return null;
51
+ }
52
+ })();
53
+ mode = saved === "light" || saved === "dark" || saved === "system" ? saved : "system";
54
+ apply(mode);
55
+
56
+ // Follow the OS while in System mode.
57
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
58
+ const onOS = () => mode === "system" && apply("system");
59
+ mql.addEventListener?.("change", onOS);
60
+
61
+ // Close the menu on outside click / Escape.
62
+ const onDocClick = (e) => {
63
+ if (openMenu && rootRef && !rootRef.contains(e.target)) openMenu = false;
64
+ };
65
+ const onKey = (e) => e.key === "Escape" && (openMenu = false);
66
+ document.addEventListener("click", onDocClick);
67
+ document.addEventListener("keydown", onKey);
68
+
69
+ return () => {
70
+ mql.removeEventListener?.("change", onOS);
71
+ document.removeEventListener("click", onDocClick);
72
+ document.removeEventListener("keydown", onKey);
73
+ };
74
+ });
75
+
24
76
  return (
25
- <button class="otfw-theme-toggle" onclick={toggle} aria-label="Toggle color theme">
77
+ <div class="otfw-theme" ref={rootRef}>
78
+ <Tooltip text="Change theme">
79
+ <button
80
+ class="otfw-navbar-icon otfw-theme-trigger"
81
+ onclick={(e) => {
82
+ e.stopPropagation();
83
+ openMenu = !openMenu;
84
+ }}
85
+ aria-label="Change theme"
86
+ aria-haspopup="menu"
87
+ aria-expanded={openMenu ? "true" : "false"}
88
+ >
89
+ {() =>
90
+ mode === "system" ? (
91
+ <span class="otfw-theme-trigger-system">
92
+ <MonitorIcon />
93
+ <span class="otfw-theme-trigger-badge">
94
+ {resolved === "dark" ? <MoonIcon small /> : <SunIcon small />}
95
+ </span>
96
+ </span>
97
+ ) : mode === "dark" ? (
98
+ <MoonIcon />
99
+ ) : (
100
+ <SunIcon />
101
+ )
102
+ }
103
+ </button>
104
+ </Tooltip>
105
+
26
106
  {() =>
27
- theme === "light" ? (
28
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
29
- <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
30
- </svg>
31
- ) : (
32
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
33
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 5a7 7 0 100 14 7 7 0 000-14z" />
34
- </svg>
35
- )
107
+ openMenu ? (
108
+ <div class="otfw-theme-menu" role="menu">
109
+ <MenuItem active={mode === "light"} onclick={() => setMode("light")}>
110
+ <SunIcon small /> Light
111
+ </MenuItem>
112
+ <MenuItem active={mode === "dark"} onclick={() => setMode("dark")}>
113
+ <MoonIcon small /> Dark
114
+ </MenuItem>
115
+ <MenuItem active={mode === "system"} onclick={() => setMode("system")}>
116
+ <MonitorIcon small /> System
117
+ </MenuItem>
118
+ </div>
119
+ ) : null
36
120
  }
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function MenuItem(props) {
126
+ return (
127
+ <button
128
+ class={props.active ? "otfw-theme-item is-active" : "otfw-theme-item"}
129
+ role="menuitemradio"
130
+ aria-checked={props.active ? "true" : "false"}
131
+ onclick={props.onclick}
132
+ >
133
+ {props.children}
37
134
  </button>
38
135
  );
39
136
  }
137
+
138
+ function SunIcon(props) {
139
+ const s = props.small ? 15 : 17;
140
+ return (
141
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
142
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 5a7 7 0 100 14 7 7 0 000-14z" />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ function MoonIcon(props) {
148
+ const s = props.small ? 15 : 17;
149
+ return (
150
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
151
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
152
+ </svg>
153
+ );
154
+ }
155
+
156
+ function MonitorIcon(props) {
157
+ const s = props.small ? 15 : 17;
158
+ return (
159
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
160
+ <rect x="2" y="3" width="20" height="14" rx="2" />
161
+ <path d="M8 21h8M12 17v4" />
162
+ </svg>
163
+ );
164
+ }
@@ -0,0 +1,19 @@
1
+ // A small hover/focus tooltip. CSS-driven (no JS state) — wrap any element and pass
2
+ // `text`; the bubble shows on hover or keyboard focus of the wrapped content.
3
+ //
4
+ // <Tooltip text="Change theme"><button>…</button></Tooltip>
5
+ //
6
+ // `placement` is "bottom" (default) or "top". The bubble is aria-hidden decorative
7
+ // chrome; give the wrapped control its own `aria-label` for assistive tech.
8
+
9
+ export default function Tooltip(props) {
10
+ const placement = props.placement === "top" ? "otfw-tooltip-top" : "otfw-tooltip-bottom";
11
+ return (
12
+ <span class={`otfw-tooltip ${placement}`}>
13
+ {props.children}
14
+ <span class="otfw-tooltip-bubble" role="tooltip" aria-hidden="true">
15
+ {props.text}
16
+ </span>
17
+ </span>
18
+ );
19
+ }