@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,525 @@
1
+ import { PagefindElement } from "./base-element";
2
+ import { Instance } from "../core/instance";
3
+ import type { FilterCounts, FilterSelection, PagefindError } from "../types";
4
+
5
+ type SortOption = "default" | "alphabetical" | "count-desc" | "count-asc";
6
+
7
+ interface FilterElementRefs {
8
+ label: HTMLLabelElement;
9
+ countSpan: HTMLSpanElement;
10
+ checkbox: HTMLInputElement;
11
+ }
12
+
13
+ interface GroupElementRefs {
14
+ group: HTMLElement;
15
+ optionsContainer: HTMLElement;
16
+ selectedCountSpan: HTMLSpanElement | null;
17
+ }
18
+
19
+ export class PagefindFilterPane extends PagefindElement {
20
+ static get observedAttributes(): string[] {
21
+ return ["show-empty", "expanded", "open", "sort", "auto-open-threshold"];
22
+ }
23
+
24
+ containerEl: HTMLElement | null = null;
25
+ showEmpty: boolean = false;
26
+ expanded: boolean = false;
27
+ openFilters: string[] = [];
28
+ sortOption: SortOption = "default";
29
+ autoOpenThreshold: number = 6;
30
+ selectedFilters: Record<string, Set<string>> = {};
31
+ availableFilters: FilterCounts | null = null;
32
+ totalFilters: FilterCounts | null = null;
33
+
34
+ filterElements: Map<string, FilterElementRefs> = new Map();
35
+ groupElements: Map<string, GroupElementRefs> = new Map();
36
+ isRendered: boolean = false;
37
+
38
+ constructor() {
39
+ super();
40
+ }
41
+
42
+ init(): void {
43
+ if (this.hasAttribute("show-empty")) {
44
+ this.showEmpty = this.getAttribute("show-empty") !== "false";
45
+ }
46
+ if (this.hasAttribute("expanded")) {
47
+ this.expanded = this.getAttribute("expanded") !== "false";
48
+ }
49
+ if (this.hasAttribute("open")) {
50
+ this.openFilters = (this.getAttribute("open") || "")
51
+ .split(",")
52
+ .map((s) => s.trim().toLowerCase())
53
+ .filter((s) => s.length > 0);
54
+ }
55
+ if (this.hasAttribute("sort")) {
56
+ const sortVal = this.getAttribute("sort") as SortOption;
57
+ if (
58
+ ["default", "alphabetical", "count-desc", "count-asc"].includes(sortVal)
59
+ ) {
60
+ this.sortOption = sortVal;
61
+ }
62
+ }
63
+ if (this.hasAttribute("auto-open-threshold")) {
64
+ this.autoOpenThreshold = parseInt(
65
+ this.getAttribute("auto-open-threshold") || "6",
66
+ 10,
67
+ );
68
+ }
69
+
70
+ this.render();
71
+ }
72
+
73
+ private sortValues(
74
+ values: [string, number][],
75
+ availableValues: Record<string, number>,
76
+ ): [string, number][] {
77
+ if (this.sortOption === "default") {
78
+ return values;
79
+ }
80
+
81
+ const sorted = [...values];
82
+ switch (this.sortOption) {
83
+ case "alphabetical":
84
+ sorted.sort((a, b) => a[0].localeCompare(b[0]));
85
+ break;
86
+ case "count-desc":
87
+ sorted.sort((a, b) => {
88
+ const countA = availableValues[a[0]] ?? a[1];
89
+ const countB = availableValues[b[0]] ?? b[1];
90
+ return countB - countA;
91
+ });
92
+ break;
93
+ case "count-asc":
94
+ sorted.sort((a, b) => {
95
+ const countA = availableValues[a[0]] ?? a[1];
96
+ const countB = availableValues[b[0]] ?? b[1];
97
+ return countA - countB;
98
+ });
99
+ break;
100
+ }
101
+ return sorted;
102
+ }
103
+
104
+ render(): void {
105
+ this.innerHTML = "";
106
+
107
+ if (this.instance?.direction === "rtl") {
108
+ this.setAttribute("dir", "rtl");
109
+ } else {
110
+ this.removeAttribute("dir");
111
+ }
112
+
113
+ this.containerEl = document.createElement("div");
114
+ this.containerEl.className = "pf-filter-pane";
115
+ this.appendChild(this.containerEl);
116
+ }
117
+
118
+ getSelectedText(count: number): string {
119
+ return String(count);
120
+ }
121
+
122
+ shouldGroupStartOpen(
123
+ filterName: string,
124
+ valueCount: number,
125
+ filterCount: number,
126
+ ): boolean {
127
+ if (this.openFilters.length > 0) {
128
+ return this.openFilters.includes(filterName.toLowerCase());
129
+ }
130
+ return (
131
+ this.autoOpenThreshold > 0 &&
132
+ filterCount === 1 &&
133
+ valueCount <= this.autoOpenThreshold
134
+ );
135
+ }
136
+
137
+ hasStructureChanged(): boolean {
138
+ if (!this.totalFilters) return false;
139
+
140
+ const currentGroups = new Set(Object.keys(this.totalFilters));
141
+ const renderedGroups = new Set(this.groupElements.keys());
142
+
143
+ if (currentGroups.size !== renderedGroups.size) return true;
144
+ for (const group of currentGroups) {
145
+ if (!renderedGroups.has(group)) return true;
146
+ }
147
+
148
+ for (const [filterName, values] of Object.entries(this.totalFilters)) {
149
+ const currentValues = new Set(Object.keys(values));
150
+ for (const value of currentValues) {
151
+ if (!this.filterElements.has(`${filterName}:${value}`)) return true;
152
+ }
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ handleFiltersUpdate(): void {
159
+ if (!this.containerEl || !this.totalFilters) return;
160
+
161
+ const filterNames = Object.keys(this.totalFilters);
162
+ if (filterNames.length === 0) {
163
+ this.containerEl.setAttribute("data-pf-hidden", "true");
164
+ return;
165
+ }
166
+
167
+ this.containerEl.removeAttribute("data-pf-hidden");
168
+
169
+ if (!this.isRendered || this.hasStructureChanged()) {
170
+ this.renderFilters();
171
+ } else {
172
+ this.updateFilters();
173
+ }
174
+ }
175
+
176
+ renderFilters(): void {
177
+ if (!this.containerEl || !this.totalFilters) return;
178
+
179
+ this.containerEl.innerHTML = "";
180
+ this.filterElements.clear();
181
+ this.groupElements.clear();
182
+
183
+ const filterNames = Object.keys(this.totalFilters);
184
+
185
+ for (const filterName of filterNames) {
186
+ const values = this.totalFilters[filterName];
187
+ const availableValues = this.availableFilters?.[filterName] || {};
188
+
189
+ const group = this.renderFilterGroup(
190
+ filterName,
191
+ values,
192
+ availableValues,
193
+ filterNames.length,
194
+ );
195
+ if (group) {
196
+ this.containerEl.appendChild(group);
197
+ }
198
+ }
199
+
200
+ this.isRendered = true;
201
+ }
202
+
203
+ updateFilters(): void {
204
+ for (const [key, elements] of this.filterElements) {
205
+ const colonIndex = key.indexOf(":");
206
+ const filterName = key.slice(0, colonIndex);
207
+ const value = key.slice(colonIndex + 1);
208
+ const availableCount = this.availableFilters?.[filterName]?.[value] ?? 0;
209
+ const totalCount = this.totalFilters?.[filterName]?.[value] ?? 0;
210
+ const isSelected = this.selectedFilters[filterName]?.has(value);
211
+
212
+ const count = isSelected ? totalCount : availableCount;
213
+ elements.countSpan.textContent = String(count);
214
+
215
+ const shouldShow = this.showEmpty || availableCount > 0 || isSelected;
216
+ elements.label.toggleAttribute("data-pf-hidden", !shouldShow);
217
+
218
+ elements.checkbox.checked = isSelected || false;
219
+ }
220
+
221
+ for (const [filterName, elements] of this.groupElements) {
222
+ const selectedCount = this.selectedFilters[filterName]?.size || 0;
223
+
224
+ if (elements.selectedCountSpan) {
225
+ if (selectedCount > 0) {
226
+ elements.selectedCountSpan.textContent =
227
+ this.getSelectedText(selectedCount);
228
+ elements.selectedCountSpan.removeAttribute("data-pf-hidden");
229
+ } else {
230
+ elements.selectedCountSpan.setAttribute("data-pf-hidden", "true");
231
+ }
232
+ }
233
+
234
+ let hasVisibleOptions = false;
235
+ const options = elements.optionsContainer.querySelectorAll(
236
+ ".pf-filter-checkbox",
237
+ );
238
+ for (const option of options) {
239
+ if (!option.hasAttribute("data-pf-hidden")) {
240
+ hasVisibleOptions = true;
241
+ break;
242
+ }
243
+ }
244
+ elements.group.toggleAttribute("data-pf-hidden", !hasVisibleOptions);
245
+ }
246
+ }
247
+
248
+ renderFilterGroup(
249
+ filterName: string,
250
+ values: Record<string, number>,
251
+ availableValues: Record<string, number>,
252
+ filterCount: number,
253
+ ): HTMLElement | null {
254
+ const rawEntries = Object.entries(values);
255
+ if (rawEntries.length === 0) return null;
256
+
257
+ const valueEntries = this.sortValues(rawEntries, availableValues);
258
+ const displayName =
259
+ filterName.charAt(0).toUpperCase() + filterName.slice(1);
260
+ const selectedCount = this.selectedFilters[filterName]?.size || 0;
261
+ const shouldOpen =
262
+ this.expanded ||
263
+ this.shouldGroupStartOpen(filterName, valueEntries.length, filterCount);
264
+
265
+ let group: HTMLElement;
266
+ let optionsContainer: HTMLElement;
267
+ let selectedCountSpan: HTMLSpanElement | null = null;
268
+
269
+ if (this.expanded) {
270
+ group = document.createElement("fieldset");
271
+ group.className = "pf-filter-group";
272
+
273
+ const legend = document.createElement("legend");
274
+ legend.className = "pf-filter-group-title";
275
+ const titleSpan = document.createElement("span");
276
+ titleSpan.className = "pf-filter-group-name";
277
+ titleSpan.textContent = displayName;
278
+ legend.appendChild(titleSpan);
279
+ group.appendChild(legend);
280
+
281
+ optionsContainer = document.createElement("div");
282
+ optionsContainer.className = "pf-filter-options";
283
+ group.appendChild(optionsContainer);
284
+ } else {
285
+ group = document.createElement("details");
286
+ group.className = "pf-filter-group";
287
+ (group as HTMLDetailsElement).dataset.filterName = filterName;
288
+ if (shouldOpen) {
289
+ (group as HTMLDetailsElement).open = true;
290
+ }
291
+
292
+ const summary = document.createElement("summary");
293
+ summary.className = "pf-filter-group-title";
294
+
295
+ const titleSpan = document.createElement("span");
296
+ titleSpan.className = "pf-filter-group-name";
297
+ titleSpan.textContent = displayName;
298
+ summary.appendChild(titleSpan);
299
+
300
+ selectedCountSpan = document.createElement("span");
301
+ selectedCountSpan.className = "pf-filter-group-count";
302
+ selectedCountSpan.setAttribute("aria-hidden", "true");
303
+ if (selectedCount > 0) {
304
+ selectedCountSpan.textContent = this.getSelectedText(selectedCount);
305
+ } else {
306
+ selectedCountSpan.setAttribute("data-pf-hidden", "true");
307
+ }
308
+ summary.appendChild(selectedCountSpan);
309
+
310
+ group.appendChild(summary);
311
+
312
+ const fieldset = document.createElement("fieldset");
313
+ fieldset.className = "pf-filter-fieldset";
314
+
315
+ const legend = document.createElement("legend");
316
+ legend.setAttribute("data-pf-sr-hidden", "");
317
+ legend.textContent = displayName;
318
+ fieldset.appendChild(legend);
319
+
320
+ optionsContainer = document.createElement("div");
321
+ optionsContainer.className = "pf-filter-options";
322
+ fieldset.appendChild(optionsContainer);
323
+
324
+ group.appendChild(fieldset);
325
+ }
326
+
327
+ this.groupElements.set(filterName, {
328
+ group,
329
+ optionsContainer,
330
+ selectedCountSpan,
331
+ });
332
+
333
+ for (const [value, totalCount] of valueEntries) {
334
+ const availableCount = availableValues[value] ?? 0;
335
+ const isSelected = this.selectedFilters[filterName]?.has(value) || false;
336
+ const count = isSelected ? totalCount : availableCount;
337
+ const shouldShow = this.showEmpty || availableCount > 0 || isSelected;
338
+
339
+ this.renderCheckbox(
340
+ optionsContainer,
341
+ filterName,
342
+ value,
343
+ count,
344
+ isSelected,
345
+ shouldShow,
346
+ );
347
+ }
348
+
349
+ return group;
350
+ }
351
+
352
+ renderCheckbox(
353
+ container: HTMLElement,
354
+ filterName: string,
355
+ value: string,
356
+ count: number,
357
+ isSelected: boolean,
358
+ shouldShow: boolean,
359
+ ): void {
360
+ const checkboxId = this.instance!.generateId(
361
+ `pf-filter-${filterName}-${value}`,
362
+ );
363
+
364
+ const label = document.createElement("label");
365
+ label.className = "pf-filter-checkbox";
366
+ label.setAttribute("for", checkboxId);
367
+ if (!shouldShow) {
368
+ label.setAttribute("data-pf-hidden", "true");
369
+ }
370
+
371
+ const checkbox = document.createElement("input");
372
+ checkbox.type = "checkbox";
373
+ checkbox.className = "pf-checkbox-input";
374
+ checkbox.id = checkboxId;
375
+ checkbox.name = filterName;
376
+ checkbox.value = value;
377
+ checkbox.checked = isSelected;
378
+ checkbox.addEventListener("change", (e) => {
379
+ this.handleCheckboxChange(
380
+ filterName,
381
+ value,
382
+ (e.target as HTMLInputElement).checked,
383
+ );
384
+ });
385
+ label.appendChild(checkbox);
386
+
387
+ const textNode = document.createTextNode(value);
388
+ label.appendChild(textNode);
389
+
390
+ const countSpan = document.createElement("span");
391
+ countSpan.className = "pf-filter-checkbox-count";
392
+ countSpan.textContent = String(count);
393
+ label.appendChild(countSpan);
394
+
395
+ container.appendChild(label);
396
+
397
+ this.filterElements.set(`${filterName}:${value}`, {
398
+ label,
399
+ countSpan,
400
+ checkbox,
401
+ });
402
+ }
403
+
404
+ handleCheckboxChange(
405
+ filterName: string,
406
+ value: string,
407
+ checked: boolean,
408
+ ): void {
409
+ if (!this.selectedFilters[filterName]) {
410
+ this.selectedFilters[filterName] = new Set();
411
+ }
412
+
413
+ if (checked) {
414
+ this.selectedFilters[filterName].add(value);
415
+ } else {
416
+ this.selectedFilters[filterName].delete(value);
417
+ }
418
+
419
+ const groupElements = this.groupElements.get(filterName);
420
+ if (groupElements?.selectedCountSpan) {
421
+ const selectedCount = this.selectedFilters[filterName].size;
422
+ if (selectedCount > 0) {
423
+ groupElements.selectedCountSpan.textContent =
424
+ this.getSelectedText(selectedCount);
425
+ groupElements.selectedCountSpan.removeAttribute("data-pf-hidden");
426
+ } else {
427
+ groupElements.selectedCountSpan.setAttribute("data-pf-hidden", "true");
428
+ }
429
+ }
430
+
431
+ const selectedValues = Array.from(this.selectedFilters[filterName]);
432
+
433
+ if (selectedValues.length === 0) {
434
+ delete this.selectedFilters[filterName];
435
+ const filters: FilterSelection = {};
436
+ for (const [name, values] of Object.entries(this.selectedFilters)) {
437
+ filters[name] = Array.from(values);
438
+ }
439
+ this.instance?.triggerFilters(filters);
440
+ } else {
441
+ this.instance?.triggerFilter(filterName, selectedValues);
442
+ }
443
+ }
444
+
445
+ register(instance: Instance): void {
446
+ instance.registerFilter(this);
447
+
448
+ instance.on(
449
+ "filters",
450
+ (filters: unknown) => {
451
+ const f = filters as { available: FilterCounts; total: FilterCounts };
452
+ this.availableFilters = f.available;
453
+ this.totalFilters = f.total;
454
+ this.handleFiltersUpdate();
455
+ },
456
+ this,
457
+ );
458
+
459
+ instance.on(
460
+ "search",
461
+ (_term: unknown, filters: unknown) => {
462
+ this.selectedFilters = {};
463
+ const f = filters as FilterSelection | undefined;
464
+ if (f) {
465
+ for (const [name, values] of Object.entries(f)) {
466
+ if (Array.isArray(values) && values.length > 0) {
467
+ this.selectedFilters[name] = new Set(values);
468
+ }
469
+ }
470
+ }
471
+ if (this.isRendered) {
472
+ this.updateFilters();
473
+ }
474
+ },
475
+ this,
476
+ );
477
+
478
+ instance.on(
479
+ "error",
480
+ (error: unknown) => {
481
+ const err = error as PagefindError;
482
+ this.showError({
483
+ message: err.message || "Failed to load filters",
484
+ details: err.bundlePath
485
+ ? `Bundle path: ${err.bundlePath}`
486
+ : undefined,
487
+ });
488
+ },
489
+ this,
490
+ );
491
+
492
+ instance.on(
493
+ "translations",
494
+ () => {
495
+ this.render();
496
+ this.isRendered = false;
497
+ this.handleFiltersUpdate();
498
+ },
499
+ this,
500
+ );
501
+ }
502
+
503
+ update(): void {
504
+ if (this.hasAttribute("show-empty")) {
505
+ this.showEmpty = this.getAttribute("show-empty") !== "false";
506
+ }
507
+ if (this.hasAttribute("expanded")) {
508
+ this.expanded = this.getAttribute("expanded") !== "false";
509
+ }
510
+ if (this.hasAttribute("open")) {
511
+ this.openFilters = (this.getAttribute("open") || "")
512
+ .split(",")
513
+ .map((s) => s.trim().toLowerCase())
514
+ .filter((s) => s.length > 0);
515
+ }
516
+ if (this.isRendered) {
517
+ this.isRendered = false;
518
+ this.handleFiltersUpdate();
519
+ }
520
+ }
521
+ }
522
+
523
+ if (!customElements.get("pagefind-filter-pane")) {
524
+ customElements.define("pagefind-filter-pane", PagefindFilterPane);
525
+ }