@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,224 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+ import type { PagefindError } from "../types";
4
+
5
+ const asyncSleep = (ms = 100): Promise<void> =>
6
+ new Promise((r) => setTimeout(r, ms));
7
+
8
+ export class PagefindInput extends PagefindElement {
9
+ static get observedAttributes(): string[] {
10
+ return ["placeholder", "debounce", "autofocus"];
11
+ }
12
+
13
+ inputEl: HTMLInputElement | null = null;
14
+ clearEl: HTMLButtonElement | null = null;
15
+ searchID: number = 0;
16
+
17
+ placeholder: string = "";
18
+ debounce: number = 300;
19
+ autofocus: boolean = false;
20
+
21
+ constructor() {
22
+ super();
23
+ }
24
+
25
+ readAttributes(): void {
26
+ if (this.hasAttribute("placeholder")) {
27
+ this.placeholder = this.getAttribute("placeholder") || "";
28
+ }
29
+ if (this.hasAttribute("debounce")) {
30
+ this.debounce =
31
+ parseInt(this.getAttribute("debounce") || "300", 10) || 300;
32
+ }
33
+ if (this.hasAttribute("autofocus")) {
34
+ this.autofocus = this.hasAttribute("autofocus");
35
+ }
36
+ }
37
+
38
+ init(): void {
39
+ this.readAttributes();
40
+ this.render();
41
+ }
42
+
43
+ render(): void {
44
+ this.innerHTML = "";
45
+
46
+ const inputId = this.instance!.generateId("pfmod-input");
47
+
48
+ const searchLabel =
49
+ this.instance?.translate("search_label") || "Search this site";
50
+ const clearText = this.instance?.translate("clear_search") || "Clear";
51
+ const placeholderText =
52
+ this.placeholder || this.instance?.translate("placeholder") || "Search";
53
+
54
+ if (this.instance?.direction === "rtl") {
55
+ this.setAttribute("dir", "rtl");
56
+ } else {
57
+ this.removeAttribute("dir");
58
+ }
59
+
60
+ const wrapper = document.createElement("form");
61
+ wrapper.className = "pf-input-wrapper";
62
+ wrapper.setAttribute("role", "search");
63
+ wrapper.setAttribute("aria-label", searchLabel);
64
+ wrapper.setAttribute("action", "javascript:void(0);");
65
+
66
+ const label = document.createElement("label");
67
+ label.setAttribute("for", inputId);
68
+ label.setAttribute("data-pf-sr-hidden", "true");
69
+ label.textContent = searchLabel;
70
+ wrapper.appendChild(label);
71
+
72
+ this.inputEl = document.createElement("input");
73
+ this.inputEl.id = inputId;
74
+ this.inputEl.className = "pf-input";
75
+ this.inputEl.setAttribute("autocapitalize", "none");
76
+ this.inputEl.setAttribute("enterkeyhint", "search");
77
+ this.inputEl.setAttribute("placeholder", placeholderText);
78
+ if (this.autofocus) {
79
+ this.inputEl.setAttribute("autofocus", "autofocus");
80
+ }
81
+ wrapper.appendChild(this.inputEl);
82
+
83
+ this.clearEl = document.createElement("button");
84
+ this.clearEl.className = "pf-input-clear";
85
+ this.clearEl.setAttribute("data-pf-suppressed", "true");
86
+ this.clearEl.textContent = clearText;
87
+ wrapper.appendChild(this.clearEl);
88
+
89
+ this.appendChild(wrapper);
90
+
91
+ this.setupEventHandlers();
92
+ }
93
+
94
+ setupEventHandlers(): void {
95
+ if (!this.inputEl || !this.clearEl) return;
96
+
97
+ this.inputEl.addEventListener("input", async (e) => {
98
+ const target = e.target as HTMLInputElement;
99
+ if (this.instance && typeof target?.value === "string") {
100
+ this.updateState(target.value);
101
+
102
+ const thisSearchID = ++this.searchID;
103
+ await asyncSleep(this.debounce);
104
+
105
+ if (thisSearchID !== this.searchID) {
106
+ return;
107
+ }
108
+
109
+ this.instance?.triggerSearch(target.value);
110
+ }
111
+ });
112
+
113
+ this.inputEl.addEventListener("keydown", (e) => {
114
+ if (e.key === "Escape") {
115
+ ++this.searchID;
116
+ if (this.inputEl) this.inputEl.value = "";
117
+ this.instance?.triggerSearch("");
118
+ this.updateState("");
119
+ }
120
+ if (e.key === "ArrowDown") {
121
+ e.preventDefault();
122
+ if (this.inputEl) {
123
+ this.instance?.focusNextResults(this.inputEl);
124
+ }
125
+ }
126
+ });
127
+
128
+ this.inputEl.addEventListener("focus", () => {
129
+ this.instance?.triggerLoad();
130
+ const navigateText =
131
+ this.instance?.translate("keyboard_navigate") || "navigate";
132
+ const clearText = this.instance?.translate("keyboard_clear") || "clear";
133
+ this.instance?.registerShortcut(
134
+ { label: "↓", description: navigateText },
135
+ this,
136
+ );
137
+ this.instance?.registerShortcut(
138
+ { label: "esc", description: clearText },
139
+ this,
140
+ );
141
+ });
142
+
143
+ this.inputEl.addEventListener("blur", () => {
144
+ this.instance?.deregisterAllShortcuts(this);
145
+ });
146
+
147
+ this.clearEl.addEventListener("click", () => {
148
+ if (this.inputEl) {
149
+ this.inputEl.value = "";
150
+ this.instance?.triggerSearch("");
151
+ this.updateState("");
152
+ this.inputEl.focus();
153
+ }
154
+ });
155
+ }
156
+
157
+ updateState(term: string): void {
158
+ if (this.clearEl) {
159
+ if (term && term?.length) {
160
+ this.clearEl.removeAttribute("data-pf-suppressed");
161
+ } else {
162
+ this.clearEl.setAttribute("data-pf-suppressed", "true");
163
+ }
164
+ }
165
+ }
166
+
167
+ register(instance: Instance): void {
168
+ instance.registerInput(this, {
169
+ keyboardNavigation: true,
170
+ });
171
+
172
+ instance.on(
173
+ "search",
174
+ (term: unknown) => {
175
+ if (this.inputEl && document.activeElement !== this.inputEl) {
176
+ this.inputEl.value = term as string;
177
+ this.updateState(term as string);
178
+ }
179
+ },
180
+ this,
181
+ );
182
+
183
+ instance.on(
184
+ "error",
185
+ (error: unknown) => {
186
+ const err = error as PagefindError;
187
+ this.showError({
188
+ message: err.message || "Search initialization failed",
189
+ details: err.bundlePath
190
+ ? `Bundle path: ${err.bundlePath}`
191
+ : undefined,
192
+ });
193
+ },
194
+ this,
195
+ );
196
+
197
+ instance.on(
198
+ "translations",
199
+ () => {
200
+ const currentValue = this.inputEl?.value || "";
201
+ this.render();
202
+ if (this.inputEl && currentValue) {
203
+ this.inputEl.value = currentValue;
204
+ this.updateState(currentValue);
205
+ }
206
+ },
207
+ this,
208
+ );
209
+ }
210
+
211
+ update(): void {
212
+ this.render();
213
+ }
214
+
215
+ focus(): void {
216
+ if (this.inputEl) {
217
+ this.inputEl.focus();
218
+ }
219
+ }
220
+ }
221
+
222
+ if (!customElements.get("pagefind-input")) {
223
+ customElements.define("pagefind-input", PagefindInput);
224
+ }
@@ -0,0 +1,62 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+
4
+ export class PagefindKeyboardHints extends PagefindElement {
5
+ init(): void {
6
+ this.classList.add("pf-keyboard-hints");
7
+ // Keyboard hints are visual aids for sighted users, not meaningful for screen readers
8
+ this.setAttribute("aria-hidden", "true");
9
+ }
10
+
11
+ render(): void {
12
+ this.innerHTML = "";
13
+
14
+ if (this.instance?.direction === "rtl") {
15
+ this.setAttribute("dir", "rtl");
16
+ } else {
17
+ this.removeAttribute("dir");
18
+ }
19
+
20
+ const shortcuts = this.instance?.getActiveShortcuts() || [];
21
+
22
+ if (shortcuts.length === 0) {
23
+ return;
24
+ }
25
+
26
+ // Deduplicate by label
27
+ // for example, only show ↑↓ once even if multiple components have it
28
+ const seen = new Set<string>();
29
+ for (const shortcut of shortcuts) {
30
+ if (seen.has(shortcut.label)) continue;
31
+ seen.add(shortcut.label);
32
+
33
+ const hint = document.createElement("div");
34
+ hint.className = "pf-keyboard-hint";
35
+
36
+ const key = document.createElement("kbd");
37
+ key.className = "pf-keyboard-key";
38
+ key.textContent = shortcut.label;
39
+ hint.appendChild(key);
40
+
41
+ hint.appendChild(document.createTextNode(` ${shortcut.description}`));
42
+ this.appendChild(hint);
43
+ }
44
+ }
45
+
46
+ register(instance: Instance): void {
47
+ instance.registerUtility(this, "keyboard-hints");
48
+ this.render();
49
+
50
+ instance.on(
51
+ "translations",
52
+ () => {
53
+ this.render();
54
+ },
55
+ this,
56
+ );
57
+ }
58
+ }
59
+
60
+ if (!customElements.get("pagefind-keyboard-hints")) {
61
+ customElements.define("pagefind-keyboard-hints", PagefindKeyboardHints);
62
+ }
@@ -0,0 +1,19 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+
4
+ export class PagefindModalBody extends PagefindElement {
5
+ init(): void {
6
+ this.classList.add("pf-modal-body");
7
+ // Prevent scrollable container from being in tab order,
8
+ // as all children should be interactable
9
+ this.setAttribute("tabindex", "-1");
10
+ }
11
+
12
+ register(_instance: Instance): void {
13
+ /* structural - unregistered */
14
+ }
15
+ }
16
+
17
+ if (!customElements.get("pagefind-modal-body")) {
18
+ customElements.define("pagefind-modal-body", PagefindModalBody);
19
+ }
@@ -0,0 +1,16 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+
4
+ export class PagefindModalFooter extends PagefindElement {
5
+ init(): void {
6
+ this.classList.add("pf-modal-footer");
7
+ }
8
+
9
+ register(_instance: Instance): void {
10
+ /* structural - unregistered */
11
+ }
12
+ }
13
+
14
+ if (!customElements.get("pagefind-modal-footer")) {
15
+ customElements.define("pagefind-modal-footer", PagefindModalFooter);
16
+ }
@@ -0,0 +1,59 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+
4
+ interface ModalElement extends HTMLElement {
5
+ close?: () => void;
6
+ }
7
+
8
+ export class PagefindModalHeader extends PagefindElement {
9
+ private closeBtn: HTMLButtonElement | null = null;
10
+
11
+ init(): void {
12
+ this.classList.add("pf-modal-header");
13
+
14
+ const content = document.createElement("div");
15
+ content.className = "pf-modal-header-content";
16
+ while (this.firstChild) {
17
+ content.appendChild(this.firstChild);
18
+ }
19
+
20
+ // Create close button visible on mobile only
21
+ this.closeBtn = document.createElement("button");
22
+ this.closeBtn.type = "button";
23
+ this.closeBtn.className = "pf-modal-close";
24
+ this.closeBtn.setAttribute(
25
+ "aria-label",
26
+ this.instance?.translate("keyboard_close") || "Close",
27
+ );
28
+ this.closeBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M15 5L5 15M5 5l10 10"/></svg>`;
29
+ this.closeBtn.addEventListener("click", () => {
30
+ const modal = this.closest("pagefind-modal") as ModalElement | null;
31
+ if (modal && typeof modal.close === "function") {
32
+ modal.close();
33
+ }
34
+ });
35
+
36
+ this.append(content, this.closeBtn);
37
+ }
38
+
39
+ register(instance: Instance): void {
40
+ instance.registerUtility(this, "modal-header");
41
+
42
+ instance.on(
43
+ "translations",
44
+ () => {
45
+ if (this.closeBtn) {
46
+ this.closeBtn.setAttribute(
47
+ "aria-label",
48
+ instance.translate("keyboard_close") || "Close",
49
+ );
50
+ }
51
+ },
52
+ this,
53
+ );
54
+ }
55
+ }
56
+
57
+ if (!customElements.get("pagefind-modal-header")) {
58
+ customElements.define("pagefind-modal-header", PagefindModalHeader);
59
+ }
@@ -0,0 +1,195 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance, PagefindComponent } from "../core/instance";
3
+
4
+ interface ModalComponent extends PagefindComponent {
5
+ dialogEl?: HTMLDialogElement;
6
+ open?: () => void;
7
+ }
8
+
9
+ interface NavigatorUAData {
10
+ platform?: string;
11
+ }
12
+
13
+ export class PagefindModalTrigger extends PagefindElement {
14
+ static get observedAttributes(): string[] {
15
+ return ["placeholder", "shortcut", "hide-shortcut", "compact"];
16
+ }
17
+
18
+ buttonEl: HTMLButtonElement | null = null;
19
+ private _userPlaceholder: string | null = null;
20
+ shortcut: string = "k";
21
+ hideShortcut: boolean = false;
22
+ compact: boolean = false;
23
+ isMac: boolean = false;
24
+ private _keydownHandler: ((e: KeyboardEvent) => void) | null = null;
25
+
26
+ constructor() {
27
+ super();
28
+ }
29
+
30
+ get placeholder(): string {
31
+ return (
32
+ this._userPlaceholder ||
33
+ this.instance?.translate("keyboard_search") ||
34
+ "Search"
35
+ );
36
+ }
37
+
38
+ init(): void {
39
+ this.isMac = this.detectMac();
40
+ this.readAttributes();
41
+ this.render();
42
+ this.setupKeyboardShortcut();
43
+ }
44
+
45
+ private detectMac(): boolean {
46
+ try {
47
+ const uaData = (
48
+ navigator as Navigator & { userAgentData?: NavigatorUAData }
49
+ ).userAgentData;
50
+ if (uaData?.platform) {
51
+ return uaData.platform.toLowerCase().includes("mac");
52
+ }
53
+ } catch (e) {}
54
+ return /mac/i.test(navigator.userAgent);
55
+ }
56
+
57
+ private readAttributes(): void {
58
+ if (this.hasAttribute("placeholder")) {
59
+ this._userPlaceholder = this.getAttribute("placeholder");
60
+ }
61
+ if (this.hasAttribute("shortcut")) {
62
+ this.shortcut = (this.getAttribute("shortcut") || "k").toLowerCase();
63
+ }
64
+ if (this.hasAttribute("hide-shortcut")) {
65
+ this.hideShortcut = this.getAttribute("hide-shortcut") !== "false";
66
+ }
67
+ if (this.hasAttribute("compact")) {
68
+ this.compact = this.getAttribute("compact") !== "false";
69
+ }
70
+ }
71
+
72
+ render(): void {
73
+ this.innerHTML = "";
74
+
75
+ if (this.instance?.direction === "rtl") {
76
+ this.setAttribute("dir", "rtl");
77
+ } else {
78
+ this.removeAttribute("dir");
79
+ }
80
+
81
+ this.buttonEl = document.createElement("button");
82
+ this.buttonEl.className = "pf-trigger-btn";
83
+ this.buttonEl.type = "button";
84
+ this.buttonEl.setAttribute("aria-haspopup", "dialog");
85
+ this.buttonEl.setAttribute("aria-expanded", "false");
86
+ this.buttonEl.setAttribute("aria-label", this.placeholder || "Search");
87
+
88
+ const icon = document.createElement("span");
89
+ icon.className = "pf-trigger-icon";
90
+ icon.setAttribute("aria-hidden", "true");
91
+ this.buttonEl.appendChild(icon);
92
+
93
+ if (!this.compact) {
94
+ const text = document.createElement("span");
95
+ text.className = "pf-trigger-text";
96
+ text.textContent = this.placeholder;
97
+ this.buttonEl.appendChild(text);
98
+ }
99
+
100
+ if (!this.hideShortcut) {
101
+ const shortcutContainer = document.createElement("span");
102
+ shortcutContainer.className = "pf-trigger-shortcut";
103
+ shortcutContainer.setAttribute("aria-hidden", "true");
104
+
105
+ const modKey = document.createElement("span");
106
+ modKey.className = "pf-trigger-key";
107
+ modKey.textContent = this.isMac ? "\u2318" : "Ctrl";
108
+ shortcutContainer.appendChild(modKey);
109
+
110
+ const shortcutKey = document.createElement("span");
111
+ shortcutKey.className = "pf-trigger-key";
112
+ shortcutKey.textContent = this.shortcut.toUpperCase();
113
+ shortcutContainer.appendChild(shortcutKey);
114
+
115
+ this.buttonEl.appendChild(shortcutContainer);
116
+ }
117
+
118
+ this.appendChild(this.buttonEl);
119
+
120
+ this.buttonEl.addEventListener("click", () => {
121
+ this.openModal();
122
+ });
123
+ }
124
+
125
+ private setupKeyboardShortcut(): void {
126
+ this._keydownHandler = (e: KeyboardEvent) => {
127
+ const modifierPressed = this.isMac ? e.metaKey : e.ctrlKey;
128
+ const keyPressed = e.key.toLowerCase() === this.shortcut;
129
+
130
+ if (modifierPressed && keyPressed) {
131
+ e.preventDefault();
132
+ this.openModal();
133
+ }
134
+ };
135
+
136
+ document.addEventListener("keydown", this._keydownHandler);
137
+ }
138
+
139
+ openModal(): void {
140
+ const modals = (this.instance?.getUtilities("modal") ||
141
+ []) as ModalComponent[];
142
+ const modal = modals[0];
143
+
144
+ if (modal && typeof modal.open === "function") {
145
+ modal.open();
146
+ if (this.buttonEl) {
147
+ this.buttonEl.setAttribute("aria-expanded", "true");
148
+ }
149
+ }
150
+ }
151
+
152
+ handleModalClose(): void {
153
+ if (this.buttonEl) {
154
+ this.buttonEl.setAttribute("aria-expanded", "false");
155
+ this.buttonEl.focus();
156
+ }
157
+ }
158
+
159
+ register(instance: Instance): void {
160
+ instance.registerUtility(this, "modal-trigger");
161
+
162
+ instance.on(
163
+ "translations",
164
+ () => {
165
+ this.render();
166
+ },
167
+ this,
168
+ );
169
+ }
170
+
171
+ reconcileAria(): void {
172
+ const modals = (this.instance?.getUtilities("modal") ||
173
+ []) as ModalComponent[];
174
+ const modal = modals[0];
175
+ if (modal?.dialogEl?.id && this.buttonEl) {
176
+ this.buttonEl.setAttribute("aria-controls", modal.dialogEl.id);
177
+ }
178
+ }
179
+
180
+ cleanup(): void {
181
+ if (this._keydownHandler) {
182
+ document.removeEventListener("keydown", this._keydownHandler);
183
+ this._keydownHandler = null;
184
+ }
185
+ }
186
+
187
+ update(): void {
188
+ this.readAttributes();
189
+ this.render();
190
+ }
191
+ }
192
+
193
+ if (!customElements.get("pagefind-modal-trigger")) {
194
+ customElements.define("pagefind-modal-trigger", PagefindModalTrigger);
195
+ }