@pagefind/component-ui 1.5.0-alpha.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.
@@ -0,0 +1,888 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+ import { compile, type Template } from "adequate-little-templates";
4
+ import type {
5
+ PagefindSearchResult,
6
+ PagefindRawResult,
7
+ PagefindResultData,
8
+ PagefindError,
9
+ } from "../types";
10
+
11
+ const asyncSleep = (ms = 100): Promise<void> =>
12
+ new Promise((r) => setTimeout(r, ms));
13
+
14
+ type TemplateResult = Element | Element[] | string;
15
+
16
+ interface SearchboxResultTemplateData {
17
+ meta: Record<string, string | undefined>;
18
+ excerpt: string;
19
+ url: string;
20
+ sub_results: Array<{
21
+ title: string;
22
+ url: string;
23
+ excerpt: string;
24
+ aria: {
25
+ result_id: string;
26
+ title_id: string;
27
+ excerpt_id: string;
28
+ };
29
+ }>;
30
+ options: {
31
+ show_sub_results: boolean;
32
+ };
33
+ aria: {
34
+ result_id: string;
35
+ title_id: string;
36
+ excerpt_id: string;
37
+ };
38
+ }
39
+
40
+ const templateNodes = (templateResult: TemplateResult): Node[] => {
41
+ if (templateResult instanceof Element) {
42
+ return [templateResult];
43
+ }
44
+ if (
45
+ Array.isArray(templateResult) &&
46
+ templateResult.every((r) => r instanceof Element)
47
+ ) {
48
+ return templateResult;
49
+ }
50
+ if (typeof templateResult === "string" || templateResult instanceof String) {
51
+ const wrap = document.createElement("div");
52
+ wrap.innerHTML = templateResult as string;
53
+ return [...wrap.childNodes];
54
+ }
55
+ console.error(
56
+ `[Pagefind Searchbox]: Expected template to return HTML element or string, got ${typeof templateResult}`,
57
+ );
58
+ return [];
59
+ };
60
+
61
+ const DEFAULT_RESULT_TEMPLATE = `<a class="pf-searchbox-result" id="{{ aria.result_id }}" href="{{ meta.url | default(url) | safeUrl }}" role="option" aria-selected="false" aria-labelledby="{{ aria.title_id }}"{{#if excerpt}} aria-describedby="{{ aria.excerpt_id }}"{{/if}}>
62
+ <p class="pf-searchbox-result-title" id="{{ aria.title_id }}">{{ meta.title | default("Untitled") }}</p>
63
+ {{#if excerpt}}
64
+ <p class="pf-searchbox-result-excerpt" id="{{ aria.excerpt_id }}">{{+ excerpt +}}</p>
65
+ {{/if}}
66
+ </a>
67
+ {{#if and(options.show_sub_results, sub_results)}}
68
+ {{#each sub_results as sub}}
69
+ <a class="pf-searchbox-result pf-searchbox-subresult" id="{{ sub.aria.result_id }}" href="{{ sub.url | safeUrl }}" role="option" aria-selected="false" aria-labelledby="{{ sub.aria.title_id }}"{{#if sub.excerpt}} aria-describedby="{{ sub.aria.excerpt_id }}"{{/if}}>
70
+ <p class="pf-searchbox-result-title" id="{{ sub.aria.title_id }}">{{ sub.title | default("Section") }}</p>
71
+ {{#if sub.excerpt}}
72
+ <p class="pf-searchbox-result-excerpt" id="{{ sub.aria.excerpt_id }}">{{+ sub.excerpt +}}</p>
73
+ {{/if}}
74
+ </a>
75
+ {{/each}}
76
+ {{/if}}`;
77
+
78
+ const defaultResultTemplate: Template<SearchboxResultTemplateData> = compile(
79
+ DEFAULT_RESULT_TEMPLATE,
80
+ );
81
+
82
+ const DEFAULT_PLACEHOLDER_TEMPLATE = `<div class="pf-searchbox-result pf-searchbox-placeholder" role="option" aria-selected="false">
83
+ <p class="pf-searchbox-result-title pf-skeleton pf-skeleton-title"></p>
84
+ <p class="pf-searchbox-result-excerpt pf-skeleton pf-skeleton-excerpt"></p>
85
+ </div>`;
86
+
87
+ const defaultPlaceholderTemplate: Template<Record<string, never>> = compile(
88
+ DEFAULT_PLACEHOLDER_TEMPLATE,
89
+ );
90
+
91
+ interface SearchboxResultOptions {
92
+ rawResult: PagefindRawResult;
93
+ placeholderEl: Element;
94
+ renderFn: (result: PagefindResultData) => TemplateResult;
95
+ intersectionRoot: Element | null;
96
+ onLoad?: () => void;
97
+ }
98
+
99
+ class SearchboxResult {
100
+ rawResult: PagefindRawResult;
101
+ placeholderEl: Element;
102
+ renderFn: (result: PagefindResultData) => TemplateResult;
103
+ intersectionRoot: Element | null;
104
+ onLoad?: () => void;
105
+ data: PagefindResultData | null = null;
106
+ private observer: IntersectionObserver | null = null;
107
+
108
+ constructor(opts: SearchboxResultOptions) {
109
+ this.rawResult = opts.rawResult;
110
+ this.placeholderEl = opts.placeholderEl;
111
+ this.renderFn = opts.renderFn;
112
+ this.intersectionRoot = opts.intersectionRoot;
113
+ this.onLoad = opts.onLoad;
114
+ this.setupObserver();
115
+ }
116
+
117
+ setupObserver(): void {
118
+ if (this.data !== null || this.observer !== null) return;
119
+
120
+ const options = {
121
+ root: this.intersectionRoot,
122
+ rootMargin: "50px", // Start loading slightly before visible
123
+ threshold: 0.01,
124
+ };
125
+
126
+ this.observer = new IntersectionObserver((entries, obs) => {
127
+ if (this.data !== null) return;
128
+ if (entries?.[0]?.isIntersecting) {
129
+ this.load();
130
+ obs.disconnect();
131
+ this.observer = null;
132
+ }
133
+ }, options);
134
+
135
+ this.observer.observe(this.placeholderEl);
136
+ }
137
+
138
+ async load(): Promise<void> {
139
+ this.data = await this.rawResult.data();
140
+ const templateResult = this.renderFn(this.data);
141
+ const nodes = templateNodes(templateResult);
142
+
143
+ if (nodes.length > 0 && this.placeholderEl.parentNode) {
144
+ const firstNode = nodes[0];
145
+ this.placeholderEl.replaceWith(...nodes);
146
+ if (firstNode instanceof Element) {
147
+ this.placeholderEl = firstNode;
148
+ }
149
+ }
150
+
151
+ this.onLoad?.();
152
+ }
153
+
154
+ cleanup(): void {
155
+ if (this.observer) {
156
+ this.observer.disconnect();
157
+ this.observer = null;
158
+ }
159
+ }
160
+ }
161
+
162
+ export class PagefindSearchbox extends PagefindElement {
163
+ static get observedAttributes(): string[] {
164
+ return [
165
+ "placeholder",
166
+ "debounce",
167
+ "autofocus",
168
+ "show-sub-results",
169
+ "max-results",
170
+ "show-keyboard-hints",
171
+ ];
172
+ }
173
+
174
+ containerEl: HTMLElement | null = null;
175
+ inputEl: HTMLInputElement | null = null;
176
+ dropdownEl: HTMLElement | null = null;
177
+ resultsEl: HTMLUListElement | null = null;
178
+ footerEl: HTMLElement | null = null;
179
+
180
+ isOpen: boolean = false;
181
+ isLoading: boolean = false;
182
+ results: SearchboxResult[] = [];
183
+ activeIndex: number = -1;
184
+ searchID: number = 0;
185
+ searchTerm: string = "";
186
+
187
+ private _userPlaceholder: string | null = null;
188
+ debounce: number = 150;
189
+ autofocus: boolean = false;
190
+ showSubResults: boolean = false;
191
+ maxResults: number = 0; // 0 means no limit
192
+ showKeyboardHints: boolean = true;
193
+
194
+ resultTemplate: ((result: PagefindResultData) => TemplateResult) | null =
195
+ null;
196
+
197
+ private compiledResultTemplate: Template<SearchboxResultTemplateData> | null =
198
+ null;
199
+ private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
200
+
201
+ constructor() {
202
+ super();
203
+ }
204
+
205
+ get placeholder(): string {
206
+ return (
207
+ this._userPlaceholder ||
208
+ this.instance?.translate("placeholder") ||
209
+ "Search..."
210
+ );
211
+ }
212
+
213
+ private readAttributes(): void {
214
+ if (this.hasAttribute("placeholder")) {
215
+ this._userPlaceholder = this.getAttribute("placeholder");
216
+ }
217
+ if (this.hasAttribute("debounce")) {
218
+ this.debounce =
219
+ parseInt(this.getAttribute("debounce") || "150", 10) || 150;
220
+ }
221
+ if (this.hasAttribute("autofocus")) {
222
+ this.autofocus = this.hasAttribute("autofocus");
223
+ }
224
+ if (this.hasAttribute("show-sub-results")) {
225
+ this.showSubResults = this.getAttribute("show-sub-results") !== "false";
226
+ }
227
+ if (this.hasAttribute("max-results")) {
228
+ this.maxResults = parseInt(this.getAttribute("max-results") || "0", 10);
229
+ }
230
+ if (this.hasAttribute("show-keyboard-hints")) {
231
+ this.showKeyboardHints =
232
+ this.getAttribute("show-keyboard-hints") !== "false";
233
+ }
234
+ }
235
+
236
+ init(): void {
237
+ this.readAttributes();
238
+ this.checkForTemplates();
239
+ this.render();
240
+ this.setupOutsideClickHandler();
241
+ }
242
+
243
+ private checkForTemplates(): void {
244
+ const resultScript = this.querySelector(
245
+ 'script[type="text/pagefind-template"]:not([data-template]), script[type="text/pagefind-template"][data-template="result"]',
246
+ );
247
+ if (resultScript) {
248
+ this.compiledResultTemplate = compile(
249
+ (resultScript.textContent || "").trim(),
250
+ );
251
+ }
252
+ }
253
+
254
+ render(): void {
255
+ const savedScripts: HTMLScriptElement[] = [];
256
+ this.querySelectorAll('script[type="text/pagefind-template"]').forEach(
257
+ (s) => {
258
+ savedScripts.push(s as HTMLScriptElement);
259
+ },
260
+ );
261
+
262
+ this.innerHTML = "";
263
+
264
+ savedScripts.forEach((s) => this.appendChild(s));
265
+
266
+ const inputId = this.instance!.generateId("pf-sb-input");
267
+ const resultsId = this.instance!.generateId("pf-sb-results");
268
+
269
+ this.containerEl = document.createElement("div");
270
+ this.containerEl.className = "pf-searchbox";
271
+ this.appendChild(this.containerEl);
272
+
273
+ const inputWrapper = document.createElement("div");
274
+ inputWrapper.className = "pf-searchbox-input-wrapper";
275
+ this.containerEl.appendChild(inputWrapper);
276
+
277
+ this.inputEl = document.createElement("input");
278
+ this.inputEl.id = inputId;
279
+ this.inputEl.className = "pf-searchbox-input";
280
+ this.inputEl.type = "text";
281
+ this.inputEl.setAttribute("role", "combobox");
282
+ this.inputEl.setAttribute("aria-autocomplete", "list");
283
+ this.inputEl.setAttribute("aria-controls", resultsId);
284
+ this.inputEl.setAttribute("aria-expanded", "false");
285
+ this.inputEl.setAttribute("autocomplete", "off");
286
+ this.inputEl.setAttribute("autocapitalize", "none");
287
+ this.inputEl.placeholder = this.placeholder;
288
+ if (this.autofocus) {
289
+ this.inputEl.setAttribute("autofocus", "autofocus");
290
+ }
291
+ inputWrapper.appendChild(this.inputEl);
292
+
293
+ this.dropdownEl = document.createElement("div");
294
+ this.dropdownEl.className = "pf-searchbox-dropdown";
295
+ this.containerEl.appendChild(this.dropdownEl);
296
+
297
+ const resultsLabel =
298
+ this.instance?.translate("results_label") || "Search results";
299
+
300
+ if (this.instance?.direction === "rtl") {
301
+ this.setAttribute("dir", "rtl");
302
+ } else {
303
+ this.removeAttribute("dir");
304
+ }
305
+
306
+ this.resultsEl = document.createElement("ul");
307
+ this.resultsEl.id = resultsId;
308
+ this.resultsEl.className = "pf-searchbox-results";
309
+ this.resultsEl.setAttribute("role", "listbox");
310
+ this.resultsEl.setAttribute("aria-label", resultsLabel);
311
+ this.dropdownEl.appendChild(this.resultsEl);
312
+
313
+ if (this.showKeyboardHints) {
314
+ this.footerEl = document.createElement("div");
315
+ this.footerEl.className = "pf-searchbox-footer";
316
+ this.footerEl.setAttribute("aria-hidden", "true");
317
+ this.dropdownEl.appendChild(this.footerEl);
318
+
319
+ this.renderFooterHints();
320
+ }
321
+ this.setupEventHandlers();
322
+ }
323
+
324
+ private renderFooterHints(): void {
325
+ if (!this.footerEl) return;
326
+ this.footerEl.innerHTML = "";
327
+
328
+ const navigateText =
329
+ this.instance?.translate("keyboard_navigate") || "navigate";
330
+ const selectText = this.instance?.translate("keyboard_select") || "select";
331
+ const closeText = this.instance?.translate("keyboard_close") || "close";
332
+
333
+ const navHint = document.createElement("div");
334
+ navHint.className = "pf-searchbox-footer-hint";
335
+ const navKeyUp = document.createElement("span");
336
+ navKeyUp.className = "pf-searchbox-footer-key";
337
+ navKeyUp.textContent = "↑";
338
+ navHint.appendChild(navKeyUp);
339
+ const navKeyDown = document.createElement("span");
340
+ navKeyDown.className = "pf-searchbox-footer-key";
341
+ navKeyDown.textContent = "↓";
342
+ navHint.appendChild(navKeyDown);
343
+ navHint.appendChild(document.createTextNode(` ${navigateText}`));
344
+ this.footerEl.appendChild(navHint);
345
+
346
+ const selectHint = document.createElement("div");
347
+ selectHint.className = "pf-searchbox-footer-hint";
348
+ const selectKey = document.createElement("span");
349
+ selectKey.className = "pf-searchbox-footer-key";
350
+ selectKey.textContent = "↵";
351
+ selectHint.appendChild(selectKey);
352
+ selectHint.appendChild(document.createTextNode(` ${selectText}`));
353
+ this.footerEl.appendChild(selectHint);
354
+
355
+ const closeHint = document.createElement("div");
356
+ closeHint.className = "pf-searchbox-footer-hint";
357
+ const closeKey = document.createElement("span");
358
+ closeKey.className = "pf-searchbox-footer-key";
359
+ closeKey.textContent = "esc";
360
+ closeHint.appendChild(closeKey);
361
+ closeHint.appendChild(document.createTextNode(` ${closeText}`));
362
+ this.footerEl.appendChild(closeHint);
363
+ }
364
+
365
+ private setupEventHandlers(): void {
366
+ if (!this.inputEl || !this.resultsEl) return;
367
+
368
+ this.inputEl.addEventListener("input", async (e) => {
369
+ const value = (e.target as HTMLInputElement).value;
370
+ this.searchTerm = value;
371
+
372
+ if (!value || !value.trim()) {
373
+ this.closeDropdown();
374
+ this.results = [];
375
+ this.instance?.triggerSearch("");
376
+ return;
377
+ }
378
+
379
+ this.openDropdown();
380
+ this.showLoadingState();
381
+
382
+ const thisSearchID = ++this.searchID;
383
+ await asyncSleep(this.debounce);
384
+
385
+ if (thisSearchID !== this.searchID) {
386
+ return;
387
+ }
388
+
389
+ this.instance?.triggerSearch(value);
390
+ });
391
+
392
+ this.inputEl.addEventListener("keydown", (e) => {
393
+ switch (e.key) {
394
+ case "ArrowDown":
395
+ e.preventDefault();
396
+ if (!this.isOpen && this.inputEl?.value.trim()) {
397
+ this.openDropdown();
398
+ }
399
+ if (this.isOpen && this.results.length > 0) {
400
+ this.moveSelection(1);
401
+ }
402
+ break;
403
+
404
+ case "ArrowUp":
405
+ e.preventDefault();
406
+ if (this.isOpen && this.results.length > 0) {
407
+ this.moveSelection(-1);
408
+ }
409
+ break;
410
+
411
+ case "Enter":
412
+ if (this.isOpen && this.activeIndex >= 0) {
413
+ e.preventDefault();
414
+ this.activateCurrentSelection(e);
415
+ } else if (!this.isOpen && this.inputEl?.value.trim()) {
416
+ e.preventDefault();
417
+ this.openDropdown();
418
+ if (this.results.length > 0) {
419
+ this.rerenderLoadedResults();
420
+ this.activeIndex = 0;
421
+ this.updateSelectionUI();
422
+ } else {
423
+ this.instance?.triggerSearch(this.inputEl.value);
424
+ }
425
+ }
426
+ break;
427
+
428
+ case "Escape":
429
+ if (this.isOpen) {
430
+ e.preventDefault();
431
+ this.closeDropdown();
432
+ }
433
+ break;
434
+
435
+ case "Tab":
436
+ if (this.isOpen) {
437
+ this.closeDropdown();
438
+ }
439
+ break;
440
+ }
441
+ });
442
+
443
+ this.inputEl.addEventListener("focus", () => {
444
+ this.instance?.triggerLoad();
445
+ });
446
+
447
+ this.resultsEl.addEventListener("click", (e) => {
448
+ const resultLink = (e.target as Element).closest("a.pf-searchbox-result");
449
+ if (resultLink) {
450
+ this.closeDropdown();
451
+ }
452
+ });
453
+
454
+ this.resultsEl.addEventListener("mousemove", (e) => {
455
+ const resultLink = (e.target as Element).closest("a.pf-searchbox-result");
456
+ if (resultLink) {
457
+ const index = this.getResultIndexFromElement(
458
+ resultLink as HTMLAnchorElement,
459
+ );
460
+ if (index !== -1 && index !== this.activeIndex) {
461
+ this.activeIndex = index;
462
+ this.updateSelectionUI(false);
463
+ }
464
+ }
465
+ });
466
+ }
467
+
468
+ private setupOutsideClickHandler(): void {
469
+ this._documentClickHandler = (e: MouseEvent) => {
470
+ if (this.isOpen && !this.contains(e.target as Node)) {
471
+ this.closeDropdown();
472
+ }
473
+ };
474
+ document.addEventListener("click", this._documentClickHandler);
475
+ }
476
+
477
+ private openDropdown(): void {
478
+ if (this.isOpen || !this.containerEl || !this.inputEl) return;
479
+ this.isOpen = true;
480
+ this.containerEl.classList.add("open");
481
+ this.inputEl.setAttribute("aria-expanded", "true");
482
+ }
483
+
484
+ private closeDropdown(): void {
485
+ if (!this.isOpen || !this.containerEl || !this.inputEl) return;
486
+ this.isOpen = false;
487
+ this.containerEl.classList.remove("open");
488
+ this.inputEl.setAttribute("aria-expanded", "false");
489
+ this.inputEl.removeAttribute("aria-activedescendant");
490
+ this.activeIndex = -1;
491
+ }
492
+
493
+ private showLoadingState(): void {
494
+ if (!this.resultsEl) return;
495
+ this.isLoading = true;
496
+ this.resultsEl.innerHTML = "";
497
+
498
+ const searchingText =
499
+ this.instance?.translate("searching", { SEARCH_TERM: this.searchTerm }) ||
500
+ "Searching...";
501
+
502
+ const loadingEl = document.createElement("div");
503
+ loadingEl.className = "pf-searchbox-loading";
504
+ loadingEl.textContent = searchingText;
505
+ this.resultsEl.appendChild(loadingEl);
506
+ }
507
+
508
+ private showEmptyState(): void {
509
+ if (!this.resultsEl) return;
510
+ this.resultsEl.innerHTML = "";
511
+
512
+ const noResultsText =
513
+ this.instance?.translate("zero_results", {
514
+ SEARCH_TERM: this.searchTerm,
515
+ }) || `No results for "${this.searchTerm}"`;
516
+
517
+ const emptyEl = document.createElement("div");
518
+ emptyEl.className = "pf-searchbox-empty";
519
+ emptyEl.textContent = noResultsText;
520
+ this.resultsEl.appendChild(emptyEl);
521
+
522
+ this.instance?.announce(
523
+ "zero_results",
524
+ { SEARCH_TERM: this.searchTerm },
525
+ "assertive",
526
+ );
527
+ }
528
+
529
+ private moveSelection(delta: number): void {
530
+ const totalItems = this.getTotalNavigableItems();
531
+ if (totalItems === 0) return;
532
+
533
+ let newIndex = this.activeIndex + delta;
534
+
535
+ if (newIndex < -1) {
536
+ newIndex = -1;
537
+ } else if (newIndex >= totalItems) {
538
+ newIndex = totalItems - 1;
539
+ }
540
+
541
+ this.activeIndex = newIndex;
542
+ this.updateSelectionUI(true);
543
+ }
544
+
545
+ private getTotalNavigableItems(): number {
546
+ if (!this.resultsEl) return 0;
547
+ return this.resultsEl.querySelectorAll(".pf-searchbox-result").length;
548
+ }
549
+
550
+ private updateSelectionUI(scroll: boolean = false): void {
551
+ if (!this.resultsEl || !this.inputEl) return;
552
+
553
+ this.resultsEl.querySelectorAll("[data-pf-selected]").forEach((el) => {
554
+ el.removeAttribute("data-pf-selected");
555
+ el.setAttribute("aria-selected", "false");
556
+ });
557
+
558
+ const activeEl = this.getResultElementByIndex(this.activeIndex);
559
+
560
+ if (activeEl) {
561
+ activeEl.setAttribute("data-pf-selected", "");
562
+ activeEl.setAttribute("aria-selected", "true");
563
+ this.inputEl.setAttribute("aria-activedescendant", activeEl.id);
564
+ if (scroll) {
565
+ this.scrollToCenter(activeEl);
566
+ }
567
+ } else {
568
+ this.inputEl.removeAttribute("aria-activedescendant");
569
+ }
570
+ }
571
+
572
+ private scrollToCenter(el: HTMLElement): void {
573
+ if (!this.resultsEl) return;
574
+ const container = this.resultsEl;
575
+ const elTop = el.offsetTop;
576
+ const elHeight = el.offsetHeight;
577
+ const containerHeight = container.clientHeight;
578
+ const targetScroll = elTop - containerHeight / 2 + elHeight / 2;
579
+ container.scrollTo({ top: targetScroll, behavior: "smooth" });
580
+ }
581
+
582
+ private getResultElementByIndex(index: number): HTMLElement | null {
583
+ if (index < 0 || !this.resultsEl) return null;
584
+ const allLinks = this.resultsEl.querySelectorAll("a.pf-searchbox-result");
585
+ return (allLinks[index] as HTMLElement) || null;
586
+ }
587
+
588
+ private getResultIndexFromElement(el: HTMLAnchorElement): number {
589
+ if (!this.resultsEl) return -1;
590
+ const allLinks = Array.from(
591
+ this.resultsEl.querySelectorAll("a.pf-searchbox-result"),
592
+ );
593
+ return allLinks.indexOf(el);
594
+ }
595
+
596
+ private activateCurrentSelection(keyboardEvent: KeyboardEvent): void {
597
+ const activeEl = this.getResultElementByIndex(
598
+ this.activeIndex,
599
+ ) as HTMLAnchorElement | null;
600
+ if (!activeEl || !activeEl.href) return;
601
+
602
+ if (keyboardEvent.metaKey || keyboardEvent.ctrlKey) {
603
+ window.open(activeEl.href, "_blank");
604
+ } else if (keyboardEvent.shiftKey) {
605
+ window.open(activeEl.href, "_blank");
606
+ } else {
607
+ window.location.href = activeEl.href;
608
+ }
609
+
610
+ this.closeDropdown();
611
+ }
612
+
613
+ private handleResults(searchResult: PagefindSearchResult): void {
614
+ this.isLoading = false;
615
+
616
+ for (const result of this.results) {
617
+ result.cleanup();
618
+ }
619
+
620
+ if (!searchResult.results || searchResult.results.length === 0) {
621
+ this.results = [];
622
+ this.showEmptyState();
623
+ return;
624
+ }
625
+
626
+ const limitedResults =
627
+ this.maxResults > 0
628
+ ? searchResult.results.slice(0, this.maxResults)
629
+ : searchResult.results;
630
+
631
+ if (this.resultsEl) {
632
+ this.resultsEl.innerHTML = "";
633
+ }
634
+
635
+ const renderer = this.getResultRenderer();
636
+
637
+ this.results = limitedResults.map((rawResult) => {
638
+ const placeholderHtml = defaultPlaceholderTemplate({});
639
+ const placeholderNodes = templateNodes(placeholderHtml);
640
+ const placeholderEl = placeholderNodes[0] as Element;
641
+
642
+ if (this.resultsEl && placeholderEl) {
643
+ this.resultsEl.appendChild(placeholderEl);
644
+ }
645
+
646
+ return new SearchboxResult({
647
+ rawResult,
648
+ placeholderEl,
649
+ renderFn: renderer,
650
+ intersectionRoot: this.resultsEl,
651
+ onLoad: () => {
652
+ this.updateSelectionUI();
653
+ },
654
+ });
655
+ });
656
+
657
+ this.activeIndex = 0;
658
+ this.updateSelectionUI();
659
+ this.announceResults();
660
+ }
661
+
662
+ private buildTemplateData(
663
+ result: PagefindResultData,
664
+ ): SearchboxResultTemplateData {
665
+ const subResults = this.showSubResults
666
+ ? this.instance!.getDisplaySubResults(result)
667
+ : [];
668
+
669
+ const resultId = this.instance!.generateId("pf-sb-result");
670
+
671
+ return {
672
+ meta: result.meta || {},
673
+ excerpt: result.excerpt || "",
674
+ url: result.url || "",
675
+ sub_results: subResults.map((sr) => {
676
+ const subResultId = this.instance!.generateId("pf-sb-result");
677
+ return {
678
+ title: sr.title,
679
+ url: sr.url,
680
+ excerpt: sr.excerpt,
681
+ aria: {
682
+ result_id: subResultId,
683
+ title_id: `${subResultId}-title`,
684
+ excerpt_id: `${subResultId}-excerpt`,
685
+ },
686
+ };
687
+ }),
688
+ options: {
689
+ show_sub_results: this.showSubResults,
690
+ },
691
+ aria: {
692
+ result_id: resultId,
693
+ title_id: `${resultId}-title`,
694
+ excerpt_id: `${resultId}-excerpt`,
695
+ },
696
+ };
697
+ }
698
+
699
+ /**
700
+ * Returns the render function for results.
701
+ * Priority: JS function > script template > default template
702
+ */
703
+ private getResultRenderer(): (result: PagefindResultData) => TemplateResult {
704
+ if (this.resultTemplate) {
705
+ return this.resultTemplate;
706
+ }
707
+
708
+ if (this.compiledResultTemplate) {
709
+ const template = this.compiledResultTemplate;
710
+ return (result) => {
711
+ const data = this.buildTemplateData(result);
712
+ return template(data);
713
+ };
714
+ }
715
+
716
+ return (result) => {
717
+ const data = this.buildTemplateData(result);
718
+ return defaultResultTemplate(data);
719
+ };
720
+ }
721
+
722
+ private rerenderLoadedResults(): void {
723
+ if (!this.resultsEl) return;
724
+ this.resultsEl.innerHTML = "";
725
+
726
+ for (const result of this.results) {
727
+ if (result.data) {
728
+ const templateData = this.buildTemplateData(result.data);
729
+ let templateResult: TemplateResult;
730
+ if (this.resultTemplate) {
731
+ templateResult = this.resultTemplate(result.data);
732
+ } else if (this.compiledResultTemplate) {
733
+ templateResult = this.compiledResultTemplate(templateData);
734
+ } else {
735
+ templateResult = defaultResultTemplate(templateData);
736
+ }
737
+ const nodes = templateNodes(templateResult);
738
+ for (const node of nodes) {
739
+ if (node instanceof Element) {
740
+ this.resultsEl.appendChild(node);
741
+ result.placeholderEl = node;
742
+ break;
743
+ }
744
+ }
745
+ for (const node of nodes.slice(1)) {
746
+ this.resultsEl.appendChild(node);
747
+ }
748
+ } else {
749
+ const placeholderHtml = defaultPlaceholderTemplate({});
750
+ const placeholderNodes = templateNodes(placeholderHtml);
751
+ const placeholderEl = placeholderNodes[0] as Element;
752
+ if (placeholderEl) {
753
+ this.resultsEl.appendChild(placeholderEl);
754
+ result.placeholderEl = placeholderEl;
755
+ result.cleanup();
756
+ result.setupObserver();
757
+ }
758
+ }
759
+ }
760
+ }
761
+
762
+ private announceResults(): void {
763
+ const count = this.results.length;
764
+ if (count === 0) {
765
+ this.instance?.announce(
766
+ "zero_results",
767
+ { SEARCH_TERM: this.searchTerm },
768
+ "assertive",
769
+ );
770
+ } else {
771
+ const key = count === 1 ? "one_result" : "many_results";
772
+ this.instance?.announce(key, {
773
+ SEARCH_TERM: this.searchTerm,
774
+ COUNT: count,
775
+ });
776
+ }
777
+ }
778
+
779
+ register(instance: Instance): void {
780
+ instance.registerInput(this, {
781
+ keyboardNavigation: true,
782
+ });
783
+ instance.registerResults(this, {
784
+ keyboardNavigation: true,
785
+ announcements: true,
786
+ });
787
+
788
+ instance.on(
789
+ "loading",
790
+ () => {
791
+ if (this.searchTerm && this.searchTerm.trim()) {
792
+ this.openDropdown();
793
+ this.showLoadingState();
794
+ }
795
+ },
796
+ this,
797
+ );
798
+
799
+ instance.on(
800
+ "results",
801
+ (results: unknown) => {
802
+ this.handleResults(results as PagefindSearchResult);
803
+ },
804
+ this,
805
+ );
806
+
807
+ instance.on(
808
+ "error",
809
+ (error: unknown) => {
810
+ const err = error as PagefindError;
811
+ this.isLoading = false;
812
+ const errorText = instance.translate("error_search") || "Search failed";
813
+ this.showError({
814
+ message: err.message || errorText,
815
+ details: err.bundlePath
816
+ ? `Bundle path: ${err.bundlePath}`
817
+ : undefined,
818
+ });
819
+
820
+ instance.announce("error_search", {}, "assertive");
821
+ },
822
+ this,
823
+ );
824
+
825
+ instance.on(
826
+ "search",
827
+ (term: unknown) => {
828
+ if (this.inputEl && document.activeElement !== this.inputEl) {
829
+ this.inputEl.value = term as string;
830
+ this.searchTerm = term as string;
831
+ }
832
+ },
833
+ this,
834
+ );
835
+
836
+ instance.on(
837
+ "translations",
838
+ () => {
839
+ const currentValue = this.inputEl?.value || "";
840
+ const wasOpen = this.isOpen;
841
+ this.render();
842
+ if (this.inputEl && currentValue) {
843
+ this.inputEl.value = currentValue;
844
+ }
845
+ if (wasOpen) {
846
+ this.openDropdown();
847
+ if (this.results.length > 0) {
848
+ this.rerenderLoadedResults();
849
+ this.updateSelectionUI();
850
+ }
851
+ }
852
+ },
853
+ this,
854
+ );
855
+ }
856
+
857
+ cleanup(): void {
858
+ for (const result of this.results) {
859
+ result.cleanup();
860
+ }
861
+ this.results = [];
862
+
863
+ if (this._documentClickHandler) {
864
+ document.removeEventListener("click", this._documentClickHandler);
865
+ this._documentClickHandler = null;
866
+ }
867
+ }
868
+
869
+ update(): void {
870
+ this.readAttributes();
871
+ if (this._documentClickHandler) {
872
+ document.removeEventListener("click", this._documentClickHandler);
873
+ this._documentClickHandler = null;
874
+ }
875
+ this.render();
876
+ this.setupOutsideClickHandler();
877
+ }
878
+
879
+ focus(): void {
880
+ if (this.inputEl) {
881
+ this.inputEl.focus();
882
+ }
883
+ }
884
+ }
885
+
886
+ if (!customElements.get("pagefind-searchbox")) {
887
+ customElements.define("pagefind-searchbox", PagefindSearchbox);
888
+ }