@internetarchive/collection-browser 4.1.0 → 4.2.0-alpha-webdev8164.0

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 +92 -8
  15. package/dist/src/collection-facets/more-facets-content.js +526 -84
  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 +69 -8
  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 +566 -96
  48. package/src/collection-facets/more-facets-pagination.ts +80 -9
  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 = false;
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,10 +205,149 @@ 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
+ }
250
+
251
+ private scrollHandler = () => this.updateScrollState();
252
+
253
+ private scrollListenerAttached = false;
254
+
255
+ /**
256
+ * Attaches a scroll event listener to the facets content element
257
+ * to track horizontal scroll position for arrow button states.
258
+ */
259
+ private attachScrollListener(): void {
260
+ if (this.scrollListenerAttached || !this.facetsContentEl) return;
261
+ this.facetsContentEl.addEventListener('scroll', this.scrollHandler, {
262
+ passive: true,
263
+ });
264
+ this.scrollListenerAttached = true;
265
+ // Defer initial state check until after browser layout, so scrollWidth
266
+ // reflects the actual content dimensions.
267
+ requestAnimationFrame(() => this.updateScrollState());
268
+ }
269
+
270
+ private removeScrollListener(): void {
271
+ if (!this.scrollListenerAttached || !this.facetsContentEl) return;
272
+ this.facetsContentEl.removeEventListener('scroll', this.scrollHandler);
273
+ this.scrollListenerAttached = false;
274
+ }
275
+
276
+ /**
277
+ * Updates the scroll arrow disabled states based on current scroll position.
278
+ */
279
+ private updateScrollState(): void {
280
+ const el = this.facetsContentEl;
281
+ if (!el) return;
282
+ this.atScrollStart = el.scrollLeft <= 0;
283
+ this.atScrollEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
284
+ }
285
+
286
+ /**
287
+ * Calculates the width of one column step (column width + gap) based on
288
+ * the CSS multi-column layout of the scroll container.
289
+ */
290
+ private getColumnStep(): number {
291
+ const el = this.facetsContentEl;
292
+ if (!el) return 0;
293
+
294
+ const facetRows = el.querySelector('.facet-rows') as HTMLElement;
295
+ const styles = facetRows
296
+ ? getComputedStyle(facetRows)
297
+ : getComputedStyle(el);
298
+
299
+ const columnCount = parseInt(styles.columnCount, 10) || 3;
300
+ const columnGap = parseInt(styles.columnGap, 10) || 15;
301
+
302
+ // Column width = (visible width - total gaps) / column count
303
+ // Column step = column width + gap = (visible width + gap) / column count
304
+ return (el.clientWidth + columnGap) / columnCount;
305
+ }
306
+
307
+ /**
308
+ * Snaps a scroll target to the nearest column boundary.
309
+ */
310
+ private snapToColumn(target: number): number {
311
+ const step = this.getColumnStep();
312
+ if (step <= 0) return target;
313
+ return Math.round(target / step) * step;
314
+ }
315
+
316
+ /**
317
+ * Scrolls the facet content left by approximately one page, snapping to
318
+ * the nearest column boundary.
319
+ */
320
+ private onScrollLeft(): void {
321
+ const el = this.facetsContentEl;
322
+ if (!el) return;
323
+ const rawTarget = el.scrollLeft - el.clientWidth;
324
+ const snapped = Math.max(0, this.snapToColumn(rawTarget));
325
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
326
+ }
327
+
328
+ /**
329
+ * Scrolls the facet content right by approximately one page, snapping to
330
+ * the nearest column boundary.
331
+ */
332
+ private onScrollRight(): void {
333
+ const el = this.facetsContentEl;
334
+ if (!el) return;
335
+ const maxScroll = el.scrollWidth - el.clientWidth;
336
+ const rawTarget = el.scrollLeft + el.clientWidth;
337
+ const snapped = Math.min(maxScroll, this.snapToColumn(rawTarget));
338
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
339
+ }
340
+
341
+ /**
342
+ * Sets up a ResizeObserver to toggle compact pagination based on component width.
343
+ */
344
+ private setupCompactViewObserver(): void {
345
+ this.resizeObserver = new ResizeObserver(entries => {
346
+ for (const entry of entries) {
347
+ this.isCompactView = entry.contentRect.width <= 560;
348
+ }
349
+ });
350
+ this.resizeObserver.observe(this);
166
351
  }
