@pagefind/component-ui 1.5.0-alpha.3 → 1.5.0-beta.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.
@@ -113,6 +113,7 @@ export class PagefindFilterDropdown extends PagefindElement {
113
113
  this.triggerEl.id = triggerId;
114
114
  this.triggerEl.className = "pf-dropdown-trigger";
115
115
  if (this.wrapLabels) this.triggerEl.classList.add("wrap");
116
+ this.triggerEl.setAttribute("role", "combobox");
116
117
  this.triggerEl.setAttribute("aria-haspopup", "listbox");
117
118
  this.triggerEl.setAttribute("aria-expanded", "false");
118
119
  this.triggerEl.setAttribute("aria-controls", menuId);
@@ -151,7 +152,6 @@ export class PagefindFilterDropdown extends PagefindElement {
151
152
  this.singleSelect ? "false" : "true",
152
153
  );
153
154
  this.optionsEl.setAttribute("aria-labelledby", triggerId);
154
- this.optionsEl.setAttribute("tabindex", "-1");
155
155
  this.menuEl.appendChild(this.optionsEl);
156
156
 
157
157
  this.wrapperEl.appendChild(this.menuEl);
@@ -180,12 +180,13 @@ export class PagefindFilterDropdown extends PagefindElement {
180
180
  this.triggerEl.addEventListener("focus", () =>
181
181
  this.instance?.triggerLoad(),
182
182
  );
183
- this.triggerEl.addEventListener("keydown", (e) =>
184
- this.handleTriggerKeydown(e),
185
- );
186
- this.optionsEl.addEventListener("keydown", (e) =>
187
- this.handleMenuKeydown(e),
188
- );
183
+ this.triggerEl.addEventListener("keydown", (e) => {
184
+ if (this.isOpen) {
185
+ this.handleMenuKeydown(e);
186
+ } else {
187
+ this.handleTriggerKeydown(e);
188
+ }
189
+ });
189
190
 
190
191
  this.isRendered = true;
191
192
  }
@@ -214,10 +215,11 @@ export class PagefindFilterDropdown extends PagefindElement {
214
215
  this.triggerEl.setAttribute("aria-expanded", "true");
215
216
  this.triggerEl.classList.add("open");
216
217
 
217
- if (this.activeIndex < 0 && this.optionElements.length > 0) {
218
- this.setActiveIndex(0);
218
+ // Always apply visual focus when opening if there are options
219
+ if (this.optionElements.length > 0) {
220
+ const targetIndex = this.activeIndex >= 0 ? this.activeIndex : 0;
221
+ this.setActiveIndex(targetIndex);
219
222
  }
220
- this.optionsEl.focus();
221
223
 
222
224
  const navigateText =
223
225
  this.instance?.translate("keyboard_navigate") || "navigate";
@@ -250,7 +252,7 @@ export class PagefindFilterDropdown extends PagefindElement {
250
252
  this.triggerEl.setAttribute("aria-expanded", "false");
251
253
  this.triggerEl.classList.remove("open");
252
254
 
253
- this.optionsEl.removeAttribute("aria-activedescendant");
255
+ this.triggerEl?.removeAttribute("aria-activedescendant");
254
256
 
255
257
  for (const { el } of this.optionElements) {
256
258
  el.classList.remove("pf-dropdown-option-focused");
@@ -348,7 +350,7 @@ export class PagefindFilterDropdown extends PagefindElement {
348
350
  const option = this.optionElements[index];
349
351
 
350
352
  option.el.classList.add("pf-dropdown-option-focused");
351
- this.optionsEl.setAttribute("aria-activedescendant", option.el.id);
353
+ this.triggerEl?.setAttribute("aria-activedescendant", option.el.id);
352
354
 
353
355
  this.scrollToCenter(option.el);
354
356
  }
@@ -524,6 +526,8 @@ export class PagefindFilterDropdown extends PagefindElement {
524
526
  }
525
527
 
526
528
  toggleOption(value: string): void {
529
+ const wasSelected = this.selectedValues.has(value);
530
+
527
531
  if (this.singleSelect) {
528
532
  if (this.selectedValues.has(value)) {
529
533
  this.selectedValues.clear();
@@ -540,6 +544,13 @@ export class PagefindFilterDropdown extends PagefindElement {
540
544
  }
541
545
  }
542
546
 
547
+ // Announce selection change for screen readers
548
+ const isNowSelected = this.selectedValues.has(value);
549
+ if (isNowSelected !== wasSelected) {
550
+ const action = isNowSelected ? "selected" : "deselected";
551
+ this.instance?.announceRaw(`${value} ${action}`);
552
+ }
553
+
543
554
  this.updateOptionStates();
544
555
  this.updateBadge();
545
556
 
@@ -57,11 +57,10 @@ export class PagefindInput extends PagefindElement {
57
57
  this.removeAttribute("dir");
58
58
  }
59
59
 
60
- const wrapper = document.createElement("form");
60
+ const wrapper = document.createElement("search");
61
61
  wrapper.className = "pf-input-wrapper";
62
62
  wrapper.setAttribute("role", "search");
63
63
  wrapper.setAttribute("aria-label", searchLabel);
64
- wrapper.setAttribute("action", "javascript:void(0);");
65
64
 
66
65
  const label = document.createElement("label");
67
66
  label.setAttribute("for", inputId);
@@ -72,16 +71,31 @@ export class PagefindInput extends PagefindElement {
72
71
  this.inputEl = document.createElement("input");
73
72
  this.inputEl.id = inputId;
74
73
  this.inputEl.className = "pf-input";
74
+ this.inputEl.setAttribute("type", "search");
75
+ this.inputEl.setAttribute("autocomplete", "off");
75
76
  this.inputEl.setAttribute("autocapitalize", "none");
76
77
  this.inputEl.setAttribute("enterkeyhint", "search");
77
78
  this.inputEl.setAttribute("placeholder", placeholderText);
78
79
  if (this.autofocus) {
79
80
  this.inputEl.setAttribute("autofocus", "autofocus");
80
81
  }
82
+
83
+ const hintId = this.instance!.generateId("pf-input-hint");
84
+ const hintText =
85
+ this.instance?.translate("input_hint") ||
86
+ "Results will appear as you type";
87
+ const hint = document.createElement("span");
88
+ hint.id = hintId;
89
+ hint.setAttribute("data-pf-sr-hidden", "true");
90
+ hint.textContent = hintText;
91
+ this.inputEl.setAttribute("aria-describedby", hintId);
92
+
81
93
  wrapper.appendChild(this.inputEl);
94
+ wrapper.appendChild(hint);
82
95
 
83
96
  this.clearEl = document.createElement("button");
84
97
  this.clearEl.className = "pf-input-clear";
98
+ this.clearEl.setAttribute("type", "button");
85
99
  this.clearEl.setAttribute("data-pf-suppressed", "true");
86
100
  this.clearEl.textContent = clearText;
87
101
  wrapper.appendChild(this.clearEl);
@@ -84,6 +84,12 @@ export class PagefindModalTrigger extends PagefindElement {
84
84
  this.buttonEl.setAttribute("aria-haspopup", "dialog");
85
85
  this.buttonEl.setAttribute("aria-expanded", "false");
86
86
  this.buttonEl.setAttribute("aria-label", this.placeholder || "Search");
87
+ this.buttonEl.setAttribute(
88
+ "aria-keyshortcuts",
89
+ this.isMac
90
+ ? `Meta+${this.shortcut.toUpperCase()}`
91
+ : `Control+${this.shortcut.toUpperCase()}`,
92
+ );
87
93
 
88
94
  const icon = document.createElement("span");
89
95
  icon.className = "pf-trigger-icon";
@@ -46,7 +46,6 @@ export class PagefindModal extends PagefindElement {
46
46
  this.dialogEl = document.createElement("dialog");
47
47
  this.dialogEl.className = "pf-modal";
48
48
  this.dialogEl.id = dialogId;
49
- this.dialogEl.setAttribute("aria-modal", "true");
50
49
  this.dialogEl.setAttribute("aria-label", searchLabel);
51
50
 
52
51
  if (hasChildren && children) {
@@ -68,7 +68,7 @@ const DEFAULT_RESULT_TEMPLATE = `<li class="pf-result">
68
68
  {{/if}}
69
69
  </li>`;
70
70
 
71
- const DEFAULT_PLACEHOLDER_TEMPLATE = `<li class="pf-result">
71
+ const DEFAULT_PLACEHOLDER_TEMPLATE = `<li class="pf-result" aria-hidden="true">
72
72
  <div class="pf-result-card">
73
73
  <div class="pf-skeleton pf-skeleton-image"></div>
74
74
  <div class="pf-result-content">
@@ -393,6 +393,15 @@ export class PagefindResults extends PagefindElement {
393
393
  : "many_results";
394
394
  const priority = count === 0 ? "assertive" : "polite";
395
395
  instance.announce(key, { SEARCH_TERM: term, COUNT: count }, priority);
396
+ } else if (instance.faceted) {
397
+ const key =
398
+ count === 0
399
+ ? "total_zero_results"
400
+ : count === 1
401
+ ? "total_one_result"
402
+ : "total_many_results";
403
+ const priority = count === 0 ? "assertive" : "polite";
404
+ instance.announce(key, { COUNT: count }, priority);
396
405
  }
397
406
 
398
407
  const resultRenderer = this.getResultRenderer();
@@ -485,14 +494,14 @@ export class PagefindResults extends PagefindElement {
485
494
  if (index < anchors.length - 1) {
486
495
  const next = anchors[index + 1];
487
496
  next.focus();
488
- this.scrollToCenter(next);
497
+ this.scrollToCenter(next, e.repeat);
489
498
  }
490
499
  } else if (e.key === "ArrowUp") {
491
500
  e.preventDefault();
492
501
  if (index > 0) {
493
502
  const prev = anchors[index - 1];
494
503
  prev.focus();
495
- this.scrollToCenter(prev);
504
+ this.scrollToCenter(prev, e.repeat);
496
505
  } else {
497
506
  // At first anchor, go back to input
498
507
  this.instance?.focusPreviousInput(document.activeElement as Element);
@@ -549,7 +558,7 @@ export class PagefindResults extends PagefindElement {
549
558
  });
550
559
  }
551
560
 
552
- private scrollToCenter(el: HTMLElement): void {
561
+ private scrollToCenter(el: HTMLElement, instant: boolean = false): void {
553
562
  const container = this.intersectionEl || nearestScrollParent(el);
554
563
  if (!container || !(container instanceof HTMLElement)) return;
555
564
  if (container === document.body || container === document.documentElement)
@@ -560,7 +569,10 @@ export class PagefindResults extends PagefindElement {
560
569
  const elRelativeTop = elRect.top - containerRect.top + container.scrollTop;
561
570
  const targetScroll =
562
571
  elRelativeTop - container.clientHeight / 2 + el.offsetHeight / 2;
563
- container.scrollTo({ top: targetScroll, behavior: "smooth" });
572
+ container.scrollTo({
573
+ top: targetScroll,
574
+ behavior: instant ? "instant" : "smooth",
575
+ });
564
576
  }
565
577
 
566
578
  clearSelection(): void {
@@ -174,7 +174,8 @@ export class PagefindSearchbox extends PagefindElement {
174
174
  containerEl: HTMLElement | null = null;
175
175
  inputEl: HTMLInputElement | null = null;
176
176
  dropdownEl: HTMLElement | null = null;
177
- resultsEl: HTMLUListElement | null = null;
177
+ resultsEl: HTMLElement | null = null;
178
+ statusEl: HTMLElement | null = null;
178
179
  footerEl: HTMLElement | null = null;
179
180
 
180
181
  isOpen: boolean = false;
@@ -303,13 +304,18 @@ export class PagefindSearchbox extends PagefindElement {
303
304
  this.removeAttribute("dir");
304
305
  }
305
306
 
306
- this.resultsEl = document.createElement("ul");
307
+ this.resultsEl = document.createElement("div");
307
308
  this.resultsEl.id = resultsId;
308
309
  this.resultsEl.className = "pf-searchbox-results";
309
310
  this.resultsEl.setAttribute("role", "listbox");
310
311
  this.resultsEl.setAttribute("aria-label", resultsLabel);
311
312
  this.dropdownEl.appendChild(this.resultsEl);
312
313
 
314
+ this.statusEl = document.createElement("div");
315
+ this.statusEl.className = "pf-searchbox-status";
316
+ this.statusEl.hidden = true;
317
+ this.dropdownEl.appendChild(this.statusEl);
318
+
313
319
  if (this.showKeyboardHints) {
314
320
  this.footerEl = document.createElement("div");
315
321
  this.footerEl.className = "pf-searchbox-footer";
@@ -491,33 +497,33 @@ export class PagefindSearchbox extends PagefindElement {
491
497
  }
492
498
 
493
499
  private showLoadingState(): void {
494
- if (!this.resultsEl) return;
500
+ if (!this.resultsEl || !this.statusEl) return;
495
501
  this.isLoading = true;
496
502
  this.resultsEl.innerHTML = "";
503
+ this.resultsEl.setAttribute("aria-busy", "true");
497
504
 
498
505
  const searchingText =
499
506
  this.instance?.translate("searching", { SEARCH_TERM: this.searchTerm }) ||
500
507
  "Searching...";
501
508
 
502
- const loadingEl = document.createElement("div");
503
- loadingEl.className = "pf-searchbox-loading";
504
- loadingEl.textContent = searchingText;
505
- this.resultsEl.appendChild(loadingEl);
509
+ this.statusEl.textContent = searchingText;
510
+ this.statusEl.className = "pf-searchbox-status pf-searchbox-loading";
511
+ this.statusEl.hidden = false;
506
512
  }
507
513
 
508
514
  private showEmptyState(): void {
509
- if (!this.resultsEl) return;
515
+ if (!this.resultsEl || !this.statusEl) return;
510
516
  this.resultsEl.innerHTML = "";
517
+ this.resultsEl.removeAttribute("aria-busy");
511
518
 
512
519
  const noResultsText =
513
520
  this.instance?.translate("zero_results", {
514
521
  SEARCH_TERM: this.searchTerm,
515
522
  }) || `No results for "${this.searchTerm}"`;
516
523
 
517
- const emptyEl = document.createElement("div");
518
- emptyEl.className = "pf-searchbox-empty";
519
- emptyEl.textContent = noResultsText;
520
- this.resultsEl.appendChild(emptyEl);
524
+ this.statusEl.textContent = noResultsText;
525
+ this.statusEl.className = "pf-searchbox-status pf-searchbox-empty";
526
+ this.statusEl.hidden = false;
521
527
 
522
528
  this.instance?.announce(
523
529
  "zero_results",
@@ -613,6 +619,13 @@ export class PagefindSearchbox extends PagefindElement {
613
619
  private handleResults(searchResult: PagefindSearchResult): void {
614
620
  this.isLoading = false;
615
621
 
622
+ if (this.resultsEl) {
623
+ this.resultsEl.removeAttribute("aria-busy");
624
+ }
625
+ if (this.statusEl) {
626
+ this.statusEl.hidden = true;
627
+ }
628
+
616
629
  for (const result of this.results) {
617
630
  result.cleanup();
618
631
  }
@@ -290,6 +290,16 @@ pagefind-modal-trigger {
290
290
  color: var(--pf-text-muted);
291
291
  }
292
292
 
293
+ /* Hide native search clear button - we have our own */
294
+ :is(*, #\#):is(*, #\#):is(*, #\#) .pf-input::-webkit-search-decoration,
295
+ :is(*, #\#):is(*, #\#):is(*, #\#) .pf-input::-webkit-search-cancel-button,
296
+ :is(*, #\#):is(*, #\#):is(*, #\#) .pf-input::-webkit-search-results-button,
297
+ :is(*, #\#):is(*, #\#):is(*, #\#) .pf-input::-webkit-search-results-decoration {
298
+ display: none;
299
+ appearance: none;
300
+ -webkit-appearance: none;
301
+ }
302
+
293
303
  :is(*, #\#):is(*, #\#):is(*, #\#) .pf-input-clear {
294
304
  position: absolute;
295
305
  right: 2px;
@@ -1242,6 +1252,7 @@ pagefind-modal-trigger {
1242
1252
  :is(*, #\#):is(*, #\#):is(*, #\#) pagefind-keyboard-hints,
1243
1253
  :is(*, #\#):is(*, #\#):is(*, #\#) .pf-keyboard-hints {
1244
1254
  display: flex;
1255
+ flex-wrap: wrap;
1245
1256
  align-items: center;
1246
1257
  gap: 16px;
1247
1258
  font-size: 12px;