@internetarchive/collection-browser 4.1.0 → 4.2.0-alpha-webdev8164.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.
Files changed (62) hide show
  1. package/.editorconfig +29 -29
  2. package/.github/workflows/ci.yml +27 -27
  3. package/.github/workflows/gh-pages-main.yml +39 -39
  4. package/.github/workflows/npm-publish.yml +39 -39
  5. package/.github/workflows/pr-preview.yml +38 -38
  6. package/.husky/pre-commit +1 -1
  7. package/.prettierignore +1 -1
  8. package/LICENSE +661 -661
  9. package/README.md +83 -83
  10. package/dist/src/collection-browser.js +761 -761
  11. package/dist/src/collection-browser.js.map +1 -1
  12. package/dist/src/collection-facets/facets-template.js +5 -0
  13. package/dist/src/collection-facets/facets-template.js.map +1 -1
  14. package/dist/src/collection-facets/more-facets-content.d.ts +95 -8
  15. package/dist/src/collection-facets/more-facets-content.js +576 -102
  16. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  17. package/dist/src/collection-facets/more-facets-pagination.d.ts +12 -3
  18. package/dist/src/collection-facets/more-facets-pagination.js +71 -9
  19. package/dist/src/collection-facets/more-facets-pagination.js.map +1 -1
  20. package/dist/src/collection-facets/toggle-switch.js +1 -0
  21. package/dist/src/collection-facets/toggle-switch.js.map +1 -1
  22. package/dist/src/data-source/collection-browser-data-source.js.map +1 -1
  23. package/dist/src/data-source/collection-browser-query-state.js.map +1 -1
  24. package/dist/src/sort-filter-bar/sort-filter-bar.js +280 -280
  25. package/dist/src/sort-filter-bar/sort-filter-bar.js.map +1 -1
  26. package/dist/test/collection-browser.test.js +189 -189
  27. package/dist/test/collection-browser.test.js.map +1 -1
  28. package/dist/test/collection-facets/more-facets-content.test.js +162 -3
  29. package/dist/test/collection-facets/more-facets-content.test.js.map +1 -1
  30. package/dist/test/collection-facets/more-facets-pagination.test.js +63 -3
  31. package/dist/test/collection-facets/more-facets-pagination.test.js.map +1 -1
  32. package/dist/test/mocks/mock-search-responses.d.ts +5 -0
  33. package/dist/test/mocks/mock-search-responses.js +44 -0
  34. package/dist/test/mocks/mock-search-responses.js.map +1 -1
  35. package/dist/test/mocks/mock-search-service.js +2 -1
  36. package/dist/test/mocks/mock-search-service.js.map +1 -1
  37. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +22 -22
  38. package/dist/test/sort-filter-bar/sort-filter-bar.test.js.map +1 -1
  39. package/eslint.config.mjs +53 -53
  40. package/index.html +24 -24
  41. package/local.archive.org.cert +86 -86
  42. package/local.archive.org.key +27 -27
  43. package/package.json +121 -120
  44. package/renovate.json +6 -6
  45. package/src/collection-browser.ts +3070 -3070
  46. package/src/collection-facets/facets-template.ts +5 -0
  47. package/src/collection-facets/more-facets-content.ts +625 -113
  48. package/src/collection-facets/more-facets-pagination.ts +84 -10
  49. package/src/collection-facets/toggle-switch.ts +1 -0
  50. package/src/data-source/collection-browser-data-source.ts +1444 -1444
  51. package/src/data-source/collection-browser-query-state.ts +60 -60
  52. package/src/sort-filter-bar/sort-filter-bar.ts +733 -733
  53. package/test/collection-browser.test.ts +2402 -2402
  54. package/test/collection-facets/more-facets-content.test.ts +251 -4
  55. package/test/collection-facets/more-facets-pagination.test.ts +87 -3
  56. package/test/mocks/mock-search-responses.ts +48 -0
  57. package/test/mocks/mock-search-service.ts +2 -0
  58. package/test/sort-filter-bar/sort-filter-bar.test.ts +443 -443
  59. package/tsconfig.json +25 -25
  60. package/web-dev-server.config.mjs +30 -30
  61. package/web-test-runner.config.mjs +52 -52
  62. package/.claude/settings.local.json +0 -8
@@ -7,7 +7,9 @@ import {
7
7
  PropertyValues,
8
8
  TemplateResult,
9
9
  } from 'lit';
