@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
@@ -1,17 +1,27 @@
1
1
  import { __decorate } from "tslib";
2
2
  import { css, html, LitElement, nothing, } from 'lit';
3
- import { customElement, property, state } from 'lit/decorators.js';
3
+ import { customElement, property, query, state } from 'lit/decorators.js';
4
+ import { classMap } from 'lit/directives/class-map.js';
5
+ import { when } from 'lit/directives/when.js';
4
6
  import { SearchType, AggregationSortType, } from '@internetarchive/search-service';
5
7
  import { msg } from '@lit/localize';
6
8
  import { facetTitles, suppressedCollections, valueFacetSort, defaultFacetSort, getDefaultSelectedFacets, tvMoreFacetSort, } from '../models';
7
9
  import '@internetarchive/elements/ia-status-indicator/ia-status-indicator';
8
- import './more-facets-pagination';
9
10
  import './facets-template';
10
11
  import { analyticsActions, analyticsCategories, } from '../utils/analytics-events';
11
12
  import './toggle-switch';
13
+ import './more-facets-pagination';
14
+ import '@internetarchive/ia-clearable-text-input';
15
+ import arrowLeftIcon from '../assets/img/icons/arrow-left';
16
+ import arrowRightIcon from '../assets/img/icons/arrow-right';
12
17
  import { srOnlyStyle } from '../styles/sr-only';
13
18
  import { mergeSelectedFacets, sortBucketsBySelectionState, updateSelectedFacetBucket, } from '../utils/facet-utils';
14
19
  import { MORE_FACETS__DEFAULT_PAGE_SIZE, MORE_FACETS__MAX_AGGREGATIONS, } from './models';
