@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,138 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+ import type { PagefindSearchResult, PagefindError } from "../types";
4
+
5
+ export class PagefindSummary extends PagefindElement {
6
+ static get observedAttributes(): string[] {
7
+ return ["default-message"];
8
+ }
9
+
10
+ containerEl: HTMLElement | null = null;
11
+ term: string = "";
12
+ defaultMessage: string = "";
13
+
14
+ constructor() {
15
+ super();
16
+ }
17
+
18
+ init(): void {
19
+ if (this.hasAttribute("default-message")) {
20
+ this.defaultMessage = this.getAttribute("default-message") || "";
21
+ }
22
+
23
+ this.render();
24
+ }
25
+
26
+ render(): void {
27
+ this.innerHTML = "";
28
+
29
+ if (this.instance?.direction === "rtl") {
30
+ this.setAttribute("dir", "rtl");
31
+ } else {
32
+ this.removeAttribute("dir");
33
+ }
34
+
35
+ this.containerEl = document.createElement("div");
36
+ this.containerEl.className = "pf-summary";
37
+ this.containerEl.textContent = this.defaultMessage;
38
+
39
+ this.appendChild(this.containerEl);
40
+ }
41
+
42
+ reconcileAria(): void {}
43
+
44
+ register(instance: Instance): void {
45
+ instance.registerSummary(this);
46
+ instance.on(
47
+ "search",
48
+ (term: unknown) => {
49
+ this.term = term as string;
50
+ },
51
+ this,
52
+ );
53
+
54
+ instance.on(
55
+ "results",
56
+ (results: unknown) => {
57
+ if (!this.containerEl || !results) return;
58
+ const searchResult = results as PagefindSearchResult;
59
+ const count = searchResult?.results?.length ?? 0;
60
+
61
+ if (!this.term) {
62
+ if (instance.faceted) {
63
+ const key =
64
+ count === 0
65
+ ? "total_zero_results"
66
+ : count === 1
67
+ ? "total_one_result"
68
+ : "total_many_results";
69
+ const text = instance.translate(key, { COUNT: count });
70
+ this.containerEl.textContent =
71
+ text || `${count} result${count === 1 ? "" : "s"}`;
72
+ } else {
73
+ this.containerEl.textContent = this.defaultMessage;
74
+ }
75
+ return;
76
+ }
77
+
78
+ const key =
79
+ count === 0
80
+ ? "zero_results"
81
+ : count === 1
82
+ ? "one_result"
83
+ : "many_results";
84
+ const text = instance.translate(key, {
85
+ SEARCH_TERM: this.term,
86
+ COUNT: count,
87
+ });
88
+ this.containerEl.textContent =
89
+ text || `${count} result${count === 1 ? "" : "s"} for ${this.term}`;
90
+ },
91
+ this,
92
+ );
93
+
94
+ instance.on(
95
+ "loading",
96
+ () => {
97
+ if (!this.containerEl) return;
98
+ const text = instance.translate("searching", {
99
+ SEARCH_TERM: this.term,
100
+ });
101
+ this.containerEl.textContent = text || `Searching for ${this.term}...`;
102
+ },
103
+ this,
104
+ );
105
+
106
+ instance.on(
107
+ "error",
108
+ (error: unknown) => {
109
+ if (!this.containerEl) return;
110
+ const err = error as PagefindError;
111
+ const errorText = instance.translate("error_search") || "Search failed";
112
+ this.containerEl.textContent = err.message || errorText;
113
+ },
114
+ this,
115
+ );
116
+
117
+ instance.on(
118
+ "translations",
119
+ () => {
120
+ this.render();
121
+ },
122
+ this,
123
+ );
124
+ }
125
+
126
+ update(): void {
127
+ if (this.hasAttribute("default-message")) {
128
+ this.defaultMessage = this.getAttribute("default-message") || "";
129
+ if (!this.term && this.containerEl) {
130
+ this.containerEl.textContent = this.defaultMessage;
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ if (!customElements.get("pagefind-summary")) {
137
+ customElements.define("pagefind-summary", PagefindSummary);
138
+ }
@@ -0,0 +1,134 @@
1
+ export type AnnouncerPriority = "polite" | "assertive";
2
+
3
+ interface AnnouncerRegions {
4
+ polite: [HTMLElement, HTMLElement];
5
+ assertive: [HTMLElement, HTMLElement];
6
+ }
7
+
8
+ // Delay before injecting content (Safari/VoiceOver timing)
9
+ const ANNOUNCE_DELAY_MS = 100;
10
+ // Delay before clearing content (enables repeat announcements)
11
+ const CLEAR_DELAY_MS = 350;
12
+
13
+ /**
14
+ * Global ARIA live region announcer.
15
+ *
16
+ * Creates two pre-rendered live regions (polite + assertive) at document root,
17
+ * double-buffered for reliable successive announcements.
18
+ */
19
+ export class Announcer {
20
+ private regions: AnnouncerRegions | null = null;
21
+ private politeIndex: 0 | 1 = 0;
22
+ private assertiveIndex: 0 | 1 = 0;
23
+ private clearTimeoutId: ReturnType<typeof setTimeout> | null = null;
24
+ private containerId: string;
25
+ private idGenerator: (prefix: string) => string;
26
+
27
+ constructor(idGenerator: (prefix: string) => string) {
28
+ this.idGenerator = idGenerator;
29
+ this.containerId = idGenerator("pf-announcer");
30
+ this.createRegions();
31
+ }
32
+
33
+ private createRegions(): void {
34
+ if (typeof document === "undefined") return;
35
+
36
+ const container = document.createElement("div");
37
+ container.id = this.containerId;
38
+ container.setAttribute("data-pagefind-announcer", "");
39
+
40
+ const createRegionPair = (
41
+ priority: AnnouncerPriority,
42
+ ): [HTMLElement, HTMLElement] => {
43
+ const regions: HTMLElement[] = [];
44
+ for (let i = 0; i < 2; i++) {
45
+ const region = document.createElement("div");
46
+ region.id = this.idGenerator(`pf-${priority}-region`);
47
+ region.setAttribute("role", "status");
48
+ region.setAttribute("aria-live", priority);
49
+ region.setAttribute("aria-atomic", "true");
50
+ region.setAttribute("data-pf-sr-hidden", "");
51
+ container.appendChild(region);
52
+ regions.push(region);
53
+ }
54
+ return regions as [HTMLElement, HTMLElement];
55
+ };
56
+
57
+ this.regions = {
58
+ polite: createRegionPair("polite"),
59
+ assertive: createRegionPair("assertive"),
60
+ };
61
+
62
+ document.body.appendChild(container);
63
+ }
64
+
65
+ /**
66
+ * Announce a message to screen readers.
67
+ */
68
+ announce(message: string, priority: AnnouncerPriority = "polite"): void {
69
+ if (!this.regions || !message) return;
70
+
71
+ if (this.clearTimeoutId) {
72
+ clearTimeout(this.clearTimeoutId);
73
+ this.clearTimeoutId = null;
74
+ }
75
+
76
+ const currentIndex =
77
+ priority === "polite" ? this.politeIndex : this.assertiveIndex;
78
+ const region = this.regions[priority][currentIndex];
79
+
80
+ if (priority === "polite") {
81
+ this.politeIndex = currentIndex === 0 ? 1 : 0;
82
+ } else {
83
+ this.assertiveIndex = currentIndex === 0 ? 1 : 0;
84
+ }
85
+
86
+ const nextIndex =
87
+ priority === "polite" ? this.politeIndex : this.assertiveIndex;
88
+ this.regions[priority][nextIndex].textContent = "";
89
+
90
+ setTimeout(() => {
91
+ region.textContent = message;
92
+
93
+ // Schedule clearing the content to enable repeat announcements of same message
94
+ this.clearTimeoutId = setTimeout(() => {
95
+ region.textContent = "";
96
+ this.clearTimeoutId = null;
97
+ }, CLEAR_DELAY_MS);
98
+ }, ANNOUNCE_DELAY_MS);
99
+ }
100
+
101
+ /**
102
+ * Clear all live regions immediately.
103
+ */
104
+ clear(): void {
105
+ if (!this.regions) return;
106
+
107
+ if (this.clearTimeoutId) {
108
+ clearTimeout(this.clearTimeoutId);
109
+ this.clearTimeoutId = null;
110
+ }
111
+
112
+ for (const priority of ["polite", "assertive"] as const) {
113
+ for (const region of this.regions[priority]) {
114
+ region.textContent = "";
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Remove announcer from DOM.
121
+ */
122
+ destroy(): void {
123
+ this.clear();
124
+
125
+ if (typeof document !== "undefined") {
126
+ const container = document.getElementById(this.containerId);
127
+ if (container) {
128
+ container.remove();
129
+ }
130
+ }
131
+
132
+ this.regions = null;
133
+ }
134
+ }
@@ -0,0 +1,89 @@
1
+ const FOCUSABLE_SELECTOR = "a[href], button, input, [tabindex]";
2
+
3
+ type FocusableElement = HTMLElement & { disabled?: boolean };
4
+
5
+ /**
6
+ * Get all tabbable elements in tab order.
7
+ */
8
+ export function getTabbablesInOrder(
9
+ container: Document | Element = document,
10
+ ): HTMLElement[] {
11
+ const elements = Array.from(
12
+ container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
13
+ );
14
+
15
+ const tabbable = elements.filter((el): el is FocusableElement => {
16
+ if (el.tabIndex < 0) return false;
17
+ if ((el as FocusableElement).disabled) return false;
18
+ if (el.hasAttribute("hidden")) return false;
19
+ if (window.getComputedStyle(el).display === "none") return false;
20
+ return true;
21
+ });
22
+
23
+ const withPositiveTabIndex: HTMLElement[] = [];
24
+ const withZeroTabIndex: HTMLElement[] = [];
25
+
26
+ for (const el of tabbable) {
27
+ if (el.tabIndex > 0) {
28
+ withPositiveTabIndex.push(el);
29
+ } else {
30
+ withZeroTabIndex.push(el);
31
+ }
32
+ }
33
+
34
+ withPositiveTabIndex.sort((a, b) => a.tabIndex - b.tabIndex);
35
+ return [...withPositiveTabIndex, ...withZeroTabIndex];
36
+ }
37
+
38
+ /**
39
+ * Given registered components and a starting element, find which component
40
+ * contains the next tabbable element in tab order.
41
+ */
42
+ export function findNextComponentInTabOrder(
43
+ fromElement: Element,
44
+ components: HTMLElement[],
45
+ ): HTMLElement | null {
46
+ const tabbables = getTabbablesInOrder();
47
+ const currentIndex = tabbables.indexOf(fromElement as HTMLElement);
48
+ if (currentIndex === -1) return null;
49
+
50
+ const componentsWithTabPos = components
51
+ .map((component) => {
52
+ const firstTabbable = tabbables.find((t) => component.contains(t));
53
+ return {
54
+ component,
55
+ tabPos: firstTabbable ? tabbables.indexOf(firstTabbable) : -1,
56
+ };
57
+ })
58
+ .filter((c) => c.tabPos > currentIndex)
59
+ .sort((a, b) => a.tabPos - b.tabPos);
60
+
61
+ return componentsWithTabPos[0]?.component || null;
62
+ }
63
+
64
+ /**
65
+ * Given registered components and a starting element, find which component
66
+ * contains the previous tabbable element in tab order.
67
+ */
68
+ export function findPreviousComponentInTabOrder(
69
+ fromElement: Element,
70
+ components: HTMLElement[],
71
+ ): HTMLElement | null {
72
+ const tabbables = getTabbablesInOrder();
73
+ const currentIndex = tabbables.indexOf(fromElement as HTMLElement);
74
+ if (currentIndex === -1) return null;
75
+
76
+ const componentsWithTabPos = components
77
+ .map((component) => {
78
+ const componentTabbables = tabbables.filter((t) => component.contains(t));
79
+ const lastTabbable = componentTabbables[componentTabbables.length - 1];
80
+ return {
81
+ component,
82
+ tabPos: lastTabbable ? tabbables.indexOf(lastTabbable) : -1,
83
+ };
84
+ })
85
+ .filter((c) => c.tabPos >= 0 && c.tabPos < currentIndex)
86
+ .sort((a, b) => b.tabPos - a.tabPos);
87
+
88
+ return componentsWithTabPos[0]?.component || null;
89
+ }