@internetarchive/collection-browser 3.1.1-alpha-webdev6778.7 → 3.1.1-alpha-webdev6778.8

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 (73) hide show
  1. package/dist/src/app-root.js +606 -606
  2. package/dist/src/app-root.js.map +1 -1
  3. package/dist/src/collection-browser.js.map +1 -1
  4. package/dist/src/collection-facets/facet-row.js +140 -140
  5. package/dist/src/collection-facets/facet-row.js.map +1 -1
  6. package/dist/src/collection-facets/models.js.map +1 -1
  7. package/dist/src/collection-facets/smart-facets/smart-facet-bar.js +75 -75
  8. package/dist/src/collection-facets/smart-facets/smart-facet-bar.js.map +1 -1
  9. package/dist/src/collection-facets/smart-facets/smart-facet-dropdown.js +54 -54
  10. package/dist/src/collection-facets/smart-facets/smart-facet-dropdown.js.map +1 -1
  11. package/dist/src/data-source/collection-browser-data-source-interface.js.map +1 -1
  12. package/dist/src/data-source/collection-browser-data-source.js +1 -3
  13. package/dist/src/data-source/collection-browser-data-source.js.map +1 -1
  14. package/dist/src/data-source/models.js.map +1 -1
  15. package/dist/src/expanded-date-picker.js +52 -52
  16. package/dist/src/expanded-date-picker.js.map +1 -1
  17. package/dist/src/manage/manage-bar.js +77 -77
  18. package/dist/src/manage/manage-bar.js.map +1 -1
  19. package/dist/src/models.js.map +1 -1
  20. package/dist/src/sort-filter-bar/sort-filter-bar.js +376 -376
  21. package/dist/src/sort-filter-bar/sort-filter-bar.js.map +1 -1
  22. package/dist/src/tiles/base-tile-component.js.map +1 -1
  23. package/dist/src/tiles/grid/account-tile.js +36 -36
  24. package/dist/src/tiles/grid/account-tile.js.map +1 -1
  25. package/dist/src/tiles/grid/search-tile.js +42 -42
  26. package/dist/src/tiles/grid/search-tile.js.map +1 -1
  27. package/dist/src/tiles/grid/styles/tile-grid-shared-styles.js +119 -119
  28. package/dist/src/tiles/grid/styles/tile-grid-shared-styles.js.map +1 -1
  29. package/dist/src/tiles/list/tile-list-compact.js +97 -97
  30. package/dist/src/tiles/list/tile-list-compact.js.map +1 -1
  31. package/dist/src/utils/analytics-events.js.map +1 -1
  32. package/dist/src/utils/format-date.js.map +1 -1
  33. package/dist/test/collection-browser.test.js +187 -187
  34. package/dist/test/collection-browser.test.js.map +1 -1
  35. package/dist/test/collection-facets/facet-row.test.js +23 -23
  36. package/dist/test/collection-facets/facet-row.test.js.map +1 -1
  37. package/dist/test/collection-facets.test.js +20 -20
  38. package/dist/test/collection-facets.test.js.map +1 -1
  39. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +37 -37
  40. package/dist/test/sort-filter-bar/sort-filter-bar.test.js.map +1 -1
  41. package/dist/test/tiles/grid/item-tile.test.js +64 -64
  42. package/dist/test/tiles/grid/item-tile.test.js.map +1 -1
  43. package/dist/test/tiles/list/tile-list-compact.test.js +57 -57
  44. package/dist/test/tiles/list/tile-list-compact.test.js.map +1 -1
  45. package/dist/test/utils/format-date.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/app-root.ts +1140 -1140
  48. package/src/collection-browser.ts +1 -1
  49. package/src/collection-facets/facet-row.ts +296 -296
  50. package/src/collection-facets/models.ts +10 -10
  51. package/src/collection-facets/smart-facets/smart-facet-bar.ts +437 -437
  52. package/src/collection-facets/smart-facets/smart-facet-dropdown.ts +185 -185
  53. package/src/data-source/collection-browser-data-source-interface.ts +333 -333
  54. package/src/data-source/collection-browser-data-source.ts +2 -4
  55. package/src/data-source/models.ts +43 -43
  56. package/src/expanded-date-picker.ts +191 -191
  57. package/src/manage/manage-bar.ts +247 -247
  58. package/src/models.ts +870 -870
  59. package/src/sort-filter-bar/sort-filter-bar.ts +1283 -1283
  60. package/src/tiles/base-tile-component.ts +53 -53
  61. package/src/tiles/grid/account-tile.ts +112 -112
  62. package/src/tiles/grid/search-tile.ts +90 -90
  63. package/src/tiles/grid/styles/tile-grid-shared-styles.ts +130 -130
  64. package/src/tiles/list/tile-list-compact.ts +236 -236
  65. package/src/utils/analytics-events.ts +29 -29
  66. package/src/utils/format-date.ts +42 -42
  67. package/test/collection-browser.test.ts +2359 -2359
  68. package/test/collection-facets/facet-row.test.ts +375 -375
  69. package/test/collection-facets.test.ts +928 -928
  70. package/test/sort-filter-bar/sort-filter-bar.test.ts +885 -885
  71. package/test/tiles/grid/item-tile.test.ts +464 -464
  72. package/test/tiles/list/tile-list-compact.test.ts +228 -228
  73. package/test/utils/format-date.test.ts +39 -39
