@farming-labs/svelte-theme 0.0.3-beta.2 → 0.0.3-beta.4

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.
@@ -1,91 +1,390 @@
1
1
  <script>
2
2
  /**
3
- * SearchDialogCmd+K search overlay.
4
- * Fetches from /api/docs?query=… (same unified handler as Next.js version).
3
+ * Omni command palette pixel-perfect default for SvelteKit, aligned with
4
+ * website/components/ui/omni-command-palette.tsx. Recents when empty,
5
+ * /api/docs search, keyboard nav, same copy and structure.
5
6
  */
6
- import { onMount } from "svelte";
7
+ import { onMount, onDestroy, tick } from "svelte";
7
8
  import { goto } from "$app/navigation";
8
9
 
10
+ const STORAGE_KEY = "fd:omni:recents";
11
+ const MAX_RECENTS = 8;
12
+ const DEBOUNCE_MS = 120;
13
+ const PLACEHOLDER = "Search documentation…";
14
+
9
15
  let { onclose } = $props();
16
+
10
17
  let query = $state("");
11
- let results = $state([]);
18
+ let currentResults = $state([]);
12
19
  let loading = $state(false);
13
- let inputEl;
14
- let debounceTimer;
20
+ let activeId = $state(null);
21
+ let recentsList = $state([]);
22
+ let inputEl = $state(null);
23
+ let listRef = $state(null);
24
+ let debounceTimer = null;
15
25
 
