@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,586 @@
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
+ type TemplateResult = Element | Element[] | string;
12
+
13
+ interface ResultTemplateData {
14
+ meta: Record<string, string | undefined>;
15
+ excerpt: string;
16
+ url: string;
17
+ sub_results: Array<{ title: string; url: string; excerpt: string }>;
18
+ options: {
19
+ link_target: string | null;
20
+ show_images: boolean;
21
+ };
22
+ }
23
+
24
+ const templateNodes = (templateResult: TemplateResult): Node[] => {
25
+ if (templateResult instanceof Element) {
26
+ return [templateResult];
27
+ }
28
+ if (
29
+ Array.isArray(templateResult) &&
30
+ templateResult.every((r) => r instanceof Element)
31
+ ) {
32
+ return templateResult;
33
+ }
34
+ if (typeof templateResult === "string" || templateResult instanceof String) {
35
+ const wrap = document.createElement("div");
36
+ wrap.innerHTML = templateResult as string;
37
+ return [...wrap.childNodes];
38
+ }
39
+ console.error(
40
+ `[Pagefind Results]: Expected template to return HTML element or string, got ${typeof templateResult}`,
41
+ );
42
+ return [];
43
+ };
44
+
45
+ const DEFAULT_RESULT_TEMPLATE = `<li class="pf-result">
46
+ <div class="pf-result-card">
47
+ {{#if and(options.show_images, meta.image)}}
48
+ <img class="pf-result-image" src="{{ meta.image }}" alt="{{ meta.image_alt | default(meta.title) }}">
49
+ {{/if}}
50
+ <div class="pf-result-content">
51
+ <p class="pf-result-title">
52
+ <a class="pf-result-link" href="{{ meta.url | default(url) | safeUrl }}"{{#if options.link_target}} target="{{ options.link_target }}"{{/if}}{{#if eq(options.link_target, "_blank")}} rel="noopener"{{/if}}>{{ meta.title }}</a>
53
+ </p>
54
+ {{#if excerpt}}
55
+ <p class="pf-result-excerpt">{{+ excerpt +}}</p>
56
+ {{/if}}
57
+ </div>
58
+ </div>
59
+ {{#if sub_results}}
60
+ <ul class="pf-heading-chips">
61
+ {{#each sub_results as sub}}
62
+ <li class="pf-heading-chip">
63
+ <a class="pf-heading-link" href="{{ sub.url | safeUrl }}"{{#if options.link_target}} target="{{ options.link_target }}"{{/if}}{{#if eq(options.link_target, "_blank")}} rel="noopener"{{/if}}>{{ sub.title }}</a>
64
+ <p class="pf-heading-excerpt">{{+ sub.excerpt +}}</p>
65
+ </li>
66
+ {{/each}}
67
+ </ul>
68
+ {{/if}}
69
+ </li>`;
70
+
71
+ const DEFAULT_PLACEHOLDER_TEMPLATE = `<li class="pf-result">
72
+ <div class="pf-result-card">
73
+ <div class="pf-skeleton pf-skeleton-image"></div>
74
+ <div class="pf-result-content">
75
+ <p class="pf-result-title pf-skeleton pf-skeleton-title"></p>
76
+ <p class="pf-result-excerpt pf-skeleton pf-skeleton-excerpt"></p>
77
+ </div>
78
+ </div>
79
+ </li>`;
80
+
81
+ const defaultResultTemplate: Template<ResultTemplateData> = compile(
82
+ DEFAULT_RESULT_TEMPLATE,
83
+ );
84
+ const defaultPlaceholderTemplate: Template<Record<string, never>> = compile(
85
+ DEFAULT_PLACEHOLDER_TEMPLATE,
86
+ );
87
+
88
+ const nearestScrollParent = (el: Element | null): Element | null => {
89
+ if (!(el instanceof HTMLElement)) return null;
90
+ const overflowY = window.getComputedStyle(el).overflowY;
91
+ const isScrollable = overflowY !== "visible" && overflowY !== "hidden";
92
+ return isScrollable
93
+ ? el
94
+ : nearestScrollParent(el.parentNode as Element | null);
95
+ };
96
+
97
+ interface ResultRenderOptions {
98
+ showImages: boolean;
99
+ showSubResults: boolean;
100
+ maxSubResults: number;
101
+ linkTarget: string | null;
102
+ }
103
+
104
+ interface ResultOptions {
105
+ result: PagefindRawResult;
106
+ placeholderNodes: Node[];
107
+ resultFn: (
108
+ result: PagefindResultData,
109
+ options: ResultRenderOptions,
110
+ ) => TemplateResult;
111
+ intersectionEl: Element | null;
112
+ showImages: boolean;
113
+ showSubResults: boolean;
114
+ maxSubResults: number;
115
+ linkTarget: string | null;
116
+ }
117
+
118
+ class Result {
119
+ rawResult: PagefindRawResult;
120
+ placeholderNodes: Node[];
121
+ resultFn: (
122
+ result: PagefindResultData,
123
+ options: ResultRenderOptions,
124
+ ) => TemplateResult;
125
+ intersectionEl: Element | null;
126
+ showImages: boolean;
127
+ showSubResults: boolean;
128
+ maxSubResults: number;
129
+ linkTarget: string | null;
130
+ result: PagefindResultData | null = null;
131
+ private observer: IntersectionObserver | null = null;
132
+
133
+ constructor(opts: ResultOptions) {
134
+ this.rawResult = opts.result;
135
+ this.placeholderNodes = opts.placeholderNodes;
136
+ this.resultFn = opts.resultFn;
137
+ this.intersectionEl = opts.intersectionEl;
138
+ this.showImages = opts.showImages;
139
+ this.showSubResults = opts.showSubResults;
140
+ this.maxSubResults = opts.maxSubResults;
141
+ this.linkTarget = opts.linkTarget;
142
+ this.setupObserver();
143
+ }
144
+
145
+ setupObserver(): void {
146
+ if (this.result !== null || this.observer !== null) return;
147
+ if (!this.placeholderNodes?.length) return;
148
+
149
+ const options = {
150
+ root: this.intersectionEl,
151
+ rootMargin: "50px", // Start loading slightly before visible
152
+ threshold: 0.01,
153
+ };
154
+
155
+ this.observer = new IntersectionObserver((entries, obs) => {
156
+ if (this.result !== null) return;
157
+ if (entries?.[0]?.isIntersecting) {
158
+ this.load();
159
+ obs.disconnect();
160
+ this.observer = null;
161
+ }
162
+ }, options);
163
+
164
+ this.observer.observe(this.placeholderNodes[0] as Element);
165
+ }
166
+
167
+ async load(): Promise<void> {
168
+ if (!this.placeholderNodes?.length) return;
169
+
170
+ this.result = await this.rawResult.data();
171
+ const resultTemplate = this.resultFn(this.result, {
172
+ showImages: this.showImages,
173
+ showSubResults: this.showSubResults,
174
+ maxSubResults: this.maxSubResults,
175
+ linkTarget: this.linkTarget,
176
+ });
177
+ const resultNodes = templateNodes(resultTemplate);
178
+
179
+ while (this.placeholderNodes.length > 1) {
180
+ const node = this.placeholderNodes.pop();
181
+ if (node instanceof Element) node.remove();
182
+ }
183
+
184
+ const firstNode = this.placeholderNodes[0];
185
+ if (firstNode instanceof Element) {
186
+ firstNode.replaceWith(...resultNodes);
187
+ }
188
+ }
189
+
190
+ cleanup(): void {
191
+ if (this.observer) {
192
+ this.observer.disconnect();
193
+ this.observer = null;
194
+ }
195
+ }
196
+ }
197
+
198
+ export class PagefindResults extends PagefindElement {
199
+ static get observedAttributes(): string[] {
200
+ return [
201
+ "show-images",
202
+ "hide-sub-results",
203
+ "max-sub-results",
204
+ "link-target",
205
+ ];
206
+ }
207
+
208
+ containerEl: HTMLUListElement | null = null;
209
+ intersectionEl: Element | null = document.body;
210
+ results: Result[] = [];
211
+ showImages: boolean = false;
212
+ hideSubResults: boolean = false;
213
+ maxSubResults: number = 3;
214
+ linkTarget: string | null = null;
215
+
216
+ resultTemplate: ((result: PagefindResultData) => TemplateResult) | null =
217
+ null;
218
+
219
+ private compiledResultTemplate: Template<ResultTemplateData> | null = null;
220
+ private compiledPlaceholderTemplate: Template<Record<string, never>> | null =
221
+ null;
222
+
223
+ selectedIndex: number = -1;
224
+
225
+ constructor() {
226
+ super();
227
+ }
228
+
229
+ init(): void {
230
+ if (this.hasAttribute("show-images")) {
231
+ this.showImages = this.getAttribute("show-images") !== "false";
232
+ }
233
+ if (this.hasAttribute("hide-sub-results")) {
234
+ this.hideSubResults = this.getAttribute("hide-sub-results") !== "false";
235
+ }
236
+ if (this.hasAttribute("max-sub-results")) {
237
+ this.maxSubResults =
238
+ parseInt(this.getAttribute("max-sub-results") || "3", 10) || 3;
239
+ }
240
+ if (this.hasAttribute("link-target")) {
241
+ this.linkTarget = this.getAttribute("link-target");
242
+ }
243
+
244
+ this.checkForTemplates();
245
+ this.render();
246
+ }
247
+
248
+ private checkForTemplates(): void {
249
+ const resultScript = this.querySelector(
250
+ 'script[type="text/pagefind-template"]:not([data-template]), script[type="text/pagefind-template"][data-template="result"]',
251
+ );
252
+ if (resultScript) {
253
+ this.compiledResultTemplate = compile(
254
+ (resultScript.textContent || "").trim(),
255
+ );
256
+ }
257
+
258
+ const placeholderScript = this.querySelector(
259
+ 'script[type="text/pagefind-template"][data-template="placeholder"]',
260
+ );
261
+ if (placeholderScript) {
262
+ this.compiledPlaceholderTemplate = compile(
263
+ (placeholderScript.textContent || "").trim(),
264
+ );
265
+ }
266
+ }
267
+
268
+ private buildTemplateData(
269
+ result: PagefindResultData,
270
+ options: ResultRenderOptions,
271
+ ): ResultTemplateData {
272
+ const subResults = options.showSubResults
273
+ ? this.instance!.getDisplaySubResults(result, options.maxSubResults)
274
+ : [];
275
+
276
+ return {
277
+ meta: result.meta || {},
278
+ excerpt: result.excerpt || "",
279
+ url: result.url || "",
280
+ sub_results: subResults.map((sr) => ({
281
+ title: sr.title,
282
+ url: sr.url,
283
+ excerpt: sr.excerpt,
284
+ })),
285
+ options: {
286
+ link_target: options.linkTarget,
287
+ show_images: options.showImages,
288
+ },
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Returns the internal render function used by the Result class.
294
+ * Priority: JS function > script template > default template
295
+ */
296
+ private getResultRenderer(): (
297
+ result: PagefindResultData,
298
+ options: ResultRenderOptions,
299
+ ) => TemplateResult {
300
+ if (this.resultTemplate) {
301
+ const userFn = this.resultTemplate;
302
+ return (result, _options) => userFn(result);
303
+ }
304
+
305
+ if (this.compiledResultTemplate) {
306
+ const template = this.compiledResultTemplate;
307
+ return (result, options) => {
308
+ const data = this.buildTemplateData(result, options);
309
+ return template(data);
310
+ };
311
+ }
312
+
313
+ return (result, options) => {
314
+ const data = this.buildTemplateData(result, options);
315
+ return defaultResultTemplate(data);
316
+ };
317
+ }
318
+
319
+ private getPlaceholder(): string {
320
+ if (this.compiledPlaceholderTemplate) {
321
+ return this.compiledPlaceholderTemplate({});
322
+ }
323
+ return defaultPlaceholderTemplate({});
324
+ }
325
+
326
+ render(): void {
327
+ const savedScripts: HTMLScriptElement[] = [];
328
+ this.querySelectorAll('script[type="text/pagefind-template"]').forEach(
329
+ (s) => {
330
+ savedScripts.push(s as HTMLScriptElement);
331
+ },
332
+ );
333
+
334
+ this.innerHTML = "";
335
+
336
+ savedScripts.forEach((s) => this.appendChild(s));
337
+
338
+ const resultsLabel =
339
+ this.instance?.translate("results_label") || "Search results";
340
+
341
+ if (this.instance?.direction === "rtl") {
342
+ this.setAttribute("dir", "rtl");
343
+ } else {
344
+ this.removeAttribute("dir");
345
+ }
346
+
347
+ this.containerEl = document.createElement("ul");
348
+ this.containerEl.className = "pf-results";
349
+ this.containerEl.setAttribute("aria-label", resultsLabel);
350
+ this.containerEl.setAttribute("aria-busy", "false");
351
+ this.appendChild(this.containerEl);
352
+
353
+ this.setupKeyboardHandlers();
354
+ }
355
+
356
+ appendResults(nodes: Node[]): void {
357
+ if (!this.containerEl) return;
358
+ for (const node of nodes) {
359
+ this.containerEl.appendChild(node);
360
+ }
361
+ }
362
+
363
+ register(instance: Instance): void {
364
+ instance.registerResults(this, {
365
+ keyboardNavigation: true,
366
+ announcements: true,
367
+ });
368
+
369
+ instance.on(
370
+ "results",
371
+ (results: unknown) => {
372
+ if (!this.containerEl) return;
373
+ const searchResult = results as PagefindSearchResult;
374
+
375
+ for (const result of this.results) {
376
+ result.cleanup();
377
+ }
378
+
379
+ this.containerEl.innerHTML = "";
380
+ this.containerEl.setAttribute("aria-busy", "false");
381
+ this.intersectionEl = nearestScrollParent(this.containerEl);
382
+
383
+ this.selectedIndex = -1;
384
+
385
+ const count = searchResult?.results?.length ?? 0;
386
+ const term = instance.searchTerm;
387
+ if (term) {
388
+ const key =
389
+ count === 0
390
+ ? "zero_results"
391
+ : count === 1
392
+ ? "one_result"
393
+ : "many_results";
394
+ const priority = count === 0 ? "assertive" : "polite";
395
+ instance.announce(key, { SEARCH_TERM: term, COUNT: count }, priority);
396
+ }
397
+
398
+ const resultRenderer = this.getResultRenderer();
399
+ this.results = searchResult.results.map((r) => {
400
+ const placeholderNodes = templateNodes(this.getPlaceholder());
401
+ this.appendResults(placeholderNodes);
402
+
403
+ return new Result({
404
+ result: r,
405
+ placeholderNodes,
406
+ resultFn: resultRenderer,
407
+ intersectionEl: this.intersectionEl,
408
+ showImages: this.showImages,
409
+ showSubResults: !this.hideSubResults,
410
+ maxSubResults: this.maxSubResults,
411
+ linkTarget: this.linkTarget,
412
+ });
413
+ });
414
+ },
415
+ this,
416
+ );
417
+
418
+ instance.on(
419
+ "loading",
420
+ () => {
421
+ if (!this.containerEl) return;
422
+
423
+ this.containerEl.innerHTML = "";
424
+ this.containerEl.setAttribute("aria-busy", "true");
425
+ this.selectedIndex = -1;
426
+ },
427
+ this,
428
+ );
429
+
430
+ instance.on(
431
+ "error",
432
+ (error: unknown) => {
433
+ const err = error as PagefindError;
434
+ if (this.containerEl) {
435
+ this.containerEl.setAttribute("aria-busy", "false");
436
+ }
437
+
438
+ instance.announce("error_search", {}, "assertive");
439
+
440
+ this.showError({
441
+ message:
442
+ err.message ||
443
+ instance.translate("error_search") ||
444
+ "Failed to load search results",
445
+ details: err.bundlePath
446
+ ? `Bundle path: ${err.bundlePath}`
447
+ : undefined,
448
+ });
449
+ },
450
+ this,
451
+ );
452
+
453
+ instance.on(
454
+ "translations",
455
+ () => {
456
+ this.render();
457
+ },
458
+ this,
459
+ );
460
+ }
461
+
462
+ getResultElements(): HTMLElement[] {
463
+ if (!this.containerEl) return [];
464
+ return Array.from(this.containerEl.querySelectorAll(".pf-result"));
465
+ }
466
+
467
+ getNavigableAnchors(): HTMLAnchorElement[] {
468
+ if (!this.containerEl) return [];
469
+ return Array.from(this.containerEl.querySelectorAll(".pf-result a"));
470
+ }
471
+
472
+ private setupKeyboardHandlers(): void {
473
+ if (!this.containerEl) return;
474
+
475
+ this.containerEl.addEventListener("keydown", (e) => {
476
+ const anchor = (e.target as Element).closest("a");
477
+ if (!anchor) return;
478
+
479
+ const anchors = this.getNavigableAnchors();
480
+ const index = anchors.indexOf(anchor as HTMLAnchorElement);
481
+ if (index === -1) return;
482
+
483
+ if (e.key === "ArrowDown") {
484
+ e.preventDefault();
485
+ if (index < anchors.length - 1) {
486
+ const next = anchors[index + 1];
487
+ next.focus();
488
+ this.scrollToCenter(next);
489
+ }
490
+ } else if (e.key === "ArrowUp") {
491
+ e.preventDefault();
492
+ if (index > 0) {
493
+ const prev = anchors[index - 1];
494
+ prev.focus();
495
+ this.scrollToCenter(prev);
496
+ } else {
497
+ // At first anchor, go back to input
498
+ this.instance?.focusPreviousInput(document.activeElement as Element);
499
+ }
500
+ } else if (e.key === "Backspace") {
501
+ e.preventDefault();
502
+ this.instance?.focusInputAndDelete(document.activeElement as Element);
503
+ } else if (e.key === "/") {
504
+ e.preventDefault();
505
+ this.instance?.focusPreviousInput(document.activeElement as Element);
506
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
507
+ // Printable character - return to input and type it
508
+ e.preventDefault();
509
+ this.instance?.focusInputAndType(
510
+ document.activeElement as Element,
511
+ e.key,
512
+ );
513
+ }
514
+ });
515
+
516
+ this.containerEl.addEventListener("focusin", (e) => {
517
+ const anchor = (e.target as Element).closest("a");
518
+ if (!anchor) return;
519
+
520
+ this.clearSelection();
521
+ anchor.setAttribute("data-pf-selected", "");
522
+
523
+ const navigateText =
524
+ this.instance?.translate("keyboard_navigate") || "navigate";
525
+ const selectText =
526
+ this.instance?.translate("keyboard_select") || "select";
527
+ const searchText =
528
+ this.instance?.translate("keyboard_search") || "search";
529
+ this.instance?.registerShortcut(
530
+ { label: "↑↓", description: navigateText },
531
+ this,
532
+ );
533
+ this.instance?.registerShortcut(
534
+ { label: "↵", description: selectText },
535
+ this,
536
+ );
537
+ this.instance?.registerShortcut(
538
+ { label: "/", description: searchText },
539
+ this,
540
+ );
541
+ });
542
+
543
+ this.containerEl.addEventListener("focusout", (e) => {
544
+ const focusEvent = e as FocusEvent;
545
+ if (!this.containerEl?.contains(focusEvent.relatedTarget as Node)) {
546
+ this.clearSelection();
547
+ this.instance?.deregisterAllShortcuts(this);
548
+ }
549
+ });
550
+ }
551
+
552
+ private scrollToCenter(el: HTMLElement): void {
553
+ const container = this.intersectionEl || nearestScrollParent(el);
554
+ if (!container || !(container instanceof HTMLElement)) return;
555
+ if (container === document.body || container === document.documentElement)
556
+ return;
557
+
558
+ const elRect = el.getBoundingClientRect();
559
+ const containerRect = container.getBoundingClientRect();
560
+ const elRelativeTop = elRect.top - containerRect.top + container.scrollTop;
561
+ const targetScroll =
562
+ elRelativeTop - container.clientHeight / 2 + el.offsetHeight / 2;
563
+ container.scrollTo({ top: targetScroll, behavior: "smooth" });
564
+ }
565
+
566
+ clearSelection(): void {
567
+ this.containerEl
568
+ ?.querySelectorAll("[data-pf-selected]")
569
+ .forEach((el) => el.removeAttribute("data-pf-selected"));
570
+ }
571
+
572
+ cleanup(): void {
573
+ for (const result of this.results) {
574
+ result.cleanup();
575
+ }
576
+ this.results = [];
577
+ }
578
+
579
+ update(): void {
580
+ this.render();
581
+ }
582
+ }
583
+
584
+ if (!customElements.get("pagefind-results")) {
585
+ customElements.define("pagefind-results", PagefindResults);
586
+ }