@@ -1,1283 +1,1283 @@
1
- import {
2
- LitElement,
3
- html,
4
- css,
5
- nothing,
6
- PropertyValues,
7
- TemplateResult,
8
- } from 'lit';
9
- import { customElement, property, query, state } from 'lit/decorators.js';
10
- import { msg } from '@lit/localize';
11
- import type {
12
- SharedResizeObserverInterface,
13
- SharedResizeObserverResizeHandlerInterface,
14
- } from '@internetarchive/shared-resize-observer';
15
- import '@internetarchive/ia-dropdown';
16
- import type { IaDropdown, optionInterface } from '@internetarchive/ia-dropdown';
17
- import type { SortDirection } from '@internetarchive/search-service';
18
- import {
19
- ALL_DATE_SORT_FIELDS,
20
- ALL_VIEWS_SORT_FIELDS,
21
- CollectionDisplayMode,
22
- DateSortField,
23
- defaultSortAvailability,
24
- PrefixFilterCounts,
25
- PrefixFilterType,
26
- SORT_OPTIONS,
27
- SortField,
28
- ViewsSortField,
29
- } from '../models';
30
- import './alpha-bar';
31
-
32
- import { sortUpIcon } from './img/sort-toggle-up';
33
- import { sortDownIcon } from './img/sort-toggle-down';
34
- import { sortDisabledIcon } from './img/sort-toggle-disabled';
35
- import { tileIcon } from './img/tile';
36
- import { listIcon } from './img/list';
37
- import { compactIcon } from './img/compact';
38
- import { srOnlyStyle } from '../styles/sr-only';
39
-
40
- type AlphaSelector = 'creator' | 'title';
41
-
42
- @customElement('sort-filter-bar')
43
- export class SortFilterBar
44
- extends LitElement
45
- implements SharedResizeObserverResizeHandlerInterface
46
- {
47
- /** Which display mode the tiles are being rendered with (grid/list-detail/list-compact) */
48
- @property({ type: String }) displayMode?: CollectionDisplayMode;
49
-
50
- /** The default sort direction to use if none is set */
51
- @property({ type: String }) defaultSortDirection: SortDirection | null = null;
52
-
53
- /** The default sort field to use if none is set */
54
- @property({ type: String }) defaultSortField: Exclude<
55
- SortField,
56
- SortField.default
57
- > = SortField.relevance;
58
-
59
- /** Which view sort option to expose by default. */
60
- @property({ type: String }) defaultViewSort: ViewsSortField =
61
- SortField.weeklyview;
62
-
63
- /** Which date sort option to expose by default */
64
- @property({ type: String }) defaultDateSort: DateSortField = SortField.date;
65
-
66
- /** The current sort direction (asc/desc), or null if none is set */
67
- @property({ type: String }) sortDirection: SortDirection | null = null;
68
-
69
- /** The field currently being sorted on (e.g., 'title'). Defaults to relevance. */
70
- @property({ type: String }) selectedSort: SortField = SortField.default;
71
-
72
- /** The currently selected title letter filter, or null if none is set */
73
- @property({ type: String }) selectedTitleFilter: string | null = null;
74
-
75
- /** The currently selected creator letter filter, or null if none is set */
76
- @property({ type: String }) selectedCreatorFilter: string | null = null;
77
-
78
- /**
79
- * Map defining which sortable fields should be included on the sort bar.
80
- *
81
- * E.g.,
82
- * ```
83
- * {
84
- * [SortField.relevance]: true,
85
- * [SortField.date]: false,
86
- * [SortField.title]: true,
87
- * ...
88
- * }
89
- * ```
90
- */
91
- @property({ type: Object }) sortFieldAvailability = defaultSortAvailability;
92
-
93
- /** Whether to replace the default sort options with a slot for customization (default `false`) */
94
- @property({ type: Boolean, reflect: true }) enableSortOptionsSlot: boolean =
95
- false;
96
-
97
- /** Whether to suppress showing the three display mode options on the right of the bar (default `false`) */
98
- @property({ type: Boolean, reflect: true })
99
- suppressDisplayModes: boolean = false;
100
-
101
- /** Maps of result counts for letters on the alphabet bar, for each letter filter type */
102
- @property({ type: Object }) prefixFilterCountMap?: Record<
103
- PrefixFilterType,
104
- PrefixFilterCounts
105
- >;
106
-
107
- @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
108
-
109
- /**
110
- * The Views sort option that was most recently selected (or the default, if none has been selected yet)
111
- */
112
- @state() private lastSelectedViewSort = this.defaultViewSort;
113
-
114
- /**
115
- * The Date sort option that was most recently selected (or the default, if none has been selected yet)
116
- */
117
- @state() private lastSelectedDateSort = this.defaultDateSort;
118
-
119
- /**
120
- * Which of the alphabet bars (title/creator) should be shown, or null if one
121
- * should not currently be rendered.
122
- */
123
- @state() alphaSelectorVisible: AlphaSelector | null = null;
124
-
125
- /**
126
- * Whether the transparent backdrop to catch clicks outside the dropdown menu
127
- * should be rendered.
128
- */
129
- @state() dropdownBackdropVisible = false;
130
-
131
- /**
132
- * The width of the desktop view sort option container, updated upon each resize.
133
- * Used for dynamically determining whether to use desktop or mobile view.
134
- */
135
- @state() desktopSortContainerWidth = 0;
136
-
137
- /**
138
- * The width of the full sort bar, updated upon each resize.
139
- * Used for dynamically determining whether to use desktop or mobile view.
140
- */
141
- @state() selectorBarContainerWidth = 0;
142
-
143
- /**
144
- * The container for all the desktop view's sort options.
145
- * Used for dynamically determining whether to use desktop or mobile view.
146
- */
147
- @query('#desktop-sort-container')
148
- private desktopSortContainer!: HTMLUListElement;
149
-
150
- /**
151
- * The container for the full sort bar.
152
- * Used for dynamically determining whether to use desktop or mobile view.
153
- */
154
- @query('#sort-selector-container')
155
- private sortSelectorContainer!: HTMLDivElement;
156
-
157
- /** The dropdown component containing options for weekly and all-time views */
158
- @query('#views-dropdown')
159
- private viewsDropdown?: IaDropdown;
160
-
161
- /** The dropdown component containing all the available date options */
162
- @query('#date-dropdown')
163
- private dateDropdown?: IaDropdown;
164
-
165
- /** The single, consolidated dropdown component shown in mobile view */
166
- @query('#mobile-dropdown')
167
- private mobileDropdown!: IaDropdown;
168
-
169
- render() {
170
- return html`
171
- <div id="container">
172
- <section id="sort-bar" aria-label="Sorting options">
173
- <slot name="sort-options-left"></slot>
174
- <div id="sort-options">
175
- ${!this.enableSortOptionsSlot
176
- ? html`
177
- <div class="sort-direction-container">
178
- ${this.sortDirectionSelectorTemplate}
179
- </div>
180
- <span class="sort-by-text">${msg('Sort by:')}</span>
181
- <div id="sort-selector-container">
182
- ${this.mobileSortSelectorTemplate}
183
- ${this.desktopSortSelectorTemplate}
184
- </div>
185
- `
186
- : html`<slot name="sort-options"></slot>`}
187
- </div>
188
- <slot name="sort-options-right"></slot>
189
-
190
- ${this.suppressDisplayModes
191
- ? nothing
192
- : html`<div id="display-style-selector">
193
- ${this.displayOptionTemplate}
194
- </div>`}
195
- </section>
196
-
197
- ${this.dropdownBackdropVisible ? this.dropdownBackdrop : nothing}
198
- ${this.alphaBarTemplate}
199
- </div>
200
- `;
201
- }
202
-
203
- willUpdate(changed: PropertyValues) {
204
- if (changed.has('selectedSort') || changed.has('defaultSortField')) {
205
- // If the sort is changed from its default without a direction set,
206
- // we adopt the default sort direction for that sort type.
207
- if (
208
- this.selectedSort &&
209
- this.selectedSort !== SortField.default &&
210
- this.sortDirection === null
211
- ) {
212
- const sortOption = SORT_OPTIONS[this.finalizedSortField];
213
- this.sortDirection = sortOption.defaultSortDirection;
214
- }
215
-
216
- if (this.viewOptionSelected) {
217
- this.lastSelectedViewSort = this.finalizedSortField as ViewsSortField;
218
- } else if (this.dateOptionSelected) {
219
- this.lastSelectedDateSort = this.finalizedSortField as DateSortField;
220
- }
221
- }
222
-
223
- // If we change which dropdown options are defaulted, update the default selections
224
- if (changed.has('defaultViewSort')) {
225
- this.lastSelectedViewSort = this.defaultViewSort;
226
- }
227
- if (changed.has('defaultDateSort')) {
228
- this.lastSelectedDateSort = this.defaultDateSort;
229
- }
230
- }
231
-
232
- updated(changed: PropertyValues) {
233
- if (changed.has('displayMode')) {
234
- this.displayModeChanged();
235
- }
236
-
237
- if (changed.has('selectedTitleFilter') && this.selectedTitleFilter) {
238
- this.alphaSelectorVisible = 'title';
239
- }
240
-
241
- if (changed.has('selectedCreatorFilter') && this.selectedCreatorFilter) {
242
- this.alphaSelectorVisible = 'creator';
243
- }
244
-
245
- if (changed.has('dropdownBackdropVisible')) {
246
- this.setupEscapeListeners();
247
- }
248
-
249
- if (changed.has('resizeObserver') || changed.has('enableSortOptionsSlot')) {
250
- const oldObserver = changed.get(
251
- 'resizeObserver',
252
- ) as SharedResizeObserverInterface;
253
- if (oldObserver) this.disconnectResizeObserver(oldObserver);
254
- this.setupResizeObserver();
255
- }
256
- }
257
-
258
- private setupEscapeListeners() {
259
- if (this.dropdownBackdropVisible) {
260
- document.addEventListener(
261
- 'keydown',
262
- this.boundSortBarSelectorEscapeListener,
263
- );
264
- } else {
265
- document.removeEventListener(
266
- 'keydown',
267
- this.boundSortBarSelectorEscapeListener,
268
- );
269
- }
270
- }
271
-
272
- private boundSortBarSelectorEscapeListener = (e: KeyboardEvent) => {
273
- if (e.key === 'Escape') {
274
- this.closeDropdowns();
275
- }
276
- };
277
-
278
- connectedCallback(): void {
279
- super.connectedCallback?.();
280
- this.setupResizeObserver();
281
- }
282
-
283
- disconnectedCallback(): void {
284
- if (this.resizeObserver) {
285
- this.disconnectResizeObserver(this.resizeObserver);
286
- }
287
- }
288
-
289
- private disconnectResizeObserver(
290
- resizeObserver: SharedResizeObserverInterface,
291
- ) {
292
- if (this.sortSelectorContainer) {
293
- resizeObserver.removeObserver({
294
- target: this.sortSelectorContainer,
295
- handler: this,
296
- });
297
- }
298
-
299
- if (this.desktopSortContainer) {
300
- resizeObserver.removeObserver({
301
- target: this.desktopSortContainer,
302
- handler: this,
303
- });
304
- }
305
- }
306
-
307
- private setupResizeObserver() {
308
- if (!this.resizeObserver) return;
309
-
310
- if (this.sortSelectorContainer) {
311
- this.resizeObserver.addObserver({
312
- target: this.sortSelectorContainer,
313
- handler: this,
314
- });
315
- }
316
-
317
- if (this.desktopSortContainer) {
318
- this.resizeObserver.addObserver({
319
- target: this.desktopSortContainer,
320
- handler: this,
321
- });
322
- }
323
- }
324
-
325
- handleResize(entry: ResizeObserverEntry): void {
326
- if (entry.target === this.desktopSortContainer) {
327
- this.desktopSortContainerWidth = entry.contentRect.width;
328
- } else if (entry.target === this.sortSelectorContainer) {
329
- this.selectorBarContainerWidth = entry.contentRect.width;
330
- }
331
- }
332
-
333
- /**
334
- * Whether to show the mobile sort bar because there is not enough space
335
- * for the desktop sort bar.
336
- */
337
- private get mobileSelectorVisible() {
338
- return this.selectorBarContainerWidth - 10 < this.desktopSortContainerWidth;
339
- }
340
-
341
- /**
342
- * Template to render the alphabet bar, or `nothing` if it should not be rendered
343
- * for the current sort
344
- */
345
- private get alphaBarTemplate(): TemplateResult | typeof nothing {
346
- if (!['title', 'creator'].includes(this.selectedSort)) return nothing;
347
-
348
- if (this.alphaSelectorVisible === null) {
349
- if (this.selectedSort === 'creator') return this.creatorSelectorBar;
350
- if (this.selectedSort === 'title') return this.titleSelectorBar;
351
- } else {
352
- return this.alphaSelectorVisible === 'creator'
353
- ? this.creatorSelectorBar
354
- : this.titleSelectorBar;
355
- }
356
-
357
- return nothing;
358
- }
359
-
360
- /** Template to render the sort direction toggle button */
361
- private get sortDirectionSelectorTemplate(): TemplateResult {
362
- const oppositeSortDirectionReadable =
363
- this.sortDirection === 'asc' ? 'descending' : 'ascending';
364
- const srLabel = `Change to ${oppositeSortDirectionReadable} sort`;
365
-
366
- return html`
367
- <button
368
- class="sort-direction-selector"
369
- ?disabled=${!this.canChangeSortDirection}
370
- @click=${this.handleSortDirectionClicked}
371
- >
372
- <span class="sr-only">${srLabel}</span>
373
- ${this.sortDirectionIcon}
374
- </button>
375
- `;
376
- }
377
-
378
- /** Template to render the sort direction button's icon in the correct current state */
379
- private get sortDirectionIcon(): TemplateResult {
380
- // Show a fully disabled icon for sort options without direction support
381
- if (!this.canChangeSortDirection) {
382
- return html`<div class="sort-direction-icon">${sortDisabledIcon}</div>`;
383
- }
384
-
385
- // For all other sorts, show the ascending/descending direction
386
- return html`
387
- <div class="sort-direction-icon">
388
- ${this.finalizedSortDirection === 'asc' ? sortUpIcon : sortDownIcon}
389
- </div>
390
- `;
391
- }
392
-
393
- /** The template to render all the sort options in desktop view */
394
- private get desktopSortSelectorTemplate() {
395
- return html`
396
- <div
397
- id="desktop-sort-container"
398
- class=${this.mobileSelectorVisible ? 'hidden' : 'visible'}
399
- >
400
- <ul id="desktop-sort-selector">
401
- <li>${this.relevanceSortSelectorTemplate}</li>
402
- <li>${this.allViewsSortOptionsTemplate}</li>
403
- <li>${this.titleSortSelectorTemplate}</li>
404
- <li>${this.allDateSortOptionsTemplate}</li>
405
- <li>${this.creatorSortSelectorTemplate}</li>
406
- </ul>
407
- </div>
408
- `;
409
- }
410
-
411
- /** The template to render all the sort options in mobile view */
412
- private get mobileSortSelectorTemplate() {
413
- const displayedOptions = Object.values(SORT_OPTIONS).filter(
414
- opt => opt.shownInSortBar && this.sortFieldAvailability[opt.field],
415
- );
416
-
417
- return html`
418
- <div
419
- id="mobile-sort-container"
420
- class=${this.mobileSelectorVisible ? 'visible' : 'hidden'}
421
- >
422
- ${this.getSortDropdown({
423
- displayName: SORT_OPTIONS[this.finalizedSortField].displayName,
424
- id: 'mobile-dropdown',
425
- selected: true,
426
- dropdownOptions: displayedOptions.map(opt =>
427
- this.getDropdownOption(opt.field),
428
- ),
429
- selectedOption: this.finalizedSortField,
430
- onOptionSelected: this.mobileSortChanged,
431
- onDropdownClick: () => {
432
- this.dropdownBackdropVisible = this.mobileDropdown.open;
433
- this.mobileDropdown.classList.toggle(
434
- 'open',
435
- this.mobileDropdown.open,
436
- );
437
- },
438
- })}
439
- </div>
440
- `;
441
- }
442
-
443
- /**
444
- * This generates each of the non-dropdown sort option buttons.
445
- *
446
- * It manages the display value and the selected state of the option.
447
- *
448
- * @param sortField Which sort field the button represents
449
- * @param options Additional options:
450
- * - `onSelected?: (e: Event) => void;` If this is provided, it will also be called when the option is selected.
451
- * Default is to clear any selected alphabetical filters.
452
- */
453
- private getSortSelectorButton(
454
- sortField: SortField,
455
- options?: {
456
- onSelected?: (e: Event) => void;
457
- },
458
- ): TemplateResult {
459
- const isSelected = this.finalizedSortField === sortField;
460
- const displayName = SORT_OPTIONS[sortField].displayName;
461
- const onSelected =
462
- options?.onSelected ?? (() => this.clearAlphaBarFilters());
463
-
464
- return html`
465
- <button
466
- class=${isSelected ? 'selected' : ''}
467
- data-title=${displayName}
468
- @click=${(e: Event) => {
469
- e.preventDefault();
470
- this.dropdownBackdropVisible = false;
471
- if (this.finalizedSortField !== sortField) {
472
- onSelected?.(e);
473
- this.setSelectedSort(sortField);
474
- }
475
- }}
476
- >
477
- ${displayName}
478
- </button>
479
- `;
480
- }
481
-
482
- /**
483
- * Generates a dropdown component containing multiple grouped sort options.
484
- *
485
- * @param options.displayName The name to use for the dropdown's visible label
486
- * @param options.id The id to apply to the dropdown element
487
- * @param options.dropdownOptions An array of option objects used to populate the dropdown
488
- * @param options.selectedOption The id of the option that should be initially selected
489
- * @param options.selected A boolean indicating whether this dropdown should use its
490
- * selected appearance
491
- * @param options.onOptionSelected A handler for optionSelected events coming from the dropdown
492
- * @param options.onDropdownClick A handler for click events on the dropdown
493
- * @param options.onLabelInteraction A handler for click events and Enter/Space keydown events
494
- * on the dropdown's label
495
- */
496
- private getSortDropdown(options: {
497
- displayName: string;
498
- id: string;
499
- dropdownOptions: optionInterface[];
500
- selectedOption?: string;
501
- selected: boolean;
502
- onOptionSelected?: (e: CustomEvent<{ option: optionInterface }>) => void;
503
- onDropdownClick?: (e: PointerEvent) => void;
504
- onLabelInteraction?: (e: Event) => void;
505
- }): TemplateResult {
506
- return html`
507
- <ia-dropdown
508
- id=${options.id}
509
- class=${options.selected ? 'selected' : ''}
510
- displayCaret
511
- closeOnSelect
512
- includeSelectedOption
513
- .openViaButton=${options.selected}
514
- .options=${options.dropdownOptions}
515
- .selectedOption=${options.selectedOption ?? ''}
516
- @optionSelected=${options.onOptionSelected ?? nothing}
517
- @click=${options.onDropdownClick ?? nothing}
518
- >
519
- <span
520
- class="dropdown-label"
521
- slot="dropdown-label"
522
- data-title=${options.displayName}
523
- @click=${options.onLabelInteraction ?? nothing}
524
- @keydown=${options.onLabelInteraction
525
- ? (e: KeyboardEvent) => {
526
- if (e.key === 'Enter' || e.key === ' ') {
527
- options.onLabelInteraction?.(e);
528
- }
529
- }
530
- : nothing}
531
- >
532
- ${options.displayName}
533
- </span>
534
- </ia-dropdown>
535
- `;
536
- }
537
-
538
- /** Generates a single dropdown option object for the given sort field */
539
- private getDropdownOption(sortField: SortField): optionInterface {
540
- return {
541
- id: sortField,
542
- selectedHandler: () => {
543
- this.selectDropdownSortField(sortField);
544
- },
545
- label: html`
546
- <span class="dropdown-option-label">
547
- ${SORT_OPTIONS[sortField].displayName}
548
- </span>
549
- `,
550
- };
551
- }
552
-
553
- /** Handler for when any sort dropdown option is selected */
554
- private dropdownOptionSelected(e: CustomEvent<{ option: optionInterface }>) {
555
- this.dropdownBackdropVisible = false;
556
- this.clearAlphaBarFilters();
557
-
558
- const sortField = e.detail.option.id as SortField;
559
- this.setSelectedSort(sortField);
560
- if (this.viewOptionSelected) {
561
- this.lastSelectedViewSort = sortField as ViewsSortField;
562
- } else if (this.dateOptionSelected) {
563
- this.lastSelectedDateSort = sortField as DateSortField;
564
- }
565
- }
566
-
567
- //
568
- // SORT OPTION TEMPLATES
569
- //
570
-
571
- /**
572
- * Template for the Relevance sort selector button, or `nothing` if the relevance
573
- * field is not available for display.
574
- */
575
- private get relevanceSortSelectorTemplate(): TemplateResult | typeof nothing {
576
- if (!this.sortFieldAvailability.relevance) return nothing;
577
- return this.getSortSelectorButton(SortField.relevance);
578
- }
579
-
580
- /**
581
- * Template for the Views sort selector button.
582
- * This is shown instead of the views dropdown when only a single views sort field
583
- * is available.
584
- */
585
- private get viewsSortSelectorTemplate(): TemplateResult | typeof nothing {
586
- const { availableViewsFields } = this;
587
- if (availableViewsFields.length < 1) return nothing;
588
- return this.getSortSelectorButton(availableViewsFields[0]);
589
- }
590
-
591
- /**
592
- * Template for the Title sort selector button, or `nothing` if the title field is
593
- * not available for display.
594
- */
595
- private get titleSortSelectorTemplate(): TemplateResult | typeof nothing {
596
- if (!this.sortFieldAvailability.title) return nothing;
597
-
598
- return this.getSortSelectorButton(SortField.title, {
599
- onSelected: () => {
600
- this.alphaSelectorVisible = 'title';
601
- this.selectedCreatorFilter = null;
602
- this.emitCreatorLetterChangedEvent();
603
- },
604
- });
605
- }
606
-
607
- /**
608
- * Template for the Date sort selector button.
609
- * This is shown instead of the dates dropdown when only a single date sort field
610
- * is available.
611
- */
612
- private get dateSortSelectorTemplate(): TemplateResult | typeof nothing {
613
- const { availableDateFields } = this;
614
- if (availableDateFields.length < 1) return nothing;
615
- return this.getSortSelectorButton(availableDateFields[0]);
616
- }
617
-
618
- /**
619
- * Template for the Creator sort selector button, or `nothing` if the creator field
620
- * is not available for display.
621
- */
622
- private get creatorSortSelectorTemplate(): TemplateResult | typeof nothing {
623
- if (!this.sortFieldAvailability.creator) return nothing;
624
-
625
- return this.getSortSelectorButton(SortField.creator, {
626
- onSelected: () => {
627
- this.alphaSelectorVisible = 'creator';
628
- this.selectedTitleFilter = null;
629
- this.emitTitleLetterChangedEvent();
630
- },
631
- });
632
- }
633
-
634
- /**
635
- * Template for the Views dropdown, shown when 2+ views sort fields are available.
636
- */
637
- private get viewsDropdownTemplate(): TemplateResult {
638
- const displayedOptions = ALL_VIEWS_SORT_FIELDS.filter(
639
- field => this.sortFieldAvailability[field],
640
- ).map(field => this.getDropdownOption(field));
641
-
642
- return this.getSortDropdown({
643
- displayName: this.viewSortDisplayName,
644
- id: 'views-dropdown',
645
- selected: this.viewOptionSelected,
646
- dropdownOptions: displayedOptions,
647
- selectedOption: this.viewOptionSelected ? this.finalizedSortField : '',
648
- onOptionSelected: this.dropdownOptionSelected,
649
- onDropdownClick: () => {
650
- if (this.dateDropdown) this.dateDropdown.open = false;
651
- const viewsDropdown = this.viewsDropdown as IaDropdown;
652
- this.dropdownBackdropVisible = viewsDropdown.open;
653
- viewsDropdown.classList.toggle('open', viewsDropdown.open);
654
- },
655
- onLabelInteraction: (e: Event) => {
656
- if (!this.viewsDropdown?.open && !this.viewOptionSelected) {
657
- e.stopPropagation();
658
- this.clearAlphaBarFilters();
659
- this.setSelectedSort(this.lastSelectedViewSort);
660
- }
661
- },
662
- });
663
- }
664
-
665
- /**
666
- * Template for the Date dropdown, shown when 2+ date sort fields are available.
667
- */
668
- private get dateDropdownTemplate(): TemplateResult {
669
- const displayedOptions = ALL_DATE_SORT_FIELDS.filter(
670
- field => this.sortFieldAvailability[field],
671
- ).map(field => this.getDropdownOption(field));
672
-
673
- return this.getSortDropdown({
674
- displayName: this.dateSortDisplayName,
675
- id: 'date-dropdown',
676
- selected: this.dateOptionSelected,
677
- dropdownOptions: displayedOptions,
678
- selectedOption: this.dateOptionSelected ? this.finalizedSortField : '',
679
- onOptionSelected: this.dropdownOptionSelected,
680
- onDropdownClick: () => {
681
- if (this.viewsDropdown) this.viewsDropdown.open = false;
682
- const dateDropdown = this.dateDropdown as IaDropdown;
683
- this.dropdownBackdropVisible = dateDropdown.open;
684
- dateDropdown.classList.toggle('open', dateDropdown.open);
685
- },
686
- onLabelInteraction: (e: Event) => {
687
- if (!this.dateDropdown?.open && !this.dateOptionSelected) {
688
- e.stopPropagation();
689
- this.clearAlphaBarFilters();
690
- this.setSelectedSort(this.lastSelectedDateSort);
691
- }
692
- },
693
- });
694
- }
695
-
696
- /**
697
- * Provides the appropriate template to use for Views sorting, depending on how many
698
- * views sort fields are available.
699
- */
700
- private get allViewsSortOptionsTemplate(): TemplateResult | typeof nothing {
701
- const numViewsSortOptions = this.availableViewsFields.length;
702
- switch (numViewsSortOptions) {
703
- case 0:
704
- return nothing;
705
- case 1:
706
- return this.viewsSortSelectorTemplate;
707
- default:
708
- return this.viewsDropdownTemplate;
709
- }
710
- }
711
-
712
- /**
713
- * Provides the appropriate template to use for Date sorting, depending on how many
714
- * date sort fields are available.
715
- */
716
- private get allDateSortOptionsTemplate(): TemplateResult | typeof nothing {
717
- const numDateSortOptions = this.availableDateFields.length;
718
- switch (numDateSortOptions) {
719
- case 0:
720
- return nothing;
721
- case 1:
722
- return this.dateSortSelectorTemplate;
723
- default:
724
- return this.dateDropdownTemplate;
725
- }
726
- }
727
-
728
- /** Handler for when a new mobile sort dropdown option is selected */
729
- private mobileSortChanged(e: CustomEvent<{ option: optionInterface }>) {
730
- this.dropdownBackdropVisible = false;
731
-
732
- const sortField = e.detail.option.id as SortField;
733
- this.setSelectedSort(sortField);
734
-
735
- this.alphaSelectorVisible = null;
736
- if (sortField !== 'title' && this.selectedTitleFilter) {
737
- this.selectedTitleFilter = null;
738
- this.emitTitleLetterChangedEvent();
739
- }
740
- if (sortField !== 'creator' && this.selectedCreatorFilter) {
741
- this.selectedCreatorFilter = null;
742
- this.emitCreatorLetterChangedEvent();
743
- }
744
- }
745
-
746
- /** Template for rendering the three display mode options */
747
- /** Added data-testid for Playwright testing * */
748
- private get displayOptionTemplate() {
749
- return html`
750
- <ul>
751
- <li>
752
- <button
753
- id="grid-button"
754
- @click=${() => {
755
- this.displayMode = 'grid';
756
- }}
757
- class=${this.displayMode === 'grid' ? 'active' : ''}
758
- title="Tile view"
759
- data-testid="grid-button"
760
- >
761
- ${tileIcon}
762
- </button>
763
- </li>
764
- <li>
765
- <button
766
- id="list-detail-button"
767
- @click=${() => {
768
- this.displayMode = 'list-detail';
769
- }}
770
- class=${this.displayMode === 'list-detail' ? 'active' : ''}
771
- title="List view"
772
- data-testid="list-detail-button"
773
- >
774
- ${listIcon}
775
- </button>
776
- </li>
777
- <li>
778
- <button
779
- id="list-compact-button"
780
- @click=${() => {
781
- this.displayMode = 'list-compact';
782
- }}
783
- class=${this.displayMode === 'list-compact' ? 'active' : ''}
784
- title="Compact list view"
785
- data-testid="list-compact-button"
786
- >
787
- ${compactIcon}
788
- </button>
789
- </li>
790
- </ul>
791
- `;
792
- }
793
-
794
- /**
795
- * Template for rendering the transparent backdrop to capture clicks outside the
796
- * dropdown menu while it is open.
797
- */
798
- private get dropdownBackdrop() {
799
- return html`
800
- <div
801
- id="sort-selector-backdrop"
802
- @keyup=${this.closeDropdowns}
803
- @click=${this.closeDropdowns}
804
- ></div>
805
- `;
806
- }
807
-
808
- /** Closes all of the sorting dropdown components' menus */
809
- private closeDropdowns() {
810
- this.dropdownBackdropVisible = false;
811
- const allDropdowns = [
812
- this.viewsDropdown,
813
- this.dateDropdown,
814
- this.mobileDropdown,
815
- ];
816
- for (const dropdown of allDropdowns) {
817
- if (!dropdown) continue;
818
- dropdown.open = false;
819
- dropdown.classList.remove('open');
820
- }
821
- }
822
-
823
- private selectDropdownSortField(sortField: SortField) {
824
- // When a dropdown sort option is selected, we additionally need to clear the backdrop
825
- this.dropdownBackdropVisible = false;
826
- this.setSelectedSort(sortField);
827
- }
828
-
829
- private clearAlphaBarFilters() {
830
- this.alphaSelectorVisible = null;
831
- this.selectedTitleFilter = null;
832
- this.selectedCreatorFilter = null;
833
- this.emitTitleLetterChangedEvent();
834
- this.emitCreatorLetterChangedEvent();
835
- }
836
-
837
- private setSortDirection(sortDirection: SortDirection) {
838
- this.sortDirection = sortDirection;
839
- this.emitSortChangedEvent();
840
- }
841
-
842
- /** Toggles the current sort direction between 'asc' and 'desc' */
843
- private toggleSortDirection() {
844
- this.setSortDirection(
845
- this.finalizedSortDirection === 'desc' ? 'asc' : 'desc',
846
- );
847
- }
848
-
849
- private handleSortDirectionClicked(): void {
850
- if (
851
- !this.sortDirection &&
852
- this.defaultSortField &&
853
- this.defaultSortDirection
854
- ) {
855
- // When the sort direction is merely defaulted (not set by the user), clicking
856
- // the toggled button should "promote" the default sort to an explicitly-set one
857
- // and then toggle it as usual.
858
- this.selectedSort = this.defaultSortField;
859
- this.sortDirection = this.defaultSortDirection;
860
- }
861
-
862
- this.toggleSortDirection();
863
- }
864
-
865
- private setSelectedSort(sort: SortField) {
866
- this.selectedSort = sort;
867
- // Apply this field's default sort direction
868
- const sortOption = SORT_OPTIONS[sort];
869
- this.sortDirection = sortOption.defaultSortDirection;
870
- this.emitSortChangedEvent();
871
- }
872
-
873
- /** The current sort field, or the default one if no explicit sort is set */
874
- private get finalizedSortField(): SortField {
875
- return this.selectedSort === SortField.default
876
- ? this.defaultSortField
877
- : this.selectedSort;
878
- }
879
-
880
- /** The current sort direction, or the default one if no explicit direction is set */
881
- private get finalizedSortDirection(): SortDirection | null {
882
- return this.sortDirection === null
883
- ? this.defaultSortDirection
884
- : this.sortDirection;
885
- }
886
-
887
- /** Whether the sort direction button should be enabled for the current sort */
888
- private get canChangeSortDirection(): boolean {
889
- return SORT_OPTIONS[this.finalizedSortField].canSetDirection;
890
- }
891
-
892
- /**
893
- * There are four date sort options.
894
- *
895
- * This checks to see if the current sort is one of them.
896
- *
897
- * @readonly
898
- * @private
899
- * @type {boolean}
900
- * @memberof SortFilterBar
901
- */
902
- private get dateOptionSelected(): boolean {
903
- const dateSortFields: SortField[] = [
904
- SortField.datefavorited,
905
- SortField.datearchived,
906
- SortField.date,
907
- SortField.datereviewed,
908
- SortField.dateadded,
909
- ];
910
- return dateSortFields.includes(this.finalizedSortField);
911
- }
912
-
913
- /**
914
- * There are two view sort options.
915
- *
916
- * This checks to see if the current sort is one of them.
917
- *
918
- * @readonly
919
- * @private
920
- * @type {boolean}
921
- * @memberof SortFilterBar
922
- */
923
- private get viewOptionSelected(): boolean {
924
- const viewSortFields: SortField[] = [
925
- SortField.alltimeview,
926
- SortField.weeklyview,
927
- ];
928
- return viewSortFields.includes(this.finalizedSortField);
929
- }
930
-
931
- /**
932
- * The display name of the last selected date field
933
- *
934
- * @readonly
935
- * @private
936
- * @type {string}
937
- * @memberof SortFilterBar
938
- */
939
- private get dateSortDisplayName(): string {
940
- return SORT_OPTIONS[this.lastSelectedDateSort].displayName;
941
- }
942
-
943
- /**
944
- * The display name of the last selected view field
945
- *
946
- * @readonly
947
- * @private
948
- * @type {string}
949
- * @memberof SortFilterBar
950
- */
951
- private get viewSortDisplayName(): string {
952
- return SORT_OPTIONS[this.lastSelectedViewSort].displayName;
953
- }
954
-
955
- /**
956
- * Array of all the views sorts that should be shown
957
- */
958
- private get availableViewsFields(): SortField[] {
959
- return ALL_VIEWS_SORT_FIELDS.filter(
960
- field => this.sortFieldAvailability[field],
961
- );
962
- }
963
-
964
- /**
965
- * Array of all the date sorts that should be shown
966
- */
967
- private get availableDateFields(): SortField[] {
968
- return ALL_DATE_SORT_FIELDS.filter(
969
- field => this.sortFieldAvailability[field],
970
- );
971
- }
972
-
973
- private get titleSelectorBar() {
974
- return html` <alpha-bar
975
- .selectedLetter=${this.selectedTitleFilter}
976
- .letterCounts=${this.prefixFilterCountMap?.title}
977
- ariaLandmarkLabel="Filter by title letter"
978
- @letterChanged=${this.titleLetterChanged}
979
- ></alpha-bar>`;
980
- }
981
-
982
- private get creatorSelectorBar() {
983
- return html` <alpha-bar
984
- .selectedLetter=${this.selectedCreatorFilter}
985
- .letterCounts=${this.prefixFilterCountMap?.creator}
986
- ariaLandmarkLabel="Filter by creator letter"
987
- @letterChanged=${this.creatorLetterChanged}
988
- ></alpha-bar>`;
989
- }
990
-
991
- private titleLetterChanged(
992
- e: CustomEvent<{ selectedLetter: string | undefined }>,
993
- ) {
994
- this.selectedTitleFilter = e.detail.selectedLetter ?? null;
995
- this.emitTitleLetterChangedEvent();
996
- }
997
-
998
- private creatorLetterChanged(
999
- e: CustomEvent<{ selectedLetter: string | undefined }>,
1000
- ) {
1001
- this.selectedCreatorFilter = e.detail.selectedLetter ?? null;
1002
- this.emitCreatorLetterChangedEvent();
1003
- }
1004
-
1005
- private emitTitleLetterChangedEvent() {
1006
- const event = new CustomEvent<{ selectedLetter: string | null }>(
1007
- 'titleLetterChanged',
1008
- {
1009
- detail: { selectedLetter: this.selectedTitleFilter },
1010
- },
1011
- );
1012
- this.dispatchEvent(event);
1013
- }
1014
-
1015
- private emitCreatorLetterChangedEvent() {
1016
- const event = new CustomEvent<{ selectedLetter: string | null }>(
1017
- 'creatorLetterChanged',
1018
- {
1019
- detail: { selectedLetter: this.selectedCreatorFilter },
1020
- },
1021
- );
1022
- this.dispatchEvent(event);
1023
- }
1024
-
1025
- private displayModeChanged() {
1026
- const event = new CustomEvent<{
1027
- displayMode?: CollectionDisplayMode;
1028
- }>('displayModeChanged', {
1029
- detail: { displayMode: this.displayMode },
1030
- });
1031
- this.dispatchEvent(event);
1032
- }
1033
-
1034
- private emitSortChangedEvent() {
1035
- const event = new CustomEvent<{
1036
- selectedSort: SortField;
1037
- sortDirection: SortDirection | null;
1038
- }>('sortChanged', {
1039
- detail: {
1040
- selectedSort: this.selectedSort,
1041
- sortDirection: this.sortDirection,
1042
- },
1043
- });
1044
- this.dispatchEvent(event);
1045
- }
1046
-
1047
- static get styles() {
1048
- return [
1049
- srOnlyStyle,
1050
- css`
1051
- #container {
1052
- position: relative;
1053
- }
1054
-
1055
- #sort-bar {
1056
- display: flex;
1057
- justify-content: flex-start;
1058
- align-items: center;
1059
- border-bottom: 1px solid #2c2c2c;
1060
- font-size: 1.4rem;
1061
- }
1062
-
1063
- #sort-options {
1064
- display: flex;
1065
- align-items: center;
1066
- flex-grow: 1;
1067
- }
1068
-
1069
- ul {
1070
- list-style: none;
1071
- display: flex;
1072
- align-items: center;
1073
- margin: 0;
1074
- padding: 0;
1075
- }
1076
-
1077
- li {
1078
- padding: 0;
1079
- }
1080
-
1081
- .sort-by-text {
1082
- margin-right: 5px;
1083
- font-weight: bold;
1084
- white-space: nowrap;
1085
- }
1086
-
1087
- .sort-direction-container {
1088
- display: flex;
1089
- align-self: stretch;
1090
- flex: 0;
1091
- margin: 0 5px;
1092
- }
1093
-
1094
- .sort-direction-selector {
1095
- padding: 0;
1096
- border: none;
1097
- appearance: none;
1098
- background: transparent;
1099
- cursor: pointer;
1100
- }
1101
-
1102
- .sort-direction-selector:disabled {
1103
- cursor: default;
1104
- }
1105
-
1106
- .sort-direction-icon {
1107
- display: flex;
1108
- align-items: center;
1109
- background: none;
1110
- color: inherit;
1111
- border: none;
1112
- padding: 0;
1113
- outline: inherit;
1114
- width: 14px;
1115
- height: 14px;
1116
- }
1117
-
1118
- .sort-direction-icon > svg {
1119
- flex: 1;
1120
- }
1121
-
1122
- #date-sort-selector,
1123
- #view-sort-selector {
1124
- position: absolute;
1125
- left: 150px;
1126
- top: 45px;
1127
-
1128
- z-index: 1;
1129
- padding: 1rem;
1130
- background-color: white;
1131
- border-radius: 2.5rem;
1132
- border: 1px solid #404142;
1133
- }
1134
-
1135
- #sort-selector-container {
1136
- flex: 1;
1137
- display: flex;
1138
- justify-content: flex-start;
1139
- align-items: center;
1140
- }
1141
-
1142
- #desktop-sort-container,
1143
- #mobile-sort-container {
1144
- display: flex;
1145
- justify-content: flex-start;
1146
- align-items: center;
1147
- }
1148
-
1149
- /*
1150
- we move the desktop sort selector offscreen instead of display: none
1151
- because we need to observe the width of it vs its container to determine
1152
- if it's wide enough to display the desktop version and if you display: none,
1153
- the width becomes 0
1154
- */
1155
- #desktop-sort-container.hidden {
1156
- position: absolute;
1157
- top: -9999px;
1158
- left: -9999px;
1159
- visibility: hidden;
1160
- }
1161
-
1162
- #mobile-sort-container.hidden {
1163
- display: none;
1164
- }
1165
-
1166
- #sort-selector-backdrop {
1167
- position: fixed;
1168
- top: 0;
1169
- left: 0;
1170
- width: 100vw;
1171
- height: 100vh;
1172
- z-index: 1;
1173
- background-color: transparent;
1174
- }
1175
-
1176
- #desktop-sort-selector {
1177
- display: inline-flex;
1178
- }
1179
-
1180
- #desktop-sort-selector li {
1181
- display: flex;
1182
- align-items: center;
1183
- padding-left: 5px;
1184
- padding-right: 5px;
1185
- }
1186
-
1187
- #desktop-sort-selector li a {
1188
- padding: 0 5px;
1189
- text-decoration: none;
1190
- color: #333;
1191
- line-height: 2;
1192
- }
1193
-
1194
- #desktop-sort-selector li button {
1195
- padding: 0px 5px;
1196
- border: none;
1197
- background: none;
1198
- font-family: inherit;
1199
- font-size: inherit;
1200
- color: #333;
1201
- line-height: 2;
1202
- cursor: pointer;
1203
- appearance: none;
1204
- }
1205
-
1206
- #desktop-sort-selector li button.selected {
1207
- font-weight: bold;
1208
- }
1209
-
1210
- /**
1211
- * Fix to not shift the sort-bar options when get selected
1212
- */
1213
- #desktop-sort-selector li button::before,
1214
- #desktop-sort-selector .dropdown-label::before {
1215
- display: block;
1216
- content: attr(data-title);
1217
- font-weight: bold;
1218
- height: 0;
1219
- overflow: hidden;
1220
- visibility: hidden;
1221
- }
1222
-
1223
- #display-style-selector {
1224
- flex: 0;
1225
- }
1226
-
1227
- #display-style-selector button {
1228
- background: none;
1229
- color: inherit;
1230
- border: none;
1231
- appearance: none;
1232
- cursor: pointer;
1233
- -webkit-appearance: none;
1234
- fill: #bbbbbb;
1235
- }
1236
-
1237
- #display-style-selector button.active {
1238
- fill: var(--ia-theme-primary-text-color, #2c2c2c);
1239
- }
1240
-
1241
- #display-style-selector button svg {
1242
- width: 24px;
1243
- height: 24px;
1244
- }
1245
-
1246
- ia-dropdown {
1247
- --dropdownTextColor: white;
1248
- --dropdownOffsetTop: 0;
1249
- --dropdownBorderTopWidth: 0;
1250
- --dropdownBorderTopLeftRadius: 0;
1251
- --dropdownBorderTopRightRadius: 0;
1252
- --dropdownWhiteSpace: nowrap;
1253
- --dropdownListZIndex: 2;
1254
- --dropdownCaretColor: var(--ia-theme-primary-text-color, #2c2c2c);
1255
- --dropdownSelectedTextColor: white;
1256
- --dropdownSelectedBgColor: rgba(255, 255, 255, 0.3);
1257
- --dropdownHoverBgColor: rgba(255, 255, 255, 0.3);
1258
- --caretHeight: 9px;
1259
- --caretWidth: 12px;
1260
- --caretPadding: 0 5px 0 0;
1261
- }
1262
- ia-dropdown.selected .dropdown-label {
1263
- font-weight: bold;
1264
- }
1265
- ia-dropdown.open {
1266
- z-index: 2;
1267
- }
1268
-
1269
- .dropdown-label {
1270
- display: inline-block;
1271
- height: 100%;
1272
- padding-left: 5px;
1273
- font-size: 1.4rem;
1274
- font-family: var(--ia-theme-base-font-family);
1275
- line-height: 2;
1276
- color: var(--ia-theme-primary-text-color, #2c2c2c);
1277
- white-space: nowrap;
1278
- user-select: none;
1279
- }
1280
- `,
1281
- ];
1282
- }
1283
- }
1
+ import {
2
+ LitElement,
3
+ html,
4
+ css,
5
+ nothing,
6
+ PropertyValues,
7
+ TemplateResult,
8
+ } from 'lit';
9
+ import { customElement, property, query, state } from 'lit/decorators.js';
10
+ import { msg } from '@lit/localize';
11
+ import type {
12
+ SharedResizeObserverInterface,
13
+ SharedResizeObserverResizeHandlerInterface,
14
+ } from '@internetarchive/shared-resize-observer';
15
+ import '@internetarchive/ia-dropdown';
16
+ import type { IaDropdown, optionInterface } from '@internetarchive/ia-dropdown';
17
+ import type { SortDirection } from '@internetarchive/search-service';
18
+ import {
19
+ ALL_DATE_SORT_FIELDS,
20
+ ALL_VIEWS_SORT_FIELDS,
21
+ CollectionDisplayMode,
22
+ DateSortField,
23
+ defaultSortAvailability,
24
+ PrefixFilterCounts,
25
+ PrefixFilterType,
26
+ SORT_OPTIONS,
27
+ SortField,
28
+ ViewsSortField,
29
+ } from '../models';
30
+ import './alpha-bar';
31
+
32
+ import { sortUpIcon } from './img/sort-toggle-up';
33
+ import { sortDownIcon } from './img/sort-toggle-down';
34
+ import { sortDisabledIcon } from './img/sort-toggle-disabled';
35
+ import { tileIcon } from './img/tile';
36
+ import { listIcon } from './img/list';
37
+ import { compactIcon } from './img/compact';
38
+ import { srOnlyStyle } from '../styles/sr-only';
39
+
40
+ type AlphaSelector = 'creator' | 'title';
41
+
42
+ @customElement('sort-filter-bar')
43
+ export class SortFilterBar
44
+ extends LitElement
45
+ implements SharedResizeObserverResizeHandlerInterface
46
+ {
47
+ /** Which display mode the tiles are being rendered with (grid/list-detail/list-compact) */
48
+ @property({ type: String }) displayMode?: CollectionDisplayMode;
49
+
50
+ /** The default sort direction to use if none is set */
51
+ @property({ type: String }) defaultSortDirection: SortDirection | null = null;
52
+
53
+ /** The default sort field to use if none is set */
54
+ @property({ type: String }) defaultSortField: Exclude<
55
+ SortField,
56
+ SortField.default
57
+ > = SortField.relevance;
58
+
59
+ /** Which view sort option to expose by default. */
60
+ @property({ type: String }) defaultViewSort: ViewsSortField =
61
+ SortField.weeklyview;
62
+
63
+ /** Which date sort option to expose by default */
64
+ @property({ type: String }) defaultDateSort: DateSortField = SortField.date;
65
+
66
+ /** The current sort direction (asc/desc), or null if none is set */
67
+ @property({ type: String }) sortDirection: SortDirection | null = null;
68
+
69
+ /** The field currently being sorted on (e.g., 'title'). Defaults to relevance. */
70
+ @property({ type: String }) selectedSort: SortField = SortField.default;
71
+
72
+ /** The currently selected title letter filter, or null if none is set */
73
+ @property({ type: String }) selectedTitleFilter: string | null = null;
74
+
75
+ /** The currently selected creator letter filter, or null if none is set */
76
+ @property({ type: String }) selectedCreatorFilter: string | null = null;
77
+
78
+ /**
79
+ * Map defining which sortable fields should be included on the sort bar.
80
+ *
81
+ * E.g.,
82
+ * ```
83
+ * {
84
+ * [SortField.relevance]: true,
85
+ * [SortField.date]: false,
86
+ * [SortField.title]: true,
87
+ * ...
88
+ * }
89
+ * ```
90
+ */
91
+ @property({ type: Object }) sortFieldAvailability = defaultSortAvailability;
92
+
93
+ /** Whether to replace the default sort options with a slot for customization (default `false`) */
94
+ @property({ type: Boolean, reflect: true }) enableSortOptionsSlot: boolean =
95
+ false;
96
+
97
+ /** Whether to suppress showing the three display mode options on the right of the bar (default `false`) */
98
+ @property({ type: Boolean, reflect: true })
99
+ suppressDisplayModes: boolean = false;
100
+
101
+ /** Maps of result counts for letters on the alphabet bar, for each letter filter type */
102
+ @property({ type: Object }) prefixFilterCountMap?: Record<
103
+ PrefixFilterType,
104
+ PrefixFilterCounts
105
+ >;
106
+
107
+ @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
108
+
109
+ /**
110
+ * The Views sort option that was most recently selected (or the default, if none has been selected yet)
111
+ */
112
+ @state() private lastSelectedViewSort = this.defaultViewSort;
113
+
114
+ /**
115
+ * The Date sort option that was most recently selected (or the default, if none has been selected yet)
116
+ */
117
+ @state() private lastSelectedDateSort = this.defaultDateSort;
118
+
119
+ /**
120
+ * Which of the alphabet bars (title/creator) should be shown, or null if one
121
+ * should not currently be rendered.
122
+ */
123
+ @state() alphaSelectorVisible: AlphaSelector | null = null;
124
+
125
+ /**
126
+ * Whether the transparent backdrop to catch clicks outside the dropdown menu
127
+ * should be rendered.
128
+ */
129
+ @state() dropdownBackdropVisible = false;
130
+
131
+ /**
132
+ * The width of the desktop view sort option container, updated upon each resize.
133
+ * Used for dynamically determining whether to use desktop or mobile view.
134
+ */
135
+ @state() desktopSortContainerWidth = 0;
136
+
137
+ /**
138
+ * The width of the full sort bar, updated upon each resize.
139
+ * Used for dynamically determining whether to use desktop or mobile view.
140
+ */
141
+ @state() selectorBarContainerWidth = 0;
142
+
143
+ /**
144
+ * The container for all the desktop view's sort options.
145
+ * Used for dynamically determining whether to use desktop or mobile view.
146
+ */
147
+ @query('#desktop-sort-container')
148
+ private desktopSortContainer!: HTMLUListElement;
149
+
150
+ /**
151
+ * The container for the full sort bar.
152
+ * Used for dynamically determining whether to use desktop or mobile view.
153
+ */
154
+ @query('#sort-selector-container')
155
+ private sortSelectorContainer!: HTMLDivElement;
156
+
157
+ /** The dropdown component containing options for weekly and all-time views */
158
+ @query('#views-dropdown')
159
+ private viewsDropdown?: IaDropdown;
160
+
161
+ /** The dropdown component containing all the available date options */
162
+ @query('#date-dropdown')
163
+ private dateDropdown?: IaDropdown;
164
+
165
+ /** The single, consolidated dropdown component shown in mobile view */
166
+ @query('#mobile-dropdown')
167
+ private mobileDropdown!: IaDropdown;
168
+
169
+ render() {
170
+ return html`
171
+ <div id="container">
172
+ <section id="sort-bar" aria-label="Sorting options">
173
+ <slot name="sort-options-left"></slot>
174
+ <div id="sort-options">
175
+ ${!this.enableSortOptionsSlot
176
+ ? html`
177
+ <div class="sort-direction-container">
178
+ ${this.sortDirectionSelectorTemplate}
179
+ </div>
180
+ <span class="sort-by-text">${msg('Sort by:')}</span>
181
+ <div id="sort-selector-container">
182
+ ${this.mobileSortSelectorTemplate}
183
+ ${this.desktopSortSelectorTemplate}
184
+ </div>
185
+ `
186
+ : html`<slot name="sort-options"></slot>`}
187
+ </div>
188
+ <slot name="sort-options-right"></slot>
189
+
190
+ ${this.suppressDisplayModes
191
+ ? nothing
192
+ : html`<div id="display-style-selector">
193
+ ${this.displayOptionTemplate}
194
+ </div>`}
195
+ </section>
196
+
197
+ ${this.dropdownBackdropVisible ? this.dropdownBackdrop : nothing}
198
+ ${this.alphaBarTemplate}
199
+ </div>
200
+ `;
201
+ }
202
+
203
+ willUpdate(changed: PropertyValues) {
204
+ if (changed.has('selectedSort') || changed.has('defaultSortField')) {
205
+ // If the sort is changed from its default without a direction set,
206
+ // we adopt the default sort direction for that sort type.
207
+ if (
208
+ this.selectedSort &&
209
+ this.selectedSort !== SortField.default &&
210
+ this.sortDirection === null
211
+ ) {
212
+ const sortOption = SORT_OPTIONS[this.finalizedSortField];
213
+ this.sortDirection = sortOption.defaultSortDirection;
214
+ }
215
+
216
+ if (this.viewOptionSelected) {
217
+ this.lastSelectedViewSort = this.finalizedSortField as ViewsSortField;
218
+ } else if (this.dateOptionSelected) {
219
+ this.lastSelectedDateSort = this.finalizedSortField as DateSortField;
220
+ }
221
+ }
222
+
223
+ // If we change which dropdown options are defaulted, update the default selections
224
+ if (changed.has('defaultViewSort')) {
225
+ this.lastSelectedViewSort = this.defaultViewSort;
226
+ }
227
+ if (changed.has('defaultDateSort')) {
228
+ this.lastSelectedDateSort = this.defaultDateSort;
229
+ }
230
+ }
231
+
232
+ updated(changed: PropertyValues) {
233
+ if (changed.has('displayMode')) {
234
+ this.displayModeChanged();
235
+ }
236
+
237
+ if (changed.has('selectedTitleFilter') && this.selectedTitleFilter) {
238
+ this.alphaSelectorVisible = 'title';
239
+ }
240
+
241
+ if (changed.has('selectedCreatorFilter') && this.selectedCreatorFilter) {
242
+ this.alphaSelectorVisible = 'creator';
243
+ }
244
+
245
+ if (changed.has('dropdownBackdropVisible')) {
246
+ this.setupEscapeListeners();
247
+ }
248
+
249
+ if (changed.has('resizeObserver') || changed.has('enableSortOptionsSlot')) {
250
+ const oldObserver = changed.get(
251
+ 'resizeObserver',
252
+ ) as SharedResizeObserverInterface;
253
+ if (oldObserver) this.disconnectResizeObserver(oldObserver);
254
+ this.setupResizeObserver();
255
+ }
256
+ }
257
+
258
+ private setupEscapeListeners() {
259
+ if (this.dropdownBackdropVisible) {
260
+ document.addEventListener(
261
+ 'keydown',
262
+ this.boundSortBarSelectorEscapeListener,
263
+ );
264
+ } else {
265
+ document.removeEventListener(
266
+ 'keydown',
267
+ this.boundSortBarSelectorEscapeListener,
268
+ );
269
+ }
270
+ }
271
+
272
+ private boundSortBarSelectorEscapeListener = (e: KeyboardEvent) => {
273
+ if (e.key === 'Escape') {
274
+ this.closeDropdowns();
275
+ }
276
+ };
277
+
278
+ connectedCallback(): void {
279
+ super.connectedCallback?.();
280
+ this.setupResizeObserver();
281
+ }
282
+
283
+ disconnectedCallback(): void {
284
+ if (this.resizeObserver) {
285
+ this.disconnectResizeObserver(this.resizeObserver);
286
+ }
287
+ }
288
+
289
+ private disconnectResizeObserver(
290
+ resizeObserver: SharedResizeObserverInterface,
291
+ ) {
292
+ if (this.sortSelectorContainer) {
293
+ resizeObserver.removeObserver({
294
+ target: this.sortSelectorContainer,
295
+ handler: this,
296
+ });
297
+ }
298
+
299
+ if (this.desktopSortContainer) {
300
+ resizeObserver.removeObserver({
301
+ target: this.desktopSortContainer,
302
+ handler: this,
303
+ });
304
+ }
305
+ }
306
+
307
+ private setupResizeObserver() {
308
+ if (!this.resizeObserver) return;
309
+
310
+ if (this.sortSelectorContainer) {
311
+ this.resizeObserver.addObserver({
312
+ target: this.sortSelectorContainer,
313
+ handler: this,
314
+ });
315
+ }
316
+
317
+ if (this.desktopSortContainer) {
318
+ this.resizeObserver.addObserver({
319
+ target: this.desktopSortContainer,
320
+ handler: this,
321
+ });
322
+ }
323
+ }
324
+
325
+ handleResize(entry: ResizeObserverEntry): void {
326
+ if (entry.target === this.desktopSortContainer) {
327
+ this.desktopSortContainerWidth = entry.contentRect.width;
328
+ } else if (entry.target === this.sortSelectorContainer) {
329
+ this.selectorBarContainerWidth = entry.contentRect.width;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Whether to show the mobile sort bar because there is not enough space
335
+ * for the desktop sort bar.
336
+ */
337
+ private get mobileSelectorVisible() {
338
+ return this.selectorBarContainerWidth - 10 < this.desktopSortContainerWidth;
339
+ }
340
+
341
+ /**
342
+ * Template to render the alphabet bar, or `nothing` if it should not be rendered
343
+ * for the current sort
344
+ */
345
+ private get alphaBarTemplate(): TemplateResult | typeof nothing {
346
+ if (!['title', 'creator'].includes(this.selectedSort)) return nothing;
347
+
348
+ if (this.alphaSelectorVisible === null) {
349
+ if (this.selectedSort === 'creator') return this.creatorSelectorBar;
350
+ if (this.selectedSort === 'title') return this.titleSelectorBar;
351
+ } else {
352
+ return this.alphaSelectorVisible === 'creator'
353
+ ? this.creatorSelectorBar
354
+ : this.titleSelectorBar;
355
+ }
356
+
357
+ return nothing;
358
+ }
359
+
360
+ /** Template to render the sort direction toggle button */
361
+ private get sortDirectionSelectorTemplate(): TemplateResult {
362
+ const oppositeSortDirectionReadable =
363
+ this.sortDirection === 'asc' ? 'descending' : 'ascending';
364
+ const srLabel = `Change to ${oppositeSortDirectionReadable} sort`;
365
+
366
+ return html`
367
+ <button
368
+ class="sort-direction-selector"
369
+ ?disabled=${!this.canChangeSortDirection}
370
+ @click=${this.handleSortDirectionClicked}
371
+ >
372
+ <span class="sr-only">${srLabel}</span>
373
+ ${this.sortDirectionIcon}
374
+ </button>
375
+ `;
376
+ }
377
+
378
+ /** Template to render the sort direction button's icon in the correct current state */
379
+ private get sortDirectionIcon(): TemplateResult {
380
+ // Show a fully disabled icon for sort options without direction support
381
+ if (!this.canChangeSortDirection) {
382
+ return html`<div class="sort-direction-icon">${sortDisabledIcon}</div>`;
383
+ }
384
+
385
+ // For all other sorts, show the ascending/descending direction
386
+ return html`
387
+ <div class="sort-direction-icon">
388
+ ${this.finalizedSortDirection === 'asc' ? sortUpIcon : sortDownIcon}
389
+ </div>
390
+ `;
391
+ }
392
+
393
+ /** The template to render all the sort options in desktop view */
394
+ private get desktopSortSelectorTemplate() {
395
+ return html`
396
+ <div
397
+ id="desktop-sort-container"
398
+ class=${this.mobileSelectorVisible ? 'hidden' : 'visible'}
399
+ >
400
+ <ul id="desktop-sort-selector">
401
+ <li>${this.relevanceSortSelectorTemplate}</li>
402
+ <li>${this.allViewsSortOptionsTemplate}</li>
403
+ <li>${this.titleSortSelectorTemplate}</li>
404
+ <li>${this.allDateSortOptionsTemplate}</li>
405
+ <li>${this.creatorSortSelectorTemplate}</li>
406
+ </ul>
407
+ </div>
408
+ `;
409
+ }
410
+
411
+ /** The template to render all the sort options in mobile view */
412
+ private get mobileSortSelectorTemplate() {
413
+ const displayedOptions = Object.values(SORT_OPTIONS).filter(
414
+ opt => opt.shownInSortBar && this.sortFieldAvailability[opt.field],
415
+ );
416
+
417
+ return html`
418
+ <div
419
+ id="mobile-sort-container"
420
+ class=${this.mobileSelectorVisible ? 'visible' : 'hidden'}
421
+ >
422
+ ${this.getSortDropdown({
423
+ displayName: SORT_OPTIONS[this.finalizedSortField].displayName,
424
+ id: 'mobile-dropdown',
425
+ selected: true,
426
+ dropdownOptions: displayedOptions.map(opt =>
427
+ this.getDropdownOption(opt.field),
428
+ ),
429
+ selectedOption: this.finalizedSortField,
430
+ onOptionSelected: this.mobileSortChanged,
431
+ onDropdownClick: () => {
432
+ this.dropdownBackdropVisible = this.mobileDropdown.open;
433
+ this.mobileDropdown.classList.toggle(
434
+ 'open',
435
+ this.mobileDropdown.open,
436
+ );
437
+ },
438
+ })}
439
+ </div>
440
+ `;
441
+ }
442
+
443
+ /**
444
+ * This generates each of the non-dropdown sort option buttons.
445
+ *
446
+ * It manages the display value and the selected state of the option.
447
+ *
448
+ * @param sortField Which sort field the button represents
449
+ * @param options Additional options:
450
+ * - `onSelected?: (e: Event) => void;` If this is provided, it will also be called when the option is selected.
451
+ * Default is to clear any selected alphabetical filters.
452
+ */
453
+ private getSortSelectorButton(
454
+ sortField: SortField,
455
+ options?: {
456
+ onSelected?: (e: Event) => void;
457
+ },
458
+ ): TemplateResult {
459
+ const isSelected = this.finalizedSortField === sortField;
460
+ const displayName = SORT_OPTIONS[sortField].displayName;
461
+ const onSelected =
462
+ options?.onSelected ?? (() => this.clearAlphaBarFilters());
463
+
464
+ return html`
465
+ <button
466
+ class=${isSelected ? 'selected' : ''}
467
+ data-title=${displayName}
468
+ @click=${(e: Event) => {
469
+ e.preventDefault();
470
+ this.dropdownBackdropVisible = false;
471
+ if (this.finalizedSortField !== sortField) {
472
+ onSelected?.(e);
473
+ this.setSelectedSort(sortField);
474
+ }
475
+ }}
476
+ >
477
+ ${displayName}
478
+ </button>
479
+ `;
480
+ }
481
+
482
+ /**
483
+ * Generates a dropdown component containing multiple grouped sort options.
484
+ *
485
+ * @param options.displayName The name to use for the dropdown's visible label
486
+ * @param options.id The id to apply to the dropdown element
487
+ * @param options.dropdownOptions An array of option objects used to populate the dropdown
488
+ * @param options.selectedOption The id of the option that should be initially selected
489
+ * @param options.selected A boolean indicating whether this dropdown should use its
490
+ * selected appearance
491
+ * @param options.onOptionSelected A handler for optionSelected events coming from the dropdown
492
+ * @param options.onDropdownClick A handler for click events on the dropdown
493
+ * @param options.onLabelInteraction A handler for click events and Enter/Space keydown events
494
+ * on the dropdown's label
495
+ */
496
+ private getSortDropdown(options: {
497
+ displayName: string;
498
+ id: string;
499
+ dropdownOptions: optionInterface[];
500
+ selectedOption?: string;
501
+ selected: boolean;
502
+ onOptionSelected?: (e: CustomEvent<{ option: optionInterface }>) => void;
503
+ onDropdownClick?: (e: PointerEvent) => void;
504
+ onLabelInteraction?: (e: Event) => void;
505
+ }): TemplateResult {
506
+ return html`
507
+ <ia-dropdown
508
+ id=${options.id}
509
+ class=${options.selected ? 'selected' : ''}
510
+ displayCaret
511
+ closeOnSelect
512
+ includeSelectedOption
513
+ .openViaButton=${options.selected}
514
+ .options=${options.dropdownOptions}
515
+ .selectedOption=${options.selectedOption ?? ''}
516
+ @optionSelected=${options.onOptionSelected ?? nothing}
517
+ @click=${options.onDropdownClick ?? nothing}
518
+ >
519
+ <span
520
+ class="dropdown-label"
521
+ slot="dropdown-label"
522
+ data-title=${options.displayName}
523
+ @click=${options.onLabelInteraction ?? nothing}
524
+ @keydown=${options.onLabelInteraction
525
+ ? (e: KeyboardEvent) => {
526
+ if (e.key === 'Enter' || e.key === ' ') {
527
+ options.onLabelInteraction?.(e);
528
+ }
529
+ }
530
+ : nothing}
531
+ >
532
+ ${options.displayName}
533
+ </span>
534
+ </ia-dropdown>
535
+ `;
536
+ }
537
+
538
+ /** Generates a single dropdown option object for the given sort field */
539
+ private getDropdownOption(sortField: SortField): optionInterface {
540
+ return {
541
+ id: sortField,
542
+ selectedHandler: () => {
543
+ this.selectDropdownSortField(sortField);
544
+ },
545
+ label: html`
546
+ <span class="dropdown-option-label">
547
+ ${SORT_OPTIONS[sortField].displayName}
548
+ </span>
549
+ `,
550
+ };
551
+ }
552
+
553
+ /** Handler for when any sort dropdown option is selected */
554
+ private dropdownOptionSelected(e: CustomEvent<{ option: optionInterface }>) {
555
+ this.dropdownBackdropVisible = false;
556
+ this.clearAlphaBarFilters();
557
+
558
+ const sortField = e.detail.option.id as SortField;
559
+ this.setSelectedSort(sortField);
560
+ if (this.viewOptionSelected) {
561
+ this.lastSelectedViewSort = sortField as ViewsSortField;
562
+ } else if (this.dateOptionSelected) {
563
+ this.lastSelectedDateSort = sortField as DateSortField;
564
+ }
565
+ }
566
+
567
+ //
568
+ // SORT OPTION TEMPLATES
569
+ //
570
+
571
+ /**
572
+ * Template for the Relevance sort selector button, or `nothing` if the relevance
573
+ * field is not available for display.
574
+ */
575
+ private get relevanceSortSelectorTemplate(): TemplateResult | typeof nothing {
576
+ if (!this.sortFieldAvailability.relevance) return nothing;
577
+ return this.getSortSelectorButton(SortField.relevance);
578
+ }
579
+
580
+ /**
581
+ * Template for the Views sort selector button.
582
+ * This is shown instead of the views dropdown when only a single views sort field
583
+ * is available.
584
+ */
585
+ private get viewsSortSelectorTemplate(): TemplateResult | typeof nothing {
586
+ const { availableViewsFields } = this;
587
+ if (availableViewsFields.length < 1) return nothing;
588
+ return this.getSortSelectorButton(availableViewsFields[0]);
589
+ }
590
+
591
+ /**
592
+ * Template for the Title sort selector button, or `nothing` if the title field is
593
+ * not available for display.
594
+ */
595
+ private get titleSortSelectorTemplate(): TemplateResult | typeof nothing {
596
+ if (!this.sortFieldAvailability.title) return nothing;
597
+
598
+ return this.getSortSelectorButton(SortField.title, {
599
+ onSelected: () => {
600
+ this.alphaSelectorVisible = 'title';
601
+ this.selectedCreatorFilter = null;
602
+ this.emitCreatorLetterChangedEvent();
603
+ },
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Template for the Date sort selector button.
609
+ * This is shown instead of the dates dropdown when only a single date sort field
610
+ * is available.
611
+ */
612
+ private get dateSortSelectorTemplate(): TemplateResult | typeof nothing {
613
+ const { availableDateFields } = this;
614
+ if (availableDateFields.length < 1) return nothing;
615
+ return this.getSortSelectorButton(availableDateFields[0]);
616
+ }
617
+
618
+ /**
619
+ * Template for the Creator sort selector button, or `nothing` if the creator field
620
+ * is not available for display.
621
+ */
622
+ private get creatorSortSelectorTemplate(): TemplateResult | typeof nothing {
623
+ if (!this.sortFieldAvailability.creator) return nothing;
624
+
625
+ return this.getSortSelectorButton(SortField.creator, {
626
+ onSelected: () => {
627
+ this.alphaSelectorVisible = 'creator';
628
+ this.selectedTitleFilter = null;
629
+ this.emitTitleLetterChangedEvent();
630
+ },
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Template for the Views dropdown, shown when 2+ views sort fields are available.
636
+ */
637
+ private get viewsDropdownTemplate(): TemplateResult {
638
+ const displayedOptions = ALL_VIEWS_SORT_FIELDS.filter(
639
+ field => this.sortFieldAvailability[field],
640
+ ).map(field => this.getDropdownOption(field));
641
+
642
+ return this.getSortDropdown({
643
+ displayName: this.viewSortDisplayName,
644
+ id: 'views-dropdown',
645
+ selected: this.viewOptionSelected,
646
+ dropdownOptions: displayedOptions,
647
+ selectedOption: this.viewOptionSelected ? this.finalizedSortField : '',
648
+ onOptionSelected: this.dropdownOptionSelected,
649
+ onDropdownClick: () => {
650
+ if (this.dateDropdown) this.dateDropdown.open = false;
651
+ const viewsDropdown = this.viewsDropdown as IaDropdown;
652
+ this.dropdownBackdropVisible = viewsDropdown.open;
653
+ viewsDropdown.classList.toggle('open', viewsDropdown.open);
654
+ },
655
+ onLabelInteraction: (e: Event) => {
656
+ if (!this.viewsDropdown?.open && !this.viewOptionSelected) {
657
+ e.stopPropagation();
658
+ this.clearAlphaBarFilters();
659
+ this.setSelectedSort(this.lastSelectedViewSort);
660
+ }
661
+ },
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Template for the Date dropdown, shown when 2+ date sort fields are available.
667
+ */
668
+ private get dateDropdownTemplate(): TemplateResult {
669
+ const displayedOptions = ALL_DATE_SORT_FIELDS.filter(
670
+ field => this.sortFieldAvailability[field],
671
+ ).map(field => this.getDropdownOption(field));
672
+
673
+ return this.getSortDropdown({
674
+ displayName: this.dateSortDisplayName,
675
+ id: 'date-dropdown',
676
+ selected: this.dateOptionSelected,
677
+ dropdownOptions: displayedOptions,
678
+ selectedOption: this.dateOptionSelected ? this.finalizedSortField : '',
679
+ onOptionSelected: this.dropdownOptionSelected,
680
+ onDropdownClick: () => {
681
+ if (this.viewsDropdown) this.viewsDropdown.open = false;
682
+ const dateDropdown = this.dateDropdown as IaDropdown;
683
+ this.dropdownBackdropVisible = dateDropdown.open;
684
+ dateDropdown.classList.toggle('open', dateDropdown.open);
685
+ },
686
+ onLabelInteraction: (e: Event) => {
687
+ if (!this.dateDropdown?.open && !this.dateOptionSelected) {
688
+ e.stopPropagation();
689
+ this.clearAlphaBarFilters();
690
+ this.setSelectedSort(this.lastSelectedDateSort);
691
+ }
692
+ },
693
+ });
694
+ }
695
+
696
+ /**
697
+ * Provides the appropriate template to use for Views sorting, depending on how many
698
+ * views sort fields are available.
699
+ */
700
+ private get allViewsSortOptionsTemplate(): TemplateResult | typeof nothing {
701
+ const numViewsSortOptions = this.availableViewsFields.length;
702
+ switch (numViewsSortOptions) {
703
+ case 0:
704
+ return nothing;
705
+ case 1:
706
+ return this.viewsSortSelectorTemplate;
707
+ default:
708
+ return this.viewsDropdownTemplate;
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Provides the appropriate template to use for Date sorting, depending on how many
714
+ * date sort fields are available.
715
+ */
716
+ private get allDateSortOptionsTemplate(): TemplateResult | typeof nothing {
717
+ const numDateSortOptions = this.availableDateFields.length;
718
+ switch (numDateSortOptions) {
719
+ case 0:
720
+ return nothing;
721
+ case 1:
722
+ return this.dateSortSelectorTemplate;
723
+ default:
724
+ return this.dateDropdownTemplate;
725
+ }
726
+ }
727
+
728
+ /** Handler for when a new mobile sort dropdown option is selected */
729
+ private mobileSortChanged(e: CustomEvent<{ option: optionInterface }>) {
730
+ this.dropdownBackdropVisible = false;
731
+
732
+ const sortField = e.detail.option.id as SortField;
733
+ this.setSelectedSort(sortField);
734
+
735
+ this.alphaSelectorVisible = null;
736
+ if (sortField !== 'title' && this.selectedTitleFilter) {
737
+ this.selectedTitleFilter = null;
738
+ this.emitTitleLetterChangedEvent();
739
+ }
740
+ if (sortField !== 'creator' && this.selectedCreatorFilter) {
741
+ this.selectedCreatorFilter = null;
742
+ this.emitCreatorLetterChangedEvent();
743
+ }
744
+ }
745
+
746
+ /** Template for rendering the three display mode options */
747
+ /** Added data-testid for Playwright testing * */
748
+ private get displayOptionTemplate() {
749
+ return html`
750
+ <ul>
751
+ <li>
752
+ <button
753
+ id="grid-button"
754
+ @click=${() => {
755
+ this.displayMode = 'grid';
756
+ }}
757
+ class=${this.displayMode === 'grid' ? 'active' : ''}
758
+ title="Tile view"
759
+ data-testid="grid-button"
760
+ >
761
+ ${tileIcon}
762
+ </button>
763
+ </li>
764
+ <li>
765
+ <button
766
+ id="list-detail-button"
767
+ @click=${() => {
768
+ this.displayMode = 'list-detail';
769
+ }}
770
+ class=${this.displayMode === 'list-detail' ? 'active' : ''}
771
+ title="List view"
772
+ data-testid="list-detail-button"
773
+ >
774
+ ${listIcon}
775
+ </button>
776
+ </li>
777
+ <li>
778
+ <button
779
+ id="list-compact-button"
780
+ @click=${() => {
781
+ this.displayMode = 'list-compact';
782
+ }}
783
+ class=${this.displayMode === 'list-compact' ? 'active' : ''}
784
+ title="Compact list view"
785
+ data-testid="list-compact-button"
786
+ >
787
+ ${compactIcon}
788
+ </button>
789
+ </li>
790
+ </ul>
791
+ `;
792
+ }
793
+
794
+ /**
795
+ * Template for rendering the transparent backdrop to capture clicks outside the
796
+ * dropdown menu while it is open.
797
+ */
798
+ private get dropdownBackdrop() {
799
+ return html`
800
+ <div
801
+ id="sort-selector-backdrop"
802
+ @keyup=${this.closeDropdowns}
803
+ @click=${this.closeDropdowns}
804
+ ></div>
805
+ `;
806
+ }
807
+
808
+ /** Closes all of the sorting dropdown components' menus */
809
+ private closeDropdowns() {
810
+ this.dropdownBackdropVisible = false;
811
+ const allDropdowns = [
812
+ this.viewsDropdown,
813
+ this.dateDropdown,
814
+ this.mobileDropdown,
815
+ ];
816
+ for (const dropdown of allDropdowns) {
817
+ if (!dropdown) continue;
818
+ dropdown.open = false;
819
+ dropdown.classList.remove('open');
820
+ }
821
+ }
822
+
823
+ private selectDropdownSortField(sortField: SortField) {
824
+ // When a dropdown sort option is selected, we additionally need to clear the backdrop
825
+ this.dropdownBackdropVisible = false;
826
+ this.setSelectedSort(sortField);
827
+ }
828
+
829
+ private clearAlphaBarFilters() {
830
+ this.alphaSelectorVisible = null;
831
+ this.selectedTitleFilter = null;
832
+ this.selectedCreatorFilter = null;
833
+ this.emitTitleLetterChangedEvent();
834
+ this.emitCreatorLetterChangedEvent();
835
+ }
836
+
837
+ private setSortDirection(sortDirection: SortDirection) {
838
+ this.sortDirection = sortDirection;
839
+ this.emitSortChangedEvent();
840
+ }
841
+
842
+ /** Toggles the current sort direction between 'asc' and 'desc' */
843
+ private toggleSortDirection() {
844
+ this.setSortDirection(
845
+ this.finalizedSortDirection === 'desc' ? 'asc' : 'desc',
846
+ );
847
+ }
848
+
849
+ private handleSortDirectionClicked(): void {
850
+ if (
851
+ !this.sortDirection &&
852
+ this.defaultSortField &&
853
+ this.defaultSortDirection
854
+ ) {
855
+ // When the sort direction is merely defaulted (not set by the user), clicking
856
+ // the toggled button should "promote" the default sort to an explicitly-set one
857
+ // and then toggle it as usual.
858
+ this.selectedSort = this.defaultSortField;
859
+ this.sortDirection = this.defaultSortDirection;
860
+ }
861
+
862
+ this.toggleSortDirection();
863
+ }
864
+
865
+ private setSelectedSort(sort: SortField) {
866
+ this.selectedSort = sort;
867
+ // Apply this field's default sort direction
868
+ const sortOption = SORT_OPTIONS[sort];
869
+ this.sortDirection = sortOption.defaultSortDirection;
870
+ this.emitSortChangedEvent();
871
+ }
872
+
873
+ /** The current sort field, or the default one if no explicit sort is set */
874
+ private get finalizedSortField(): SortField {
875
+ return this.selectedSort === SortField.default
876
+ ? this.defaultSortField
877
+ : this.selectedSort;
878
+ }
879
+
880
+ /** The current sort direction, or the default one if no explicit direction is set */
881
+ private get finalizedSortDirection(): SortDirection | null {
882
+ return this.sortDirection === null
883
+ ? this.defaultSortDirection
884
+ : this.sortDirection;
885
+ }
886
+
887
+ /** Whether the sort direction button should be enabled for the current sort */
888
+ private get canChangeSortDirection(): boolean {
889
+ return SORT_OPTIONS[this.finalizedSortField].canSetDirection;
890
+ }
891
+
892
+ /**
893
+ * There are four date sort options.
894
+ *
895
+ * This checks to see if the current sort is one of them.
896
+ *
897
+ * @readonly
898
+ * @private
899
+ * @type {boolean}
900
+ * @memberof SortFilterBar
901
+ */
902
+ private get dateOptionSelected(): boolean {
903
+ const dateSortFields: SortField[] = [
904
+ SortField.datefavorited,
905
+ SortField.datearchived,
906
+ SortField.date,
907
+ SortField.datereviewed,
908
+ SortField.dateadded,
909
+ ];
910
+ return dateSortFields.includes(this.finalizedSortField);
911
+ }
912
+
913
+ /**
914
+ * There are two view sort options.
915
+ *
916
+ * This checks to see if the current sort is one of them.
917
+ *
918
+ * @readonly
919
+ * @private
920
+ * @type {boolean}
921
+ * @memberof SortFilterBar
922
+ */
923
+ private get viewOptionSelected(): boolean {
924
+ const viewSortFields: SortField[] = [
925
+ SortField.alltimeview,
926
+ SortField.weeklyview,
927
+ ];
928
+ return viewSortFields.includes(this.finalizedSortField);
929
+ }
930
+
931
+ /**
932
+ * The display name of the last selected date field
933
+ *
934
+ * @readonly
935
+ * @private
936
+ * @type {string}
937
+ * @memberof SortFilterBar
938
+ */
939
+ private get dateSortDisplayName(): string {
940
+ return SORT_OPTIONS[this.lastSelectedDateSort].displayName;
941
+ }
942
+
943
+ /**
944
+ * The display name of the last selected view field
945
+ *
946
+ * @readonly
947
+ * @private
948
+ * @type {string}
949
+ * @memberof SortFilterBar
950
+ */
951
+ private get viewSortDisplayName(): string {
952
+ return SORT_OPTIONS[this.lastSelectedViewSort].displayName;
953
+ }
954
+
955
+ /**
956
+ * Array of all the views sorts that should be shown
957
+ */
958
+ private get availableViewsFields(): SortField[] {
959
+ return ALL_VIEWS_SORT_FIELDS.filter(
960
+ field => this.sortFieldAvailability[field],
961
+ );
962
+ }
963
+
964
+ /**
965
+ * Array of all the date sorts that should be shown
966
+ */
967
+ private get availableDateFields(): SortField[] {
968
+ return ALL_DATE_SORT_FIELDS.filter(
969
+ field => this.sortFieldAvailability[field],
970
+ );
971
+ }
972
+
973
+ private get titleSelectorBar() {
974
+ return html` <alpha-bar
975
+ .selectedLetter=${this.selectedTitleFilter}
976
+ .letterCounts=${this.prefixFilterCountMap?.title}
977
+ ariaLandmarkLabel="Filter by title letter"
978
+ @letterChanged=${this.titleLetterChanged}
979
+ ></alpha-bar>`;
980
+ }
981
+
982
+ private get creatorSelectorBar() {
983
+ return html` <alpha-bar
984
+ .selectedLetter=${this.selectedCreatorFilter}
985
+ .letterCounts=${this.prefixFilterCountMap?.creator}
986
+ ariaLandmarkLabel="Filter by creator letter"
987
+ @letterChanged=${this.creatorLetterChanged}
988
+ ></alpha-bar>`;
989
+ }
990
+
991
+ private titleLetterChanged(
992
+ e: CustomEvent<{ selectedLetter: string | undefined }>,
993
+ ) {
994
+ this.selectedTitleFilter = e.detail.selectedLetter ?? null;
995
+ this.emitTitleLetterChangedEvent();
996
+ }
997
+
998
+ private creatorLetterChanged(
999
+ e: CustomEvent<{ selectedLetter: string | undefined }>,
1000
+ ) {
1001
+ this.selectedCreatorFilter = e.detail.selectedLetter ?? null;
1002
+ this.emitCreatorLetterChangedEvent();
1003
+ }
1004
+
1005
+ private emitTitleLetterChangedEvent() {
1006
+ const event = new CustomEvent<{ selectedLetter: string | null }>(
1007
+ 'titleLetterChanged',
1008
+ {
1009
+ detail: { selectedLetter: this.selectedTitleFilter },
1010
+ },
1011
+ );
1012
+ this.dispatchEvent(event);
1013
+ }
1014
+
1015
+ private emitCreatorLetterChangedEvent() {
1016
+ const event = new CustomEvent<{ selectedLetter: string | null }>(
1017
+ 'creatorLetterChanged',
1018
+ {
1019
+ detail: { selectedLetter: this.selectedCreatorFilter },
1020
+ },
1021
+ );
1022
+ this.dispatchEvent(event);
1023
+ }
1024
+
1025
+ private displayModeChanged() {
1026
+ const event = new CustomEvent<{
1027
+ displayMode?: CollectionDisplayMode;
1028
+ }>('displayModeChanged', {
1029
+ detail: { displayMode: this.displayMode },
1030
+ });
1031
+ this.dispatchEvent(event);
1032
+ }
1033
+
1034
+ private emitSortChangedEvent() {
1035
+ const event = new CustomEvent<{
1036
+ selectedSort: SortField;
1037
+ sortDirection: SortDirection | null;
1038
+ }>('sortChanged', {
1039
+ detail: {
1040
+ selectedSort: this.selectedSort,
1041
+ sortDirection: this.sortDirection,
1042
+ },
1043
+ });
1044
+ this.dispatchEvent(event);
1045
+ }
1046
+
1047
+ static get styles() {
1048
+ return [
1049
+ srOnlyStyle,
1050
+ css`
1051
+ #container {
1052
+ position: relative;
1053
+ }
1054
+
1055
+ #sort-bar {
1056
+ display: flex;
1057
+ justify-content: flex-start;
1058
+ align-items: center;
1059
+ border-bottom: 1px solid #2c2c2c;
1060
+ font-size: 1.4rem;
1061
+ }
1062
+
1063
+ #sort-options {
1064
+ display: flex;
1065
+ align-items: center;
1066
+ flex-grow: 1;
1067
+ }
1068
+
1069
+ ul {
1070
+ list-style: none;
1071
+ display: flex;
1072
+ align-items: center;
1073
+ margin: 0;
1074
+ padding: 0;
1075
+ }
1076
+
1077
+ li {
1078
+ padding: 0;
1079
+ }
1080
+
1081
+ .sort-by-text {
1082
+ margin-right: 5px;
1083
+ font-weight: bold;
1084
+ white-space: nowrap;
1085
+ }
1086
+
1087
+ .sort-direction-container {
1088
+ display: flex;
1089
+ align-self: stretch;
1090
+ flex: 0;
1091
+ margin: 0 5px;
1092
+ }
1093
+
1094
+ .sort-direction-selector {
1095
+ padding: 0;
1096
+ border: none;
1097
+ appearance: none;
1098
+ background: transparent;
1099
+ cursor: pointer;
1100
+ }
1101
+
1102
+ .sort-direction-selector:disabled {
1103
+ cursor: default;
1104
+ }
1105
+
1106
+ .sort-direction-icon {
1107
+ display: flex;
1108
+ align-items: center;
1109
+ background: none;
1110
+ color: inherit;
1111
+ border: none;
1112
+ padding: 0;
1113
+ outline: inherit;
1114
+ width: 14px;
1115
+ height: 14px;
1116
+ }
1117
+
1118
+ .sort-direction-icon > svg {
1119
+ flex: 1;
1120
+ }
1121
+
1122
+ #date-sort-selector,
1123
+ #view-sort-selector {
1124
+ position: absolute;
1125
+ left: 150px;
1126
+ top: 45px;
1127
+
1128
+ z-index: 1;
1129
+ padding: 1rem;
1130
+ background-color: white;
1131
+ border-radius: 2.5rem;
1132
+ border: 1px solid #404142;
1133
+ }
1134
+
1135
+ #sort-selector-container {
1136
+ flex: 1;
1137
+ display: flex;
1138
+ justify-content: flex-start;
1139
+ align-items: center;
1140
+ }
1141
+
1142
+ #desktop-sort-container,
1143
+ #mobile-sort-container {
1144
+ display: flex;
1145
+ justify-content: flex-start;
1146
+ align-items: center;
1147
+ }
1148
+
1149
+ /*
1150
+ we move the desktop sort selector offscreen instead of display: none
1151
+ because we need to observe the width of it vs its container to determine
1152
+ if it's wide enough to display the desktop version and if you display: none,
1153
+ the width becomes 0
1154
+ */
1155
+ #desktop-sort-container.hidden {
1156
+ position: absolute;
1157
+ top: -9999px;
1158
+ left: -9999px;
1159
+ visibility: hidden;
1160
+ }
1161
+
1162
+ #mobile-sort-container.hidden {
1163
+ display: none;
1164
+ }
1165
+
1166
+ #sort-selector-backdrop {
1167
+ position: fixed;
1168
+ top: 0;
1169
+ left: 0;
1170
+ width: 100vw;
1171
+ height: 100vh;
1172
+ z-index: 1;
1173
+ background-color: transparent;
1174
+ }
1175
+
1176
+ #desktop-sort-selector {
1177
+ display: inline-flex;
1178
+ }
1179
+
1180
+ #desktop-sort-selector li {
1181
+ display: flex;
1182
+ align-items: center;
1183
+ padding-left: 5px;
1184
+ padding-right: 5px;
1185
+ }
1186
+
1187
+ #desktop-sort-selector li a {
1188
+ padding: 0 5px;
1189
+ text-decoration: none;
1190
+ color: #333;
1191
+ line-height: 2;
1192
+ }
1193
+
1194
+ #desktop-sort-selector li button {
1195
+ padding: 0px 5px;
1196
+ border: none;
1197
+ background: none;
1198
+ font-family: inherit;
1199
+ font-size: inherit;
1200
+ color: #333;
1201
+ line-height: 2;
1202
+ cursor: pointer;
1203
+ appearance: none;
1204
+ }
1205
+
1206
+ #desktop-sort-selector li button.selected {
1207
+ font-weight: bold;
1208
+ }
1209
+
1210
+ /**
1211
+ * Fix to not shift the sort-bar options when get selected
1212
+ */
1213
+ #desktop-sort-selector li button::before,
1214
+ #desktop-sort-selector .dropdown-label::before {
1215
+ display: block;
1216
+ content: attr(data-title);
1217
+ font-weight: bold;
1218
+ height: 0;
1219
+ overflow: hidden;
1220
+ visibility: hidden;
1221
+ }
1222
+
1223
+ #display-style-selector {
1224
+ flex: 0;
1225
+ }
1226
+
1227
+ #display-style-selector button {
1228
+ background: none;
1229
+ color: inherit;
1230
+ border: none;
1231
+ appearance: none;
1232
+ cursor: pointer;
1233
+ -webkit-appearance: none;
1234
+ fill: #bbbbbb;
1235
+ }
1236
+
1237
+ #display-style-selector button.active {
1238
+ fill: var(--ia-theme-primary-text-color, #2c2c2c);
1239
+ }
1240
+
1241
+ #display-style-selector button svg {
1242
+ width: 24px;
1243
+ height: 24px;
1244
+ }
1245
+
1246
+ ia-dropdown {
1247
+ --dropdownTextColor: white;
1248
+ --dropdownOffsetTop: 0;
1249
+ --dropdownBorderTopWidth: 0;
1250
+ --dropdownBorderTopLeftRadius: 0;
1251
+ --dropdownBorderTopRightRadius: 0;
1252
+ --dropdownWhiteSpace: nowrap;
1253
+ --dropdownListZIndex: 2;
1254
+ --dropdownCaretColor: var(--ia-theme-primary-text-color, #2c2c2c);
1255
+ --dropdownSelectedTextColor: white;
1256
+ --dropdownSelectedBgColor: rgba(255, 255, 255, 0.3);
1257
+ --dropdownHoverBgColor: rgba(255, 255, 255, 0.3);
1258
+ --caretHeight: 9px;
1259
+ --caretWidth: 12px;
1260
+ --caretPadding: 0 5px 0 0;
1261
+ }
1262
+ ia-dropdown.selected .dropdown-label {
1263
+ font-weight: bold;
1264
+ }
1265
+ ia-dropdown.open {
1266
+ z-index: 2;
1267
+ }
1268
+
1269
+ .dropdown-label {
1270
+ display: inline-block;
1271
+ height: 100%;
1272
+ padding-left: 5px;
1273
+ font-size: 1.4rem;
1274
+ font-family: var(--ia-theme-base-font-family);
1275
+ line-height: 2;
1276
+ color: var(--ia-theme-primary-text-color, #2c2c2c);
1277
+ white-space: nowrap;
1278
+ user-select: none;
1279
+ }
1280
+ `,
1281
+ ];
1282
+ }
1283
+ }