@farming-labs/nuxt-theme 0.0.3-beta.2 → 0.0.3-beta.3

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,95 +1,374 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, onMounted } from "vue";
2
+ /**
3
+ * Omni command palette — same behavior as website/components/ui/omni-command-palette.tsx
4
+ * and Astro SearchDialog: recents when empty, /api/docs search, keyboard nav, click to navigate.
5
+ */
6
+ import { ref, computed, watch, onMounted, nextTick } from "vue";
7
+
8
+ const STORAGE_KEY = "fd:omni:recents";
9
+ const MAX_RECENTS = 8;
10
+ const DEBOUNCE_MS = 150;
3
11
 
4
12
  const emit = defineEmits<{ (e: "close"): void }>();
5
13
 
6
14
  const query = ref("");
7
- const results = ref<{ content: string; url?: string; description?: string }[]>([]);
15
+ const currentResults = ref<{ content: string; url: string; description?: string }[]>([]);
8
16
  const loading = ref(false);
17
+ const activeIndex = ref(0);
9
18
  const inputEl = ref<HTMLInputElement | null>(null);
19
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
10
20
 
11
- let debounceTimer: ReturnType<typeof setTimeout>;
21
+ interface RecentEntry {
22
+ id: string;
23
+ label: string;
24
+ url: string;
25
+ }
12
26
 