10
- import { customElement, property, state } from 'lit/decorators.js';
10
+ import { customElement, property, query, state } from 'lit/decorators.js';
11
+ import { classMap } from 'lit/directives/class-map.js';
12
+ import { when } from 'lit/directives/when.js';
11
13
  import {
12
14
  Aggregation,
13
15
  Bucket,
@@ -40,13 +42,16 @@ import type {
40
42
  TVChannelAliases,
41
43
  } from '../data-source/models';
42
44
  import '@internetarchive/elements/ia-status-indicator/ia-status-indicator';
43
- import './more-facets-pagination';
44
45
  import './facets-template';
45
46
  import {
46
47
  analyticsActions,
47
48
  analyticsCategories,
48
49
  } from '../utils/analytics-events';
49
50
  import './toggle-switch';
51
+ import './more-facets-pagination';
52
+ import '@internetarchive/ia-clearable-text-input';
53
+ import arrowLeftIcon from '../assets/img/icons/arrow-left';
54
+ import arrowRightIcon from '../assets/img/icons/arrow-right';
50
55
  import { srOnlyStyle } from '../styles/sr-only';
51
56
  import {
52
57
  mergeSelectedFacets,
@@ -58,6 +63,12 @@ import {
58
63
  MORE_FACETS__MAX_AGGREGATIONS,
59
64
  } from './models';
60
65
 
66
+ /**
67
+ * Threshold for switching from horizontal scroll to pagination.
68
+ * If facet count >= this value, use pagination. Otherwise use horizontal scroll.
69
+ */
70
+ const PAGINATION_THRESHOLD = 1000;
71
+
61
72
  @customElement('more-facets-content')
62
73
  export class MoreFacetsContent extends LitElement {
63
74
  @property({ type: String }) facetKey?: FacetOption;
@@ -126,10 +137,38 @@ export class MoreFacetsContent extends LitElement {
126
137
  getDefaultSelectedFacets();
127
138
 
128
139
  /**
129
- * Which page of facets we are showing.
140
+ * Text entered by the user to filter facet buckets.
141
+ * Applied to bucket.key for case-insensitive matching.
142
+ */
143
+ @state() private filterText = '';
144
+
145
+ /**
146
+ * Current page number for pagination (when facet count >= PAGINATION_THRESHOLD).
130
147
  */
131
148
  @state() private pageNumber = 1;
132
149
 
150
+ /**
151
+ * Whether the component is narrow enough to warrant compact pagination.
152
+ * Updated via a ResizeObserver-based container query approach.
153
+ */
154
+ @state() private isCompactView = false;
155
+
156
+ /**
157
+ * Whether the horizontal scroll is at the leftmost position.
158
+ */
159
+ @state() private atScrollStart = true;
160
+
161
+ /**
162
+ * Whether the horizontal scroll is at the rightmost position.
163
+ */
164
+ @state() private atScrollEnd = true;
165
+
166
+ @query('ia-clearable-text-input')
167
+ private filterInput!: HTMLElement;
168
+
169
+ @query('.facets-content')
170
+ private facetsContentEl!: HTMLElement;
171
+
133
172
  willUpdate(changed: PropertyValues): void {
134
173
  if (
135
174
  changed.has('aggregations') ||
@@ -143,6 +182,13 @@ export class MoreFacetsContent extends LitElement {
143
182
  this.facetGroup = this.mergedFacets;
144
183
  }
145
184
 
185
+ // Reset to page 1 when filter text changes (only matters for pagination mode)
186
+ if (changed.has('filterText')) {
187
+ this.pageNumber = 1;
188
+ }
189
+ }
190
+
191
+ updated(changed: PropertyValues): void {
146
192
  // If any of the search properties change, it triggers a facet fetch
147
193
  if (
148
194
  changed.has('facetKey') ||
@@ -159,24 +205,167 @@ export class MoreFacetsContent extends LitElement {
159
205
 
160
206
  this.updateSpecificFacets();
161
207
  }
208
+
209
+ // Reset horizontal scroll when filter text changes (e.g., switching from
210
+ // horizontal-scroll mode back to pagination mode)
211
+ if (changed.has('filterText')) {
212
+ const facetsContent = this.shadowRoot?.querySelector('.facets-content');
213
+ if (facetsContent) {
214
+ facetsContent.scrollLeft = 0;
215
+ }
216
+ }
217
+
218
+ // Manage scroll listener for horizontal scroll mode arrows.
219
+ // Only re-evaluate when properties that affect the displayed content change.
220
+ if (
221
+ changed.has('filterText') ||
222
+ changed.has('aggregations') ||
223
+ changed.has('facetKey') ||
224
+ changed.has('sortedBy') ||
225
+ changed.has('selectedFacets') ||
226
+ changed.has('unappliedFacetChanges')
227
+ ) {
228
+ if (!this.usePagination) {
229
+ this.attachScrollListener();
230
+ // Refresh scroll state whenever content may have changed (e.g., filtering)
231
+ requestAnimationFrame(() => this.updateScrollState());
232
+ } else {
233
+ this.removeScrollListener();
234
+ }
235
+ }
162
236
  }
163
237
 
238
+ private resizeObserver?: ResizeObserver;
239
+
164
240
  firstUpdated(): void {
165
241
  this.setupEscapeListeners();
242
+ this.setupCompactViewObserver();
243
+ }
244
+
245
+ disconnectedCallback(): void {
246
+ super.disconnectedCallback();
247
+ this.resizeObserver?.disconnect();
248
+ this.removeScrollListener();
249
+ document.removeEventListener('keydown', this.escapeHandler);
250
+ }
251
+
252
+ private scrollHandler = () => this.updateScrollState();
253
+
254
+ private scrollListenerAttached = false;
255
+
256
+ private scrollListenerTarget?: HTMLElement;
257
+
258
+ /**
259
+ * Attaches a scroll event listener to the facets content element
260
+ * to track horizontal scroll position for arrow button states.
261
+ */
262
+ private attachScrollListener(): void {
263
+ if (this.scrollListenerAttached || !this.facetsContentEl) return;
264
+ this.scrollListenerTarget = this.facetsContentEl;
265
+ this.scrollListenerTarget.addEventListener('scroll', this.scrollHandler, {
266
+ passive: true,
267
+ });
268
+ this.scrollListenerAttached = true;
269
+ // Defer initial state check until after browser layout, so scrollWidth
270
+ // reflects the actual content dimensions.
271
+ requestAnimationFrame(() => this.updateScrollState());
272
+ }
273
+
274
+ private removeScrollListener(): void {
275
+ if (!this.scrollListenerAttached || !this.scrollListenerTarget) return;
276
+ this.scrollListenerTarget.removeEventListener('scroll', this.scrollHandler);
277
+ this.scrollListenerTarget = undefined;
278
+ this.scrollListenerAttached = false;
279
+ }
280
+
281
+ /**
282
+ * Updates the scroll arrow disabled states based on current scroll position.
283
+ */
284
+ private updateScrollState(): void {
285
+ const el = this.facetsContentEl;
286
+ if (!el) return;
287
+ this.atScrollStart = el.scrollLeft <= 0;
288
+ this.atScrollEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
289
+ }
290
+
291
+ /**
292
+ * Calculates the width of one column step (column width + gap) based on
293
+ * the CSS multi-column layout of the scroll container.
294
+ */
295
+ private getColumnStep(): number {
296
+ const el = this.facetsContentEl;
297
+ if (!el) return 0;
298
+
299
+ const facetRows = el.querySelector('.facet-rows') as HTMLElement;
300
+ const styles = facetRows
301
+ ? getComputedStyle(facetRows)
302
+ : getComputedStyle(el);
303
+
304
+ const columnCount = parseInt(styles.columnCount, 10) || 3;
305
+ const columnGap = parseInt(styles.columnGap, 10) || 15;
306
+
307
+ // Column width = (visible width - total gaps) / column count
308
+ // Column step = column width + gap = (visible width + gap) / column count
309
+ return (el.clientWidth + columnGap) / columnCount;
310
+ }
311
+
312
+ /**
313
+ * Snaps a scroll target to the nearest column boundary.
314
+ */
315
+ private snapToColumn(target: number): number {
316
+ const step = this.getColumnStep();
317
+ if (step <= 0) return target;
318
+ return Math.round(target / step) * step;
319
+ }
320
+
321
+ /**
322
+ * Scrolls the facet content left by approximately one page, snapping to
323
+ * the nearest column boundary.
324
+ */
325
+ private onScrollLeft(): void {
326
+ const el = this.facetsContentEl;
327
+ if (!el) return;
328
+ const rawTarget = el.scrollLeft - el.clientWidth;
329
+ const snapped = Math.max(0, this.snapToColumn(rawTarget));
330
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
331
+ }
332
+
333
+ /**
334
+ * Scrolls the facet content right by approximately one page, snapping to
335
+ * the nearest column boundary.
336
+ */
337
+ private onScrollRight(): void {
338
+ const el = this.facetsContentEl;
339
+ if (!el) return;
340
+ const maxScroll = el.scrollWidth - el.clientWidth;
341
+ const rawTarget = el.scrollLeft + el.clientWidth;
342
+ const snapped = Math.min(maxScroll, this.snapToColumn(rawTarget));
343
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
344
+ }
345
+
346
+ /**
347
+ * Sets up a ResizeObserver to toggle compact pagination based on component width.
348
+ */
349
+ private setupCompactViewObserver(): void {
350
+ this.resizeObserver = new ResizeObserver(entries => {
351
+ for (const entry of entries) {
352
+ const compact = entry.contentRect.width <= 560;
353
+ if (this.isCompactView !== compact) this.isCompactView = compact;
354
+ }
355
+ });
356
+ this.resizeObserver.observe(this);
166
357
  }
167
358
 
168
359
  /**
169
360
  * Close more facets modal on Escape click
170
361
  */
362
+ private escapeHandler = (e: KeyboardEvent) => {
363
+ if (e.key === 'Escape') this.modalManager?.closeModal();
364
+ };
365
+
171
366
  private setupEscapeListeners() {
172
367
  if (this.modalManager) {
173
- document.addEventListener('keydown', (e: KeyboardEvent) => {
174
- if (e.key === 'Escape') {
175
- this.modalManager?.closeModal();
176
- }
177
- });
178
- } else {
179
- document.removeEventListener('keydown', () => {});
368
+ document.addEventListener('keydown', this.escapeHandler);
180
369
  }
181
370
  }
182
371
 
@@ -215,34 +404,21 @@ export class MoreFacetsContent extends LitElement {
215
404
  rows: 0, // todo - do we want server-side pagination with offset/page/limit flag?
216
405
  };
217
406
 
218
- const results = await this.searchService?.search(params, this.searchType);
219
- this.aggregations = results?.success?.response.aggregations;
220
- this.facetsLoading = false;
407
+ try {
408
+ const results = await this.searchService?.search(params, this.searchType);
409
+ this.aggregations = results?.success?.response.aggregations;
221
410
 
222
- const collectionTitles = results?.success?.response?.collectionTitles;
223
- if (collectionTitles) {
224
- for (const [id, title] of Object.entries(collectionTitles)) {
225
- this.collectionTitles?.set(id, title);
411
+ const collectionTitles = results?.success?.response?.collectionTitles;
412
+ if (collectionTitles) {
413
+ for (const [id, title] of Object.entries(collectionTitles)) {
414
+ this.collectionTitles?.set(id, title);
415
+ }
226
416
  }
417
+ } finally {
418
+ this.facetsLoading = false;
227
419
  }
228
420
  }
229
421
 
230
- /**
231
- * Handler for page number changes from the pagination widget.
232
- */
233
- private pageNumberClicked(e: CustomEvent<{ page: number }>) {
234
- const page = e?.detail?.page;
235
- if (page) {
236
- this.pageNumber = Number(page);
237
- }
238
-
239
- this.analyticsHandler?.sendEvent({
240
- category: analyticsCategories.default,
241
- action: analyticsActions.moreFacetsPageChange,
242
- label: `${this.pageNumber}`,
243
- });
244
- }
245
-
246
422
  /**
247
423
  * Combines the selected facets with the aggregations to create a single list of facets
248
424
  */
@@ -324,7 +500,10 @@ export class MoreFacetsContent extends LitElement {
324
500
 
325
501
  const buckets: FacetBucket[] = Object.entries(selectedFacetsForKey).map(
326
502
  ([value, data]) => {
327
- const displayText: string = value;
503
+ const displayText =
504
+ (this.facetKey === 'collection'
505
+ ? this.collectionTitles?.get(value)
506
+ : undefined) ?? value;
328
507
  return {
329
508
  displayText,
330
509
  key: value,
@@ -368,17 +547,33 @@ export class MoreFacetsContent extends LitElement {
368
547
  });
369
548
  }
370
549
 
371
- // Construct the array of facet buckets from the aggregation buckets
550
+ // Construct the array of facet buckets from the aggregation buckets,
551
+ // using collection display titles where available.
372
552
  const facetBuckets: FacetBucket[] = sortedBuckets.map(bucket => {
373
553
  const bucketKeyStr = `${bucket.key}`;
554
+ const displayText =
555
+ (this.facetKey === 'collection'
556
+ ? this.collectionTitles?.get(bucketKeyStr)
557
+ : undefined) ?? bucketKeyStr;
374
558
  return {
375
- displayText: `${bucketKeyStr}`,
559
+ displayText,
376
560
  key: `${bucketKeyStr}`,
377
561
  count: bucket.doc_count,
378
562
  state: 'none',
379
563
  };
380
564
  });
381
565
 
566
+ // For collection facets sorted alphabetically, re-sort by display title
567
+ // instead of the raw identifier used by getSortedBuckets.
568
+ if (
569
+ this.facetKey === 'collection' &&
570
+ this.sortedBy === AggregationSortType.ALPHABETICAL
571
+ ) {
572
+ facetBuckets.sort((a, b) =>
573
+ (a.displayText ?? a.key).localeCompare(b.displayText ?? b.key),
574
+ );
575
+ }
576
+
382
577
  return {
383
578
  title: facetGroupTitle,
384
579
  key: this.facetKey,
@@ -387,29 +582,81 @@ export class MoreFacetsContent extends LitElement {
387
582
  }
388
583
 
389
584
  /**
390
- * Returns a FacetGroup representing only the current page of facet buckets to show.
585
+ * Returns the facet group with buckets filtered by the current filter text.
586
+ * Filters are applied to the full bucket list before pagination.
391
587
  */
392
- private get facetGroupForCurrentPage(): FacetGroup | undefined {
393
- const { facetGroup } = this;
588
+ private get filteredFacetGroup(): FacetGroup | undefined {
589
+ const { facetGroup, filterText } = this;
394
590
  if (!facetGroup) return undefined;
395
591
 
396
- // Slice out only the current page of facet buckets
397
- const firstBucketIndexOnPage = (this.pageNumber - 1) * this.facetsPerPage;
398
- const truncatedBuckets = facetGroup.buckets.slice(
399
- firstBucketIndexOnPage,
400
- firstBucketIndexOnPage + this.facetsPerPage,
401
- );
592
+ // If no filter text, return the full group
593
+ if (!filterText.trim()) {
594
+ return facetGroup;
595
+ }
596
+
597
+ // Filter buckets by the text the user actually sees.
598
+ // For collections, match against the displayed collection title (not the identifier).
599
+ // For other facet types, match against the bucket key (which is also the display text).
600
+ const lowerFilter = filterText.toLowerCase().trim();
601
+ const filteredBuckets = facetGroup.buckets.filter(bucket => {
602
+ const displayText = this.collectionTitles?.get(bucket.key) ?? bucket.key;
603
+ return displayText.toLowerCase().includes(lowerFilter);
604
+ });
402
605
 
403
606
  return {
404
607
  ...facetGroup,
405
- buckets: truncatedBuckets,
608
+ buckets: filteredBuckets,
609
+ };
610
+ }
611
+
612
+ /**
613
+ * Determines whether to use pagination based on the number of filtered facets.
614
+ * Returns true if facet count >= PAGINATION_THRESHOLD, false otherwise.
615
+ */
616
+ private get usePagination(): boolean {
617
+ const facetCount = this.filteredFacetGroup?.buckets.length ?? 0;
618
+ return facetCount >= PAGINATION_THRESHOLD;
619
+ }
620
+
621
+ /**
622
+ * Returns the facet group for the current page.
623
+ * If using pagination (>= 1000 facets), slices to show only the current page.
624
+ * Otherwise, returns all facets for horizontal scrolling.
625
+ */
626
+ private get facetGroupForCurrentPage(): FacetGroup | undefined {
627
+ const filteredGroup = this.filteredFacetGroup;
628
+ if (!filteredGroup) return undefined;
629
+
630
+ // If facet count is below threshold, show all facets with horizontal scroll
631
+ if (!this.usePagination) {
632
+ return filteredGroup;
633
+ }
634
+
635
+ // Otherwise, use pagination - slice to current page
636
+ const startIndex = (this.pageNumber - 1) * this.facetsPerPage;
637
+ const endIndex = startIndex + this.facetsPerPage;
638
+ const slicedBuckets = filteredGroup.buckets.slice(startIndex, endIndex);
639
+
640
+ return {
641
+ ...filteredGroup,
642
+ buckets: slicedBuckets,
406
643
  };
407
644
  }
408
645
 
409
646
  private get moreFacetsTemplate(): TemplateResult {
647
+ const facetGroup = this.facetGroupForCurrentPage;
648
+
649
+ // Show empty state if filtering returned no results
650
+ if (
651
+ this.filterText.trim() &&
652
+ (!facetGroup || facetGroup.buckets.length === 0)
653
+ ) {
654
+ return this.emptyFilterResultsTemplate;
655
+ }
656
+
410
657
  return html`
411
658
  <facets-template
412
- .facetGroup=${this.facetGroupForCurrentPage}
659
+ .facetGroup=${facetGroup}
413
660
  .selectedFacets=${this.selectedFacets}
414
661
  .collectionTitles=${this.collectionTitles}
415
662
  @facetClick=${(e: CustomEvent<FacetEventDetails>) => {
@@ -434,50 +681,51 @@ export class MoreFacetsContent extends LitElement {
434
681
  `;
435
682
  }
436
683
 
684
+ private get emptyFilterResultsTemplate(): TemplateResult {
685
+ return html`
686
+ <div class="empty-results">
687
+ <p>${msg('No matching values found.')}</p>
688
+ <p class="hint">${msg('Try a different search term.')}</p>
689
+ </div>
690
+ `;
691
+ }
692
+
437
693
  /**
438
- * How many pages of facets to show in the modal pagination widget
694
+ * Number of pages for pagination (only used when facet count >= PAGINATION_THRESHOLD).
439
695
  */
440
696
  private get paginationSize(): number {
441
- if (!this.aggregations || !this.facetKey) return 0;
442
-
443
- // Calculate the appropriate number of pages to show in the modal pagination widget
444
- const length = this.aggregations[this.facetKey]?.buckets.length;
445
- return Math.ceil(length / this.facetsPerPage);
697
+ const filteredBuckets = this.filteredFacetGroup?.buckets ?? [];
698
+ return Math.ceil(filteredBuckets.length / this.facetsPerPage);
446
699
  }
447
700
 
448
- // render pagination if more then 1 page
701
+ /**
702
+ * Template for pagination component.
703
+ */
449
704
  private get facetsPaginationTemplate() {
450
- return this.paginationSize > 1
451
- ? html`<more-facets-pagination
452
- .size=${this.paginationSize}
453
- .currentPage=${1}
454
- @pageNumberClicked=${this.pageNumberClicked}
455
- ></more-facets-pagination>`
456
- : nothing;
705
+ return html`<more-facets-pagination
706
+ .size=${this.paginationSize}
707
+ .currentPage=${this.pageNumber}
708
+ .compact=${this.isCompactView}
709
+ @pageNumberClicked=${this.pageNumberClicked}
710
+ ></more-facets-pagination>`;
457
711
  }
458
712
 
459
713
  private get footerTemplate() {
460
- if (this.paginationSize > 0) {
461
- return html`${this.facetsPaginationTemplate}
462
- <div class="footer">
463
- <button
464
- class="btn btn-cancel"
465
- type="button"
466
- @click=${this.cancelClick}
467
- >
468
- Cancel
469
- </button>
470
- <button
471
- class="btn btn-submit"
472
- type="button"
473
- @click=${this.applySearchFacetsClicked}
474
- >
475
- Apply filters
476
- </button>
477
- </div> `;
478
- }
479
-
480
- return nothing;
714
+ return html`
715
+ ${when(this.usePagination, () => this.facetsPaginationTemplate)}
716
+ <div class="footer">
717
+ <button class="btn btn-cancel" type="button" @click=${this.cancelClick}>
718
+ Cancel
719
+ </button>
720
+ <button
721
+ class="btn btn-submit"
722
+ type="button"
723
+ @click=${this.applySearchFacetsClicked}
724
+ >
725
+ Apply filters
726
+ </button>
727
+ </div>
728
+ `;
481
729
  }
482
730
 
483
731
  private sortFacetAggregation(facetSortType: AggregationSortType) {
@@ -487,6 +735,44 @@ export class MoreFacetsContent extends LitElement {
487
735
  );
488
736
  }
489
737
 
738
+ /**
739
+ * Handler for filter input changes. Updates the filter text and triggers re-render.
740
+ */
741
+ private handleFilterInput(e: Event): void {
742
+ const input = e.target as HTMLElement & { value: string };
743
+ this.filterText = input.value;
744
+ }
745
+
746
+ /**
747
+ * Handler for when the filter input is cleared via the clear button.
748
+ */
749
+ private handleFilterClear(): void {
750
+ this.filterText = '';
751
+ }
752
+
753
+ /**
754
+ * Handler for pagination page number clicks.
755
+ * Only used when facet count >= PAGINATION_THRESHOLD.
756
+ */
757
+ private pageNumberClicked(e: CustomEvent<{ page: number }>) {
758
+ this.pageNumber = e.detail.page;
759
+
760
+ // Track page navigation in analytics
761
+ this.analyticsHandler?.sendEvent({
762
+ category: analyticsCategories.default,
763
+ action: analyticsActions.moreFacetsPageChange,
764
+ label: `${this.pageNumber}`,
765
+ });
766
+
767
+ this.dispatchEvent(
768
+ new CustomEvent('pageChanged', {
769
+ detail: this.pageNumber,
770
+ bubbles: true,
771
+ composed: true,
772
+ }),
773
+ );
774
+ }
775
+
490
776
  private get modalHeaderTemplate(): TemplateResult {
491
777
  const facetSort =
492
778
  this.sortedBy ?? defaultFacetSort[this.facetKey as FacetOption];
@@ -494,36 +780,103 @@ export class MoreFacetsContent extends LitElement {
494
780
  facetSort === AggregationSortType.COUNT ? 'left' : 'right';
495
781
 
496
782
  return html`<span class="sr-only">${msg('More facets for:')}</span>
497
- <span class="title">
498
- ${this.facetGroup?.title}
499
-
500
- <label class="sort-label">${msg('Sort by:')}</label>
501
- ${this.facetKey
502
- ? html`<toggle-switch
503
- class="sort-toggle"
504
- leftValue=${AggregationSortType.COUNT}
505
- leftLabel="Count"
506
- rightValue=${valueFacetSort[this.facetKey]}
507
- .rightLabel=${this.facetGroup?.title}
508
- side=${defaultSwitchSide}
509
- @change=${(e: CustomEvent<string>) => {
510
- this.sortFacetAggregation(
511
- Number(e.detail) as AggregationSortType,
512
- );
513
- }}
514
- ></toggle-switch>`
515
- : nothing}
783
+ <span class="title"> ${this.facetGroup?.title} </span>
784
+ <span class="header-controls">
785
+ <span class="sort-controls">
786
+ <label class="sort-label">${msg('Sort by:')}</label>
787
+ ${this.facetKey
788
+ ? html`<toggle-switch
789
+ class="sort-toggle"
790
+ leftValue=${AggregationSortType.COUNT}
791
+ leftLabel="Count"
792
+ rightValue=${valueFacetSort[this.facetKey]}
793
+ .rightLabel=${this.facetGroup?.title}
794
+ side=${defaultSwitchSide}
795
+ @change=${(e: CustomEvent<string>) => {
796
+ this.sortFacetAggregation(
797
+ Number(e.detail) as AggregationSortType,
798
+ );
799
+ }}
800
+ ></toggle-switch>`
801
+ : nothing}
802
+ </span>
803
+
804
+ <span class="filter-controls">
805
+ <label class="filter-label">${msg('Filter by:')}</label>
806
+ <ia-clearable-text-input
807
+ class="filter-input"
808
+ .value=${this.filterText}
809
+ .placeholder=${msg('Search...')}
810
+ .screenReaderLabel=${msg('Filter facets')}
811
+ .clearButtonScreenReaderLabel=${msg('Clear filter')}
812
+ @input=${this.handleFilterInput}
813
+ @clear=${this.handleFilterClear}
814
+ ></ia-clearable-text-input>
815
+ </span>
516
816
  </span>`;
517
817
  }
518
818
 
819
+ private get horizontalScrollTemplate(): TemplateResult {
820
+ const contentClasses = classMap({
821
+ 'facets-content': true,
822
+ 'horizontal-scroll-mode': true,
823
+ });
824
+ const showArrows = !this.atScrollStart || !this.atScrollEnd;
825
+
826
+ return html`<div class="scroll-nav-container">
827
+ ${when(
828
+ showArrows,
829
+ () =>
830
+ html`<button
831
+ class="scroll-arrow scroll-left"
832
+ @click=${this.onScrollLeft}
833
+ ?disabled=${this.atScrollStart}
834
+ aria-label="Scroll facets left"
835
+ >
836
+ ${arrowLeftIcon}
837
+ </button>`,
838
+ )}
839
+ <div class=${contentClasses}>
840
+ <div class="facets-horizontal-container">
841
+ ${this.moreFacetsTemplate}
842
+ </div>
843
+ </div>
844
+ ${when(
845
+ showArrows,
846
+ () =>
847
+ html`<button
848
+ class="scroll-arrow scroll-right"
849
+ @click=${this.onScrollRight}
850
+ ?disabled=${this.atScrollEnd}
851
+ aria-label="Scroll facets right"
852
+ >
853
+ ${arrowRightIcon}
854
+ </button>`,
855
+ )}
856
+ </div>`;
857
+ }
858
+
519
859
  render() {
860
+ const sectionClasses = classMap({
861
+ 'pagination-mode': this.usePagination,
862
+ 'horizontal-scroll-mode': !this.usePagination,
863
+ });
864
+ const contentClasses = classMap({
865
+ 'facets-content': true,
866
+ 'pagination-mode': this.usePagination,
867
+ });
868
+
520
869
  return html`
521
870
  ${this.facetsLoading
522
871
  ? this.loaderTemplate
523
872
  : html`
524
- <section id="more-facets">
873
+ <section id="more-facets" class=${sectionClasses}>
525
874
  <div class="header-content">${this.modalHeaderTemplate}</div>
526
- <div class="facets-content">${this.moreFacetsTemplate}</div>
875
+ ${this.usePagination
876
+ ? html`<div class=${contentClasses}>
877
+ ${this.moreFacetsTemplate}
878
+ </div>`
879
+ : this.horizontalScrollTemplate}
527
880
  ${this.footerTemplate}
528
881
  </section>
529
882
  `}
@@ -546,6 +899,9 @@ export class MoreFacetsContent extends LitElement {
546
899
  // Reset the unapplied changes back to default, now that they have been applied
547
900
  this.unappliedFacetChanges = getDefaultSelectedFacets();
548
901
 
902
+ // Reset filter text
903
+ this.filterText = '';
904
+
549
905
  this.modalManager?.closeModal();
550
906
  this.analyticsHandler?.sendEvent({
551
907
  category: analyticsCategories.default,
@@ -558,6 +914,9 @@ export class MoreFacetsContent extends LitElement {
558
914
  // Reset the unapplied changes back to default
559
915
  this.unappliedFacetChanges = getDefaultSelectedFacets();
560
916
 
917
+ // Reset filter text
918
+ this.filterText = '';
919
+
561
920
  this.modalManager?.closeModal();
562
921
  this.analyticsHandler?.sendEvent({
563
922
  category: analyticsCategories.default,
@@ -573,10 +932,26 @@ export class MoreFacetsContent extends LitElement {
573
932
  srOnlyStyle,
574
933
  css`
575
934
  section#more-facets {
576
- overflow: auto;
577
- padding: 10px; /* leaves room for scroll bar to appear without overlaying on content */
935
+ display: flex;
936
+ flex-direction: column;
937
+ max-height: calc(100vh - 16.5rem);
938
+ padding: 10px;
939
+ box-sizing: border-box;
578
940
  --facetsColumnCount: 3;
579
941
  }
942
+
943
+ /* Both modes need a height constraint for proper column flow */
944
+ section#more-facets.horizontal-scroll-mode,
945
+ section#more-facets.pagination-mode {
946
+ --facetsMaxHeight: 280px;
947
+ }
948
+ .header-content {
949
+ flex-shrink: 0;
950
+ position: relative;
951
+ z-index: 1;
952
+ background: #fff;
953
+ }
954
+
580
955
  .header-content .title {
581
956
  display: block;
582
957
  text-align: left;
@@ -585,8 +960,22 @@ export class MoreFacetsContent extends LitElement {
585
960
  font-weight: bold;
586
961
  }
587
962
 
963
+ .header-controls {
964
+ display: flex;
965
+ flex-wrap: wrap;
966
+ align-items: center;
967
+ gap: 8px 20px;
968
+ padding: 0 10px 8px;
969
+ }
970
+
971
+ .sort-controls {
972
+ display: inline-flex;
973
+ align-items: center;
974
+ white-space: nowrap;
975
+ gap: 5px;
976
+ }
977
+
588
978
  .sort-label {
589
- margin-left: 20px;
590
979
  font-size: 1.3rem;
591
980
  }
592
981
 
@@ -594,11 +983,115 @@ export class MoreFacetsContent extends LitElement {
594
983
  font-weight: normal;
595
984
  }
596
985
 
986
+ .filter-controls {
987
+ display: inline-flex;
988
+ align-items: center;
989
+ white-space: nowrap;
990
+ }
991
+
992
+ .filter-label {
993
+ font-size: 1.3rem;
994
+ }
995
+
996
+ .filter-input {
997
+ --input-height: 2.5rem;
998
+ --input-font-size: 1.3rem;
999
+ --input-border-radius: 4px;
1000
+ --input-padding: 4px 8px;
1001
+ --input-focused-border-color: ${modalSubmitButton};
1002
+ width: 150px;
1003
+ margin-left: 5px;
1004
+ }
1005
+
1006
+ .empty-results {
1007
+ text-align: center;
1008
+ padding: 40px 20px;
1009
+ color: #666;
1010
+ }
1011
+
1012
+ .empty-results .hint {
1013
+ margin-top: 10px;
1014
+ }
1015
+
597
1016
  .facets-content {
598
1017
  font-size: 1.2rem;
599
- max-height: 300px;
600
- overflow: auto;
1018
+ flex: 1 1 auto;
1019
+ min-height: 0;
1020
+ overflow-y: auto;
1021
+ overflow-x: hidden;
601
1022
  padding: 10px;
1023
+ /* Force scrollbar to always be visible */
1024
+ scrollbar-width: thin; /* Firefox */
1025
+ scrollbar-color: #888 #f1f1f1; /* Firefox - thumb and track colors */
1026
+ }
1027
+
1028
+ /* Horizontal scroll mode: horizontal scrolling only */
1029
+ .facets-content.horizontal-scroll-mode {
1030
+ overflow-x: auto;
1031
+ overflow-y: hidden;
1032
+ }
1033
+
1034
+ /* Webkit browsers scrollbar styling - always visible */
1035
+ .facets-content::-webkit-scrollbar {
1036
+ width: 12px; /* Vertical scrollbar width */
1037
+ height: 12px; /* Horizontal scrollbar height */
1038
+ }
1039
+
1040
+ .facets-content::-webkit-scrollbar-track {
1041
+ background: #f1f1f1;
1042
+ border-radius: 6px;
1043
+ }
1044
+
1045
+ .facets-content::-webkit-scrollbar-thumb {
1046
+ background: #888;
1047
+ border-radius: 6px;
1048
+ min-height: 30px; /* Ensure thumb is always visible when scrolling is possible */
1049
+ }
1050
+
1051
+ .facets-content::-webkit-scrollbar-thumb:hover {
1052
+ background: #555;
1053
+ }
1054
+
1055
+ /* Force corner to match track color */
1056
+ .facets-content::-webkit-scrollbar-corner {
1057
+ background: #f1f1f1;
1058
+ }
1059
+
1060
+ .facets-horizontal-container {
1061
+ display: inline-block;
1062
+ min-width: 100%;
1063
+ /* Allow natural width expansion based on content */
1064
+ width: fit-content;
1065
+ }
1066
+
1067
+ .scroll-nav-container {
1068
+ display: flex;
1069
+ align-items: center;
1070
+ flex: 1 1 auto;
1071
+ min-height: 0;
1072
+ }
1073
+
1074
+ .scroll-nav-container .facets-content {
1075
+ flex: 1 1 auto;
1076
+ min-width: 0;
1077
+ }
1078
+
1079
+ .scroll-arrow {
1080
+ background: none;
1081
+ border: none;
1082
+ cursor: pointer;
1083
+ padding: 5px;
1084
+ flex-shrink: 0;
1085
+ }
1086
+
1087
+ .scroll-arrow svg {
1088
+ height: 14px;
1089
+ fill: #2c2c2c;
1090
+ }
1091
+
1092
+ .scroll-arrow:disabled {
1093
+ opacity: 0.3;
1094
+ cursor: default;
602
1095
  }
603
1096
  .facets-loader {
604
1097
  --icon-width: 70px;
@@ -614,6 +1107,7 @@ export class MoreFacetsContent extends LitElement {
614
1107
  width: auto;
615
1108
  border-radius: 4px;
616
1109
  cursor: pointer;
1110
+ font-family: inherit;
617
1111
  }
618
1112
  .btn-cancel {
619
1113
  background-color: #2c2c2c;
@@ -623,19 +1117,37 @@ export class MoreFacetsContent extends LitElement {
623
1117
  background-color: ${modalSubmitButton};
624
1118
  color: white;
625
1119
  }
1120
+ more-facets-pagination {
1121
+ flex-shrink: 0;
1122
+ }
1123
+
626
1124
  .footer {
627
1125
  text-align: center;
628
1126
  margin-top: 10px;
1127
+ flex-shrink: 0;
629
1128
  }
630
1129
 
631
1130
  @media (max-width: 560px) {
632
- section#more-facets {
633
- max-height: 450px;
634
- --facetsColumnCount: 1;
1131
+ section#more-facets.horizontal-scroll-mode,
1132
+ section#more-facets.pagination-mode {
1133
+ --facetsColumnCount: 1; /* Single column on mobile */
1134
+ --facetsMaxHeight: none; /* Remove fixed height for vertical scrolling */
635
1135
  }
636
- .facets-content {
1136
+ /* On mobile, always use vertical scrolling regardless of mode */
1137
+ .facets-content,
1138
+ .facets-content.horizontal-scroll-mode {
637
1139
  overflow-y: auto;
638
- height: 300px;
1140
+ overflow-x: hidden;
1141
+ }
1142
+ .scroll-nav-container {
1143
+ display: contents; /* Remove wrapper from layout so section flex-column works */
1144
+ }
1145
+ .scroll-arrow {
1146
+ display: none;
1147
+ }
1148
+ .filter-input {
1149
+ width: 120px;
1150
+ --input-font-size: 1.2rem;
639
1151
  }
640
1152
  }
641
1153
  `,