20
+ /**
21
+ * Threshold for switching from horizontal scroll to pagination.
22
+ * If facet count >= this value, use pagination. Otherwise use horizontal scroll.
23
+ */
24
+ const PAGINATION_THRESHOLD = 1000;
15
25
  let MoreFacetsContent = class MoreFacetsContent extends LitElement {
16
26
  constructor() {
17
27
  super(...arguments);
@@ -34,9 +44,36 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
34
44
  */
35
45
  this.unappliedFacetChanges = getDefaultSelectedFacets();
36
46
  /**
37
- * Which page of facets we are showing.
47
+ * Text entered by the user to filter facet buckets.
48
+ * Applied to bucket.key for case-insensitive matching.
49
+ */
50
+ this.filterText = '';
51
+ /**
52
+ * Current page number for pagination (when facet count >= PAGINATION_THRESHOLD).
38
53
  */
39
54
  this.pageNumber = 1;
55
+ /**
56
+ * Whether the component is narrow enough to warrant compact pagination.
57
+ * Updated via a ResizeObserver-based container query approach.
58
+ */
59
+ this.isCompactView = false;
60
+ /**
61
+ * Whether the horizontal scroll is at the leftmost position.
62
+ */
63
+ this.atScrollStart = true;
64
+ /**
65
+ * Whether the horizontal scroll is at the rightmost position.
66
+ */
67
+ this.atScrollEnd = true;
68
+ this.scrollHandler = () => this.updateScrollState();
69
+ this.scrollListenerAttached = false;
70
+ /**
71
+ * Close more facets modal on Escape click
72
+ */
73
+ this.escapeHandler = (e) => {
74
+ if (e.key === 'Escape')
75
+ this.modalManager?.closeModal();
76
+ };
40
77
  }
41
78
  willUpdate(changed) {
42
79
  if (changed.has('aggregations') ||
@@ -48,6 +85,12 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
48
85
  // store it for reuse across pages.
49
86
  this.facetGroup = this.mergedFacets;
50
87
  }
88
+ // Reset to page 1 when filter text changes (only matters for pagination mode)
89
+ if (changed.has('filterText')) {
90
+ this.pageNumber = 1;
91
+ }
92
+ }
93
+ updated(changed) {
51
94
  // If any of the search properties change, it triggers a facet fetch
52
95
  if (changed.has('facetKey') ||
53
96
  changed.has('query') ||
@@ -61,23 +104,143 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
61
104
  : defaultFacetSort[this.facetKey];
62
105
  this.updateSpecificFacets();
63
106
  }
107
+ // Reset horizontal scroll when filter text changes (e.g., switching from
108
+ // horizontal-scroll mode back to pagination mode)
109
+ if (changed.has('filterText')) {
110
+ const facetsContent = this.shadowRoot?.querySelector('.facets-content');
111
+ if (facetsContent) {
112
+ facetsContent.scrollLeft = 0;
113
+ }
114
+ }
115
+ // Manage scroll listener for horizontal scroll mode arrows.
116
+ // Only re-evaluate when properties that affect the displayed content change.
117
+ if (changed.has('filterText') ||
118
+ changed.has('aggregations') ||
119
+ changed.has('facetKey') ||
120
+ changed.has('sortedBy') ||
121
+ changed.has('selectedFacets') ||
122
+ changed.has('unappliedFacetChanges')) {
123
+ if (!this.usePagination) {
124
+ this.attachScrollListener();
125
+ // Refresh scroll state whenever content may have changed (e.g., filtering)
126
+ requestAnimationFrame(() => this.updateScrollState());
127
+ }
128
+ else {
129
+ this.removeScrollListener();
130
+ }
131
+ }
64
132
  }
65
133
  firstUpdated() {
66
134
  this.setupEscapeListeners();
135
+ this.setupCompactViewObserver();
136
+ }
137
+ disconnectedCallback() {
138
+ super.disconnectedCallback();
139
+ this.resizeObserver?.disconnect();
140
+ this.removeScrollListener();
141
+ document.removeEventListener('keydown', this.escapeHandler);
142
+ }
143
+ /**
144
+ * Attaches a scroll event listener to the facets content element
145
+ * to track horizontal scroll position for arrow button states.
146
+ */
147
+ attachScrollListener() {
148
+ if (this.scrollListenerAttached || !this.facetsContentEl)
149
+ return;
150
+ this.scrollListenerTarget = this.facetsContentEl;
151
+ this.scrollListenerTarget.addEventListener('scroll', this.scrollHandler, {
152
+ passive: true,
153
+ });
154
+ this.scrollListenerAttached = true;
155
+ // Defer initial state check until after browser layout, so scrollWidth
156
+ // reflects the actual content dimensions.
157
+ requestAnimationFrame(() => this.updateScrollState());
158
+ }
159
+ removeScrollListener() {
160
+ if (!this.scrollListenerAttached || !this.scrollListenerTarget)
161
+ return;
162
+ this.scrollListenerTarget.removeEventListener('scroll', this.scrollHandler);
163
+ this.scrollListenerTarget = undefined;
164
+ this.scrollListenerAttached = false;
165
+ }
166
+ /**
167
+ * Updates the scroll arrow disabled states based on current scroll position.
168
+ */
169
+ updateScrollState() {
170
+ const el = this.facetsContentEl;
171
+ if (!el)
172
+ return;
173
+ this.atScrollStart = el.scrollLeft <= 0;
174
+ this.atScrollEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
175
+ }
176
+ /**
177
+ * Calculates the width of one column step (column width + gap) based on
178
+ * the CSS multi-column layout of the scroll container.
179
+ */
180
+ getColumnStep() {
181
+ const el = this.facetsContentEl;
182
+ if (!el)
183
+ return 0;
184
+ const facetRows = el.querySelector('.facet-rows');
185
+ const styles = facetRows
186
+ ? getComputedStyle(facetRows)
187
+ : getComputedStyle(el);
188
+ const columnCount = parseInt(styles.columnCount, 10) || 3;
189
+ const columnGap = parseInt(styles.columnGap, 10) || 15;
190
+ // Column width = (visible width - total gaps) / column count
191
+ // Column step = column width + gap = (visible width + gap) / column count
192
+ return (el.clientWidth + columnGap) / columnCount;
193
+ }
194
+ /**
195
+ * Snaps a scroll target to the nearest column boundary.
196
+ */
197
+ snapToColumn(target) {
198
+ const step = this.getColumnStep();
199
+ if (step <= 0)
200
+ return target;
201
+ return Math.round(target / step) * step;
202
+ }
203
+ /**
204
+ * Scrolls the facet content left by approximately one page, snapping to
205
+ * the nearest column boundary.
206
+ */
207
+ onScrollLeft() {
208
+ const el = this.facetsContentEl;
209
+ if (!el)
210
+ return;
211
+ const rawTarget = el.scrollLeft - el.clientWidth;
212
+ const snapped = Math.max(0, this.snapToColumn(rawTarget));
213
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
214
+ }
215
+ /**
216
+ * Scrolls the facet content right by approximately one page, snapping to
217
+ * the nearest column boundary.
218
+ */
219
+ onScrollRight() {
220
+ const el = this.facetsContentEl;
221
+ if (!el)
222
+ return;
223
+ const maxScroll = el.scrollWidth - el.clientWidth;
224
+ const rawTarget = el.scrollLeft + el.clientWidth;
225
+ const snapped = Math.min(maxScroll, this.snapToColumn(rawTarget));
226
+ el.scrollTo({ left: snapped, behavior: 'smooth' });
67
227
  }
68
228
  /**
69
- * Close more facets modal on Escape click
229
+ * Sets up a ResizeObserver to toggle compact pagination based on component width.
70
230
  */
231
+ setupCompactViewObserver() {
232
+ this.resizeObserver = new ResizeObserver(entries => {
233
+ for (const entry of entries) {
234
+ const compact = entry.contentRect.width <= 560;
235
+ if (this.isCompactView !== compact)
236
+ this.isCompactView = compact;
237
+ }
238
+ });
239
+ this.resizeObserver.observe(this);
240
+ }
71
241
  setupEscapeListeners() {
72
242
  if (this.modalManager) {
73
- document.addEventListener('keydown', (e) => {
74
- if (e.key === 'Escape') {
75
- this.modalManager?.closeModal();
76
- }
77
- });
78
- }
79
- else {
80
- document.removeEventListener('keydown', () => { });
243
+ document.addEventListener('keydown', this.escapeHandler);
81
244
  }
82
245
  }
83
246
  /**
@@ -112,29 +275,19 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
112
275
  aggregationsSize,
113
276
  rows: 0, // todo - do we want server-side pagination with offset/page/limit flag?
114
277
  };
115
- const results = await this.searchService?.search(params, this.searchType);
116
- this.aggregations = results?.success?.response.aggregations;
117
- this.facetsLoading = false;
118
- const collectionTitles = results?.success?.response?.collectionTitles;
119
- if (collectionTitles) {
120
- for (const [id, title] of Object.entries(collectionTitles)) {
121
- this.collectionTitles?.set(id, title);
278
+ try {
279
+ const results = await this.searchService?.search(params, this.searchType);
280
+ this.aggregations = results?.success?.response.aggregations;
281
+ const collectionTitles = results?.success?.response?.collectionTitles;
282
+ if (collectionTitles) {
283
+ for (const [id, title] of Object.entries(collectionTitles)) {
284
+ this.collectionTitles?.set(id, title);
285
+ }
122
286
  }
123
287
  }
124
- }
125
- /**
126
- * Handler for page number changes from the pagination widget.
127
- */
128
- pageNumberClicked(e) {
129
- const page = e?.detail?.page;
130
- if (page) {
131
- this.pageNumber = Number(page);
288
+ finally {
289
+ this.facetsLoading = false;
132
290
  }
133
- this.analyticsHandler?.sendEvent({
134
- category: analyticsCategories.default,
135
- action: analyticsActions.moreFacetsPageChange,
136
- label: `${this.pageNumber}`,
137
- });
138
291
  }
139
292
  /**
140
293
  * Combines the selected facets with the aggregations to create a single list of facets
@@ -202,7 +355,9 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
202
355
  return undefined;
203
356
  const facetGroupTitle = facetTitles[this.facetKey];
204
357
  const buckets = Object.entries(selectedFacetsForKey).map(([value, data]) => {
205
- const displayText = value;
358
+ const displayText = (this.facetKey === 'collection'
359
+ ? this.collectionTitles?.get(value)
360
+ : undefined) ?? value;
206
361
  return {
207
362
  displayText,
208
363
  key: value,
@@ -236,16 +391,26 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
236
391
  return (!suppressedCollections[bucketKey] && !bucketKey?.startsWith('fav-'));
237
392
  });
238
393
  }
239
- // Construct the array of facet buckets from the aggregation buckets
394
+ // Construct the array of facet buckets from the aggregation buckets,
395
+ // using collection display titles where available.
240
396
  const facetBuckets = sortedBuckets.map(bucket => {
241
397
  const bucketKeyStr = `${bucket.key}`;
398
+ const displayText = (this.facetKey === 'collection'
399
+ ? this.collectionTitles?.get(bucketKeyStr)
400
+ : undefined) ?? bucketKeyStr;
242
401
  return {
243
- displayText: `${bucketKeyStr}`,
402
+ displayText,
244
403
  key: `${bucketKeyStr}`,
245
404
  count: bucket.doc_count,
246
405
  state: 'none',
247
406
  };
248
407
  });
408
+ // For collection facets sorted alphabetically, re-sort by display title
409
+ // instead of the raw identifier used by getSortedBuckets.
410
+ if (this.facetKey === 'collection' &&
411
+ this.sortedBy === AggregationSortType.ALPHABETICAL) {
412
+ facetBuckets.sort((a, b) => (a.displayText ?? a.key).localeCompare(b.displayText ?? b.key));
413
+ }
249
414
  return {
250
415
  title: facetGroupTitle,
251
416
  key: this.facetKey,
@@ -253,24 +418,70 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
253
418
  };
254
419
  }
255
420
  /**
256
- * Returns a FacetGroup representing only the current page of facet buckets to show.
421
+ * Returns the facet group with buckets filtered by the current filter text.
422
+ * Filters are applied to the full bucket list before pagination.
257
423
  */
258
- get facetGroupForCurrentPage() {
259
- const { facetGroup } = this;
424
+ get filteredFacetGroup() {
425
+ const { facetGroup, filterText } = this;
260
426
  if (!facetGroup)
261
427
  return undefined;
262
- // Slice out only the current page of facet buckets
263
- const firstBucketIndexOnPage = (this.pageNumber - 1) * this.facetsPerPage;
264
- const truncatedBuckets = facetGroup.buckets.slice(firstBucketIndexOnPage, firstBucketIndexOnPage + this.facetsPerPage);
428
+ // If no filter text, return the full group
429
+ if (!filterText.trim()) {
430
+ return facetGroup;
431
+ }
432
+ // Filter buckets by the text the user actually sees.
433
+ // For collections, match against the displayed collection title (not the identifier).
434
+ // For other facet types, match against the bucket key (which is also the display text).
435
+ const lowerFilter = filterText.toLowerCase().trim();
436
+ const filteredBuckets = facetGroup.buckets.filter(bucket => {
437
+ const displayText = this.collectionTitles?.get(bucket.key) ?? bucket.key;
438
+ return displayText.toLowerCase().includes(lowerFilter);
439
+ });
265
440
  return {
266
441
  ...facetGroup,
267
- buckets: truncatedBuckets,
442
+ buckets: filteredBuckets,
443
+ };
444
+ }
445
+ /**
446
+ * Determines whether to use pagination based on the number of filtered facets.
447
+ * Returns true if facet count >= PAGINATION_THRESHOLD, false otherwise.
448
+ */
449
+ get usePagination() {
450
+ const facetCount = this.filteredFacetGroup?.buckets.length ?? 0;
451
+ return facetCount >= PAGINATION_THRESHOLD;
452
+ }
453
+ /**
454
+ * Returns the facet group for the current page.
455
+ * If using pagination (>= 1000 facets), slices to show only the current page.
456
+ * Otherwise, returns all facets for horizontal scrolling.
457
+ */
458
+ get facetGroupForCurrentPage() {
459
+ const filteredGroup = this.filteredFacetGroup;
460
+ if (!filteredGroup)
461
+ return undefined;
462
+ // If facet count is below threshold, show all facets with horizontal scroll
463
+ if (!this.usePagination) {
464
+ return filteredGroup;
465
+ }
466
+ // Otherwise, use pagination - slice to current page
467
+ const startIndex = (this.pageNumber - 1) * this.facetsPerPage;
468
+ const endIndex = startIndex + this.facetsPerPage;
469
+ const slicedBuckets = filteredGroup.buckets.slice(startIndex, endIndex);
470
+ return {
471
+ ...filteredGroup,
472
+ buckets: slicedBuckets,
268
473
  };
269
474
  }
270
475
  get moreFacetsTemplate() {
476
+ const facetGroup = this.facetGroupForCurrentPage;
477
+ // Show empty state if filtering returned no results
478
+ if (this.filterText.trim() &&
479
+ (!facetGroup || facetGroup.buckets.length === 0)) {
480
+ return this.emptyFilterResultsTemplate;
481
+ }
271
482
  return html `
272
483
  <facets-template
273
- .facetGroup=${this.facetGroupForCurrentPage}
484
+ .facetGroup=${facetGroup}
274
485
  .selectedFacets=${this.selectedFacets}
275
486
  .collectionTitles=${this.collectionTitles}
276
487
  @facetClick=${(e) => {
@@ -289,83 +500,171 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
289
500
  ></ia-status-indicator>
290
501
  `;
291
502
  }
503
+ get emptyFilterResultsTemplate() {
504
+ return html `
505
+ <div class="empty-results">
506
+ <p>${msg('No matching values found.')}</p>
507
+ <p class="hint">${msg('Try a different search term.')}</p>
508
+ </div>
509
+ `;
510
+ }
292
511
  /**
293
- * How many pages of facets to show in the modal pagination widget
512
+ * Number of pages for pagination (only used when facet count >= PAGINATION_THRESHOLD).
294
513
  */
295
514
  get paginationSize() {
296
- if (!this.aggregations || !this.facetKey)
297
- return 0;
298
- // Calculate the appropriate number of pages to show in the modal pagination widget
299
- const length = this.aggregations[this.facetKey]?.buckets.length;
300
- return Math.ceil(length / this.facetsPerPage);
515
+ const filteredBuckets = this.filteredFacetGroup?.buckets ?? [];
516
+ return Math.ceil(filteredBuckets.length / this.facetsPerPage);
301
517
  }
302
- // render pagination if more then 1 page
518
+ /**
519
+ * Template for pagination component.
520
+ */
303
521
  get facetsPaginationTemplate() {
304
- return this.paginationSize > 1
305
- ? html `<more-facets-pagination
306
- .size=${this.paginationSize}
307
- .currentPage=${1}
308
- @pageNumberClicked=${this.pageNumberClicked}
309
- ></more-facets-pagination>`
310
- : nothing;
522
+ return html `<more-facets-pagination
523
+ .size=${this.paginationSize}
524
+ .currentPage=${this.pageNumber}
525
+ .compact=${this.isCompactView}
526
+ @pageNumberClicked=${this.pageNumberClicked}
527
+ ></more-facets-pagination>`;
311
528
  }
312
529
  get footerTemplate() {
313
- if (this.paginationSize > 0) {
314
- return html `${this.facetsPaginationTemplate}
315
- <div class="footer">
316
- <button
317
- class="btn btn-cancel"
318
- type="button"
319
- @click=${this.cancelClick}
320
- >
321
- Cancel
322
- </button>
323
- <button
324
- class="btn btn-submit"
325
- type="button"
326
- @click=${this.applySearchFacetsClicked}
327
- >
328
- Apply filters
329
- </button>
330
- </div> `;
331
- }
332
- return nothing;
530
+ return html `
531
+ ${when(this.usePagination, () => this.facetsPaginationTemplate)}
532
+ <div class="footer">
533
+ <button class="btn btn-cancel" type="button" @click=${this.cancelClick}>
534
+ Cancel
535
+ </button>
536
+ <button
537
+ class="btn btn-submit"
538
+ type="button"
539
+ @click=${this.applySearchFacetsClicked}
540
+ >
541
+ Apply filters
542
+ </button>
543
+ </div>
544
+ `;
333
545
  }
334
546
  sortFacetAggregation(facetSortType) {
335
547
  this.sortedBy = facetSortType;
336
548
  this.dispatchEvent(new CustomEvent('sortedFacets', { detail: this.sortedBy }));
337
549
  }
550
+ /**
551
+ * Handler for filter input changes. Updates the filter text and triggers re-render.
552
+ */
553
+ handleFilterInput(e) {
554
+ const input = e.target;
555
+ this.filterText = input.value;
556
+ }
557
+ /**
558
+ * Handler for when the filter input is cleared via the clear button.
559
+ */
560
+ handleFilterClear() {
561
+ this.filterText = '';
562
+ }
563
+ /**
564
+ * Handler for pagination page number clicks.
565
+ * Only used when facet count >= PAGINATION_THRESHOLD.
566
+ */
567
+ pageNumberClicked(e) {
568
+ this.pageNumber = e.detail.page;
569
+ // Track page navigation in analytics
570
+ this.analyticsHandler?.sendEvent({
571
+ category: analyticsCategories.default,
572
+ action: analyticsActions.moreFacetsPageChange,
573
+ label: `${this.pageNumber}`,
574
+ });
575
+ this.dispatchEvent(new CustomEvent('pageChanged', {
576
+ detail: this.pageNumber,
577
+ bubbles: true,
578
+ composed: true,
579
+ }));
580
+ }
338
581
  get modalHeaderTemplate() {
339
582
  const facetSort = this.sortedBy ?? defaultFacetSort[this.facetKey];
340
583
  const defaultSwitchSide = facetSort === AggregationSortType.COUNT ? 'left' : 'right';
341
584
  return html `<span class="sr-only">${msg('More facets for:')}</span>
342
- <span class="title">
343
- ${this.facetGroup?.title}
344
-
345
- <label class="sort-label">${msg('Sort by:')}</label>
346
- ${this.facetKey
585
+ <span class="title"> ${this.facetGroup?.title} </span>
586
+ <span class="header-controls">
587
+ <span class="sort-controls">
588
+ <label class="sort-label">${msg('Sort by:')}</label>
589
+ ${this.facetKey
347
590
  ? html `<toggle-switch
348
- class="sort-toggle"
349
- leftValue=${AggregationSortType.COUNT}
350
- leftLabel="Count"
351
- rightValue=${valueFacetSort[this.facetKey]}
352
- .rightLabel=${this.facetGroup?.title}
353
- side=${defaultSwitchSide}
354
- @change=${(e) => {
591
+ class="sort-toggle"
592
+ leftValue=${AggregationSortType.COUNT}
593
+ leftLabel="Count"
594
+ rightValue=${valueFacetSort[this.facetKey]}
595
+ .rightLabel=${this.facetGroup?.title}
596
+ side=${defaultSwitchSide}
597
+ @change=${(e) => {
355
598
  this.sortFacetAggregation(Number(e.detail));
356
599
  }}
357
- ></toggle-switch>`
600
+ ></toggle-switch>`
358
601
  : nothing}
602
+ </span>
603
+
604
+ <span class="filter-controls">
605
+ <label class="filter-label">${msg('Filter by:')}</label>
606
+ <ia-clearable-text-input
607
+ class="filter-input"
608
+ .value=${this.filterText}
609
+ .placeholder=${msg('Search...')}
610
+ .screenReaderLabel=${msg('Filter facets')}
611
+ .clearButtonScreenReaderLabel=${msg('Clear filter')}
612
+ @input=${this.handleFilterInput}
613
+ @clear=${this.handleFilterClear}
614
+ ></ia-clearable-text-input>
615
+ </span>
359
616
  </span>`;
360
617
  }
618
+ get horizontalScrollTemplate() {
619
+ const contentClasses = classMap({
620
+ 'facets-content': true,
621
+ 'horizontal-scroll-mode': true,
622
+ });
623
+ const showArrows = !this.atScrollStart || !this.atScrollEnd;
624
+ return html `<div class="scroll-nav-container">
625
+ ${when(showArrows, () => html `<button
626
+ class="scroll-arrow scroll-left"
627
+ @click=${this.onScrollLeft}
628
+ ?disabled=${this.atScrollStart}
629
+ aria-label="Scroll facets left"
630
+ >
631
+ ${arrowLeftIcon}
632
+ </button>`)}
633
+ <div class=${contentClasses}>
634
+ <div class="facets-horizontal-container">
635
+ ${this.moreFacetsTemplate}
636
+ </div>
637
+ </div>
638
+ ${when(showArrows, () => html `<button
639
+ class="scroll-arrow scroll-right"
640
+ @click=${this.onScrollRight}
641
+ ?disabled=${this.atScrollEnd}
642
+ aria-label="Scroll facets right"
643
+ >
644
+ ${arrowRightIcon}
645
+ </button>`)}
646
+ </div>`;
647
+ }
361
648
  render() {
649
+ const sectionClasses = classMap({
650
+ 'pagination-mode': this.usePagination,
651
+ 'horizontal-scroll-mode': !this.usePagination,
652
+ });
653
+ const contentClasses = classMap({
654
+ 'facets-content': true,
655
+ 'pagination-mode': this.usePagination,
656
+ });
362
657
  return html `
363
658
  ${this.facetsLoading
364
659
  ? this.loaderTemplate
365
660
  : html `
366
- <section id="more-facets">
661
+ <section id="more-facets" class=${sectionClasses}>
367
662
  <div class="header-content">${this.modalHeaderTemplate}</div>
368
- <div class="facets-content">${this.moreFacetsTemplate}</div>
663
+ ${this.usePagination
664
+ ? html `<div class=${contentClasses}>
665
+ ${this.moreFacetsTemplate}
666
+ </div>`
667
+ : this.horizontalScrollTemplate}
369
668
  ${this.footerTemplate}
370
669
  </section>
371
670
  `}
@@ -381,6 +680,8 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
381
680
  this.dispatchEvent(event);
382
681
  // Reset the unapplied changes back to default, now that they have been applied
383
682
  this.unappliedFacetChanges = getDefaultSelectedFacets();
683
+ // Reset filter text
684
+ this.filterText = '';
384
685
  this.modalManager?.closeModal();
385
686
  this.analyticsHandler?.sendEvent({
386
687
  category: analyticsCategories.default,
@@ -391,6 +692,8 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
391
692
  cancelClick() {
392
693
  // Reset the unapplied changes back to default
393
694
  this.unappliedFacetChanges = getDefaultSelectedFacets();
695
+ // Reset filter text
696
+ this.filterText = '';
394
697
  this.modalManager?.closeModal();
395
698
  this.analyticsHandler?.sendEvent({
396
699
  category: analyticsCategories.default,
@@ -404,10 +707,26 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
404
707
  srOnlyStyle,
405
708
  css `
406
709
  section#more-facets {
407
- overflow: auto;
408
- padding: 10px; /* leaves room for scroll bar to appear without overlaying on content */
710
+ display: flex;
711
+ flex-direction: column;
712
+ max-height: calc(100vh - 16.5rem);
713
+ padding: 10px;
714
+ box-sizing: border-box;
409
715
  --facetsColumnCount: 3;
410
716
  }
717
+
718
+ /* Both modes need a height constraint for proper column flow */
719
+ section#more-facets.horizontal-scroll-mode,
720
+ section#more-facets.pagination-mode {
721
+ --facetsMaxHeight: 280px;
722
+ }
723
+ .header-content {
724
+ flex-shrink: 0;
725
+ position: relative;
726
+ z-index: 1;
727
+ background: #fff;
728
+ }
729
+
411
730
  .header-content .title {
412
731
  display: block;
413
732
  text-align: left;
@@ -416,8 +735,22 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
416
735
  font-weight: bold;
417
736
  }
418
737
 
738
+ .header-controls {
739
+ display: flex;
740
+ flex-wrap: wrap;
741
+ align-items: center;
742
+ gap: 8px 20px;
743
+ padding: 0 10px 8px;
744
+ }
745
+
746
+ .sort-controls {
747
+ display: inline-flex;
748
+ align-items: center;
749
+ white-space: nowrap;
750
+ gap: 5px;
751
+ }
752
+
419
753
  .sort-label {
420
- margin-left: 20px;
421
754
  font-size: 1.3rem;
422
755
  }
423
756
 
@@ -425,11 +758,115 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
425
758
  font-weight: normal;
426
759
  }
427
760
 
761
+ .filter-controls {
762
+ display: inline-flex;
763
+ align-items: center;
764
+ white-space: nowrap;
765
+ }
766
+
767
+ .filter-label {
768
+ font-size: 1.3rem;
769
+ }
770
+
771
+ .filter-input {
772
+ --input-height: 2.5rem;
773
+ --input-font-size: 1.3rem;
774
+ --input-border-radius: 4px;
775
+ --input-padding: 4px 8px;
776
+ --input-focused-border-color: ${modalSubmitButton};
777
+ width: 150px;
778
+ margin-left: 5px;
779
+ }
780
+
781
+ .empty-results {
782
+ text-align: center;
783
+ padding: 40px 20px;
784
+ color: #666;
785
+ }
786
+
787
+ .empty-results .hint {
788
+ margin-top: 10px;
789
+ }
790
+
428
791
  .facets-content {
429
792
  font-size: 1.2rem;
430
- max-height: 300px;
431
- overflow: auto;
793
+ flex: 1 1 auto;
794
+ min-height: 0;
795
+ overflow-y: auto;
796
+ overflow-x: hidden;
432
797
  padding: 10px;
798
+ /* Force scrollbar to always be visible */
799
+ scrollbar-width: thin; /* Firefox */
800
+ scrollbar-color: #888 #f1f1f1; /* Firefox - thumb and track colors */
801
+ }
802
+
803
+ /* Horizontal scroll mode: horizontal scrolling only */
804
+ .facets-content.horizontal-scroll-mode {
805
+ overflow-x: auto;
806
+ overflow-y: hidden;
807
+ }
808
+
809
+ /* Webkit browsers scrollbar styling - always visible */
810
+ .facets-content::-webkit-scrollbar {
811
+ width: 12px; /* Vertical scrollbar width */
812
+ height: 12px; /* Horizontal scrollbar height */
813
+ }
814
+
815
+ .facets-content::-webkit-scrollbar-track {
816
+ background: #f1f1f1;
817
+ border-radius: 6px;
818
+ }
819
+
820
+ .facets-content::-webkit-scrollbar-thumb {
821
+ background: #888;
822
+ border-radius: 6px;
823
+ min-height: 30px; /* Ensure thumb is always visible when scrolling is possible */
824
+ }
825
+
826
+ .facets-content::-webkit-scrollbar-thumb:hover {
827
+ background: #555;
828
+ }
829
+
830
+ /* Force corner to match track color */
831
+ .facets-content::-webkit-scrollbar-corner {
832
+ background: #f1f1f1;
833
+ }
834
+
835
+ .facets-horizontal-container {
836
+ display: inline-block;
837
+ min-width: 100%;
838
+ /* Allow natural width expansion based on content */
839
+ width: fit-content;
840
+ }
841
+
842
+ .scroll-nav-container {
843
+ display: flex;
844
+ align-items: center;
845
+ flex: 1 1 auto;
846
+ min-height: 0;
847
+ }
848
+
849
+ .scroll-nav-container .facets-content {
850
+ flex: 1 1 auto;
851
+ min-width: 0;
852
+ }
853
+
854
+ .scroll-arrow {
855
+ background: none;
856
+ border: none;
857
+ cursor: pointer;
858
+ padding: 5px;
859
+ flex-shrink: 0;
860
+ }
861
+
862
+ .scroll-arrow svg {
863
+ height: 14px;
864
+ fill: #2c2c2c;
865
+ }
866
+
867
+ .scroll-arrow:disabled {
868
+ opacity: 0.3;
869
+ cursor: default;
433
870
  }
434
871
  .facets-loader {
435
872
  --icon-width: 70px;
@@ -445,6 +882,7 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
445
882
  width: auto;
446
883
  border-radius: 4px;
447
884
  cursor: pointer;
885
+ font-family: inherit;
448
886
  }
449
887
  .btn-cancel {
450
888
  background-color: #2c2c2c;
@@ -454,19 +892,37 @@ let MoreFacetsContent = class MoreFacetsContent extends LitElement {
454
892
  background-color: ${modalSubmitButton};
455
893
  color: white;
456
894
  }
895
+ more-facets-pagination {
896
+ flex-shrink: 0;
897
+ }
898
+
457
899
  .footer {
458
900
  text-align: center;
459
901
  margin-top: 10px;
902
+ flex-shrink: 0;
460
903
  }
461
904
 
462
905
  @media (max-width: 560px) {
463
- section#more-facets {
464
- max-height: 450px;
465
- --facetsColumnCount: 1;
906
+ section#more-facets.horizontal-scroll-mode,
907
+ section#more-facets.pagination-mode {
908
+ --facetsColumnCount: 1; /* Single column on mobile */
909
+ --facetsMaxHeight: none; /* Remove fixed height for vertical scrolling */
466
910
  }
467
- .facets-content {
911
+ /* On mobile, always use vertical scrolling regardless of mode */
912
+ .facets-content,
913
+ .facets-content.horizontal-scroll-mode {
468
914
  overflow-y: auto;
469
- height: 300px;
915
+ overflow-x: hidden;
916
+ }
917
+ .scroll-nav-container {
918
+ display: contents; /* Remove wrapper from layout so section flex-column works */
919
+ }
920
+ .scroll-arrow {
921
+ display: none;
922
+ }
923
+ .filter-input {
924
+ width: 120px;
925
+ --input-font-size: 1.2rem;
470
926
  }
471
927
  }
472
928
  `,
@@ -530,9 +986,27 @@ __decorate([
530
986
  __decorate([
531
987
  state()
532
988
  ], MoreFacetsContent.prototype, "unappliedFacetChanges", void 0);
989
+ __decorate([
990
+ state()
991
+ ], MoreFacetsContent.prototype, "filterText", void 0);
533
992
  __decorate([
534
993
  state()
535
994
  ], MoreFacetsContent.prototype, "pageNumber", void 0);
995
+ __decorate([
996
+ state()
997
+ ], MoreFacetsContent.prototype, "isCompactView", void 0);
998
+ __decorate([
999
+ state()
1000
+ ], MoreFacetsContent.prototype, "atScrollStart", void 0);
1001
+ __decorate([
1002
+ state()
1003
+ ], MoreFacetsContent.prototype, "atScrollEnd", void 0);
1004
+ __decorate([
1005
+ query('ia-clearable-text-input')
1006
+ ], MoreFacetsContent.prototype, "filterInput", void 0);
1007
+ __decorate([
1008
+ query('.facets-content')
1009
+ ], MoreFacetsContent.prototype, "facetsContentEl", void 0);
536
1010
  MoreFacetsContent = __decorate([
537
1011
  customElement('more-facets-content')
538
1012
  ], MoreFacetsContent);