13
- watch(query, (q) => {
14
- clearTimeout(debounceTimer);
15
- if (!q.trim()) {
16
- results.value = [];
17
- return;
27
+ function getRecents(): RecentEntry[] {
28
+ if (typeof localStorage === "undefined") return [];
29
+ try {
30
+ const raw = localStorage.getItem(STORAGE_KEY);
31
+ return raw ? JSON.parse(raw) : [];
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ function saveRecent(entry: RecentEntry) {
38
+ try {
39
+ const recents = getRecents();
40
+ const next = [entry, ...recents.filter((r) => r.id !== entry.id)].slice(0, MAX_RECENTS);
41
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
42
+ } catch {}
43
+ }
44
+
45
+ const recentsList = ref<RecentEntry[]>([]);
46
+
47
+ const allItems = computed(() => {
48
+ const q = query.value.trim();
49
+ if (q && currentResults.value.length) return currentResults.value.map((r) => ({ id: r.url, label: r.content, url: r.url, subtitle: r.description ?? "Page" }));
50
+ return recentsList.value.map((r) => ({ id: r.id, label: r.label, url: r.url, subtitle: "Recently viewed" }));
51
+ });
52
+
53
+ const showRecents = computed(() => !query.value.trim());
54
+ const showDocs = computed(() => !!query.value.trim() && currentResults.value.length > 0);
55
+ const showEmpty = computed(() => {
56
+ if (query.value.trim()) return currentResults.value.length === 0 && !loading.value;
57
+ return recentsList.value.length === 0;
58
+ });
59
+ const emptyText = computed(() =>
60
+ query.value.trim() ? "No results found. Try a different query." : "Type to search the docs, or browse recent items.",
61
+ );
62
+
63
+ function loadRecents() {
64
+ recentsList.value = getRecents();
65
+ }
66
+
67
+ function close() {
68
+ if (typeof document !== "undefined") document.body.style.overflow = "";
69
+ emit("close");
70
+ }
71
+
72
+ function executeItem(item: { url: string; label?: string; content?: string }) {
73
+ const label = item.label ?? item.content ?? item.url;
74
+ saveRecent({ id: item.url, label, url: item.url });
75
+ if (item.url.startsWith("http")) {
76
+ window.open(item.url, "_blank", "noopener,noreferrer");
77
+ } else {
78
+ navigateTo(item.url);
18
79
  }
19
- loading.value = true;
80
+ close();
81
+ }
82
+
83
+ function moveActive(delta: number) {
84
+ const items = allItems.value;
85
+ if (!items.length) return;
86
+ activeIndex.value = activeIndex.value + delta;
87
+ if (activeIndex.value < 0) activeIndex.value = items.length - 1;
88
+ if (activeIndex.value >= items.length) activeIndex.value = 0;
89
+ scrollActiveIntoView();
90
+ }
91
+
92
+ function executeActive() {
93
+ const items = allItems.value;
94
+ const item = items[activeIndex.value];
95
+ if (item) executeItem(item);
96
+ }
97
+
98
+ function scrollActiveIntoView() {
99
+ nextTick(() => {
100
+ const listbox = document.getElementById("fd-omni-listbox");
101
+ if (!listbox) return;
102
+ const q = query.value.trim();
103
+ const container = q ? document.getElementById("fd-omni-docs-items") : document.getElementById("fd-omni-recent-items");
104
+ if (!container) return;
105
+ const items = container.querySelectorAll(".omni-item[data-url]");
106
+ if (items[activeIndex.value]) (items[activeIndex.value] as HTMLElement).scrollIntoView({ block: "nearest" });
107
+ });
108
+ }
109
+
110
+ function onInput() {
111
+ loadRecents();
112
+ const q = query.value.trim();
113
+ loading.value = false;
114
+ currentResults.value = [];
115
+ activeIndex.value = 0;
116
+ if (!q) return;
117
+ if (debounceTimer) clearTimeout(debounceTimer);
20
118
  debounceTimer = setTimeout(async () => {
119
+ loading.value = true;
21
120
  try {
22
121
  const res = await fetch(`/api/docs?query=${encodeURIComponent(q)}`);
23
- if (res.ok) {
24
- const data = await res.json();
25
- results.value = data ?? [];
26
- }
122
+ const data = res.ok ? await res.json() : [];
123
+ currentResults.value = Array.isArray(data) ? data : [];
124
+ activeIndex.value = 0;
27
125
  } catch {
28
- results.value = [];
126
+ currentResults.value = [];
29
127
  } finally {
30
128
  loading.value = false;
31
129
  }
32
- }, 200);
33
- });
130
+ }, DEBOUNCE_MS);
131
+ }
34
132
 
35
- onMounted(() => {
36
- inputEl.value?.focus();
37
- });
133
+ watch(query, onInput);
38
134
 
39
- function navigate(url: string) {
40
- emit("close");
41
- if (typeof navigateTo === "function") {
42
- navigateTo(url);
43
- } else {
135
+ function handleKeydown(e: KeyboardEvent) {
136
+ if (e.key === "Escape") {
137
+ e.preventDefault();
138
+ close();
139
+ return;
140
+ }
141
+ if (e.key === "ArrowDown") {
142
+ e.preventDefault();
143
+ moveActive(1);
144
+ return;
145
+ }
146
+ if (e.key === "ArrowUp") {
147
+ e.preventDefault();
148
+ moveActive(-1);
149
+ return;
150
+ }
151
+ if (e.key === "Enter") {
152
+ e.preventDefault();
153
+ executeActive();
154
+ }
155
+ }
156
+
157
+ function onOverlayClick() {
158
+ close();
159
+ }
160
+
161
+ function onContentClick(e: MouseEvent) {
162
+ e.stopPropagation();
163
+ }
164
+
165
+ function onRowClick(item: { url: string; label?: string; content?: string }) {
166
+ executeItem(item);
167
+ }
168
+
169
+ function onExternalClick(e: Event, url: string) {
170
+ e.preventDefault();
171
+ e.stopPropagation();
172
+ try {
173
+ window.open(url, "_blank", "noopener,noreferrer");
174
+ } catch {
44
175
  window.location.href = url;
45
176
  }
46
177
  }
47
178
 
48
- function handleKeydown(e: KeyboardEvent) {
49
- if (e.key === "Escape") emit("close");
179
+ function onRowMouseEnter(container: "recent" | "docs", index: number) {
180
+ activeIndex.value = index;
50
181
  }
182
+
183
+ onMounted(() => {
184
+ loadRecents();
185
+ if (typeof document !== "undefined") document.body.style.overflow = "hidden";
186
+ nextTick(() => {
187
+ inputEl.value?.focus();
188
+ });
189
+ });
51
190
  </script>
52
191
 
53
192
  <template>
54
- <div class="fd-search-overlay" role="dialog" @click.self="emit('close')" @keydown="handleKeydown">
55
- <div class="fd-search-dialog" role="document" @click.stop>
56
- <div class="fd-search-input-wrap">
57
- <svg
58
- width="18"
59
- height="18"
60
- viewBox="0 0 24 24"
61
- fill="none"
62
- stroke="currentColor"
63
- stroke-width="2"
64
- >
65
- <circle cx="11" cy="11" r="8" />
66
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
67
- </svg>
68
- <input
69
- ref="inputEl"
70
- v-model="query"
71
- class="fd-search-input"
72
- placeholder="Search documentation..."
73
- type="text"
74
- />
75
- <kbd class="fd-search-kbd">ESC</kbd>
193
+ <div class="omni-overlay" aria-hidden="true" @click="onOverlayClick" @keydown="handleKeydown">
194
+ <div
195
+ class="omni-content"
196
+ role="dialog"
197
+ aria-label="Search documentation"
198
+ @click="onContentClick"
199
+ >
200
+ <div class="omni-header">
201
+ <div class="omni-search-row">
202
+ <span class="omni-search-icon" aria-hidden="true">
203
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
204
+ <circle cx="11" cy="11" r="8" />
205
+ <path d="m21 21-4.3-4.3" />
206
+ </svg>
207
+ </span>
208
+ <input
209
+ ref="inputEl"
210
+ v-model="query"
211
+ type="text"
212
+ class="omni-search-input"
213
+ role="combobox"
214
+ aria-expanded="true"
215
+ aria-controls="fd-omni-listbox"
216
+ placeholder="Search documentation…"
217
+ autocomplete="off"
218
+ @keydown="handleKeydown"
219
+ />
220
+ <kbd class="omni-kbd">⌘K</kbd>
221
+ <button type="button" aria-label="Close" class="omni-close-btn" @click="close">
222
+ <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">
223
+ <path d="M18 6 6 18" />
224
+ <path d="m6 6 12 12" />
225
+ </svg>
226
+ </button>
227
+ </div>
228
+ </div>
229
+
230
+ <div id="fd-omni-listbox" class="omni-body" role="listbox" aria-label="Search results">
231
+ <div v-if="loading" class="omni-loading">
232
+ <svg class="omni-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
233
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
234
+ </svg>
235
+ Searching…
236
+ </div>
237
+
238
+ <div v-else-if="showRecents && recentsList.length" id="fd-omni-recent-group" class="omni-group">
239
+ <div class="omni-group-label">Recent</div>
240
+ <div id="fd-omni-recent-items" class="omni-group-items">
241
+ <div
242
+ v-for="(r, i) in recentsList"
243
+ :key="r.id"
244
+ class="omni-item"
245
+ :class="{ 'omni-item-active': showRecents && i === activeIndex }"
246
+ :data-url="r.url"
247
+ role="option"
248
+ :aria-selected="showRecents && i === activeIndex"
249
+ tabindex="-1"
250
+ @click="onRowClick(r)"
251
+ @mouseenter="onRowMouseEnter('recent', i)"
252
+ >
253
+ <div class="omni-item-icon">
254
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
255
+ <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
256
+ <path d="M14 2v4a2 2 0 0 0 2 2h4" />
257
+ </svg>
258
+ </div>
259
+ <div class="omni-item-text">
260
+ <div class="omni-item-label">{{ r.label }}</div>
261
+ <div class="omni-item-subtitle">Recently viewed</div>
262
+ </div>
263
+ <a
264
+ :href="r.url"
265
+ class="omni-item-ext"
266
+ title="Open in new tab"
267
+ target="_blank"
268
+ rel="noopener noreferrer"
269
+ @click.prevent="onExternalClick($event, r.url)"
270
+ >
271
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
272
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
273
+ <polyline points="15 3 21 3 21 9" />
274
+ <line x1="10" y1="14" x2="21" y2="3" />
275
+ </svg>
276
+ </a>
277
+ <span class="omni-item-chevron" aria-hidden="true">
278
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
279
+ <polyline points="9 18 15 12 9 6" />
280
+ </svg>
281
+ </span>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <div v-if="showDocs" id="fd-omni-docs-group" class="omni-group">
287
+ <div class="omni-group-label">Documentation</div>
288
+ <div id="fd-omni-docs-items" class="omni-group-items">
289
+ <div
290
+ v-for="(r, i) in currentResults"
291
+ :key="r.url"
292
+ class="omni-item"
293
+ :class="{ 'omni-item-active': showDocs && i === activeIndex }"
294
+ :data-url="r.url"
295
+ role="option"
296
+ :aria-selected="showDocs && i === activeIndex"
297
+ tabindex="-1"
298
+ @click="onRowClick(r)"
299
+ @mouseenter="onRowMouseEnter('docs', i)"
300
+ >
301
+ <div class="omni-item-icon">
302
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
303
+ <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
304
+ <path d="M14 2v4a2 2 0 0 0 2 2h4" />
305
+ </svg>
306
+ </div>
307
+ <div class="omni-item-text">
308
+ <div class="omni-item-label">{{ r.content }}</div>
309
+ <div class="omni-item-subtitle">{{ r.description ?? "Page" }}</div>
310
+ </div>
311
+ <a
312
+ :href="r.url"
313
+ class="omni-item-ext"
314
+ title="Open in new tab"
315
+ target="_blank"
316
+ rel="noopener noreferrer"
317
+ @click.prevent="onExternalClick($event, r.url)"
318
+ >
319
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
320
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
321
+ <polyline points="15 3 21 3 21 9" />
322
+ <line x1="10" y1="14" x2="21" y2="3" />
323
+ </svg>
324
+ </a>
325
+ <span class="omni-item-chevron" aria-hidden="true">
326
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
327
+ <polyline points="9 18 15 12 9 6" />
328
+ </svg>
329
+ </span>
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ <div v-if="showEmpty" class="omni-empty">
335
+ <div class="omni-empty-icon">
336
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
337
+ <circle cx="12" cy="12" r="10" />
338
+ <path d="M12 6v6l4 2" />
339
+ </svg>
340
+ </div>
341
+ <span>{{ emptyText }}</span>
342
+ </div>
76
343
  </div>
77
344
 
78
- <div class="fd-search-results">
79
- <div v-if="loading" class="fd-search-empty">Searching...</div>
80
- <div v-else-if="query && results.length === 0" class="fd-search-empty">
81
- No results found for "{{ query }}"
345
+ <div class="omni-footer">
346
+ <div class="omni-footer-inner">
347
+ <div class="omni-footer-hints">
348
+ <span class="omni-footer-hint">
349
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
350
+ <polyline points="9 18 15 12 9 6" />
351
+ </svg>
352
+ to select
353
+ </span>
354
+ <span class="omni-footer-hint">
355
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
356
+ <path d="M18 15l-6-6-6 6" />
357
+ </svg>
358
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
359
+ <path d="M6 9l6 6 6-6" />
360
+ </svg>
361
+ to navigate
362
+ </span>
363
+ <span class="omni-footer-hint omni-footer-hint-desktop">
364
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
365
+ <path d="M18 6 6 18" />
366
+ <path d="m6 6 12 12" />
367
+ </svg>
368
+ to close
369
+ </span>
370
+ </div>
82
371
  </div>
83
- <button
84
- v-for="result in results"
85
- v-else
86
- :key="result.url ?? result.content"
87
- class="fd-search-result"
88
- @click="result.url && navigate(result.url)"
89
- >
90
- <span class="fd-search-result-title">{{ result.content }}</span>
91
- <span v-if="result.url" class="fd-search-result-url">{{ result.url }}</span>
92
- </button>
93
372
  </div>
94
373
  </div>
95
374
  </div>
@@ -22,7 +22,38 @@ const thumbTop = ref(0);
22
22
  const thumbHeight = ref(0);
23
23
  const svgPath = ref("");
24
24
 
25
+ const ACTIVE_ZONE_TOP = 120; // px from top of viewport — heading "active" when near this line
26
+
25
27
  let observer: IntersectionObserver | null = null;
28
+ let scrollRafId = 0;
29
+
30
+ function getActiveIdFromScroll(): Set<string> {
31
+ const ids = items.value.map((item) => item.url.slice(1));
32
+ let bestId: string | null = null;
33
+ let bestDistance = Infinity;
34
+ for (const id of ids) {
35
+ const el = document.getElementById(id);
36
+ if (!el) continue;
37
+ const rect = el.getBoundingClientRect();
38
+ const mid = rect.top + rect.height / 2;
39
+ const distance = Math.abs(mid - ACTIVE_ZONE_TOP);
40
+ if (distance < bestDistance) {
41
+ bestDistance = distance;
42
+ bestId = id;
43
+ }
44
+ }
45
+ return bestId ? new Set([bestId]) : new Set();
46
+ }
47
+
48
+ function updateActiveFromScroll() {
49
+ scrollRafId = 0;
50
+ activeIds.value = getActiveIdFromScroll();
51
+ }
52
+
53
+ function scheduleActiveUpdate() {
54
+ if (scrollRafId !== 0) return;
55
+ scrollRafId = requestAnimationFrame(updateActiveFromScroll);
56
+ }
26
57
 
27
58
  function getItemOffset(depth: number): number {
28
59
  if (depth <= 2) return 14;
@@ -155,41 +186,49 @@ function maskSvgUrl(): string {
155
186
  return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
156
187
  }
157
188
 
189
+ function onScroll() {
190
+ scheduleActiveUpdate();
191
+ }
192
+
158
193
  onMounted(() => {
159
194
  observer = new IntersectionObserver(
160
- (entries) => {
161
- const next = new Set(activeIds.value);
162
- for (const entry of entries) {
163
- if (entry.isIntersecting) {
164
- next.add(entry.target.id);
165
- } else {
166
- next.delete(entry.target.id);
167
- }
168
- }
169
- activeIds.value = next;
195
+ () => {
196
+ scheduleActiveUpdate();
170
197
  },
171
- { rootMargin: "-80px 0px -80% 0px" },
198
+ { rootMargin: "-80px 0px -60% 0px" },
172
199
  );
173
200
  observeHeadings();
201
+ window.addEventListener("scroll", onScroll, { passive: true, capture: true });
202
+ document.addEventListener("scroll", onScroll, { passive: true, capture: true });
174
203
 
175
- if (isDirectional.value) {
176
- nextTick(() => {
204
+ nextTick(() => {
205
+ activeIds.value = getActiveIdFromScroll();
206
+ if (isDirectional.value) {
177
207
  buildSvgPath();
178
208
  calcThumb();
179
- });
180
- }
209
+ }
210
+ });
211
+ // Run again after content has finished rendering (e.g. after async slot)
212
+ setTimeout(() => {
213
+ activeIds.value = getActiveIdFromScroll();
214
+ if (isDirectional.value) {
215
+ buildSvgPath();
216
+ calcThumb();
217
+ }
218
+ }, 150);
181
219
  });
182
220
 
183
221
  watch(
184
222
  items,
185
223
  () => {
186
224
  observeHeadings();
187
- if (isDirectional.value) {
188
- nextTick(() => {
225
+ nextTick(() => {
226
+ activeIds.value = getActiveIdFromScroll();
227
+ if (isDirectional.value) {
189
228
  buildSvgPath();
190
229
  calcThumb();
191
- });
192
- }
230
+ }
231
+ });
193
232
  },
194
233
  { flush: "post" },
195
234
  );
@@ -214,6 +253,10 @@ watch(isDirectional, (val) => {
214
253
  });
215
254
 
216
255
  onUnmounted(() => {
256
+ if (scrollRafId) cancelAnimationFrame(scrollRafId);
257
+ scrollRafId = 0;
258
+ window.removeEventListener("scroll", onScroll, { capture: true });
259
+ document.removeEventListener("scroll", onScroll, { capture: true });
217
260
  observer?.disconnect();
218
261
  });
219
262
  </script>
@@ -252,41 +295,42 @@ onUnmounted(() => {
252
295
  </li>
253
296
  </ul>
254
297
 
255
- <!-- Clerk / directional style -->
256
- <ul v-else ref="listRef" class="fd-toc-list fd-toc-clerk" style="position: relative">
257
- <li v-for="(item, index) in items" :key="item.url" class="fd-toc-item">
258
- <a
259
- :href="item.url"
260
- class="fd-toc-link fd-toc-clerk-link"
261
- :style="clerkLinkStyle(item)"
262
- :data-active="isActive(item) || undefined"
263
- >
264
- <!-- Vertical line segment -->
265
- <div :style="verticalLineStyle(item, index)" />
266
-
267
- <!-- Diagonal SVG connector when depth changes -->
268
- <svg
269
- v-if="hasDiagonal(index)"
270
- viewBox="0 0 16 16"
271
- width="16"
272
- height="16"
273
- style="position: absolute; top: -6px; left: 0"
298
+ <!-- Clerk / directional style: wrapper for valid HTML (ul may not contain div) -->
299
+ <div v-else class="fd-toc-clerk-wrap" style="position: relative">
300
+ <ul ref="listRef" class="fd-toc-list fd-toc-clerk">
301
+ <li v-for="(item, index) in items" :key="item.url" class="fd-toc-item">
302
+ <a
303
+ :href="item.url"
304
+ class="fd-toc-link fd-toc-clerk-link"
305
+ :style="clerkLinkStyle(item)"
306
+ :data-active="isActive(item) || undefined"
274
307
  >
275
- <line
276
- :x1="diagonalSvg(index).upperOffset"
277
- y1="0"
278
- :x2="diagonalSvg(index).currentOffset"
279
- y2="12"
280
- stroke="hsla(0, 0%, 50%, 0.1)"
281
- stroke-width="1"
282
- />
283
- </svg>
284
-
285
- {{ item.title }}
286
- </a>
287
- </li>
288
-
289
- <!-- Mask container with thumb for active highlight -->
308
+ <!-- Vertical line segment -->
309
+ <div :style="verticalLineStyle(item, index)" />
310
+
311
+ <!-- Diagonal SVG connector when depth changes -->
312
+ <svg
313
+ v-if="hasDiagonal(index)"
314
+ viewBox="0 0 16 16"
315
+ width="16"
316
+ height="16"
317
+ style="position: absolute; top: -6px; left: 0"
318
+ >
319
+ <line
320
+ :x1="diagonalSvg(index).upperOffset"
321
+ y1="0"
322
+ :x2="diagonalSvg(index).currentOffset"
323
+ y2="12"
324
+ stroke="hsla(0, 0%, 50%, 0.1)"
325
+ stroke-width="1"
326
+ />
327
+ </svg>
328
+
329
+ {{ item.title }}
330
+ </a>
331
+ </li>
332
+ </ul>
333
+ <!-- Mask container with thumb for active highlight (sibling to ul for valid HTML) -->
290
334
  <div
291
335
  v-if="svgPath"
292
336
  class="fd-toc-clerk-mask"
@@ -313,6 +357,6 @@ onUnmounted(() => {
313
357
  }"
314
358
  />
315
359
  </div>
316
- </ul>
360
+ </div>
317
361
  </div>
318
362
  </template>
@@ -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: {
@@ -24,7 +24,7 @@ const DefaultUIDefaults = {
24
24
  layout: {
25
25
  contentWidth: 768,
26
26
  sidebarWidth: 280,
27
- toc: { enabled: true, depth: 3 },
27
+ toc: { enabled: true, depth: 3, style: "default" },
28
28
  header: { height: 72, sticky: true },
29
29
  },
30
30
  components: {