167
352
 
168
353
  /**
@@ -227,22 +412,6 @@ export class MoreFacetsContent extends LitElement {
227
412
  }
228
413
  }
229
414
 
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
415
  /**
247
416
  * Combines the selected facets with the aggregations to create a single list of facets
248
417
  */
@@ -387,29 +556,81 @@ export class MoreFacetsContent extends LitElement {
387
556
  }
388
557
 
389
558
  /**
390
- * Returns a FacetGroup representing only the current page of facet buckets to show.
559
+ * Returns the facet group with buckets filtered by the current filter text.
560
+ * Filters are applied to the full bucket list before pagination.
391
561
  */
392
- private get facetGroupForCurrentPage(): FacetGroup | undefined {
393
- const { facetGroup } = this;
562
+ private get filteredFacetGroup(): FacetGroup | undefined {
563
+ const { facetGroup, filterText } = this;
394
564
  if (!facetGroup) return undefined;
395
565
 
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
- );
566
+ // If no filter text, return the full group
567
+ if (!filterText.trim()) {
568
+ return facetGroup;
569
+ }
570
+
571
+ // Filter buckets by the text the user actually sees.
572
+ // For collections, match against the displayed collection title (not the identifier).
573
+ // For other facet types, match against the bucket key (which is also the display text).
574
+ const lowerFilter = filterText.toLowerCase().trim();
575
+ const filteredBuckets = facetGroup.buckets.filter(bucket => {
576
+ const displayText = this.collectionTitles?.get(bucket.key) ?? bucket.key;
577
+ return displayText.toLowerCase().includes(lowerFilter);
578
+ });
402
579
 
403
580
  return {
404
581
  ...facetGroup,
405
- buckets: truncatedBuckets,
582
+ buckets: filteredBuckets,
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Determines whether to use pagination based on the number of filtered facets.
588
+ * Returns true if facet count >= PAGINATION_THRESHOLD, false otherwise.
589
+ */
590
+ private get usePagination(): boolean {
591
+ const facetCount = this.filteredFacetGroup?.buckets.length ?? 0;
592
+ return facetCount >= PAGINATION_THRESHOLD;
593
+ }
594
+
595
+ /**
596
+ * Returns the facet group for the current page.
597
+ * If using pagination (>= 1000 facets), slices to show only the current page.
598
+ * Otherwise, returns all facets for horizontal scrolling.
599
+ */
600
+ private get facetGroupForCurrentPage(): FacetGroup | undefined {
601
+ const filteredGroup = this.filteredFacetGroup;
602
+ if (!filteredGroup) return undefined;
603
+
604
+ // If facet count is below threshold, show all facets with horizontal scroll
605
+ if (!this.usePagination) {
606
+ return filteredGroup;
607
+ }
608
+
609
+ // Otherwise, use pagination - slice to current page
610
+ const startIndex = (this.pageNumber - 1) * this.facetsPerPage;
611
+ const endIndex = startIndex + this.facetsPerPage;
612
+ const slicedBuckets = filteredGroup.buckets.slice(startIndex, endIndex);
613
+
614
+ return {
615
+ ...filteredGroup,
616
+ buckets: slicedBuckets,
406
617
  };
407
618
  }
408
619
 
409
620
  private get moreFacetsTemplate(): TemplateResult {
621
+ const facetGroup = this.facetGroupForCurrentPage;
622
+
623
+ // Show empty state if filtering returned no results
624
+ if (
625
+ this.filterText.trim() &&
626
+ (!facetGroup || facetGroup.buckets.length === 0)
627
+ ) {
628
+ return this.emptyFilterResultsTemplate;
629
+ }
630
+
410
631
  return html`
411
632
  <facets-template
412
- .facetGroup=${this.facetGroupForCurrentPage}
633
+ .facetGroup=${facetGroup}
413
634
  .selectedFacets=${this.selectedFacets}
414
635
  .collectionTitles=${this.collectionTitles}
415
636
  @facetClick=${(e: CustomEvent<FacetEventDetails>) => {
@@ -434,50 +655,51 @@ export class MoreFacetsContent extends LitElement {
434
655
  `;
435
656
  }
436
657
 
658
+ private get emptyFilterResultsTemplate(): TemplateResult {
659
+ return html`
660
+ <div class="empty-results">
661
+ <p>${msg('No matching values found.')}</p>
662
+ <p class="hint">${msg('Try a different search term.')}</p>
663
+ </div>
664
+ `;
665
+ }
666
+
437
667
  /**
438
- * How many pages of facets to show in the modal pagination widget
668
+ * Number of pages for pagination (only used when facet count >= PAGINATION_THRESHOLD).
439
669
  */
440
670
  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);
671
+ const filteredBuckets = this.filteredFacetGroup?.buckets ?? [];
672
+ return Math.ceil(filteredBuckets.length / this.facetsPerPage);
446
673
  }
