starlight-telescope 0.0.1
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/README.md +15 -0
- package/index.ts +28 -0
- package/package.json +54 -0
- package/src/env.d.ts +4 -0
- package/src/libs/integration.ts +82 -0
- package/src/libs/modal.ts +43 -0
- package/src/libs/telescope-element.ts +73 -0
- package/src/libs/telescope-search.ts +1103 -0
- package/src/libs/url.ts +125 -0
- package/src/libs/vite.ts +19 -0
- package/src/pages/pages.json.ts +72 -0
- package/src/schemas/config.ts +103 -0
- package/src/styles/telescope.css +662 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import Fuse, { type FuseResult, type FuseResultMatch, type IFuseOptions } from 'fuse.js';
|
|
2
|
+
import type { TelescopeConfig, TelescopePage } from '../schemas/config.js';
|
|
3
|
+
import { getLocaleFromUrl } from './url.js';
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface Window {
|
|
7
|
+
__telescopeInitialized?: boolean;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Maximum number of pinned pages to prevent unbounded localStorage growth
|
|
12
|
+
const MAX_PINNED_PAGES = 50;
|
|
13
|
+
|
|
14
|
+
export default class TelescopeSearch {
|
|
15
|
+
private config: TelescopeConfig;
|
|
16
|
+
private isOpen: boolean = false;
|
|
17
|
+
private isLoading: boolean = true;
|
|
18
|
+
private searchQuery: string = '';
|
|
19
|
+
private allPages: TelescopePage[] = [];
|
|
20
|
+
private filteredPages: TelescopePage[] = [];
|
|
21
|
+
private selectedIndex: number = 0;
|
|
22
|
+
private fuseInstance: Fuse<TelescopePage> | null = null;
|
|
23
|
+
private recentPages: TelescopePage[];
|
|
24
|
+
private pinnedPages: TelescopePage[];
|
|
25
|
+
private currentTab: 'search' | 'recent' = 'search';
|
|
26
|
+
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
private currentOrigin: string | null = null;
|
|
28
|
+
private searchResultsWithMatches: FuseResult<TelescopePage>[] = [];
|
|
29
|
+
private currentLocale: string | undefined = undefined;
|
|
30
|
+
private hasMouseMovedSinceOpen: boolean = false;
|
|
31
|
+
private fetchInProgress: boolean = false;
|
|
32
|
+
private abortController: AbortController | null = null;
|
|
33
|
+
private isInNavigationMode: boolean = false;
|
|
34
|
+
|
|
35
|
+
// DOM elements
|
|
36
|
+
private dialogElement: HTMLDialogElement | null;
|
|
37
|
+
private liveRegion: HTMLElement | null;
|
|
38
|
+
private searchInputElement: HTMLInputElement | null;
|
|
39
|
+
private resultsContainerElement: HTMLElement | null;
|
|
40
|
+
private recentResultsContainerElement: HTMLElement | null;
|
|
41
|
+
private tabs: NodeListOf<HTMLElement>;
|
|
42
|
+
private closeButton: HTMLElement | null;
|
|
43
|
+
|
|
44
|
+
private getActiveResultsContainer(): HTMLElement | null {
|
|
45
|
+
if (this.currentTab === 'recent') {
|
|
46
|
+
return this.recentResultsContainerElement;
|
|
47
|
+
}
|
|
48
|
+
return this.resultsContainerElement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
constructor(config: TelescopeConfig) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
this.recentPages = this.loadRecentPages();
|
|
54
|
+
this.pinnedPages = this.loadPinnedPages();
|
|
55
|
+
|
|
56
|
+
// DOM elements
|
|
57
|
+
this.dialogElement = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
|
|
58
|
+
this.liveRegion = document.getElementById('telescope-live-region');
|
|
59
|
+
this.searchInputElement = document.getElementById(
|
|
60
|
+
'telescope-search-input'
|
|
61
|
+
) as HTMLInputElement | null;
|
|
62
|
+
this.resultsContainerElement = document.getElementById('telescope-results');
|
|
63
|
+
this.recentResultsContainerElement = document.getElementById('telescope-recent-results');
|
|
64
|
+
this.tabs = document.querySelectorAll('.telescope__tab');
|
|
65
|
+
this.closeButton = document.getElementById('telescope-close-button');
|
|
66
|
+
|
|
67
|
+
// Bind methods
|
|
68
|
+
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
69
|
+
this.handleSearchKeyDown = this.handleSearchKeyDown.bind(this);
|
|
70
|
+
this.handleSearchInput = this.handleSearchInput.bind(this);
|
|
71
|
+
this.close = this.close.bind(this);
|
|
72
|
+
this.switchTab = this.switchTab.bind(this);
|
|
73
|
+
this.togglePinPage = this.togglePinPage.bind(this);
|
|
74
|
+
this.togglePinForSelectedItem = this.togglePinForSelectedItem.bind(this);
|
|
75
|
+
|
|
76
|
+
// Apply theme
|
|
77
|
+
this.applyTheme();
|
|
78
|
+
|
|
79
|
+
// Initialize
|
|
80
|
+
this.fetchPages();
|
|
81
|
+
this.setupListeners();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate if a string is a valid CSS color value.
|
|
86
|
+
*/
|
|
87
|
+
private isValidCssColor(color: string): boolean {
|
|
88
|
+
const testEl = document.createElement('div');
|
|
89
|
+
testEl.style.color = color;
|
|
90
|
+
return testEl.style.color !== '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Safely set a CSS custom property with color validation.
|
|
95
|
+
*/
|
|
96
|
+
private setThemeColor(root: HTMLElement, property: string, color: string | undefined): void {
|
|
97
|
+
if (color && this.isValidCssColor(color)) {
|
|
98
|
+
root.style.setProperty(property, color);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private applyTheme(): void {
|
|
103
|
+
const { theme } = this.config;
|
|
104
|
+
const root = document.documentElement;
|
|
105
|
+
|
|
106
|
+
// Apply theme colors with validation
|
|
107
|
+
this.setThemeColor(root, '--telescope-overlay-bg', theme.overlayBackground);
|
|
108
|
+
this.setThemeColor(root, '--telescope-modal-bg', theme.modalBackground);
|
|
109
|
+
this.setThemeColor(root, '--telescope-modal-bg-alt', theme.modalBackgroundAlt);
|
|
110
|
+
this.setThemeColor(root, '--telescope-accent', theme.accentColor);
|
|
111
|
+
this.setThemeColor(root, '--telescope-accent-hover', theme.accentHover);
|
|
112
|
+
this.setThemeColor(root, '--telescope-accent-selected', theme.accentSelected);
|
|
113
|
+
this.setThemeColor(root, '--telescope-text-primary', theme.textPrimary);
|
|
114
|
+
this.setThemeColor(root, '--telescope-text-secondary', theme.textSecondary);
|
|
115
|
+
this.setThemeColor(root, '--telescope-border', theme.border);
|
|
116
|
+
this.setThemeColor(root, '--telescope-border-active', theme.borderActive);
|
|
117
|
+
this.setThemeColor(root, '--telescope-pin-color', theme.pinColor);
|
|
118
|
+
this.setThemeColor(root, '--telescope-tag-color', theme.tagColor);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async fetchPages(): Promise<void> {
|
|
122
|
+
// Prevent concurrent fetches
|
|
123
|
+
if (this.fetchInProgress) return;
|
|
124
|
+
this.fetchInProgress = true;
|
|
125
|
+
|
|
126
|
+
this.isLoading = true;
|
|
127
|
+
this.updateLoadingState();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const basePath = (import.meta.env.BASE_URL || '').replace(/\/$/, '');
|
|
131
|
+
|
|
132
|
+
// Detect current locale using shared utility (DRY)
|
|
133
|
+
this.currentLocale = getLocaleFromUrl(new URL(window.location.href));
|
|
134
|
+
const localePath = this.currentLocale ? `/${this.currentLocale}` : '';
|
|
135
|
+
|
|
136
|
+
const response = await fetch(`${basePath}${localePath}/pages.json`);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(`Failed to fetch pages: ${response.status}`);
|
|
139
|
+
}
|
|
140
|
+
this.allPages = await response.json();
|
|
141
|
+
|
|
142
|
+
// Get the URL from the response object
|
|
143
|
+
this.currentOrigin = new URL(response.url).origin;
|
|
144
|
+
|
|
145
|
+
// Validate stored pages against current site's pages
|
|
146
|
+
// This filters out pages from other sites sharing the same localStorage
|
|
147
|
+
const validatedRecent = this.validateStoredPages(this.recentPages);
|
|
148
|
+
const validatedPinned = this.validateStoredPages(this.pinnedPages);
|
|
149
|
+
|
|
150
|
+
// Update and save if any pages were filtered out
|
|
151
|
+
if (validatedRecent.length !== this.recentPages.length) {
|
|
152
|
+
this.recentPages = validatedRecent;
|
|
153
|
+
localStorage.setItem(this.getStorageKey('recentPages'), JSON.stringify(this.recentPages));
|
|
154
|
+
}
|
|
155
|
+
if (validatedPinned.length !== this.pinnedPages.length) {
|
|
156
|
+
this.pinnedPages = validatedPinned;
|
|
157
|
+
localStorage.setItem(this.getStorageKey('pinnedPages'), JSON.stringify(this.pinnedPages));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Initialize Fuse.js after fetching pages
|
|
161
|
+
this.initializeFuse();
|
|
162
|
+
|
|
163
|
+
// Re-render if dialog is open to show validated data
|
|
164
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
|
|
165
|
+
if (dialog?.open) {
|
|
166
|
+
this.filteredPages = [...this.allPages];
|
|
167
|
+
this.renderSearchResults();
|
|
168
|
+
this.renderRecentResults();
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error('Error fetching pages:', error);
|
|
172
|
+
this.allPages = [];
|
|
173
|
+
// Clear stored pages on error since we can't validate them
|
|
174
|
+
this.recentPages = [];
|
|
175
|
+
this.pinnedPages = [];
|
|
176
|
+
} finally {
|
|
177
|
+
this.isLoading = false;
|
|
178
|
+
this.fetchInProgress = false;
|
|
179
|
+
this.updateLoadingState();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private updateLoadingState(): void {
|
|
184
|
+
const loadingEl = document.getElementById('telescope-loading');
|
|
185
|
+
const resultsEl = document.getElementById('telescope-results');
|
|
186
|
+
if (loadingEl) loadingEl.style.display = this.isLoading ? 'flex' : 'none';
|
|
187
|
+
if (resultsEl) resultsEl.style.display = this.isLoading ? 'none' : 'block';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private escapeHtml(text: string): string {
|
|
191
|
+
const escapeMap: Record<string, string> = {
|
|
192
|
+
'&': '&',
|
|
193
|
+
'<': '<',
|
|
194
|
+
'>': '>',
|
|
195
|
+
'"': '"',
|
|
196
|
+
"'": ''',
|
|
197
|
+
};
|
|
198
|
+
return text.replace(/[&<>"']/g, (char) => escapeMap[char] || char);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private highlightMatches(
|
|
202
|
+
text: string,
|
|
203
|
+
matches: readonly FuseResultMatch[] | undefined,
|
|
204
|
+
key: string
|
|
205
|
+
): string {
|
|
206
|
+
if (!matches || !text) return this.escapeHtml(text || '');
|
|
207
|
+
|
|
208
|
+
const match = matches.find((m) => m.key === key);
|
|
209
|
+
if (!match || !match.indices || match.indices.length === 0) {
|
|
210
|
+
return this.escapeHtml(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build highlighted string from indices
|
|
214
|
+
let result = '';
|
|
215
|
+
let lastIndex = 0;
|
|
216
|
+
|
|
217
|
+
// Sort indices and merge overlapping ones
|
|
218
|
+
const sortedIndices = [...match.indices].sort((a, b) => a[0] - b[0]);
|
|
219
|
+
|
|
220
|
+
for (const [start, end] of sortedIndices) {
|
|
221
|
+
if (start > lastIndex) {
|
|
222
|
+
result += this.escapeHtml(text.slice(lastIndex, start));
|
|
223
|
+
}
|
|
224
|
+
if (start >= lastIndex) {
|
|
225
|
+
result += `<mark class="telescope__highlight">${this.escapeHtml(text.slice(start, end + 1))}</mark>`;
|
|
226
|
+
lastIndex = end + 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
result += this.escapeHtml(text.slice(lastIndex));
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private getStorageKey(key: string): string {
|
|
235
|
+
return `telescope_${key}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private getMatchesForPage(page: TelescopePage): readonly FuseResultMatch[] | undefined {
|
|
239
|
+
const result = this.searchResultsWithMatches.find((r) => r.item.path === page.path);
|
|
240
|
+
return result?.matches;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private validateStoredPages(storedPages: TelescopePage[]): TelescopePage[] {
|
|
244
|
+
// Return empty array if allPages hasn't loaded yet - don't show unvalidated data
|
|
245
|
+
if (this.allPages.length === 0) return [];
|
|
246
|
+
|
|
247
|
+
const validPaths = new Set(this.allPages.map(p => p.path));
|
|
248
|
+
return storedPages.filter(page => validPaths.has(page.path));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private loadRecentPages(): TelescopePage[] {
|
|
252
|
+
try {
|
|
253
|
+
const recent = localStorage.getItem(this.getStorageKey('recentPages'));
|
|
254
|
+
return recent ? JSON.parse(recent) : [];
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.warn('Failed to parse recent pages from localStorage:', error);
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private loadPinnedPages(): TelescopePage[] {
|
|
262
|
+
try {
|
|
263
|
+
const pinned = localStorage.getItem(this.getStorageKey('pinnedPages'));
|
|
264
|
+
return pinned ? JSON.parse(pinned) : [];
|
|
265
|
+
} catch (error) {
|
|
266
|
+
console.warn('Failed to parse pinned pages from localStorage:', error);
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private saveRecentPage(page: TelescopePage): void {
|
|
272
|
+
// Remove if already exists
|
|
273
|
+
this.recentPages = this.recentPages.filter((p) => p.path !== page.path);
|
|
274
|
+
// Add to beginning
|
|
275
|
+
this.recentPages.unshift(page);
|
|
276
|
+
// Keep only configured number of items
|
|
277
|
+
this.recentPages = this.recentPages.slice(0, this.config.recentPagesCount);
|
|
278
|
+
// Save to localStorage
|
|
279
|
+
localStorage.setItem(this.getStorageKey('recentPages'), JSON.stringify(this.recentPages));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private savePinnedPages(): void {
|
|
283
|
+
localStorage.setItem(this.getStorageKey('pinnedPages'), JSON.stringify(this.pinnedPages));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private clearRecentPages(): void {
|
|
287
|
+
this.recentPages = [];
|
|
288
|
+
localStorage.removeItem(this.getStorageKey('recentPages'));
|
|
289
|
+
this.renderSearchResults();
|
|
290
|
+
this.renderRecentResults();
|
|
291
|
+
this.announce('Recent pages cleared');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private clearPinnedPages(): void {
|
|
295
|
+
this.pinnedPages = [];
|
|
296
|
+
localStorage.removeItem(this.getStorageKey('pinnedPages'));
|
|
297
|
+
this.renderSearchResults();
|
|
298
|
+
this.announce('Pinned pages cleared');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private isPagePinned(path: string): boolean {
|
|
302
|
+
return this.pinnedPages.some((page) => page.path === path);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private togglePinPage(page: TelescopePage): void {
|
|
306
|
+
const pageIndex = this.pinnedPages.findIndex((p) => p.path === page.path);
|
|
307
|
+
|
|
308
|
+
if (pageIndex > -1) {
|
|
309
|
+
// Remove from pinned
|
|
310
|
+
this.pinnedPages.splice(pageIndex, 1);
|
|
311
|
+
} else {
|
|
312
|
+
// Add to pinned with limit enforcement
|
|
313
|
+
if (this.pinnedPages.length >= MAX_PINNED_PAGES) {
|
|
314
|
+
// Remove oldest pinned page to make room
|
|
315
|
+
this.pinnedPages.shift();
|
|
316
|
+
}
|
|
317
|
+
this.pinnedPages.push(page);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this.savePinnedPages();
|
|
321
|
+
|
|
322
|
+
// Refresh the UI
|
|
323
|
+
this.renderSearchResults();
|
|
324
|
+
this.renderRecentResults();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private togglePinForSelectedItem(): void {
|
|
328
|
+
// Get the currently selected item from the DOM
|
|
329
|
+
const selectedItem = document.querySelector('.telescope__result-item--selected');
|
|
330
|
+
|
|
331
|
+
if (selectedItem && selectedItem.hasAttribute('data-path')) {
|
|
332
|
+
const path = selectedItem.getAttribute('data-path');
|
|
333
|
+
const page =
|
|
334
|
+
this.allPages.find((p) => p.path === path) ||
|
|
335
|
+
this.recentPages.find((p) => p.path === path);
|
|
336
|
+
|
|
337
|
+
if (page) {
|
|
338
|
+
this.togglePinPage(page);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private initializeFuse(): void {
|
|
344
|
+
// Only initialize if Fuse.js is available and we have pages
|
|
345
|
+
if (typeof Fuse !== 'undefined' && this.allPages.length > 0) {
|
|
346
|
+
const { fuseOptions } = this.config;
|
|
347
|
+
|
|
348
|
+
const options: IFuseOptions<TelescopePage> = {
|
|
349
|
+
keys: fuseOptions.keys,
|
|
350
|
+
threshold: fuseOptions.threshold,
|
|
351
|
+
includeScore: true,
|
|
352
|
+
ignoreLocation: fuseOptions.ignoreLocation,
|
|
353
|
+
distance: fuseOptions.distance,
|
|
354
|
+
minMatchCharLength: fuseOptions.minMatchCharLength,
|
|
355
|
+
findAllMatches: fuseOptions.findAllMatches,
|
|
356
|
+
useExtendedSearch: true,
|
|
357
|
+
includeMatches: true,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
this.fuseInstance = new Fuse(this.allPages, options);
|
|
361
|
+
} else {
|
|
362
|
+
console.warn('Fuse.js not available or no pages to index');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private debounce<T extends (...args: unknown[]) => void>(
|
|
367
|
+
callback: T,
|
|
368
|
+
wait: number
|
|
369
|
+
): (event: Event) => void {
|
|
370
|
+
return (event: Event) => {
|
|
371
|
+
if (this.debounceTimeout) {
|
|
372
|
+
clearTimeout(this.debounceTimeout);
|
|
373
|
+
}
|
|
374
|
+
this.debounceTimeout = setTimeout(() => callback.call(this, event), wait);
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private setupListeners(): void {
|
|
379
|
+
// Create AbortController for cleanup
|
|
380
|
+
this.abortController = new AbortController();
|
|
381
|
+
const { signal } = this.abortController;
|
|
382
|
+
|
|
383
|
+
// Global keyboard shortcut
|
|
384
|
+
document.addEventListener('keydown', this.handleKeyDown, { signal });
|
|
385
|
+
|
|
386
|
+
// Add event listeners to the search input
|
|
387
|
+
if (this.searchInputElement) {
|
|
388
|
+
// Use debounced version of handleSearchInput to prevent excessive searches
|
|
389
|
+
const debouncedSearchInput = this.debounce(this.handleSearchInput, this.config.debounceMs);
|
|
390
|
+
this.searchInputElement.addEventListener('input', debouncedSearchInput, { signal });
|
|
391
|
+
this.searchInputElement.addEventListener('keydown', this.handleSearchKeyDown, { signal });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Use event delegation for dialog interactions - works regardless of when elements exist
|
|
395
|
+
document.addEventListener('click', (event) => {
|
|
396
|
+
const target = event.target as HTMLElement;
|
|
397
|
+
|
|
398
|
+
// Close button clicked
|
|
399
|
+
if (target.closest('#telescope-close-button')) {
|
|
400
|
+
this.close();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Tab clicked
|
|
405
|
+
const tab = target.closest('.telescope__tab') as HTMLElement;
|
|
406
|
+
if (tab?.dataset.tab) {
|
|
407
|
+
this.switchTab(tab.dataset.tab as 'search' | 'recent');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Backdrop clicked (click on dialog but outside modal)
|
|
412
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
|
|
413
|
+
if (dialog?.open && event.target === dialog) {
|
|
414
|
+
this.close();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}, { signal });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Cleanup method to remove event listeners and timers.
|
|
422
|
+
* Call this when the component is destroyed.
|
|
423
|
+
*/
|
|
424
|
+
public destroy(): void {
|
|
425
|
+
// Abort all event listeners
|
|
426
|
+
if (this.abortController) {
|
|
427
|
+
this.abortController.abort();
|
|
428
|
+
this.abortController = null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Clear debounce timer
|
|
432
|
+
if (this.debounceTimeout) {
|
|
433
|
+
clearTimeout(this.debounceTimeout);
|
|
434
|
+
this.debounceTimeout = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private handleKeyDown(event: KeyboardEvent): void {
|
|
439
|
+
const { shortcut } = this.config;
|
|
440
|
+
|
|
441
|
+
// Check if the configured shortcut is pressed
|
|
442
|
+
const keyMatches = event.key === shortcut.key;
|
|
443
|
+
const ctrlMatches = shortcut.ctrl ? event.ctrlKey : !event.ctrlKey;
|
|
444
|
+
const metaMatches = shortcut.meta ? event.metaKey : !event.metaKey;
|
|
445
|
+
const shiftMatches = shortcut.shift ? event.shiftKey : !event.shiftKey;
|
|
446
|
+
const altMatches = shortcut.alt ? event.altKey : !event.altKey;
|
|
447
|
+
|
|
448
|
+
// For the default "/" shortcut, allow either Ctrl or Meta (Cmd on Mac)
|
|
449
|
+
const modifierMatches =
|
|
450
|
+
shortcut.ctrl && shortcut.meta
|
|
451
|
+
? event.ctrlKey || event.metaKey
|
|
452
|
+
: ctrlMatches && metaMatches;
|
|
453
|
+
|
|
454
|
+
if (keyMatches && modifierMatches && shiftMatches && altMatches) {
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
this.open();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Escape to close - check actual dialog state
|
|
460
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
|
|
461
|
+
if (event.key === 'Escape' && dialog?.open) {
|
|
462
|
+
event.preventDefault();
|
|
463
|
+
this.close();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private handleSearchKeyDown(event: KeyboardEvent): void {
|
|
468
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
|
|
469
|
+
if (!dialog?.open) return;
|
|
470
|
+
|
|
471
|
+
switch (event.key) {
|
|
472
|
+
case 'Escape':
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
this.close();
|
|
475
|
+
break;
|
|
476
|
+
|
|
477
|
+
case 'ArrowDown':
|
|
478
|
+
event.preventDefault();
|
|
479
|
+
this.isInNavigationMode = true;
|
|
480
|
+
this.navigateResults(1);
|
|
481
|
+
break;
|
|
482
|
+
|
|
483
|
+
case 'ArrowUp':
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
this.isInNavigationMode = true;
|
|
486
|
+
this.navigateResults(-1);
|
|
487
|
+
break;
|
|
488
|
+
|
|
489
|
+
case 'Enter':
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this.selectCurrentItem();
|
|
492
|
+
break;
|
|
493
|
+
|
|
494
|
+
case ' ':
|
|
495
|
+
// Handle space for bookmarking if input is empty, cursor at start, or navigating results
|
|
496
|
+
if (
|
|
497
|
+
this.searchInputElement &&
|
|
498
|
+
(this.searchInputElement.value === '' ||
|
|
499
|
+
this.searchInputElement.selectionStart === 0 ||
|
|
500
|
+
this.isInNavigationMode)
|
|
501
|
+
) {
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
this.togglePinForSelectedItem();
|
|
504
|
+
}
|
|
505
|
+
// Otherwise, let the space be typed normally
|
|
506
|
+
break;
|
|
507
|
+
|
|
508
|
+
default:
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private navigateResults(direction: number): void {
|
|
514
|
+
const container = this.getActiveResultsContainer();
|
|
515
|
+
if (!container) return;
|
|
516
|
+
|
|
517
|
+
// Count actual rendered items - single source of truth
|
|
518
|
+
const totalItems = container.querySelectorAll('.telescope__result-item').length;
|
|
519
|
+
if (totalItems === 0) return;
|
|
520
|
+
|
|
521
|
+
if (direction > 0) {
|
|
522
|
+
this.selectedIndex = (this.selectedIndex + 1) % totalItems;
|
|
523
|
+
} else {
|
|
524
|
+
this.selectedIndex = (this.selectedIndex - 1 + totalItems) % totalItems;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.updateSelectedResult();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private handleSearchInput(event: Event): void {
|
|
531
|
+
this.isInNavigationMode = false;
|
|
532
|
+
const target = event.target as HTMLInputElement;
|
|
533
|
+
this.searchQuery = target.value;
|
|
534
|
+
|
|
535
|
+
if (this.currentTab === 'recent') {
|
|
536
|
+
// Filter recent pages (use validated pages)
|
|
537
|
+
const validatedRecent = this.validateStoredPages(this.recentPages);
|
|
538
|
+
const filteredRecent = validatedRecent.filter(
|
|
539
|
+
(page) =>
|
|
540
|
+
page.title.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
|
|
541
|
+
page.path.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
|
|
542
|
+
(page.description &&
|
|
543
|
+
page.description.toLowerCase().includes(this.searchQuery.toLowerCase()))
|
|
544
|
+
);
|
|
545
|
+
this.filteredPages = filteredRecent;
|
|
546
|
+
this.selectedIndex = 0;
|
|
547
|
+
this.renderRecentResults();
|
|
548
|
+
this.updateSelectedResult();
|
|
549
|
+
} else {
|
|
550
|
+
// Filter all pages
|
|
551
|
+
this.filterPages();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private filterPages(): void {
|
|
556
|
+
const query = this.searchQuery.trim();
|
|
557
|
+
const lowerQuery = query.toLowerCase();
|
|
558
|
+
|
|
559
|
+
if (!query) {
|
|
560
|
+
this.filteredPages = [...this.allPages];
|
|
561
|
+
this.searchResultsWithMatches = [];
|
|
562
|
+
} else if (this.fuseInstance) {
|
|
563
|
+
// Fuse.js is available and initialized
|
|
564
|
+
const searchResults = this.fuseInstance.search(query);
|
|
565
|
+
|
|
566
|
+
// Custom sort: exact > prefix > word-start > fuzzy score
|
|
567
|
+
// This prioritizes quick navigation matches over deep fuzzy matches
|
|
568
|
+
searchResults.sort((a, b) => {
|
|
569
|
+
const aTitle = (a.item.title || '').toLowerCase();
|
|
570
|
+
const bTitle = (b.item.title || '').toLowerCase();
|
|
571
|
+
|
|
572
|
+
// Exact title match gets highest priority
|
|
573
|
+
const aExact = aTitle === lowerQuery;
|
|
574
|
+
const bExact = bTitle === lowerQuery;
|
|
575
|
+
if (aExact && !bExact) return -1;
|
|
576
|
+
if (bExact && !aExact) return 1;
|
|
577
|
+
|
|
578
|
+
// Title starts with query is next priority
|
|
579
|
+
const aPrefix = aTitle.startsWith(lowerQuery);
|
|
580
|
+
const bPrefix = bTitle.startsWith(lowerQuery);
|
|
581
|
+
if (aPrefix && !bPrefix) return -1;
|
|
582
|
+
if (bPrefix && !aPrefix) return 1;
|
|
583
|
+
|
|
584
|
+
// Word-start match (e.g., "auth" matches "OAuth Authentication")
|
|
585
|
+
const aWordStart = aTitle.split(/\s+/).some((w) => w.startsWith(lowerQuery));
|
|
586
|
+
const bWordStart = bTitle.split(/\s+/).some((w) => w.startsWith(lowerQuery));
|
|
587
|
+
if (aWordStart && !bWordStart) return -1;
|
|
588
|
+
if (bWordStart && !aWordStart) return 1;
|
|
589
|
+
|
|
590
|
+
// Fall back to Fuse.js score (lower = better)
|
|
591
|
+
return (a.score || 0) - (b.score || 0);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Store results with matches for highlighting
|
|
595
|
+
this.searchResultsWithMatches = searchResults;
|
|
596
|
+
|
|
597
|
+
// Extract just the items after sorting
|
|
598
|
+
this.filteredPages = searchResults.map((result) => result.item);
|
|
599
|
+
} else {
|
|
600
|
+
// Fallback to basic filtering if Fuse.js is not available
|
|
601
|
+
this.filteredPages = this.allPages.filter((page) => {
|
|
602
|
+
const title = (page.title || '').toLowerCase();
|
|
603
|
+
const path = (page.path || '').toLowerCase();
|
|
604
|
+
const description = (page.description || '').toLowerCase();
|
|
605
|
+
|
|
606
|
+
return (
|
|
607
|
+
title.includes(lowerQuery) || path.includes(lowerQuery) || description.includes(lowerQuery)
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
this.selectedIndex = 0;
|
|
613
|
+
this.renderResults();
|
|
614
|
+
this.updateSelectedResult();
|
|
615
|
+
|
|
616
|
+
// Announce result count for screen readers
|
|
617
|
+
const count = this.filteredPages.length;
|
|
618
|
+
const maxResults = this.config.maxResults;
|
|
619
|
+
const hasMore = count > maxResults;
|
|
620
|
+
const announcement = count === 0
|
|
621
|
+
? 'No results found'
|
|
622
|
+
: hasMore
|
|
623
|
+
? `Showing ${maxResults} of ${count} results. Refine your search to see more.`
|
|
624
|
+
: `${count} result${count === 1 ? '' : 's'} found`;
|
|
625
|
+
this.announce(announcement);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private updateSelectedResult(): void {
|
|
629
|
+
const container = this.getActiveResultsContainer();
|
|
630
|
+
if (!container) return;
|
|
631
|
+
|
|
632
|
+
// Remove selection from all items in the active container
|
|
633
|
+
const items = container.querySelectorAll('.telescope__result-item');
|
|
634
|
+
items.forEach((item) => {
|
|
635
|
+
item.classList.remove('telescope__result-item--selected');
|
|
636
|
+
item.setAttribute('aria-selected', 'false');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Add selection to current item within the active container
|
|
640
|
+
const selectedItem = container.querySelector(`[data-index="${this.selectedIndex}"]`);
|
|
641
|
+
if (selectedItem) {
|
|
642
|
+
selectedItem.classList.add('telescope__result-item--selected');
|
|
643
|
+
selectedItem.setAttribute('aria-selected', 'true');
|
|
644
|
+
|
|
645
|
+
// Update aria-activedescendant on the input
|
|
646
|
+
const itemId = selectedItem.id || `telescope-option-${this.selectedIndex}`;
|
|
647
|
+
selectedItem.id = itemId;
|
|
648
|
+
if (this.searchInputElement) {
|
|
649
|
+
this.searchInputElement.setAttribute('aria-activedescendant', itemId);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Scroll into view if needed
|
|
653
|
+
selectedItem.scrollIntoView({
|
|
654
|
+
behavior: 'smooth',
|
|
655
|
+
block: 'nearest',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private getCompletePath(path: string): string {
|
|
661
|
+
// This includes protocol, domain, and port
|
|
662
|
+
const origin = this.currentOrigin || window.location.origin;
|
|
663
|
+
|
|
664
|
+
// Get the base path from the config or use a default
|
|
665
|
+
const basePath = (import.meta.env.BASE_URL || '').replace(/\/$/, '');
|
|
666
|
+
|
|
667
|
+
// Include locale if present (pages.json paths don't include locale prefix)
|
|
668
|
+
const localeSegment = this.currentLocale ? `/${this.currentLocale}` : '';
|
|
669
|
+
|
|
670
|
+
// Ensure path starts with /
|
|
671
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
672
|
+
|
|
673
|
+
return `${origin}${basePath}${localeSegment}${normalizedPath}`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private navigateToPage(partialPath: string): void {
|
|
677
|
+
const page = this.allPages.find((p) => p.path === partialPath);
|
|
678
|
+
if (page) {
|
|
679
|
+
this.saveRecentPage(page);
|
|
680
|
+
}
|
|
681
|
+
this.close();
|
|
682
|
+
|
|
683
|
+
const path = this.getCompletePath(partialPath);
|
|
684
|
+
window.location.href = path;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
public open(): void {
|
|
688
|
+
// Query dialog fresh (handles multiple instances)
|
|
689
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
|
|
690
|
+
if (!dialog || dialog.open) return;
|
|
691
|
+
|
|
692
|
+
this.isOpen = true;
|
|
693
|
+
this.searchQuery = '';
|
|
694
|
+
this.selectedIndex = 0;
|
|
695
|
+
this.filteredPages = [...this.allPages];
|
|
696
|
+
|
|
697
|
+
dialog.showModal();
|
|
698
|
+
|
|
699
|
+
// Disable pointer events until mouse moves to prevent false hover on open
|
|
700
|
+
dialog.classList.add('telescope--pointer-disabled');
|
|
701
|
+
this.hasMouseMovedSinceOpen = false;
|
|
702
|
+
dialog.addEventListener('mousemove', () => {
|
|
703
|
+
this.hasMouseMovedSinceOpen = true;
|
|
704
|
+
dialog.classList.remove('telescope--pointer-disabled');
|
|
705
|
+
}, { once: true });
|
|
706
|
+
|
|
707
|
+
this.switchTab('search');
|
|
708
|
+
|
|
709
|
+
// Render initial content
|
|
710
|
+
this.renderRecentResults();
|
|
711
|
+
this.renderSearchResults();
|
|
712
|
+
|
|
713
|
+
// Ensure first item is selected with proper ARIA
|
|
714
|
+
this.updateSelectedResult();
|
|
715
|
+
|
|
716
|
+
// Focus the search input
|
|
717
|
+
requestAnimationFrame(() => {
|
|
718
|
+
const input = document.getElementById('telescope-search-input') as HTMLInputElement | null;
|
|
719
|
+
input?.focus();
|
|
720
|
+
if (input) {
|
|
721
|
+
input.value = '';
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
this.announce('Search dialog opened');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
public close(): void {
|
|
729
|
+
// Check actual dialog state, not internal flag (handles multiple instances)
|
|
730
|
+
const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
|
|
731
|
+
if (!dialog?.open) return;
|
|
732
|
+
|
|
733
|
+
this.isOpen = false;
|
|
734
|
+
dialog.close();
|
|
735
|
+
|
|
736
|
+
// Clear debounce timer to prevent stale callbacks
|
|
737
|
+
if (this.debounceTimeout) {
|
|
738
|
+
clearTimeout(this.debounceTimeout);
|
|
739
|
+
this.debounceTimeout = null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Clear aria-activedescendant
|
|
743
|
+
const input = document.getElementById('telescope-search-input');
|
|
744
|
+
if (input) {
|
|
745
|
+
input.setAttribute('aria-activedescendant', '');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private renderResults(): void {
|
|
750
|
+
if (!this.resultsContainerElement) return;
|
|
751
|
+
|
|
752
|
+
// Clear current results
|
|
753
|
+
this.resultsContainerElement.innerHTML = '';
|
|
754
|
+
|
|
755
|
+
// Use a running index counter for sequential indexing
|
|
756
|
+
let currentIndex = 0;
|
|
757
|
+
|
|
758
|
+
// Use validated pages to prevent showing pages from other sites
|
|
759
|
+
const validatedRecent = this.validateStoredPages(this.recentPages);
|
|
760
|
+
|
|
761
|
+
// Show recent pages if no search query
|
|
762
|
+
if (!this.searchQuery.trim() && validatedRecent.length > 0) {
|
|
763
|
+
this.resultsContainerElement.appendChild(
|
|
764
|
+
this.createSectionHeader('Recently Visited', () => this.clearRecentPages())
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
validatedRecent.forEach((page) => {
|
|
768
|
+
const listItem = this.createResultItem(page, currentIndex);
|
|
769
|
+
this.resultsContainerElement!.appendChild(listItem);
|
|
770
|
+
currentIndex++;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (this.filteredPages.length === 0) {
|
|
775
|
+
// Show no results message
|
|
776
|
+
const noResults = document.createElement('li');
|
|
777
|
+
noResults.className = 'telescope__no-results';
|
|
778
|
+
noResults.setAttribute('role', 'presentation');
|
|
779
|
+
noResults.textContent = 'No pages found matching your search';
|
|
780
|
+
this.resultsContainerElement.appendChild(noResults);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Limit results to maxResults for performance
|
|
785
|
+
const maxResults = this.config.maxResults;
|
|
786
|
+
const hasMoreResults = this.filteredPages.length > maxResults;
|
|
787
|
+
const displayResults = this.filteredPages.slice(0, maxResults);
|
|
788
|
+
|
|
789
|
+
// Append items directly to the ul (no nested ul)
|
|
790
|
+
displayResults.forEach((page) => {
|
|
791
|
+
const listItem = this.createResultItem(page, currentIndex);
|
|
792
|
+
this.resultsContainerElement!.appendChild(listItem);
|
|
793
|
+
currentIndex++;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Show indicator when results are truncated
|
|
797
|
+
if (hasMoreResults) {
|
|
798
|
+
const indicator = document.createElement('li');
|
|
799
|
+
indicator.className = 'telescope__more-results';
|
|
800
|
+
indicator.setAttribute('role', 'presentation');
|
|
801
|
+
indicator.textContent = `Showing ${maxResults} of ${this.filteredPages.length} results. Refine your search to see more.`;
|
|
802
|
+
this.resultsContainerElement!.appendChild(indicator);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private createResultItem(page: TelescopePage, index: number): HTMLLIElement {
|
|
807
|
+
const listItem = document.createElement('li');
|
|
808
|
+
const itemId = `telescope-option-${index}`;
|
|
809
|
+
|
|
810
|
+
listItem.id = itemId;
|
|
811
|
+
listItem.className = `telescope__result-item${index === this.selectedIndex ? ' telescope__result-item--selected' : ''}`;
|
|
812
|
+
listItem.setAttribute('role', 'option');
|
|
813
|
+
listItem.setAttribute('aria-selected', index === this.selectedIndex ? 'true' : 'false');
|
|
814
|
+
listItem.setAttribute('data-index', String(index));
|
|
815
|
+
listItem.setAttribute('data-path', page.path);
|
|
816
|
+
|
|
817
|
+
// Add pin button
|
|
818
|
+
const isPinned = this.isPagePinned(page.path);
|
|
819
|
+
const pinButton = document.createElement('button');
|
|
820
|
+
pinButton.className = `telescope__pin-button${isPinned ? ' telescope__pin-button--pinned' : ''}`;
|
|
821
|
+
pinButton.innerHTML = `<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
822
|
+
<path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"></path>
|
|
823
|
+
</svg>`;
|
|
824
|
+
pinButton.title = isPinned ? 'Unpin page' : 'Pin page';
|
|
825
|
+
pinButton.setAttribute('aria-label', isPinned ? 'Unpin page' : 'Pin page');
|
|
826
|
+
|
|
827
|
+
// Stop event propagation to prevent navigation and flickering
|
|
828
|
+
pinButton.addEventListener('click', (event) => {
|
|
829
|
+
event.stopPropagation();
|
|
830
|
+
event.preventDefault();
|
|
831
|
+
this.togglePinPage(page);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Prevent hover events from bubbling and causing flickering
|
|
835
|
+
pinButton.addEventListener('mouseenter', (event) => {
|
|
836
|
+
event.stopPropagation();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
pinButton.addEventListener('mouseleave', (event) => {
|
|
840
|
+
event.stopPropagation();
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Single row content
|
|
844
|
+
const contentRow = document.createElement('div');
|
|
845
|
+
contentRow.className = 'telescope__result-content-row';
|
|
846
|
+
|
|
847
|
+
// Get matches for highlighting (only if there's an active search query)
|
|
848
|
+
const matches = this.searchQuery.trim() ? this.getMatchesForPage(page) : undefined;
|
|
849
|
+
|
|
850
|
+
// Title (with highlighting if matches exist)
|
|
851
|
+
const titleDiv = document.createElement('div');
|
|
852
|
+
titleDiv.className = 'telescope__result-title';
|
|
853
|
+
titleDiv.innerHTML = this.highlightMatches(page.title || '', matches, 'title');
|
|
854
|
+
|
|
855
|
+
contentRow.appendChild(titleDiv);
|
|
856
|
+
|
|
857
|
+
// Description (if available, with highlighting if matches exist)
|
|
858
|
+
if (page.description) {
|
|
859
|
+
const descriptionDiv = document.createElement('div');
|
|
860
|
+
descriptionDiv.className = 'telescope__result-description';
|
|
861
|
+
descriptionDiv.innerHTML = this.highlightMatches(page.description, matches, 'description');
|
|
862
|
+
contentRow.appendChild(descriptionDiv);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Tags (if available)
|
|
866
|
+
if (page.tags && page.tags.length > 0) {
|
|
867
|
+
const tagsDiv = document.createElement('div');
|
|
868
|
+
tagsDiv.className = 'telescope__result-tags';
|
|
869
|
+
|
|
870
|
+
page.tags.forEach((tag) => {
|
|
871
|
+
const tagSpan = document.createElement('span');
|
|
872
|
+
tagSpan.className = 'telescope__tag';
|
|
873
|
+
tagSpan.textContent = tag;
|
|
874
|
+
tagsDiv.appendChild(tagSpan);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
contentRow.appendChild(tagsDiv);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Add content row to list item
|
|
881
|
+
listItem.appendChild(contentRow);
|
|
882
|
+
listItem.appendChild(pinButton);
|
|
883
|
+
|
|
884
|
+
// Add event listeners
|
|
885
|
+
listItem.addEventListener('click', () => {
|
|
886
|
+
this.navigateToPage(page.path);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
listItem.addEventListener('mouseenter', () => {
|
|
890
|
+
// Ignore mouseenter until user has actually moved the mouse
|
|
891
|
+
// This prevents false selection when modal opens under cursor
|
|
892
|
+
if (!this.hasMouseMovedSinceOpen) return;
|
|
893
|
+
|
|
894
|
+
this.selectedIndex = index;
|
|
895
|
+
this.updateSelectedResult();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
return listItem;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private switchTab(tabName: 'search' | 'recent'): void {
|
|
902
|
+
this.currentTab = tabName;
|
|
903
|
+
this.selectedIndex = 0;
|
|
904
|
+
|
|
905
|
+
// Reset search state when switching tabs
|
|
906
|
+
this.searchQuery = '';
|
|
907
|
+
if (this.searchInputElement) {
|
|
908
|
+
this.searchInputElement.value = '';
|
|
909
|
+
}
|
|
910
|
+
this.filteredPages = [...this.allPages];
|
|
911
|
+
|
|
912
|
+
// Update tab buttons
|
|
913
|
+
this.tabs.forEach((tab) => {
|
|
914
|
+
const isActive = tab.dataset.tab === tabName;
|
|
915
|
+
tab.classList.toggle('telescope__tab--active', isActive);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// Update sections
|
|
919
|
+
document.querySelectorAll('.telescope__section').forEach((section) => {
|
|
920
|
+
section.classList.toggle('telescope__section--active', section.id === `telescope-${tabName}-section`);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// Update search input placeholder
|
|
924
|
+
if (this.searchInputElement) {
|
|
925
|
+
this.searchInputElement.placeholder =
|
|
926
|
+
tabName === 'recent' ? 'Filter recent pages...' : 'Search pages...';
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Render appropriate content
|
|
930
|
+
if (tabName === 'recent') {
|
|
931
|
+
this.renderRecentResults();
|
|
932
|
+
} else {
|
|
933
|
+
this.renderSearchResults();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Update selection after rendering
|
|
937
|
+
this.updateSelectedResult();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private renderRecentResults(): void {
|
|
941
|
+
if (!this.recentResultsContainerElement) return;
|
|
942
|
+
|
|
943
|
+
this.recentResultsContainerElement.innerHTML = '';
|
|
944
|
+
|
|
945
|
+
// Use filtered pages if there's a search query, otherwise show all validated recent pages
|
|
946
|
+
const pagesToRender = this.searchQuery.trim()
|
|
947
|
+
? this.filteredPages
|
|
948
|
+
: this.validateStoredPages(this.recentPages);
|
|
949
|
+
|
|
950
|
+
if (pagesToRender.length === 0) {
|
|
951
|
+
const noResults = document.createElement('li');
|
|
952
|
+
noResults.className = 'telescope__no-results';
|
|
953
|
+
noResults.setAttribute('role', 'presentation');
|
|
954
|
+
noResults.textContent = this.searchQuery.trim()
|
|
955
|
+
? 'No pages found matching your search'
|
|
956
|
+
: 'No recently visited pages';
|
|
957
|
+
this.recentResultsContainerElement.appendChild(noResults);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Append items directly to the ul (no nested ul)
|
|
962
|
+
pagesToRender.forEach((page, index) => {
|
|
963
|
+
const listItem = this.createResultItem(page, index);
|
|
964
|
+
this.recentResultsContainerElement!.appendChild(listItem);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private createSectionHeader(text: string, onClear?: () => void): HTMLLIElement {
|
|
969
|
+
const header = document.createElement('li');
|
|
970
|
+
header.className = 'telescope__section-separator';
|
|
971
|
+
header.setAttribute('role', 'presentation');
|
|
972
|
+
|
|
973
|
+
const title = document.createElement('span');
|
|
974
|
+
title.textContent = text;
|
|
975
|
+
header.appendChild(title);
|
|
976
|
+
|
|
977
|
+
if (onClear) {
|
|
978
|
+
const clearBtn = document.createElement('button');
|
|
979
|
+
clearBtn.className = 'telescope__clear-btn';
|
|
980
|
+
clearBtn.textContent = 'Clear';
|
|
981
|
+
clearBtn.setAttribute('aria-label', `Clear ${text.toLowerCase()}`);
|
|
982
|
+
clearBtn.addEventListener('click', (e) => {
|
|
983
|
+
e.stopPropagation();
|
|
984
|
+
onClear();
|
|
985
|
+
});
|
|
986
|
+
header.appendChild(clearBtn);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return header;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private renderSearchResults(): void {
|
|
993
|
+
if (!this.resultsContainerElement) return;
|
|
994
|
+
|
|
995
|
+
this.resultsContainerElement.innerHTML = '';
|
|
996
|
+
|
|
997
|
+
// Use validated pages to prevent showing pages from other sites
|
|
998
|
+
const validatedPinned = this.validateStoredPages(this.pinnedPages);
|
|
999
|
+
const validatedRecent = this.validateStoredPages(this.recentPages);
|
|
1000
|
+
|
|
1001
|
+
// Show pinned pages section
|
|
1002
|
+
if (validatedPinned.length > 0) {
|
|
1003
|
+
this.resultsContainerElement.appendChild(
|
|
1004
|
+
this.createSectionHeader('Pinned Pages', () => this.clearPinnedPages())
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
validatedPinned.forEach((page, index) => {
|
|
1008
|
+
const listItem = this.createResultItem(page, index);
|
|
1009
|
+
this.resultsContainerElement!.appendChild(listItem);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Show recent pages section
|
|
1014
|
+
if (validatedRecent.length > 0) {
|
|
1015
|
+
this.resultsContainerElement.appendChild(
|
|
1016
|
+
this.createSectionHeader('Recently Visited', () => this.clearRecentPages())
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
// Get recent pages that aren't pinned
|
|
1020
|
+
const nonPinnedRecent = validatedRecent.filter((page) => !this.isPagePinned(page.path));
|
|
1021
|
+
const pinnedCount = validatedPinned.length;
|
|
1022
|
+
|
|
1023
|
+
nonPinnedRecent.slice(0, this.config.recentPagesCount).forEach((page, index) => {
|
|
1024
|
+
const realIndex = pinnedCount + index;
|
|
1025
|
+
const listItem = this.createResultItem(page, realIndex);
|
|
1026
|
+
this.resultsContainerElement!.appendChild(listItem);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Filter out pinned and recent pages from search results to avoid duplicates
|
|
1031
|
+
const pinnedPaths = validatedPinned.map((p) => p.path);
|
|
1032
|
+
const recentPaths = validatedRecent.map((p) => p.path);
|
|
1033
|
+
const filteredResults = this.filteredPages.filter(
|
|
1034
|
+
(page) => !pinnedPaths.includes(page.path) && !recentPaths.includes(page.path)
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
// Add search results separator if we have other sections
|
|
1038
|
+
if ((validatedRecent.length > 0 || validatedPinned.length > 0) && filteredResults.length > 0) {
|
|
1039
|
+
this.resultsContainerElement.appendChild(this.createSectionHeader('Search Results'));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Show search results or no results message
|
|
1043
|
+
if (filteredResults.length === 0 && validatedPinned.length === 0 && validatedRecent.length === 0) {
|
|
1044
|
+
const noResults = document.createElement('li');
|
|
1045
|
+
noResults.className = 'telescope__no-results';
|
|
1046
|
+
noResults.setAttribute('role', 'presentation');
|
|
1047
|
+
noResults.textContent = 'No pages found matching your search';
|
|
1048
|
+
this.resultsContainerElement.appendChild(noResults);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const pinnedCount = validatedPinned.length;
|
|
1053
|
+
const recentCount = Math.min(
|
|
1054
|
+
validatedRecent.filter((p) => !this.isPagePinned(p.path)).length,
|
|
1055
|
+
this.config.recentPagesCount
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
// Limit results to maxResults for performance
|
|
1059
|
+
const maxResults = this.config.maxResults;
|
|
1060
|
+
const hasMoreResults = filteredResults.length > maxResults;
|
|
1061
|
+
const displayResults = filteredResults.slice(0, maxResults);
|
|
1062
|
+
|
|
1063
|
+
displayResults.forEach((page, index) => {
|
|
1064
|
+
const realIndex = pinnedCount + recentCount + index;
|
|
1065
|
+
const listItem = this.createResultItem(page, realIndex);
|
|
1066
|
+
this.resultsContainerElement!.appendChild(listItem);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// Show indicator when results are truncated
|
|
1070
|
+
if (hasMoreResults) {
|
|
1071
|
+
const indicator = document.createElement('li');
|
|
1072
|
+
indicator.className = 'telescope__more-results';
|
|
1073
|
+
indicator.setAttribute('role', 'presentation');
|
|
1074
|
+
indicator.textContent = `Showing ${maxResults} of ${filteredResults.length} results. Refine your search to see more.`;
|
|
1075
|
+
this.resultsContainerElement!.appendChild(indicator);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private selectCurrentItem(): void {
|
|
1080
|
+
// Get the currently selected item directly from the DOM
|
|
1081
|
+
const selectedItem = document.querySelector('.telescope__result-item--selected');
|
|
1082
|
+
|
|
1083
|
+
if (selectedItem && selectedItem.hasAttribute('data-path')) {
|
|
1084
|
+
// Navigate to the page using the path stored in the data-path attribute
|
|
1085
|
+
const path = selectedItem.getAttribute('data-path');
|
|
1086
|
+
if (path) {
|
|
1087
|
+
this.navigateToPage(path);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private announce(message: string): void {
|
|
1093
|
+
if (this.liveRegion) {
|
|
1094
|
+
// Clear first, then use requestAnimationFrame for better screen reader timing
|
|
1095
|
+
this.liveRegion.textContent = '';
|
|
1096
|
+
requestAnimationFrame(() => {
|
|
1097
|
+
if (this.liveRegion) {
|
|
1098
|
+
this.liveRegion.textContent = message;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|