16
- onMount(() => {
17
- inputEl?.focus();
26
+ let flatItems = $derived.by(() => {
27
+ const q = query.trim();
28
+ if (q && currentResults.length) {
29
+ return currentResults.map((r) => ({
30
+ id: r.url,
31
+ label: r.content,
32
+ url: r.url,
33
+ subtitle: r.description ?? "Page",
34
+ }));
35
+ }
36
+ return recentsList.map((r) => ({
37
+ id: r.id,
38
+ label: r.label,
39
+ url: r.url,
40
+ subtitle: "Recently used",
41
+ }));
18
42
  });
19
43
 
20
- $effect(() => {
21
- clearTimeout(debounceTimer);
22
- if (!query.trim()) {
23
- results = [];
24
- return;
44
+ let showRecents = $derived(!query.trim());
45
+ let showDocs = $derived(!!query.trim() && currentResults.length > 0);
46
+ let showEmpty = $derived(
47
+ query.trim() ? currentResults.length === 0 && !loading : recentsList.length === 0
48
+ );
49
+ let emptyText = $derived(
50
+ query.trim()
51
+ ? "No results found. Try a different query."
52
+ : "Type to search the docs, or browse recent items."
53
+ );
54
+
55
+ function getRecents() {
56
+ if (typeof document === "undefined" || typeof localStorage === "undefined") return [];
57
+ try {
58
+ const raw = localStorage.getItem(STORAGE_KEY);
59
+ return raw ? JSON.parse(raw) : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function saveRecent(entry) {
66
+ if (typeof localStorage === "undefined") return;
67
+ try {
68
+ const recents = getRecents();
69
+ const next = [entry, ...recents.filter((r) => r.id !== entry.id)].slice(0, MAX_RECENTS);
70
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
71
+ } catch {}
72
+ }
73
+
74
+ function loadRecents() {
75
+ recentsList = getRecents();
76
+ }
77
+
78
+ function close() {
79
+ if (typeof document !== "undefined") document.body.style.overflow = "";
80
+ onclose?.();
81
+ }
82
+
83
+ function executeItem(item) {
84
+ saveRecent({ id: item.url, label: item.label ?? item.content ?? item.url, url: item.url });
85
+ if (item.url.startsWith("http")) {
86
+ if (typeof window !== "undefined") window.open(item.url, "_blank", "noopener,noreferrer");
87
+ } else {
88
+ goto(item.url);
25
89
  }
26
- loading = true;
90
+ close();
91
+ }
92
+
93
+ function moveActive(delta) {
94
+ if (!flatItems.length) return;
95
+ const idx = flatItems.findIndex((i) => i.id === activeId);
96
+ const nextIdx = idx < 0 ? 0 : (((idx + delta) % flatItems.length) + flatItems.length) % flatItems.length;
97
+ activeId = flatItems[nextIdx].id;
98
+ scrollActiveIntoView();
99
+ }
100
+
101
+ function executeActive() {
102
+ const idx = activeId != null ? flatItems.findIndex((i) => i.id === activeId) : 0;
103
+ const item = flatItems[idx ?? 0];
104
+ if (item) executeItem(item);
105
+ }
106
+
107
+ function scrollActiveIntoView() {
108
+ tick().then(() => {
109
+ if (typeof document === "undefined" || !listRef) return;
110
+ const node = listRef.querySelector(`[data-id="${activeId}"]`);
111
+ node?.scrollIntoView({ block: "nearest" });
112
+ });
113
+ }
114
+
115
+ function onInput() {
116
+ loadRecents();
117
+ loading = false;
118
+ currentResults = [];
119
+ activeId = null;
120
+ const q = query.trim();
121
+ if (!q) return;
122
+ if (debounceTimer) clearTimeout(debounceTimer);
27
123
  debounceTimer = setTimeout(async () => {
124
+ loading = true;
28
125
  try {
29
- const res = await fetch(`/api/docs?query=${encodeURIComponent(query)}`);
30
- if (res.ok) {
31
- const data = await res.json();
32
- results = data ?? [];
33
- }
126
+ const res = await fetch(`/api/docs?query=${encodeURIComponent(q)}`);
127
+ const data = res.ok ? await res.json() : [];
128
+ currentResults = Array.isArray(data) ? data : [];
129
+ activeId = currentResults[0]?.url ?? null;
34
130
  } catch {
35
- results = [];
131
+ currentResults = [];
36
132
  } finally {
37
133
  loading = false;
38
134
  }
39
- }, 200);
40
- });
41
-
42
- function navigate(url) {
43
- onclose?.();
44
- goto(url);
135
+ }, DEBOUNCE_MS);
45
136
  }
46
137
 
138
+ $effect(() => {
139
+ void query;
140
+ onInput();
141
+ });
142
+
47
143
  function handleKeydown(e) {
48
144
  if (e.key === "Escape") {
49
- onclose?.();
145
+ e.preventDefault();
146
+ close();
147
+ return;
148
+ }
149
+ if (e.key === "ArrowDown") {
150
+ e.preventDefault();
151
+ moveActive(1);
152
+ return;
153
+ }
154
+ if (e.key === "ArrowUp") {
155
+ e.preventDefault();
156
+ moveActive(-1);
157
+ return;
158
+ }
159
+ if (e.key === "Enter") {
160
+ e.preventDefault();
161
+ executeActive();
162
+ }
163
+ }
164
+
165
+ function onOverlayClick() {
166
+ close();
167
+ }
168
+
169
+ function onContentClick(e) {
170
+ e.stopPropagation();
171
+ }
172
+
173
+ function onExternalClick(e, url) {
174
+ e.preventDefault();
175
+ e.stopPropagation();
176
+ try {
177
+ if (typeof window !== "undefined") window.open(url, "_blank", "noopener,noreferrer");
178
+ } catch {
179
+ if (typeof window !== "undefined") window.location.href = url;
50
180
  }
51
181
  }
182
+
183
+ const FileIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>`;
184
+ const ExternalIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
185
+ const ChevronIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>`;
186
+ const EmptyIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>`;
187
+
188
+ onMount(() => {
189
+ loadRecents();
190
+ if (typeof document !== "undefined") document.body.style.overflow = "hidden";
191
+ tick().then(() => inputEl?.focus());
192
+ });
193
+
194
+ onDestroy(() => {
195
+ if (typeof document !== "undefined") document.body.style.overflow = "";
196
+ if (debounceTimer) clearTimeout(debounceTimer);
197
+ });
52
198
  </script>
53
199
 
200
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
54
201
  <!-- svelte-ignore a11y_no_static_element_interactions -->
55
- <div class="fd-search-overlay" onclick={onclose} onkeydown={handleKeydown} role="dialog">
202
+ <div
203
+ class="omni-overlay"
204
+ aria-hidden="true"
205
+ role="presentation"
206
+ onclick={onOverlayClick}
207
+ onkeydown={handleKeydown}
208
+ >
56
209
  <!-- svelte-ignore a11y_click_events_have_key_events -->
57
210
  <!-- svelte-ignore a11y_no_static_element_interactions -->
58
- <div class="fd-search-dialog" onclick={(e) => e.stopPropagation()} role="document">
59
- <div class="fd-search-input-wrap">
60
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
61
- <circle cx="11" cy="11" r="8" />
62
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
63
- </svg>
64
- <input
65
- bind:this={inputEl}
66
- bind:value={query}
67
- class="fd-search-input"
68
- placeholder="Search documentation..."
69
- type="text"
70
- />
71
- <kbd class="fd-search-kbd">ESC</kbd>
211
+ <div
212
+ class="omni-content"
213
+ role="dialog"
214
+ aria-label="Command palette"
215
+ tabindex="-1"
216
+ onclick={onContentClick}
217
+ >
218
+ <div class="omni-header">
219
+ <div class="omni-search-row">
220
+ <span class="omni-search-icon" aria-hidden="true">
221
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
222
+ <circle cx="11" cy="11" r="8" />
223
+ <path d="m21 21-4.3-4.3" />
224
+ </svg>
225
+ </span>
226
+ <input
227
+ bind:this={inputEl}
228
+ bind:value={query}
229
+ type="text"
230
+ class="omni-search-input"
231
+ role="combobox"
232
+ aria-expanded="true"
233
+ aria-controls="omni-listbox"
234
+ aria-activedescendant={activeId ? `omni-item-${activeId}` : undefined}
235
+ placeholder={PLACEHOLDER}
236
+ autocomplete="off"
237
+ onkeydown={handleKeydown}
238
+ />
239
+ <kbd class="omni-kbd">⌘K</kbd>
240
+ <button type="button" aria-label="Close" class="omni-close-btn" onclick={close}>
241
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
242
+ <path d="M18 6 6 18" />
243
+ <path d="m6 6 12 12" />
244
+ </svg>
245
+ </button>
246
+ </div>
72
247
  </div>
73
248
 
74
- <div class="fd-search-results">
249
+ <div
250
+ bind:this={listRef}
251
+ id="omni-listbox"
252
+ class="omni-body"
253
+ role="listbox"
254
+ aria-label="Command results"
255
+ >
75
256
  {#if loading}
76
- <div class="fd-search-empty">Searching...</div>
77
- {:else if query && results.length === 0}
78
- <div class="fd-search-empty">No results found for "{query}"</div>
79
- {:else}
80
- {#each results as result}
81
- <button class="fd-search-result" onclick={() => navigate(result.url)}>
82
- <span class="fd-search-result-title">{result.content}</span>
83
- {#if result.url}
84
- <span class="fd-search-result-url">{result.url}</span>
85
- {/if}
86
- </button>
87
- {/each}
257
+ <div class="omni-loading">
258
+ <svg class="omni-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
259
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
260
+ </svg>
261
+ Fetching results
262
+ </div>
263
+
264
+ {:else if showRecents && recentsList.length > 0}
265
+ <div class="omni-group">
266
+ <div class="omni-group-label">Recent</div>
267
+ <div class="omni-group-items">
268
+ {#each flatItems as item, i}
269
+ {@const active = item.id === activeId || (activeId == null && i === 0)}
270
+ <button
271
+ type="button"
272
+ id="omni-item-{item.id}"
273
+ data-id={item.id}
274
+ class="omni-item"
275
+ class:omni-item-active={active}
276
+ role="option"
277
+ aria-selected={active}
278
+ onmouseenter={() => (activeId = item.id)}
279
+ onclick={() => executeItem(item)}
280
+ >
281
+ <div class="omni-item-icon">
282
+ {@html FileIcon}
283
+ </div>
284
+ <div class="omni-item-text">
285
+ <div class="omni-item-label">{item.label}</div>
286
+ <div class="omni-item-subtitle">{item.subtitle}</div>
287
+ </div>
288
+ <a
289
+ href={item.url}
290
+ class="omni-item-badge"
291
+ title="Open in new tab"
292
+ target="_blank"
293
+ rel="noopener noreferrer"
294
+ aria-hidden="true"
295
+ onclick={(e) => onExternalClick(e, item.url)}
296
+ >
297
+ {@html ExternalIcon}
298
+ </a>
299
+ <span class="omni-item-chevron" aria-hidden="true">
300
+ {@html ChevronIcon}
301
+ </span>
302
+ </button>
303
+ {/each}
304
+ </div>
305
+ </div>
306
+
307
+ {:else if showDocs}
308
+ <div class="omni-group">
309
+ <div class="omni-group-label">Documentation</div>
310
+ <div class="omni-group-items">
311
+ {#each flatItems as item, i}
312
+ {@const active = item.id === activeId || (activeId == null && i === 0)}
313
+ <button
314
+ type="button"
315
+ id="omni-item-{item.id}"
316
+ data-id={item.id}
317
+ class="omni-item"
318
+ class:omni-item-active={active}
319
+ role="option"
320
+ aria-selected={active}
321
+ onmouseenter={() => (activeId = item.id)}
322
+ onclick={() => executeItem(item)}
323
+ >
324
+ <div class="omni-item-icon">
325
+ {@html FileIcon}
326
+ </div>
327
+ <div class="omni-item-text">
328
+ <div class="omni-item-label">{item.label}</div>
329
+ <div class="omni-item-subtitle">{item.subtitle}</div>
330
+ </div>
331
+ <a
332
+ href={item.url}
333
+ class="omni-item-badge"
334
+ title="Open in new tab"
335
+ target="_blank"
336
+ rel="noopener noreferrer"
337
+ aria-hidden="true"
338
+ onclick={(e) => onExternalClick(e, item.url)}
339
+ >
340
+ {@html ExternalIcon}
341
+ </a>
342
+ <span class="omni-item-chevron" aria-hidden="true">
343
+ {@html ChevronIcon}
344
+ </span>
345
+ </button>
346
+ {/each}
347
+ </div>
348
+ </div>
88
349
  {/if}
350
+
351
+ {#if showEmpty}
352
+ <div class="omni-empty">
353
+ <div class="omni-empty-icon">
354
+ {@html EmptyIcon}
355
+ </div>
356
+ {emptyText}
357
+ </div>
358
+ {/if}
359
+ </div>
360
+
361
+ <div class="omni-footer">
362
+ <div class="omni-footer-inner">
363
+ <div class="omni-footer-hints">
364
+ <span class="omni-footer-hint">
365
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
366
+ <polyline points="9 18 15 12 9 6" />
367
+ </svg>
368
+ to select
369
+ </span>
370
+ <span class="omni-footer-hint">
371
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
372
+ <path d="M18 15l-6-6-6 6" />
373
+ </svg>
374
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
375
+ <path d="M6 9l6 6 6-6" />
376
+ </svg>
377
+ to navigate
378
+ </span>
379
+ <span class="omni-footer-hint omni-footer-hint-desktop">
380
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
381
+ <path d="M18 6 6 18" />
382
+ <path d="m6 6 12 12" />
383
+ </svg>
384
+ to close
385
+ </span>
386
+ </div>
387
+ </div>
89
388
  </div>
90
389
  </div>
91
390
  </div>
@@ -1,10 +1,14 @@
1
1
  <script>
2
2
  import { onMount, onDestroy, tick } from "svelte";
3
3
 
4
+ const ACTIVE_ZONE_TOP = 120;
5
+ const HYSTERESIS_PX = 65;
6
+
4
7
  let { items = [], tocStyle = "default" } = $props();
5
8
  let activeIds = $state(new Set());
6
- let observer;
7
- let listEl;
9
+ let listEl = $state(null);
10
+ let lastStableId = null;
11
+ let scrollRafId = 0;
8
12
 
9
13
  let svgPath = $state("");
10
14
  let svgWidth = $state(0);
@@ -14,6 +18,63 @@
14
18
 
15
19
  const isDirectional = $derived(tocStyle === "directional");
16
20
 
21
+ function getDistanceToZone(id) {
22
+ const el = document.getElementById(id);
23
+ if (!el) return Infinity;
24
+ const rect = el.getBoundingClientRect();
25
+ const mid = rect.top + rect.height / 2;
26
+ return Math.abs(mid - ACTIVE_ZONE_TOP);
27
+ }
28
+
29
+ function getClosestId() {
30
+ const ids = items.map((item) => item.url.slice(1));
31
+ let bestId = null;
32
+ let bestDistance = Infinity;
33
+ for (const id of ids) {
34
+ const d = getDistanceToZone(id);
35
+ if (d < bestDistance) {
36
+ bestDistance = d;
37
+ bestId = id;
38
+ }
39
+ }
40
+ return bestId;
41
+ }
42
+
43
+ function isInView(id) {
44
+ const el = document.getElementById(id);
45
+ if (!el) return false;
46
+ const rect = el.getBoundingClientRect();
47
+ return rect.top < window.innerHeight && rect.bottom > 0;
48
+ }
49
+
50
+ function updateActiveFromScroll() {
51
+ scrollRafId = 0;
52
+ const newId = getClosestId();
53
+ if (!newId) {
54
+ activeIds = new Set();
55
+ return;
56
+ }
57
+ if (lastStableId === null) {
58
+ lastStableId = newId;
59
+ activeIds = new Set([newId]);
60
+ return;
61
+ }
62
+ if (newId === lastStableId) {
63
+ activeIds = new Set([newId]);
64
+ return;
65
+ }
66
+ const newDist = getDistanceToZone(newId);
67
+ const currentDist = getDistanceToZone(lastStableId);
68
+ const switchToNew = newDist <= currentDist - HYSTERESIS_PX || !isInView(lastStableId);
69
+ if (switchToNew) lastStableId = newId;
70
+ activeIds = new Set([lastStableId]);
71
+ }
72
+
73
+ function scheduleActiveUpdate() {
74
+ if (scrollRafId !== 0) return;
75
+ scrollRafId = requestAnimationFrame(updateActiveFromScroll);
76
+ }
77
+
17
78
  function getItemOffset(depth) {
18
79
  if (depth <= 2) return 14;
19
80
  if (depth === 3) return 26;
@@ -82,15 +143,6 @@
82
143
  return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
83
144
  }
84
145
 
85
- function observeHeadings() {
86
- if (!observer) return;
87
- observer.disconnect();
88
- for (const item of items) {
89
- const el = document.querySelector(item.url);
90
- if (el) observer.observe(el);
91
- }
92
- }
93
-
94
146
  function isActive(item) {
95
147
  return activeIds.has(item.url.slice(1));
96
148
  }
@@ -124,33 +176,32 @@
124
176
  }
125
177
 
126
178
  onMount(() => {
127
- observer = new IntersectionObserver(
128
- (entries) => {
129
- for (const entry of entries) {
130
- if (entry.isIntersecting) {
131
- activeIds.add(entry.target.id);
132
- } else {
133
- activeIds.delete(entry.target.id);
134
- }
135
- }
136
- activeIds = new Set(activeIds);
137
- },
138
- { rootMargin: "-80px 0px -80% 0px" }
139
- );
140
- observeHeadings();
179
+ const onScroll = () => scheduleActiveUpdate();
180
+ window.addEventListener("scroll", onScroll, { passive: true, capture: true });
181
+ document.addEventListener("scroll", onScroll, { passive: true, capture: true });
141
182
 
142
- if (isDirectional) {
143
- tick().then(() => {
183
+ tick().then(() => {
184
+ const id = getClosestId();
185
+ if (id) {
186
+ lastStableId = id;
187
+ activeIds = new Set([id]);
188
+ }
189
+ if (isDirectional) {
144
190
  buildSvgPath();
145
191
  calcThumb();
146
- });
147
- }
192
+ }
193
+ });
194
+
195
+ return () => {
196
+ window.removeEventListener("scroll", onScroll, { capture: true });
197
+ document.removeEventListener("scroll", onScroll, { capture: true });
198
+ if (scrollRafId) cancelAnimationFrame(scrollRafId);
199
+ };
148
200
  });
149
201
 
150
202
  $effect(() => {
151
203
  void items;
152
- observeHeadings();
153
- if (isDirectional) {
204
+ if (isDirectional && listEl) {
154
205
  tick().then(() => {
155
206
  buildSvgPath();
156
207
  calcThumb();
@@ -165,9 +216,7 @@
165
216
  }
166
217
  });
167
218
 
168
- onDestroy(() => {
169
- observer?.disconnect();
170
- });
219
+ onDestroy(() => {});
171
220
  </script>
172
221
 
173
222
  <div class="fd-toc-inner" class:fd-toc-directional={isDirectional}>
@@ -24,7 +24,7 @@ const ColorfulUIDefaults = {
24
24
  layout: {
25
25
  contentWidth: 768,
26
26
  sidebarWidth: 260,
27
- toc: { enabled: true, depth: 3, style: "directional" },
27
+ toc: { enabled: true, depth: 3, style: "default" },
28
28
  header: { height: 56, sticky: true },
29
29
  },
30
30
  components: {