447
674
 
448
- // render pagination if more then 1 page
675
+ /**
676
+ * Template for pagination component.
677
+ */
449
678
  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;
679
+ return html`<more-facets-pagination
680
+ .size=${this.paginationSize}
681
+ .currentPage=${this.pageNumber}
682
+ .compact=${this.isCompactView}
683
+ @pageNumberClicked=${this.pageNumberClicked}
684
+ ></more-facets-pagination>`;
457
685
  }
458
686
 
459
687
  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;
688
+ return html`
689
+ ${when(this.usePagination, () => this.facetsPaginationTemplate)}
690
+ <div class="footer">
691
+ <button class="btn btn-cancel" type="button" @click=${this.cancelClick}>
692
+ Cancel
693
+ </button>
694
+ <button
695
+ class="btn btn-submit"
696
+ type="button"
697
+ @click=${this.applySearchFacetsClicked}
698
+ >
699
+ Apply filters
700
+ </button>
701
+ </div>
702
+ `;
481
703
  }
482
704
 
483
705
  private sortFacetAggregation(facetSortType: AggregationSortType) {
@@ -487,6 +709,40 @@ export class MoreFacetsContent extends LitElement {
487
709
  );
488
710
  }
489
711
 
712
+ /**
713
+ * Handler for filter input changes. Updates the filter text and triggers re-render.
714
+ */
715
+ private handleFilterInput(e: Event): void {
716
+ const input = e.target as HTMLElement & { value: string };
717
+ this.filterText = input.value;
718
+ }
719
+
720
+ /**
721
+ * Handler for when the filter input is cleared via the clear button.
722
+ */
723
+ private handleFilterClear(): void {
724
+ this.filterText = '';
725
+ }
726
+
727
+ /**
728
+ * Handler for pagination page number clicks.
729
+ * Only used when facet count >= PAGINATION_THRESHOLD.
730
+ */
731
+ private pageNumberClicked(e: CustomEvent<{ page: number }>) {
732
+ this.pageNumber = e.detail.page;
733
+
734
+ // Track page navigation in analytics
735
+ this.analyticsHandler?.sendEvent({
736
+ category: analyticsCategories.default,
737
+ action: analyticsActions.moreFacetsPageChange,
738
+ label: `${this.pageNumber}`,
739
+ });
740
+
741
+ this.dispatchEvent(
742
+ new CustomEvent('pageChanged', { detail: this.pageNumber }),
743
+ );
744
+ }
745
+
490
746
  private get modalHeaderTemplate(): TemplateResult {
491
747
  const facetSort =
492
748
  this.sortedBy ?? defaultFacetSort[this.facetKey as FacetOption];
@@ -494,36 +750,94 @@ export class MoreFacetsContent extends LitElement {
494
750
  facetSort === AggregationSortType.COUNT ? 'left' : 'right';
495
751
 
496
752
  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}
753
+ <span class="title"> ${this.facetGroup?.title} </span>
754
+ <span class="header-controls">
755
+ <span class="sort-controls">
756
+ <label class="sort-label">${msg('Sort by:')}</label>
757
+ ${this.facetKey
758
+ ? html`<toggle-switch
759
+ class="sort-toggle"
760
+ leftValue=${AggregationSortType.COUNT}
761
+ leftLabel="Count"
762
+ rightValue=${valueFacetSort[this.facetKey]}
763
+ .rightLabel=${this.facetGroup?.title}
764
+ side=${defaultSwitchSide}
765
+ @change=${(e: CustomEvent<string>) => {
766
+ this.sortFacetAggregation(
767
+ Number(e.detail) as AggregationSortType,
768
+ );
769
+ }}
770
+ ></toggle-switch>`
771
+ : nothing}
772
+ </span>
773
+
774
+ <span class="filter-controls">
775
+ <label class="filter-label">${msg('Filter by:')}</label>
776
+ <ia-clearable-text-input
777
+ class="filter-input"
778
+ .value=${this.filterText}
779
+ .placeholder=${msg('Search...')}
780
+ .screenReaderLabel=${msg('Filter facets')}
781
+ .clearButtonScreenReaderLabel=${msg('Clear filter')}
782
+ @input=${this.handleFilterInput}
783
+ @clear=${this.handleFilterClear}
784
+ ></ia-clearable-text-input>
785
+ </span>
516
786
  </span>`;
517
787
  }
518
788
 
519
789
  render() {
790
+ const sectionClasses = classMap({
791
+ 'pagination-mode': this.usePagination,
792
+ 'horizontal-scroll-mode': !this.usePagination,
793
+ });
794
+ const contentClasses = classMap({
795
+ 'facets-content': true,
796
+ 'pagination-mode': this.usePagination,
797
+ 'horizontal-scroll-mode': !this.usePagination,
798
+ });
799
+
520
800
  return html`
521
801
  ${this.facetsLoading
522
802
  ? this.loaderTemplate
523
803
  : html`
524
- <section id="more-facets">
804
+ <section id="more-facets" class=${sectionClasses}>
525
805
  <div class="header-content">${this.modalHeaderTemplate}</div>
526
- <div class="facets-content">${this.moreFacetsTemplate}</div>
806
+ ${this.usePagination
807
+ ? html`<div class=${contentClasses}>
808
+ ${this.moreFacetsTemplate}
809
+ </div>`
810
+ : html`<div class="scroll-nav-container">
811
+ ${when(
812
+ !this.atScrollStart || !this.atScrollEnd,
813
+ () =>
814
+ html`<button
815
+ class="scroll-arrow scroll-left"
816
+ @click=${this.onScrollLeft}
817
+ ?disabled=${this.atScrollStart}
818
+ aria-label="Scroll facets left"
819
+ >
820
+ ${arrowLeftIcon}
821
+ </button>`,
822
+ )}
823
+ <div class=${contentClasses}>
824
+ <div class="facets-horizontal-container">
825
+ ${this.moreFacetsTemplate}
826
+ </div>
827
+ </div>
828
+ ${when(
829
+ !this.atScrollStart || !this.atScrollEnd,
830
+ () =>
831
+ html`<button
832
+ class="scroll-arrow scroll-right"
833
+ @click=${this.onScrollRight}
834
+ ?disabled=${this.atScrollEnd}
835
+ aria-label="Scroll facets right"
836
+ >
837
+ ${arrowRightIcon}
838
+ </button>`,
839
+ )}
840
+ </div>`}
527
841
  ${this.footerTemplate}
528
842
  </section>
529
843
  `}
@@ -546,6 +860,9 @@ export class MoreFacetsContent extends LitElement {
546
860
  // Reset the unapplied changes back to default, now that they have been applied
547
861
  this.unappliedFacetChanges = getDefaultSelectedFacets();
548
862
 
863
+ // Reset filter text
864
+ this.filterText = '';
865
+
549
866
  this.modalManager?.closeModal();
550
867
  this.analyticsHandler?.sendEvent({
551
868
  category: analyticsCategories.default,
@@ -558,6 +875,9 @@ export class MoreFacetsContent extends LitElement {
558
875
  // Reset the unapplied changes back to default
559
876
  this.unappliedFacetChanges = getDefaultSelectedFacets();
560
877
 
878
+ // Reset filter text
879
+ this.filterText = '';
880
+
561
881
  this.modalManager?.closeModal();
562
882
  this.analyticsHandler?.sendEvent({
563
883
  category: analyticsCategories.default,
@@ -573,10 +893,23 @@ export class MoreFacetsContent extends LitElement {
573
893
  srOnlyStyle,
574
894
  css`
575
895
  section#more-facets {
576
- overflow: auto;
577
- padding: 10px; /* leaves room for scroll bar to appear without overlaying on content */
896
+ display: flex;
897
+ flex-direction: column;
898
+ max-height: calc(100vh - 16.5rem);
899
+ padding: 10px;
900
+ box-sizing: border-box;
578
901
  --facetsColumnCount: 3;
579
902
  }
903
+
904
+ /* Both modes need a height constraint for proper column flow */
905
+ section#more-facets.horizontal-scroll-mode,
906
+ section#more-facets.pagination-mode {
907
+ --facetsMaxHeight: 280px;
908
+ }
909
+ .header-content {
910
+ flex-shrink: 0;
911
+ }
912
+
580
913
  .header-content .title {
581
914
  display: block;
582
915
  text-align: left;
@@ -585,8 +918,22 @@ export class MoreFacetsContent extends LitElement {
585
918
  font-weight: bold;
586
919
  }
587
920
 
921
+ .header-controls {
922
+ display: flex;
923
+ flex-wrap: wrap;
924
+ align-items: center;
925
+ gap: 4px 20px;
926
+ padding: 0 10px;
927
+ }
928
+
929
+ .sort-controls {
930
+ display: inline-flex;
931
+ align-items: center;
932
+ white-space: nowrap;
933
+ gap: 5px;
934
+ }
935
+
588
936
  .sort-label {
589
- margin-left: 20px;
590
937
  font-size: 1.3rem;
591
938
  }
592
939
 
@@ -594,11 +941,115 @@ export class MoreFacetsContent extends LitElement {
594
941
  font-weight: normal;
595
942
  }
596
943
 
944
+ .filter-controls {
945
+ display: inline-flex;
946
+ align-items: center;
947
+ white-space: nowrap;
948
+ }
949
+
950
+ .filter-label {
951
+ font-size: 1.3rem;
952
+ }
953
+
954
+ .filter-input {
955
+ --input-height: 2.5rem;
956
+ --input-font-size: 1.3rem;
957
+ --input-border-radius: 4px;
958
+ --input-padding: 4px 8px;
959
+ --input-focused-border-color: ${modalSubmitButton};
960
+ width: 150px;
961
+ margin-left: 5px;
962
+ }
963
+
964
+ .empty-results {
965
+ text-align: center;
966
+ padding: 40px 20px;
967
+ color: #666;
968
+ }
969
+
970
+ .empty-results .hint {
971
+ margin-top: 10px;
972
+ }
973
+
597
974
  .facets-content {
598
975
  font-size: 1.2rem;
599
- max-height: 300px;
600
- overflow: auto;
976
+ flex: 1 1 auto;
977
+ min-height: 0;
978
+ overflow-y: auto;
979
+ overflow-x: hidden;
601
980
  padding: 10px;
981
+ /* Force scrollbar to always be visible */
982
+ scrollbar-width: thin; /* Firefox */
983
+ scrollbar-color: #888 #f1f1f1; /* Firefox - thumb and track colors */
984
+ }
985
+
986
+ /* Horizontal scroll mode: horizontal scrolling only */
987
+ .facets-content.horizontal-scroll-mode {
988
+ overflow-x: auto;
989
+ overflow-y: hidden;
990
+ }
991
+
992
+ /* Webkit browsers scrollbar styling - always visible */
993
+ .facets-content::-webkit-scrollbar {
994
+ width: 12px; /* Vertical scrollbar width */
995
+ height: 12px; /* Horizontal scrollbar height */
996
+ }
997
+
998
+ .facets-content::-webkit-scrollbar-track {
999
+ background: #f1f1f1;
1000
+ border-radius: 6px;
1001
+ }
1002
+
1003
+ .facets-content::-webkit-scrollbar-thumb {
1004
+ background: #888;
1005
+ border-radius: 6px;
1006
+ min-height: 30px; /* Ensure thumb is always visible when scrolling is possible */
1007
+ }
1008
+
1009
+ .facets-content::-webkit-scrollbar-thumb:hover {
1010
+ background: #555;
1011
+ }
1012
+
1013
+ /* Force corner to match track color */
1014
+ .facets-content::-webkit-scrollbar-corner {
1015
+ background: #f1f1f1;
1016
+ }
1017
+
1018
+ .facets-horizontal-container {
1019
+ display: inline-block;
1020
+ min-width: 100%;
1021
+ /* Allow natural width expansion based on content */
1022
+ width: fit-content;
1023
+ }
1024
+
1025
+ .scroll-nav-container {
1026
+ display: flex;
1027
+ align-items: center;
1028
+ flex: 1 1 auto;
1029
+ min-height: 0;
1030
+ }
1031
+
1032
+ .scroll-nav-container .facets-content {
1033
+ flex: 1 1 auto;
1034
+ min-width: 0;
1035
+ }
1036
+
1037
+ .scroll-arrow {
1038
+ background: none;
1039
+ border: none;
1040
+ cursor: pointer;
1041
+ padding: 5px;
1042
+ flex-shrink: 0;
1043
+ }
1044
+
1045
+ .scroll-arrow svg {
1046
+ height: 14px;
1047
+ fill: #2c2c2c;
1048
+ }
1049
+
1050
+ .scroll-arrow:disabled {
1051
+ opacity: 0.3;
1052
+ cursor: default;
602
1053
  }
603
1054
  .facets-loader {
604
1055
  --icon-width: 70px;
@@ -614,6 +1065,7 @@ export class MoreFacetsContent extends LitElement {
614
1065
  width: auto;
615
1066
  border-radius: 4px;
616
1067
  cursor: pointer;
1068
+ font-family: inherit;
617
1069
  }
618
1070
  .btn-cancel {
619
1071
  background-color: #2c2c2c;
@@ -623,19 +1075,37 @@ export class MoreFacetsContent extends LitElement {
623
1075
  background-color: ${modalSubmitButton};
624
1076
  color: white;
625
1077
  }
1078
+ more-facets-pagination {
1079
+ flex-shrink: 0;
1080
+ }
1081
+
626
1082
  .footer {
627
1083
  text-align: center;
628
1084
  margin-top: 10px;
1085
+ flex-shrink: 0;
629
1086
  }
630
1087
 
631
1088
  @media (max-width: 560px) {
632
- section#more-facets {
633
- max-height: 450px;
634
- --facetsColumnCount: 1;
1089
+ section#more-facets.horizontal-scroll-mode,
1090
+ section#more-facets.pagination-mode {
1091
+ --facetsColumnCount: 1; /* Single column on mobile */
1092
+ --facetsMaxHeight: none; /* Remove fixed height for vertical scrolling */
635
1093
  }
636
- .facets-content {
1094
+ /* On mobile, always use vertical scrolling regardless of mode */
1095
+ .facets-content,
1096
+ .facets-content.horizontal-scroll-mode {
637
1097
  overflow-y: auto;
638
- height: 300px;
1098
+ overflow-x: hidden;
1099
+ }
1100
+ .scroll-nav-container {
1101
+ display: contents; /* Remove wrapper from layout so section flex-column works */
1102
+ }
1103
+ .scroll-arrow {
1104
+ display: none;
1105
+ }
1106
+ .filter-input {
1107
+ width: 120px;
1108
+ --input-font-size: 1.2rem;
639
1109
  }
640
1110
  }
641
1111
  `,