@internetarchive/collection-browser 0.3.5-alpha.1 → 0.3.5

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 (236) hide show
  1. package/.editorconfig +29 -29
  2. package/.github/workflows/ci.yml +26 -26
  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 +4 -4
  7. package/LICENSE +661 -661
  8. package/README.md +83 -83
  9. package/dist/index.d.ts +9 -9
  10. package/dist/index.js +9 -9
  11. package/dist/src/app-root.d.ts +47 -43
  12. package/dist/src/app-root.js +331 -285
  13. package/dist/src/app-root.js.map +1 -1
  14. package/dist/src/assets/img/icons/arrow-left.d.ts +2 -2
  15. package/dist/src/assets/img/icons/arrow-left.js +2 -2
  16. package/dist/src/assets/img/icons/arrow-right.d.ts +2 -2
  17. package/dist/src/assets/img/icons/arrow-right.js +2 -2
  18. package/dist/src/assets/img/icons/chevron.d.ts +2 -2
  19. package/dist/src/assets/img/icons/chevron.js +2 -2
  20. package/dist/src/assets/img/icons/empty-query.d.ts +2 -2
  21. package/dist/src/assets/img/icons/empty-query.js +2 -2
  22. package/dist/src/assets/img/icons/eye-closed.d.ts +2 -2
  23. package/dist/src/assets/img/icons/eye-closed.js +2 -2
  24. package/dist/src/assets/img/icons/eye.d.ts +2 -2
  25. package/dist/src/assets/img/icons/eye.js +2 -2
  26. package/dist/src/assets/img/icons/favorite-filled.d.ts +1 -1
  27. package/dist/src/assets/img/icons/favorite-filled.js +2 -2
  28. package/dist/src/assets/img/icons/login-required.d.ts +1 -1
  29. package/dist/src/assets/img/icons/login-required.js +2 -2
  30. package/dist/src/assets/img/icons/mediatype/account.d.ts +1 -1
  31. package/dist/src/assets/img/icons/mediatype/account.js +2 -2
  32. package/dist/src/assets/img/icons/mediatype/audio.d.ts +1 -1
  33. package/dist/src/assets/img/icons/mediatype/audio.js +2 -2
  34. package/dist/src/assets/img/icons/mediatype/collection.d.ts +1 -1
  35. package/dist/src/assets/img/icons/mediatype/collection.js +2 -2
  36. package/dist/src/assets/img/icons/mediatype/data.d.ts +1 -1
  37. package/dist/src/assets/img/icons/mediatype/data.js +2 -2
  38. package/dist/src/assets/img/icons/mediatype/etree.d.ts +1 -1
  39. package/dist/src/assets/img/icons/mediatype/etree.js +2 -2
  40. package/dist/src/assets/img/icons/mediatype/film.d.ts +1 -1
  41. package/dist/src/assets/img/icons/mediatype/film.js +2 -2
  42. package/dist/src/assets/img/icons/mediatype/images.d.ts +1 -1
  43. package/dist/src/assets/img/icons/mediatype/images.js +2 -2
  44. package/dist/src/assets/img/icons/mediatype/radio.d.ts +1 -1
  45. package/dist/src/assets/img/icons/mediatype/radio.js +2 -2
  46. package/dist/src/assets/img/icons/mediatype/software.d.ts +1 -1
  47. package/dist/src/assets/img/icons/mediatype/software.js +2 -2
  48. package/dist/src/assets/img/icons/mediatype/texts.d.ts +1 -1
  49. package/dist/src/assets/img/icons/mediatype/texts.js +2 -2
  50. package/dist/src/assets/img/icons/mediatype/tv.d.ts +1 -1
  51. package/dist/src/assets/img/icons/mediatype/tv.js +2 -2
  52. package/dist/src/assets/img/icons/mediatype/video.d.ts +1 -1
  53. package/dist/src/assets/img/icons/mediatype/video.js +2 -2
  54. package/dist/src/assets/img/icons/mediatype/web.d.ts +1 -1
  55. package/dist/src/assets/img/icons/mediatype/web.js +2 -2
  56. package/dist/src/assets/img/icons/null-result.d.ts +2 -2
  57. package/dist/src/assets/img/icons/null-result.js +2 -2
  58. package/dist/src/assets/img/icons/restricted.d.ts +1 -1
  59. package/dist/src/assets/img/icons/restricted.js +2 -2
  60. package/dist/src/assets/img/icons/reviews.d.ts +1 -1
  61. package/dist/src/assets/img/icons/reviews.js +2 -2
  62. package/dist/src/assets/img/icons/upload.d.ts +1 -1
  63. package/dist/src/assets/img/icons/upload.js +2 -2
  64. package/dist/src/assets/img/icons/views.d.ts +1 -1
  65. package/dist/src/assets/img/icons/views.js +2 -2
  66. package/dist/src/circular-activity-indicator.d.ts +5 -5
  67. package/dist/src/circular-activity-indicator.js +17 -17
  68. package/dist/src/collection-browser.d.ts +246 -245
  69. package/dist/src/collection-browser.js +1370 -1359
  70. package/dist/src/collection-browser.js.map +1 -1
  71. package/dist/src/collection-facets/facet-tombstone-row.d.ts +5 -5
  72. package/dist/src/collection-facets/facet-tombstone-row.js +42 -42
  73. package/dist/src/collection-facets/facet-tombstone-row.js.map +1 -1
  74. package/dist/src/collection-facets/facets-template.d.ts +16 -16
  75. package/dist/src/collection-facets/facets-template.js +130 -128
  76. package/dist/src/collection-facets/facets-template.js.map +1 -1
  77. package/dist/src/collection-facets/more-facets-content.d.ts +76 -76
  78. package/dist/src/collection-facets/more-facets-content.js +353 -353
  79. package/dist/src/collection-facets/more-facets-pagination.d.ts +36 -36
  80. package/dist/src/collection-facets/more-facets-pagination.js +192 -192
  81. package/dist/src/collection-facets.d.ts +77 -77
  82. package/dist/src/collection-facets.js +551 -551
  83. package/dist/src/collection-facets.js.map +1 -1
  84. package/dist/src/empty-placeholder.d.ts +11 -11
  85. package/dist/src/empty-placeholder.js +42 -42
  86. package/dist/src/language-code-handler/language-code-handler.d.ts +37 -37
  87. package/dist/src/language-code-handler/language-code-handler.js +26 -26
  88. package/dist/src/language-code-handler/language-code-mapping.d.ts +1 -1
  89. package/dist/src/language-code-handler/language-code-mapping.js +562 -562
  90. package/dist/src/mediatype/mediatype-config.d.ts +3 -3
  91. package/dist/src/mediatype/mediatype-config.js +85 -85
  92. package/dist/src/models.d.ts +103 -102
  93. package/dist/src/models.js +117 -117
  94. package/dist/src/models.js.map +1 -1
  95. package/dist/src/restoration-state-handler.d.ts +46 -45
  96. package/dist/src/restoration-state-handler.js +230 -220
  97. package/dist/src/restoration-state-handler.js.map +1 -1
  98. package/dist/src/sort-filter-bar/alpha-bar.d.ts +9 -9
  99. package/dist/src/sort-filter-bar/alpha-bar.js +41 -41
  100. package/dist/src/sort-filter-bar/img/compact.d.ts +1 -1
  101. package/dist/src/sort-filter-bar/img/compact.js +2 -2
  102. package/dist/src/sort-filter-bar/img/list.d.ts +1 -1
  103. package/dist/src/sort-filter-bar/img/list.js +2 -2
  104. package/dist/src/sort-filter-bar/img/sort-triangle.d.ts +1 -1
  105. package/dist/src/sort-filter-bar/img/sort-triangle.js +2 -2
  106. package/dist/src/sort-filter-bar/img/tile.d.ts +1 -1
  107. package/dist/src/sort-filter-bar/img/tile.js +2 -2
  108. package/dist/src/sort-filter-bar/sort-filter-bar.d.ts +107 -107
  109. package/dist/src/sort-filter-bar/sort-filter-bar.js +423 -423
  110. package/dist/src/styles/item-image-styles.d.ts +8 -8
  111. package/dist/src/styles/item-image-styles.js +9 -9
  112. package/dist/src/tiles/collection-browser-loading-tile.d.ts +5 -5
  113. package/dist/src/tiles/collection-browser-loading-tile.js +15 -15
  114. package/dist/src/tiles/grid/account-tile.d.ts +8 -8
  115. package/dist/src/tiles/grid/account-tile.js +20 -20
  116. package/dist/src/tiles/grid/collection-tile.d.ts +7 -7
  117. package/dist/src/tiles/grid/collection-tile.js +23 -23
  118. package/dist/src/tiles/grid/item-tile.d.ts +24 -24
  119. package/dist/src/tiles/grid/item-tile.js +87 -87
  120. package/dist/src/tiles/grid/tile-stats.d.ts +10 -10
  121. package/dist/src/tiles/grid/tile-stats.js +46 -40
  122. package/dist/src/tiles/grid/tile-stats.js.map +1 -1
  123. package/dist/src/tiles/image-block.d.ts +17 -17
  124. package/dist/src/tiles/image-block.js +69 -69
  125. package/dist/src/tiles/item-image.d.ts +31 -31
  126. package/dist/src/tiles/item-image.js +103 -103
  127. package/dist/src/tiles/list/account-label.d.ts +1 -1
  128. package/dist/src/tiles/list/account-label.js +6 -6
  129. package/dist/src/tiles/list/date-label.d.ts +1 -1
  130. package/dist/src/tiles/list/date-label.js +12 -12
  131. package/dist/src/tiles/list/tile-list-compact-header.d.ts +12 -12
  132. package/dist/src/tiles/list/tile-list-compact-header.js +41 -41
  133. package/dist/src/tiles/list/tile-list-compact.d.ts +21 -20
  134. package/dist/src/tiles/list/tile-list-compact.js +94 -90
  135. package/dist/src/tiles/list/tile-list-compact.js.map +1 -1
  136. package/dist/src/tiles/list/tile-list.d.ts +50 -50
  137. package/dist/src/tiles/list/tile-list.js +271 -268
  138. package/dist/src/tiles/list/tile-list.js.map +1 -1
  139. package/dist/src/tiles/mediatype-icon.d.ts +9 -9
  140. package/dist/src/tiles/mediatype-icon.js +49 -47
  141. package/dist/src/tiles/mediatype-icon.js.map +1 -1
  142. package/dist/src/tiles/overlay/icon-overlay.d.ts +7 -7
  143. package/dist/src/tiles/overlay/icon-overlay.js +30 -30
  144. package/dist/src/tiles/overlay/text-overlay.d.ts +8 -8
  145. package/dist/src/tiles/overlay/text-overlay.js +31 -31
  146. package/dist/src/tiles/text-snippet-block.d.ts +29 -29
  147. package/dist/src/tiles/text-snippet-block.js +81 -81
  148. package/dist/src/tiles/tile-dispatcher.d.ts +36 -36
  149. package/dist/src/tiles/tile-dispatcher.js +128 -128
  150. package/dist/src/utils/analytics-events.d.ts +22 -22
  151. package/dist/src/utils/analytics-events.js +24 -24
  152. package/dist/src/utils/format-count.d.ts +7 -7
  153. package/dist/src/utils/format-count.js +76 -76
  154. package/dist/src/utils/format-date.d.ts +2 -2
  155. package/dist/src/utils/format-date.js +23 -23
  156. package/dist/test/collection-browser.test.d.ts +1 -1
  157. package/dist/test/collection-browser.test.js +481 -415
  158. package/dist/test/collection-browser.test.js.map +1 -1
  159. package/dist/test/collection-facets/facets-template.test.d.ts +1 -1
  160. package/dist/test/collection-facets/facets-template.test.js +62 -62
  161. package/dist/test/collection-facets/facets-template.test.js.map +1 -1
  162. package/dist/test/collection-facets/more-facets-content.test.d.ts +1 -1
  163. package/dist/test/collection-facets/more-facets-content.test.js +114 -114
  164. package/dist/test/collection-facets/more-facets-pagination.test.d.ts +1 -1
  165. package/dist/test/collection-facets/more-facets-pagination.test.js +117 -117
  166. package/dist/test/collection-facets.test.d.ts +2 -2
  167. package/dist/test/collection-facets.test.js +498 -498
  168. package/dist/test/empty-placeholder.test.d.ts +1 -1
  169. package/dist/test/empty-placeholder.test.js +33 -33
  170. package/dist/test/icon-overlay.test.d.ts +1 -1
  171. package/dist/test/icon-overlay.test.js +24 -24
  172. package/dist/test/image-block.test.d.ts +1 -1
  173. package/dist/test/image-block.test.js +48 -48
  174. package/dist/test/item-image.test.d.ts +1 -1
  175. package/dist/test/item-image.test.js +56 -56
  176. package/dist/test/mediatype-config.test.d.ts +1 -1
  177. package/dist/test/mediatype-config.test.js +16 -16
  178. package/dist/test/mocks/mock-analytics-handler.d.ts +10 -10
  179. package/dist/test/mocks/mock-analytics-handler.js +15 -15
  180. package/dist/test/mocks/mock-collection-name-cache.d.ts +7 -7
  181. package/dist/test/mocks/mock-collection-name-cache.js +13 -13
  182. package/dist/test/mocks/mock-search-responses.d.ts +8 -8
  183. package/dist/test/mocks/mock-search-responses.js +198 -198
  184. package/dist/test/mocks/mock-search-responses.js.map +1 -1
  185. package/dist/test/mocks/mock-search-service.d.ts +13 -13
  186. package/dist/test/mocks/mock-search-service.js +32 -32
  187. package/dist/test/mocks/mock-search-service.js.map +1 -1
  188. package/dist/test/restoration-state-handler.test.d.ts +1 -1
  189. package/dist/test/restoration-state-handler.test.js +126 -117
  190. package/dist/test/restoration-state-handler.test.js.map +1 -1
  191. package/dist/test/sort-filter-bar/sort-filter-bar.test.d.ts +1 -1
  192. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +113 -113
  193. package/dist/test/text-overlay.test.d.ts +1 -1
  194. package/dist/test/text-overlay.test.js +41 -41
  195. package/dist/test/text-snippet-block.test.d.ts +1 -1
  196. package/dist/test/text-snippet-block.test.js +57 -57
  197. package/dist/test/tile-stats.test.d.ts +1 -1
  198. package/dist/test/tile-stats.test.js +33 -33
  199. package/dist/test/tiles/grid/item-tile.test.d.ts +1 -1
  200. package/dist/test/tiles/grid/item-tile.test.js +107 -107
  201. package/dist/test/tiles/list/tile-list-compact.test.d.ts +1 -1
  202. package/dist/test/tiles/list/tile-list-compact.test.js +125 -26
  203. package/dist/test/tiles/list/tile-list-compact.test.js.map +1 -1
  204. package/dist/test/tiles/list/tile-list.test.d.ts +1 -1
  205. package/dist/test/tiles/list/tile-list.test.js +79 -47
  206. package/dist/test/tiles/list/tile-list.test.js.map +1 -1
  207. package/dist/test/utils/format-count.test.d.ts +1 -1
  208. package/dist/test/utils/format-count.test.js +23 -23
  209. package/dist/test/utils/format-date.test.d.ts +1 -1
  210. package/dist/test/utils/format-date.test.js +17 -17
  211. package/index.html +24 -24
  212. package/local.archive.org.cert +86 -86
  213. package/local.archive.org.key +27 -27
  214. package/package.json +115 -115
  215. package/renovate.json +6 -6
  216. package/src/app-root.ts +104 -55
  217. package/src/collection-browser.ts +1503 -1488
  218. package/src/collection-facets/facet-tombstone-row.ts +40 -40
  219. package/src/collection-facets/facets-template.ts +5 -3
  220. package/src/collection-facets.ts +635 -635
  221. package/src/models.ts +1 -0
  222. package/src/restoration-state-handler.ts +19 -1
  223. package/src/tiles/grid/tile-stats.ts +18 -5
  224. package/src/tiles/list/tile-list-compact.ts +7 -3
  225. package/src/tiles/list/tile-list.ts +6 -1
  226. package/src/tiles/mediatype-icon.ts +2 -0
  227. package/test/collection-browser.test.ts +679 -599
  228. package/test/collection-facets/facets-template.test.ts +5 -3
  229. package/test/mocks/mock-search-responses.ts +226 -226
  230. package/test/mocks/mock-search-service.ts +61 -61
  231. package/test/restoration-state-handler.test.ts +12 -0
  232. package/test/tiles/list/tile-list-compact.test.ts +110 -0
  233. package/test/tiles/list/tile-list.test.ts +36 -0
  234. package/tsconfig.json +21 -21
  235. package/web-dev-server.config.mjs +30 -30
  236. package/web-test-runner.config.mjs +41 -41
@@ -1,1488 +1,1503 @@
1
- /* eslint-disable import/no-duplicates */
2
- import {
3
- html,
4
- css,
5
- LitElement,
6
- PropertyValues,
7
- TemplateResult,
8
- nothing,
9
- } from 'lit';
10
- import { customElement, property, query, state } from 'lit/decorators.js';
11
- import { ifDefined } from 'lit/directives/if-defined.js';
12
-
13
- import type { AnalyticsManagerInterface } from '@internetarchive/analytics-manager';
14
- import type {
15
- InfiniteScroller,
16
- InfiniteScrollerCellProviderInterface,
17
- } from '@internetarchive/infinite-scroller';
18
- import {
19
- Aggregation,
20
- SearchParams,
21
- SearchResult,
22
- SearchServiceInterface,
23
- SearchType,
24
- SortDirection,
25
- SortParam,
26
- } from '@internetarchive/search-service';
27
- import type {
28
- SharedResizeObserverInterface,
29
- SharedResizeObserverResizeHandlerInterface,
30
- } from '@internetarchive/shared-resize-observer';
31
- import '@internetarchive/infinite-scroller';
32
- import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
33
- import type { ModalManagerInterface } from '@internetarchive/modal-manager';
34
- import './tiles/tile-dispatcher';
35
- import './tiles/collection-browser-loading-tile';
36
- import './sort-filter-bar/sort-filter-bar';
37
- import './collection-facets';
38
- import './circular-activity-indicator';
39
- import './sort-filter-bar/sort-filter-bar';
40
- import {
41
- SelectedFacets,
42
- SortField,
43
- SortFieldToMetadataField,
44
- CollectionBrowserContext,
45
- defaultSelectedFacets,
46
- TileModel,
47
- CollectionDisplayMode,
48
- FacetOption,
49
- } from './models';
50
- import {
51
- RestorationStateHandlerInterface,
52
- RestorationStateHandler,
53
- RestorationState,
54
- } from './restoration-state-handler';
55
- import chevronIcon from './assets/img/icons/chevron';
56
- import { LanguageCodeHandler } from './language-code-handler/language-code-handler';
57
- import type { PlaceholderType } from './empty-placeholder';
58
- import './empty-placeholder';
59
-
60
- import {
61
- analyticsActions,
62
- analyticsCategories,
63
- } from './utils/analytics-events';
64
-
65
- @customElement('collection-browser')
66
- export class CollectionBrowser
67
- extends LitElement
68
- implements
69
- InfiniteScrollerCellProviderInterface,
70
- SharedResizeObserverResizeHandlerInterface
71
- {
72
- @property({ type: String }) baseNavigationUrl?: string;
73
-
74
- @property({ type: String }) baseImageUrl: string = 'https://archive.org';
75
-
76
- @property({ type: Object }) searchService?: SearchServiceInterface;
77
-
78
- @property({ type: String }) searchType: SearchType = SearchType.METADATA;
79
-
80
- @property({ type: String }) baseQuery?: string;
81
-
82
- @property({ type: String }) displayMode?: CollectionDisplayMode;
83
-
84
- @property({ type: Object }) sortParam: SortParam | null = null;
85
-
86
- @property({ type: String }) selectedSort: SortField = SortField.relevance;
87
-
88
- @property({ type: String }) selectedTitleFilter: string | null = null;
89
-
90
- @property({ type: String }) selectedCreatorFilter: string | null = null;
91
-
92
- @property({ type: String }) sortDirection: SortDirection | null = null;
93
-
94
- @property({ type: String }) dateRangeQueryClause?: string;
95
-
96
- @property({ type: Number }) pageSize = 50;
97
-
98
- @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
99
-
100
- @property({ type: String }) titleQuery?: string;
101
-
102
- @property({ type: String }) creatorQuery?: string;
103
-
104
- @property({ type: Number }) currentPage?: number;
105
-
106
- @property({ type: String }) minSelectedDate?: string;
107
-
108
- @property({ type: String }) maxSelectedDate?: string;
109
-
110
- @property({ type: Object }) selectedFacets?: SelectedFacets;
111
-
112
- @property({ type: Boolean }) showHistogramDatePicker = false;
113
-
114
- /** describes where this component is being used */
115
- @property({ type: String, reflect: true }) searchContext: string =
116
- analyticsCategories.default;
117
-
118
- @property({ type: Object })
119
- collectionNameCache?: CollectionNameCacheInterface;
120
-
121
- @property({ type: String }) pageContext: CollectionBrowserContext = 'search';
122
-
123
- @property({ type: Object })
124
- restorationStateHandler: RestorationStateHandlerInterface = new RestorationStateHandler(
125
- {
126
- context: this.pageContext,
127
- }
128
- );
129
-
130
- @property({ type: Number }) mobileBreakpoint = 600;
131
-
132
- @property({ type: Boolean }) loggedIn = false;
133
-
134
- @property({ type: Object }) modalManager?: ModalManagerInterface = undefined;
135
-
136
- /**
137
- * If item management UI active
138
- */
139
- @property({ type: Boolean }) isManageView = false;
140
-
141
- /**
142
- * The page that the consumer wants to load.
143
- */
144
- private initialPageNumber = 1;
145
-
146
- /**
147
- * This the the number of pages that we want to show.
148
- *
149
- * The data isn't necessarily loaded for all of the pages, but this lets us
150
- * know how many cells we should render.
151
- */
152
- @state() private pagesToRender = this.initialPageNumber;
153
-
154
- @state() private searchResultsLoading = false;
155
-
156
- @state() private facetsLoading = false;
157
-
158
- @state() private fullYearAggregationLoading = false;
159
-
160
- @state() private aggregations?: Record<string, Aggregation>;
161
-
162
- @state() private fullYearsHistogramAggregation: Aggregation | undefined;
163
-
164
- /**
165
- * The search type of the previous search (i.e., the currently displayed
166
- * search results), which may differ from the one that is currently selected
167
- * to be used for the next search.
168
- */
169
- @state() private previousSearchType?: SearchType;
170
-
171
- @state() private totalResults?: number;
172
-
173
- @state() private mobileView = false;
174
-
175
- @state() private mobileFacetsVisible = false;
176
-
177
- @state() private placeholderType: PlaceholderType = null;
178
-
179
- @query('#content-container') private contentContainer!: HTMLDivElement;
180
-
181
- private languageCodeHandler = new LanguageCodeHandler();
182
-
183
- @property({ type: Object, attribute: false })
184
- private analyticsHandler?: AnalyticsManagerInterface;
185
-
186
- /**
187
- * When we're animated scrolling to the page, we don't want to fetch
188
- * all of the pages as it scrolls so this lets us know if we're scrolling
189
- */
190
- private isScrollingToCell = false;
191
-
192
- /**
193
- * When we've reached the end of the data, stop trying to fetch more
194
- */
195
- private endOfDataReached = false;
196
-
197
- /**
198
- * When page width resizes from desktop to mobile, set true to
199
- * disable expand/collapse transition when loading.
200
- */
201
- private isResizeToMobile = false;
202
-
203
- private placeholderCellTemplate = html`<collection-browser-loading-tile></collection-browser-loading-tile>`;
204
-
205
- private tileModelAtCellIndex(index: number): TileModel | undefined {
206
- const pageNumber = Math.floor(index / this.pageSize) + 1;
207
- const itemIndex = index % this.pageSize;
208
- const model = this.dataSource[pageNumber]?.[itemIndex];
209
- /**
210
- * If we encounter a model we don't have yet and we're not in the middle of an
211
- * automated scroll, fetch the page and just return undefined.
212
- * The datasource will be updated once the page is loaded and the cell will be rendered.
213
- *
214
- * We disable it during the automated scroll since we may fetch pages for cells the
215
- * user may never see.
216
- */
217
- if (!model && !this.isScrollingToCell) {
218
- this.fetchPage(pageNumber);
219
- }
220
- return model;
221
- }
222
-
223
- private get sortFilterQueries(): string {
224
- const queries = [this.titleQuery, this.creatorQuery];
225
- return queries.filter(q => q).join(' AND ');
226
- }
227
-
228
- // this is the total number of tiles we expect if
229
- // the data returned is a full page worth
230
- // this is useful for putting in placeholders for the expected number of tiles
231
- private get estimatedTileCount(): number {
232
- return this.pagesToRender * this.pageSize;
233
- }
234
-
235
- // this is the actual number of tiles in the datasource,
236
- // which is useful for removing excess placeholder tiles
237
- // once we reached the end of the data
238
- private get actualTileCount(): number {
239
- return Object.keys(this.dataSource).reduce(
240
- (acc, page) => acc + this.dataSource[page].length,
241
- 0
242
- );
243
- }
244
-
245
- /**
246
- * The results per page so we can paginate.
247
- *
248
- * This allows us to start in the middle of the search results and
249
- * fetch data before or after the current page. If we don't have a key
250
- * for the previous/next page, we'll fetch the next/previous page to populate it
251
- */
252
- private dataSource: Record<string, TileModel[]> = {};
253
-
254
- @query('infinite-scroller')
255
- private infiniteScroller!: InfiniteScroller;
256
-
257
- /**
258
- * Go to the given page of results
259
- *
260
- * @param pageNumber
261
- */
262
- goToPage(pageNumber: number) {
263
- this.initialPageNumber = pageNumber;
264
- this.pagesToRender = pageNumber;
265
- this.scrollToPage(pageNumber);
266
- }
267
-
268
- clearFilters() {
269
- this.selectedFacets = defaultSelectedFacets;
270
- this.sortParam = null;
271
- this.selectedTitleFilter = null;
272
- this.selectedCreatorFilter = null;
273
- this.titleQuery = undefined;
274
- this.creatorQuery = undefined;
275
- this.selectedSort = SortField.relevance;
276
- this.sortDirection = null;
277
- }
278
-
279
- /**
280
- * Manually requests to perform a search, which will only be executed if one of
281
- * the query, the search type, or the sort has changed.
282
- */
283
- requestSearch() {
284
- this.handleQueryChange();
285
- }
286
-
287
- render() {
288
- this.setPlaceholderType();
289
- return html`
290
- <div
291
- id="content-container"
292
- class=${this.mobileView ? 'mobile' : 'desktop'}
293
- >
294
- ${this.placeholderType
295
- ? this.emptyPlaceholderTemplate
296
- : this.collectionBrowserTemplate}
297
- </div>
298
- `;
299
- }
300
-
301
- private setPlaceholderType() {
302
- this.placeholderType = null;
303
- if (!this.baseQuery) {
304
- this.placeholderType = 'empty-query';
305
- }
306
-
307
- if (
308
- (!this.searchResultsLoading && this.totalResults === 0) ||
309
- !this.searchService
310
- ) {
311
- this.placeholderType = 'null-result';
312
- }
313
- }
314
-
315
- private get emptyPlaceholderTemplate() {
316
- return html`
317
- <empty-placeholder
318
- .placeholderType=${this.placeholderType}
319
- ?isMobileView=${this.mobileView}
320
- ></empty-placeholder>
321
- `;
322
- }
323
-
324
- private get collectionBrowserTemplate() {
325
- return html`<div
326
- id="left-column"
327
- class="column${this.isResizeToMobile ? ' preload' : ''}"
328
- >
329
- <div id="mobile-header-container">
330
- ${this.mobileView ? this.mobileFacetsTemplate : nothing}
331
- <div id="results-total">
332
- <span id="big-results-count">
333
- ${this.totalResults !== undefined
334
- ? this.totalResults.toLocaleString()
335
- : '-'}
336
- </span>
337
- <span id="big-results-label">
338
- ${this.totalResults === 1 ? 'Result' : 'Results'}
339
- </span>
340
- </div>
341
- </div>
342
- <div
343
- id="facets-container"
344
- class=${!this.mobileView || this.mobileFacetsVisible
345
- ? 'expanded'
346
- : ''}
347
- >
348
- ${this.facetsTemplate}
349
- </div>
350
- </div>
351
- <div id="right-column" class="column">
352
- ${this.sortFilterBarTemplate}
353
- ${this.displayMode === `list-compact`
354
- ? this.listHeaderTemplate
355
- : nothing}
356
- ${this.infiniteScrollerTemplate}
357
- </div>`;
358
- }
359
-
360
- private get infiniteScrollerTemplate() {
361
- return html`<infinite-scroller
362
- class="${ifDefined(this.displayMode)}"
363
- .cellProvider=${this}
364
- .placeholderCellTemplate=${this.placeholderCellTemplate}
365
- @scrollThresholdReached=${this.scrollThresholdReached}
366
- @visibleCellsChanged=${this.visibleCellsChanged}
367
- ></infinite-scroller>`;
368
- }
369
-
370
- private get sortFilterBarTemplate() {
371
- return html`
372
- <sort-filter-bar
373
- .selectedSort=${this.selectedSort}
374
- .sortDirection=${this.sortDirection}
375
- .displayMode=${this.displayMode}
376
- .selectedTitleFilter=${this.selectedTitleFilter}
377
- .selectedCreatorFilter=${this.selectedCreatorFilter}
378
- .resizeObserver=${this.resizeObserver}
379
- @sortChanged=${this.userChangedSort}
380
- @displayModeChanged=${this.displayModeChanged}
381
- @titleLetterChanged=${this.titleLetterSelected}
382
- @creatorLetterChanged=${this.creatorLetterSelected}
383
- >
384
- </sort-filter-bar>
385
- `;
386
- }
387
-
388
- private userChangedSort(
389
- e: CustomEvent<{
390
- selectedSort: SortField;
391
- sortDirection: SortDirection | null;
392
- }>
393
- ) {
394
- const { selectedSort, sortDirection } = e.detail;
395
- this.selectedSort = selectedSort;
396
- this.sortDirection = sortDirection;
397
-
398
- if ((this.currentPage ?? 1) > 1) {
399
- this.goToPage(1);
400
- }
401
- this.currentPage = 1;
402
- }
403
-
404
- private sendSortByAnalytics(prevSortDirection: SortDirection | null): void {
405
- const directionCleared = prevSortDirection && !this.sortDirection;
406
-
407
- this.analyticsHandler?.sendEventNoSampling({
408
- category: this.searchContext,
409
- action: analyticsActions.sortBy,
410
- label: `${this.selectedSort}${
411
- this.sortDirection || directionCleared ? `-${this.sortDirection}` : ''
412
- }`,
413
- });
414
- }
415
-
416
- private selectedSortChanged(): void {
417
- if (this.selectedSort === 'relevance' || this.sortDirection === null) {
418
- this.sortParam = null;
419
- return;
420
- }
421
- const sortField = SortFieldToMetadataField[this.selectedSort];
422
-
423
- if (!sortField) return;
424
- this.sortParam = { field: sortField, direction: this.sortDirection };
425
- }
426
-
427
- private displayModeChanged(
428
- e: CustomEvent<{ displayMode: CollectionDisplayMode }>
429
- ) {
430
- this.displayMode = e.detail.displayMode;
431
-
432
- if (this.displayMode) {
433
- this.analyticsHandler?.sendEventNoSampling({
434
- category: this.searchContext,
435
- action: analyticsActions.displayMode,
436
- label: this.displayMode,
437
- });
438
- }
439
- }
440
-
441
- /** Send Analytics when sorting by title's first letter
442
- * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
443
- * */
444
- private sendFilterByTitleAnalytics(prevSelectedLetter: string | null): void {
445
- if (!prevSelectedLetter && !this.selectedTitleFilter) {
446
- return;
447
- }
448
- const cleared = prevSelectedLetter && this.selectedTitleFilter === null;
449
-
450
- this.analyticsHandler?.sendEventNoSampling({
451
- category: this.searchContext,
452
- action: analyticsActions.filterByTitle,
453
- label: cleared
454
- ? `clear-${prevSelectedLetter}`
455
- : `${prevSelectedLetter || 'start'}-${this.selectedTitleFilter}`,
456
- });
457
- }
458
-
459
- private selectedTitleLetterChanged(): void {
460
- this.titleQuery = this.selectedTitleFilter
461
- ? `firstTitle:${this.selectedTitleFilter}`
462
- : undefined;
463
- }
464
-
465
- /** Send Analytics when filtering by creator's first letter
466
- * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
467
- * */
468
- private sendFilterByCreatorAnalytics(
469
- prevSelectedLetter: string | null
470
- ): void {
471
- if (!prevSelectedLetter && !this.selectedCreatorFilter) {
472
- return;
473
- }
474
- const cleared = prevSelectedLetter && this.selectedCreatorFilter === null;
475
-
476
- this.analyticsHandler?.sendEventNoSampling({
477
- category: this.searchContext,
478
- action: analyticsActions.filterByCreator,
479
- label: cleared
480
- ? `clear-${prevSelectedLetter}`
481
- : `${prevSelectedLetter || 'start'}-${this.selectedCreatorFilter}`,
482
- });
483
- }
484
-
485
- private selectedCreatorLetterChanged(): void {
486
- this.creatorQuery = this.selectedCreatorFilter
487
- ? `firstCreator:${this.selectedCreatorFilter}`
488
- : undefined;
489
- }
490
-
491
- private titleLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
492
- this.selectedCreatorFilter = null;
493
- this.selectedTitleFilter = e.detail.selectedLetter;
494
- }
495
-
496
- private creatorLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
497
- this.selectedTitleFilter = null;
498
- this.selectedCreatorFilter = e.detail.selectedLetter;
499
- }
500
-
501
- private get facetDataLoading(): boolean {
502
- return this.facetsLoading || this.fullYearAggregationLoading;
503
- }
504
-
505
- private get mobileFacetsTemplate() {
506
- return html`
507
- <div id="mobile-filter-collapse">
508
- <h1
509
- @click=${() => {
510
- this.isResizeToMobile = false;
511
- this.mobileFacetsVisible = !this.mobileFacetsVisible;
512
- }}
513
- @keyup=${() => {
514
- this.isResizeToMobile = false;
515
- this.mobileFacetsVisible = !this.mobileFacetsVisible;
516
- }}
517
- >
518
- <span class="collapser ${this.mobileFacetsVisible ? 'open' : ''}">
519
- ${chevronIcon}
520
- </span>
521
- Filters
522
- </h1>
523
- </div>
524
- `;
525
- }
526
-
527
- private get facetsTemplate() {
528
- return html`
529
- <collection-facets
530
- @facetsChanged=${this.facetsChanged}
531
- @histogramDateRangeUpdated=${this.histogramDateRangeUpdated}
532
- .searchService=${this.searchService}
533
- .searchType=${this.searchType}
534
- .aggregations=${this.aggregations}
535
- .fullYearsHistogramAggregation=${this.fullYearsHistogramAggregation}
536
- .moreLinksVisible=${this.previousSearchType !== SearchType.FULLTEXT}
537
- .minSelectedDate=${this.minSelectedDate}
538
- .maxSelectedDate=${this.maxSelectedDate}
539
- .selectedFacets=${this.selectedFacets}
540
- .collectionNameCache=${this.collectionNameCache}
541
- .languageCodeHandler=${this.languageCodeHandler}
542
- .showHistogramDatePicker=${this.showHistogramDatePicker}
543
- .fullQuery=${this.fullQuery}
544
- .modalManager=${this.modalManager}
545
- ?collapsableFacets=${this.mobileView}
546
- ?facetsLoading=${this.facetDataLoading}
547
- ?fullYearAggregationLoading=${this.fullYearAggregationLoading}
548
- .onFacetClick=${this.facetClickHandler}
549
- .analyticsHandler=${this.analyticsHandler}
550
- >
551
- </collection-facets>
552
- `;
553
- }
554
-
555
- private get loadingTemplate() {
556
- return html`
557
- <div class="loading-cover">
558
- <circular-activity-indicator></circular-activity-indicator>
559
- </div>
560
- `;
561
- }
562
-
563
- private get listHeaderTemplate() {
564
- return html`
565
- <div id="list-header">
566
- <tile-dispatcher
567
- .tileDisplayMode=${'list-header'}
568
- .resizeObserver=${this.resizeObserver}
569
- .sortParam=${this.sortParam}
570
- .mobileBreakpoint=${this.mobileBreakpoint}
571
- .loggedIn=${this.loggedIn}
572
- >
573
- </tile-dispatcher>
574
- </div>
575
- `;
576
- }
577
-
578
- private get queryDebuggingTemplate() {
579
- return html`
580
- <div>
581
- <ul>
582
- <li>Base Query: ${this.baseQuery}</li>
583
- <li>Facet Query: ${this.facetQuery}</li>
584
- <li>Sort Filter Query: ${this.sortFilterQueries}</li>
585
- <li>Date Range Query: ${this.dateRangeQueryClause}</li>
586
- <li>Sort: ${this.sortParam?.field} ${this.sortParam?.direction}</li>
587
- <li>Full Query: ${this.fullQuery}</li>
588
- </ul>
589
- </div>
590
- `;
591
- }
592
-
593
- private histogramDateRangeUpdated(
594
- e: CustomEvent<{
595
- minDate: string;
596
- maxDate: string;
597
- }>
598
- ) {
599
- const { minDate, maxDate } = e.detail;
600
- this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
601
-
602
- if (this.dateRangeQueryClause) {
603
- this.analyticsHandler?.sendEventNoSampling({
604
- category: this.searchContext,
605
- action: analyticsActions.histogramChanged,
606
- label: this.dateRangeQueryClause,
607
- });
608
- }
609
- }
610
-
611
- firstUpdated(): void {
612
- this.setupStateRestorationObserver();
613
- this.restoreState();
614
- }
615
-
616
- updated(changed: PropertyValues) {
617
- if (
618
- changed.has('displayMode') ||
619
- changed.has('baseNavigationUrl') ||
620
- changed.has('baseImageUrl') ||
621
- changed.has('loggedIn')
622
- ) {
623
- this.infiniteScroller?.reload();
624
- }
625
- if (changed.has('baseQuery')) {
626
- this.emitBaseQueryChanged();
627
- }
628
- if (changed.has('currentPage') || changed.has('displayMode')) {
629
- this.persistState();
630
- }
631
- if (
632
- changed.has('baseQuery') ||
633
- changed.has('titleQuery') ||
634
- changed.has('creatorQuery') ||
635
- changed.has('dateRangeQueryClause') ||
636
- changed.has('sortParam') ||
637
- changed.has('selectedFacets') ||
638
- changed.has('searchService')
639
- ) {
640
- this.handleQueryChange();
641
- }
642
- if (changed.has('selectedSort') || changed.has('sortDirection')) {
643
- const prevSortDirection = changed.get('sortDirection') as SortDirection;
644
- this.sendSortByAnalytics(prevSortDirection);
645
- this.selectedSortChanged();
646
- }
647
- if (changed.has('selectedTitleFilter')) {
648
- this.sendFilterByTitleAnalytics(
649
- changed.get('selectedTitleFilter') as string
650
- );
651
- this.selectedTitleLetterChanged();
652
- }
653
- if (changed.has('selectedCreatorFilter')) {
654
- this.sendFilterByCreatorAnalytics(
655
- changed.get('selectedCreatorFilter') as string
656
- );
657
- this.selectedCreatorLetterChanged();
658
- }
659
- if (changed.has('pagesToRender')) {
660
- if (!this.endOfDataReached && this.infiniteScroller) {
661
- this.infiniteScroller.itemCount = this.estimatedTileCount;
662
- }
663
- }
664
- if (changed.has('resizeObserver')) {
665
- const oldObserver = changed.get(
666
- 'resizeObserver'
667
- ) as SharedResizeObserverInterface;
668
- if (oldObserver) this.disconnectResizeObserver(oldObserver);
669
- this.setupResizeObserver();
670
- }
671
- }
672
-
673
- disconnectedCallback(): void {
674
- if (this.resizeObserver) {
675
- this.disconnectResizeObserver(this.resizeObserver);
676
- }
677
- if (this.boundNavigationHandler) {
678
- window.removeEventListener('popstate', this.boundNavigationHandler);
679
- }
680
- }
681
-
682
- handleResize(entry: ResizeObserverEntry): void {
683
- const previousView = this.mobileView;
684
- if (entry.target === this.contentContainer) {
685
- this.mobileView = entry.contentRect.width < this.mobileBreakpoint;
686
- // If changing from desktop to mobile disable transition
687
- if (this.mobileView && !previousView) {
688
- this.isResizeToMobile = true;
689
- }
690
- }
691
- }
692
-
693
- private emitBaseQueryChanged() {
694
- this.dispatchEvent(
695
- new CustomEvent<{ baseQuery?: string }>('baseQueryChanged', {
696
- detail: {
697
- baseQuery: this.baseQuery,
698
- },
699
- })
700
- );
701
- }
702
-
703
- private disconnectResizeObserver(
704
- resizeObserver: SharedResizeObserverInterface
705
- ) {
706
- resizeObserver.removeObserver({
707
- target: this.contentContainer,
708
- handler: this,
709
- });
710
- }
711
-
712
- private setupResizeObserver() {
713
- if (!this.resizeObserver) return;
714
- this.resizeObserver.addObserver({
715
- target: this.contentContainer,
716
- handler: this,
717
- });
718
- }
719
-
720
- /**
721
- * When the visible cells change from the infinite scroller, we want to emit
722
- * which page is currently visible so the consumer can update its UI or the URL
723
- *
724
- * @param e
725
- * @returns
726
- */
727
- private visibleCellsChanged(
728
- e: CustomEvent<{ visibleCellIndices: number[] }>
729
- ) {
730
- if (this.isScrollingToCell) return;
731
- const { visibleCellIndices } = e.detail;
732
- if (visibleCellIndices.length === 0) return;
733
- const lastVisibleCellIndex =
734
- visibleCellIndices[visibleCellIndices.length - 1];
735
- const lastVisibleCellPage =
736
- Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
737
- if (this.currentPage !== lastVisibleCellPage) {
738
- this.currentPage = lastVisibleCellPage;
739
- }
740
- const event = new CustomEvent('visiblePageChanged', {
741
- detail: {
742
- pageNumber: lastVisibleCellPage,
743
- },
744
- });
745
- this.dispatchEvent(event);
746
- }
747
-
748
- // we only want to scroll on the very first query change
749
- // so this keeps track of whether we've already set the initial query
750
- private initialQueryChangeHappened = false;
751
-
752
- private historyPopOccurred = false;
753
-
754
- // this lets us store the query key so we know if it's actually changed or not
755
- private previousQueryKey?: string;
756
-
757
- private async handleQueryChange() {
758
- // only reset if the query has actually changed
759
- if (!this.searchService || this.pageFetchQueryKey === this.previousQueryKey)
760
- return;
761
- this.previousQueryKey = this.pageFetchQueryKey;
762
-
763
- this.dataSource = {};
764
- this.pageFetchesInProgress = {};
765
- this.endOfDataReached = false;
766
- this.pagesToRender = this.initialPageNumber;
767
- if (!this.initialQueryChangeHappened && this.initialPageNumber > 1) {
768
- this.scrollToPage(this.initialPageNumber);
769
- }
770
- this.initialQueryChangeHappened = true;
771
- // if the query changed as part of a window.history pop event, we don't want to
772
- // persist the state because it overwrites the forward history
773
- if (!this.historyPopOccurred) {
774
- this.persistState();
775
- this.historyPopOccurred = false;
776
- }
777
-
778
- await Promise.all([
779
- this.doInitialPageFetch(),
780
- this.fetchFacets(),
781
- this.fetchFullYearHistogram(),
782
- ]);
783
- }
784
-
785
- private setupStateRestorationObserver() {
786
- if (this.boundNavigationHandler) return;
787
- this.boundNavigationHandler = this.historyNavigationHandler.bind(this);
788
- // when the user navigates back, we want to update the UI to match the URL
789
- window.addEventListener('popstate', this.boundNavigationHandler);
790
- }
791
-
792
- private boundNavigationHandler?: () => void;
793
-
794
- private historyNavigationHandler() {
795
- this.historyPopOccurred = true;
796
- this.restoreState();
797
- }
798
-
799
- private restoreState() {
800
- const restorationState = this.restorationStateHandler.getRestorationState();
801
- this.displayMode = restorationState.displayMode;
802
- this.selectedSort = restorationState.selectedSort ?? SortField.relevance;
803
- this.sortDirection = restorationState.sortDirection ?? null;
804
- this.selectedTitleFilter = restorationState.selectedTitleFilter ?? null;
805
- this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
806
- this.selectedFacets = restorationState.selectedFacets;
807
- this.baseQuery = restorationState.baseQuery;
808
- this.titleQuery = restorationState.titleQuery;
809
- this.creatorQuery = restorationState.creatorQuery;
810
- this.dateRangeQueryClause = restorationState.dateRangeQueryClause;
811
- this.sortParam = restorationState.sortParam ?? null;
812
- this.currentPage = restorationState.currentPage ?? 1;
813
- this.minSelectedDate = restorationState.minSelectedDate;
814
- this.maxSelectedDate = restorationState.maxSelectedDate;
815
- if (this.currentPage > 1) {
816
- this.goToPage(this.currentPage);
817
- }
818
- }
819
-
820
- private persistState() {
821
- const restorationState: RestorationState = {
822
- displayMode: this.displayMode,
823
- sortParam: this.sortParam ?? undefined,
824
- selectedSort: this.selectedSort,
825
- sortDirection: this.sortDirection ?? undefined,
826
- selectedFacets: this.selectedFacets ?? defaultSelectedFacets,
827
- baseQuery: this.baseQuery,
828
- currentPage: this.currentPage,
829
- dateRangeQueryClause: this.dateRangeQueryClause,
830
- titleQuery: this.titleQuery,
831
- creatorQuery: this.creatorQuery,
832
- minSelectedDate: this.minSelectedDate,
833
- maxSelectedDate: this.maxSelectedDate,
834
- selectedTitleFilter: this.selectedTitleFilter ?? undefined,
835
- selectedCreatorFilter: this.selectedCreatorFilter ?? undefined,
836
- };
837
- this.restorationStateHandler.persistState(restorationState);
838
- }
839
-
840
- private async doInitialPageFetch() {
841
- this.searchResultsLoading = true;
842
- await this.fetchPage(this.initialPageNumber);
843
- this.searchResultsLoading = false;
844
- }
845
-
846
- private get fullQuery(): string | undefined {
847
- let { fullQueryWithoutDate } = this;
848
- const { dateRangeQueryClause } = this;
849
- if (dateRangeQueryClause) {
850
- fullQueryWithoutDate += ` AND ${dateRangeQueryClause}`;
851
- }
852
- return fullQueryWithoutDate;
853
- }
854
-
855
- private get fullQueryWithoutDate(): string | undefined {
856
- if (!this.baseQuery) return undefined;
857
- let fullQuery = this.baseQuery;
858
- const { facetQuery, sortFilterQueries } = this;
859
- if (facetQuery) {
860
- fullQuery += ` AND ${facetQuery}`;
861
- }
862
- if (sortFilterQueries) {
863
- fullQuery += ` AND ${sortFilterQueries}`;
864
- }
865
- return fullQuery;
866
- }
867
-
868
- /**
869
- * Generates a query string for the given facets
870
- *
871
- * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
872
- */
873
- private get facetQuery(): string | undefined {
874
- if (!this.selectedFacets) return undefined;
875
- const facetQuery = [];
876
- for (const [facetName, facetValues] of Object.entries(
877
- this.selectedFacets
878
- )) {
879
- const facetEntries = Object.entries(facetValues);
880
- const facetQueryName =
881
- facetName === 'lending' ? 'lending___status' : facetName;
882
- // eslint-disable-next-line no-continue
883
- if (facetEntries.length === 0) continue;
884
- const facetValuesArray: string[] = [];
885
- for (const [key, facetData] of facetEntries) {
886
- const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
887
-
888
- if (facetName === 'language') {
889
- const languages =
890
- this.languageCodeHandler.getCodeArrayFromCodeString(key);
891
- for (const language of languages) {
892
- facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
893
- }
894
- } else {
895
- facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
896
- }
897
- }
898
- const valueQuery = facetValuesArray.join(` OR `);
899
- facetQuery.push(`${facetQueryName}:(${valueQuery})`);
900
- }
901
- return facetQuery.length > 0 ? `(${facetQuery.join(' AND ')})` : undefined;
902
- }
903
-
904
- facetsChanged(e: CustomEvent<SelectedFacets>) {
905
- this.selectedFacets = e.detail;
906
- }
907
-
908
- facetClickHandler(
909
- name: FacetOption,
910
- facetSelected: boolean,
911
- negative: boolean
912
- ): void {
913
- if (negative) {
914
- this.analyticsHandler?.sendEventNoSampling({
915
- category: this.searchContext,
916
- action: facetSelected
917
- ? analyticsActions.facetNegativeSelected
918
- : analyticsActions.facetNegativeDeselected,
919
- label: name,
920
- });
921
- } else {
922
- this.analyticsHandler?.sendEventNoSampling({
923
- category: this.searchContext,
924
- action: facetSelected
925
- ? analyticsActions.facetSelected
926
- : analyticsActions.facetDeselected,
927
- label: name,
928
- });
929
- }
930
- }
931
-
932
- private async fetchFacets() {
933
- if (!this.fullQuery) return;
934
-
935
- const params: SearchParams = {
936
- query: this.fullQuery,
937
- rows: 0,
938
- // Fetch a few extra buckets beyond the 6 we show, in case some get suppressed
939
- aggregationsSize: 10,
940
- // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
941
- // The default aggregations for the search_results page type should be what we need here.
942
- };
943
-
944
- this.facetsLoading = true;
945
- this.previousSearchType = this.searchType;
946
- const results = await this.searchService?.search(params, this.searchType);
947
- this.facetsLoading = false;
948
-
949
- this.aggregations = results?.success?.response.aggregations;
950
- }
951
-
952
- /**
953
- * If we haven't changed the query, we don't need to fetch the full year histogram
954
- *
955
- * @private
956
- * @type {string}
957
- * @memberof CollectionBrowser
958
- */
959
- private previousFullQueryNoDate?: string;
960
-
961
- /**
962
- * The query key is a string that uniquely identifies the current query
963
- * without the date range.
964
- *
965
- * If this doesn't change, we don't need to re-fetch the histogram date range
966
- */
967
- private get fullQueryNoDateKey() {
968
- return `${this.fullQueryWithoutDate}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
969
- }
970
-
971
- /**
972
- * This method is similar to fetching the facets above,
973
- * but only fetching the year histogram. There is a subtle difference
974
- * in how you have to fetch the year histogram where you can't use the
975
- * advanced JSON syntax like the other aggregations. It's a special
976
- * case that @ximm put it place.
977
- */
978
- private async fetchFullYearHistogram(): Promise<void> {
979
- const { fullQueryNoDateKey } = this;
980
- if (
981
- !this.fullQueryWithoutDate ||
982
- fullQueryNoDateKey === this.previousFullQueryNoDate
983
- )
984
- return;
985
- this.previousFullQueryNoDate = fullQueryNoDateKey;
986
-
987
- const aggregations = {
988
- simpleParams: ['year'],
989
- };
990
-
991
- const params = {
992
- query: this.fullQueryWithoutDate,
993
- aggregations,
994
- rows: 0,
995
- };
996
-
997
- this.fullYearAggregationLoading = true;
998
- const results = await this.searchService?.search(params, this.searchType);
999
- this.fullYearAggregationLoading = false;
1000
-
1001
- this.fullYearsHistogramAggregation =
1002
- results?.success?.response?.aggregations?.year_histogram ??
1003
- results?.success?.response?.aggregations?.['year-histogram']; // Temp fix until PPS FTS key is fixed to use underscore
1004
- }
1005
-
1006
- private scrollToPage(pageNumber: number) {
1007
- const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
1008
- // without this setTimeout, Safari just pauses until the `fetchPage` is complete
1009
- // then scrolls to the cell
1010
- setTimeout(() => {
1011
- this.isScrollingToCell = true;
1012
- this.infiniteScroller.scrollToCell(cellIndexToScrollTo, true);
1013
- // This timeout is to give the scroll animation time to finish
1014
- // then updating the infinite scroller once we're done scrolling
1015
- // There's no scroll animation completion callback so we're
1016
- // giving it 0.5s to finish.
1017
- setTimeout(() => {
1018
- this.isScrollingToCell = false;
1019
- this.infiniteScroller.reload();
1020
- }, 500);
1021
- }, 0);
1022
- }
1023
-
1024
- /**
1025
- * The query key is a string that uniquely identifies the current query
1026
- *
1027
- * This lets us keep track of queries so we don't persist data that's
1028
- * no longer relevant.
1029
- */
1030
- private get pageFetchQueryKey() {
1031
- return `${this.fullQuery}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
1032
- }
1033
-
1034
- // this maps the query to the pages being fetched for that query
1035
- private pageFetchesInProgress: Record<string, Set<number>> = {};
1036
-
1037
- async fetchPage(pageNumber: number) {
1038
- if (!this.fullQuery) return;
1039
-
1040
- // if we already have data, don't fetch again
1041
- if (this.dataSource[pageNumber]) return;
1042
-
1043
- if (this.endOfDataReached) return;
1044
-
1045
- // if a fetch is already in progress for this query and page, don't fetch again
1046
- const { pageFetchQueryKey } = this;
1047
- const pageFetches =
1048
- this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
1049
- if (pageFetches.has(pageNumber)) return;
1050
- pageFetches.add(pageNumber);
1051
- this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
1052
-
1053
- const sortParams = this.sortParam ? [this.sortParam] : [];
1054
- const params: SearchParams = {
1055
- query: this.fullQuery,
1056
- page: pageNumber,
1057
- rows: this.pageSize,
1058
- sort: sortParams,
1059
- aggregations: { omit: true },
1060
- };
1061
- const searchResponse = await this.searchService?.search(
1062
- params,
1063
- this.searchType
1064
- );
1065
- const success = searchResponse?.success;
1066
-
1067
- if (!success) return;
1068
-
1069
- this.totalResults = success.response.totalResults;
1070
-
1071
- // this is checking to see if the query has changed since the data was fetched
1072
- // if so, we just want to discard the data since there should be a new query
1073
- // right behind it
1074
- const searchQuery = success.request.clientParameters.user_query;
1075
- const searchSort = success.request.clientParameters.sort;
1076
- let sortChanged = false;
1077
- if (!searchSort || searchSort.length === 0) {
1078
- // if we went from no sort to sort, the sort has changed
1079
- if (this.sortParam) {
1080
- sortChanged = true;
1081
- }
1082
- } else {
1083
- // check if the sort has changed
1084
- for (const sortType of searchSort) {
1085
- const [field, direction] = sortType.split(':');
1086
- if (
1087
- field !== this.sortParam?.field ||
1088
- direction !== this.sortParam?.direction
1089
- ) {
1090
- sortChanged = true;
1091
- break;
1092
- }
1093
- }
1094
- }
1095
- const queryChangedSinceFetch =
1096
- searchQuery !== this.fullQuery || sortChanged;
1097
- if (queryChangedSinceFetch) return;
1098
-
1099
- const { results } = success.response;
1100
- if (results && results.length > 0) {
1101
- this.preloadCollectionNames(results);
1102
- this.updateDataSource(pageNumber, results);
1103
- }
1104
- if (results.length < this.pageSize) {
1105
- this.endOfDataReached = true;
1106
- // this updates the infinite scroller to show the actual size
1107
- if (this.infiniteScroller) {
1108
- this.infiniteScroller.itemCount = this.actualTileCount;
1109
- }
1110
- }
1111
- this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber);
1112
- this.searchResultsLoading = false;
1113
- }
1114
-
1115
- private preloadCollectionNames(results: SearchResult[]) {
1116
- const collectionIds = results
1117
- .map(result => result.collection?.values)
1118
- .flat();
1119
- const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
1120
- this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
1121
- }
1122
-
1123
- /**
1124
- * This is useful for determining whether we need to reload the scroller.
1125
- *
1126
- * When the fetch completes, we need to reload the scroller if the cells for that
1127
- * page are visible, but if the page is not currenlty visible, we don't need to reload
1128
- */
1129
- private get currentVisiblePageNumbers(): number[] {
1130
- const visibleCells = this.infiniteScroller?.getVisibleCellIndices() ?? [];
1131
- const visiblePages = new Set<number>();
1132
- visibleCells.forEach(cellIndex => {
1133
- const visiblePage = Math.floor(cellIndex / this.pageSize) + 1;
1134
- visiblePages.add(visiblePage);
1135
- });
1136
- return Array.from(visiblePages);
1137
- }
1138
-
1139
- /**
1140
- * Update the datasource from the fetch response
1141
- *
1142
- * @param pageNumber
1143
- * @param results
1144
- */
1145
- private updateDataSource(pageNumber: number, results: SearchResult[]) {
1146
- // copy our existing datasource so when we set it below, it gets set
1147
- // instead of modifying the existing dataSource since object changes
1148
- // don't trigger a re-render
1149
- const datasource = { ...this.dataSource };
1150
- const tiles: TileModel[] = [];
1151
- results?.forEach(result => {
1152
- if (!result.identifier) return;
1153
-
1154
- let loginRequired = false;
1155
- let contentWarning = false;
1156
- // Check if item and item in "modifying" collection, setting above flags
1157
- if (
1158
- result.collection?.values.length &&
1159
- result.mediatype?.value !== 'collection'
1160
- ) {
1161
- for (const collection of result.collection?.values ?? []) {
1162
- if (collection === 'loggedin') {
1163
- loginRequired = true;
1164
- if (contentWarning) break;
1165
- }
1166
- if (collection === 'no-preview') {
1167
- contentWarning = true;
1168
- if (loginRequired) break;
1169
- }
1170
- }
1171
- }
1172
-
1173
- tiles.push({
1174
- averageRating: result.avg_rating?.value,
1175
- collections: result.collection?.values ?? [],
1176
- commentCount: result.num_reviews?.value ?? 0,
1177
- creator: result.creator?.value,
1178
- creators: result.creator?.values ?? [],
1179
- dateAdded: result.addeddate?.value,
1180
- dateArchived: result.publicdate?.value,
1181
- datePublished: result.date?.value,
1182
- dateReviewed: result.reviewdate?.value,
1183
- description: result.description?.value,
1184
- favCount: result.num_favorites?.value ?? 0,
1185
- identifier: result.identifier,
1186
- issue: result.issue?.value,
1187
- itemCount: result.item_count?.value ?? 0,
1188
- mediatype: result.mediatype?.value ?? 'data',
1189
- snippets: result.highlight?.values ?? [],
1190
- source: result.source?.value,
1191
- subjects: result.subject?.values ?? [],
1192
- title: this.etreeTitle(
1193
- result.title?.value,
1194
- result.mediatype?.value,
1195
- result.collection?.values
1196
- ),
1197
- volume: result.volume?.value,
1198
- viewCount: result.downloads?.value ?? 0,
1199
- loginRequired,
1200
- contentWarning,
1201
- });
1202
- });
1203
- datasource[pageNumber] = tiles;
1204
- this.dataSource = datasource;
1205
- const visiblePages = this.currentVisiblePageNumbers;
1206
- const needsReload = visiblePages.includes(pageNumber);
1207
- if (needsReload) {
1208
- this.infiniteScroller.reload();
1209
- }
1210
- }
1211
-
1212
- /*
1213
- * Convert etree titles
1214
- * "[Creator] Live at [Place] on [Date]" => "[Date]: [Place]"
1215
- *
1216
- * Todo: Check collection(s) for etree, need to get as array.
1217
- * Current search-service only returns first collection as string.
1218
- */
1219
- private etreeTitle(
1220
- title: string | undefined,
1221
- mediatype: string | undefined,
1222
- collections: string[] | undefined
1223
- ): string {
1224
- if (mediatype === 'etree' || collections?.includes('etree')) {
1225
- const regex = /^(.*) Live at (.*) on (\d\d\d\d-\d\d-\d\d)$/;
1226
- const newTitle = title?.replace(regex, '$3: $2');
1227
- if (newTitle) {
1228
- return `${newTitle}`;
1229
- }
1230
- }
1231
- return title ?? '';
1232
- }
1233
-
1234
- /**
1235
- * Callback when a result is selected
1236
- */
1237
- resultSelected(event: CustomEvent<TileModel>): void {
1238
- this.analyticsHandler?.sendEventNoSampling({
1239
- category: this.searchContext,
1240
- action: analyticsActions.resultSelected,
1241
- label: event.detail.mediatype,
1242
- });
1243
-
1244
- this.analyticsHandler?.sendEventNoSampling({
1245
- category: this.searchContext,
1246
- action: analyticsActions.resultSelected,
1247
- label: `page-${this.currentPage}`,
1248
- });
1249
- }
1250
-
1251
- cellForIndex(index: number): TemplateResult | undefined {
1252
- const model = this.tileModelAtCellIndex(index);
1253
- if (!model) return undefined;
1254
-
1255
- return html`
1256
- <tile-dispatcher
1257
- .baseNavigationUrl=${this.baseNavigationUrl}
1258
- .baseImageUrl=${this.baseImageUrl}
1259
- .model=${model}
1260
- .tileDisplayMode=${this.displayMode}
1261
- .resizeObserver=${this.resizeObserver}
1262
- .collectionNameCache=${this.collectionNameCache}
1263
- .sortParam=${this.sortParam}
1264
- .mobileBreakpoint=${this.mobileBreakpoint}
1265
- .loggedIn=${this.loggedIn}
1266
- @resultSelected=${(e: CustomEvent) => this.resultSelected(e)}
1267
- >
1268
- </tile-dispatcher>
1269
- `;
1270
- }
1271
-
1272
- /**
1273
- * When the user scrolls near to the bottom of the page, fetch the next page
1274
- * increase the number of pages to render and start fetching data for the new page
1275
- */
1276
- private scrollThresholdReached() {
1277
- this.pagesToRender += 1;
1278
- this.fetchPage(this.pagesToRender);
1279
- }
1280
-
1281
- static styles = css`
1282
- :host {
1283
- display: block;
1284
- }
1285
-
1286
- /**
1287
- * When page width resizes from desktop to mobile, use this class to
1288
- * disable expand/collapse transition when loading.
1289
- */
1290
- .preload * {
1291
- transition: none !important;
1292
- -webkit-transition: none !important;
1293
- -moz-transition: none !important;
1294
- -ms-transition: none !important;
1295
- -o-transition: none !important;
1296
- }
1297
-
1298
- #content-container {
1299
- display: flex;
1300
- }
1301
-
1302
- .collapser {
1303
- display: inline-block;
1304
- }
1305
-
1306
- .collapser svg {
1307
- width: 10px;
1308
- height: 10px;
1309
- transition: transform 0.2s ease-out;
1310
- }
1311
-
1312
- .collapser.open svg {
1313
- transform: rotate(90deg);
1314
- }
1315
-
1316
- #mobile-filter-collapse h1 {
1317
- cursor: pointer;
1318
- }
1319
-
1320
- #content-container.mobile {
1321
- display: block;
1322
- }
1323
-
1324
- .column {
1325
- padding-top: 2rem;
1326
- }
1327
-
1328
- #right-column {
1329
- flex: 1;
1330
- position: relative;
1331
- border-left: 1px solid rgb(232, 232, 232);
1332
- padding-left: 1rem;
1333
- }
1334
-
1335
- .mobile #right-column {
1336
- border-left: none;
1337
- padding: 0;
1338
- }
1339
-
1340
- #left-column {
1341
- width: 18rem;
1342
- min-width: 18rem; /* Prevents Safari from shrinking col at first draw */
1343
- padding-right: 12px;
1344
- padding-right: 2.5rem;
1345
- }
1346
-
1347
- .desktop #left-column::-webkit-scrollbar {
1348
- display: none;
1349
- }
1350
-
1351
- .mobile #left-column {
1352
- width: 100%;
1353
- padding: 0;
1354
- }
1355
-
1356
- .desktop #left-column {
1357
- top: 0;
1358
- position: sticky;
1359
- max-height: 100vh;
1360
- overflow: scroll;
1361
- -ms-overflow-style: none; /* hide scrollbar IE and Edge */
1362
- scrollbar-width: none; /* hide scrollbar Firefox */
1363
- }
1364
-
1365
- #mobile-header-container {
1366
- display: flex;
1367
- justify-content: space-between;
1368
- }
1369
-
1370
- #facets-container {
1371
- position: relative;
1372
- max-height: 0;
1373
- transition: max-height 0.2s ease-in-out;
1374
- z-index: 1;
1375
- padding-bottom: 2rem;
1376
- }
1377
-
1378
- .mobile #facets-container {
1379
- overflow: hidden;
1380
- padding-bottom: 0;
1381
- }
1382
-
1383
- #facets-container.expanded {
1384
- max-height: 2000px;
1385
- }
1386
-
1387
- #results-total {
1388
- display: flex;
1389
- align-items: center;
1390
- margin-bottom: 5rem;
1391
- }
1392
-
1393
- .mobile #results-total {
1394
- margin-bottom: 0;
1395
- }
1396
-
1397
- #big-results-count {
1398
- font-size: 2.4rem;
1399
- font-weight: 500;
1400
- margin-right: 5px;
1401
- }
1402
-
1403
- #big-results-label {
1404
- font-size: 1rem;
1405
- font-weight: 200;
1406
- text-transform: uppercase;
1407
- }
1408
-
1409
- #list-header {
1410
- max-height: 4.2rem;
1411
- }
1412
-
1413
- .loading-cover {
1414
- position: absolute;
1415
- top: 0;
1416
- left: 0;
1417
- width: 100%;
1418
- height: 100%;
1419
- display: flex;
1420
- justify-content: center;
1421
- z-index: 1;
1422
- padding-top: 50px;
1423
- }
1424
-
1425
- circular-activity-indicator {
1426
- width: 30px;
1427
- height: 30px;
1428
- }
1429
-
1430
- sort-filter-bar {
1431
- display: block;
1432
- margin-bottom: 4rem;
1433
- }
1434
-
1435
- infinite-scroller {
1436
- display: block;
1437
- --infiniteScrollerRowGap: var(--collectionBrowserRowGap, 1.7rem);
1438
- --infiniteScrollerColGap: var(--collectionBrowserColGap, 1.7rem);
1439
- }
1440
-
1441
- infinite-scroller.list-compact {
1442
- --infiniteScrollerCellMinWidth: var(
1443
- --collectionBrowserCellMinWidth,
1444
- 100%
1445
- );
1446
- --infiniteScrollerCellMinHeight: 34px; /* override infinite scroller component */
1447
- --infiniteScrollerCellMaxHeight: 56px;
1448
- --infiniteScrollerRowGap: 0px;
1449
- }
1450
-
1451
- infinite-scroller.list-detail {
1452
- --infiniteScrollerCellMinWidth: var(
1453
- --collectionBrowserCellMinWidth,
1454
- 100%
1455
- );
1456
- --infiniteScrollerCellMinHeight: var(
1457
- --collectionBrowserCellMinHeight,
1458
- 5rem
1459
- );
1460
- /*
1461
- 30px in spec, compensating for a -4px margin
1462
- to align title with top of item image
1463
- src/tiles/list/tile-list.ts
1464
- */
1465
- --infiniteScrollerRowGap: 34px;
1466
- }
1467
-
1468
- .mobile infinite-scroller.list-detail {
1469
- --infiniteScrollerRowGap: 24px;
1470
- }
1471
-
1472
- infinite-scroller.grid {
1473
- --infiniteScrollerCellMinWidth: var(
1474
- --collectionBrowserCellMinWidth,
1475
- 18rem
1476
- );
1477
- --infiniteScrollerCellMaxWidth: var(--collectionBrowserCellMaxWidth, 1fr);
1478
- --infiniteScrollerCellMinHeight: var(
1479
- --collectionBrowserCellMinHeight,
1480
- 29rem
1481
- );
1482
- --infiniteScrollerCellMaxHeight: var(
1483
- --collectionBrowserCellMaxHeight,
1484
- 29rem
1485
- );
1486
- }
1487
- `;
1488
- }
1
+ /* eslint-disable import/no-duplicates */
2
+ import {
3
+ html,
4
+ css,
5
+ LitElement,
6
+ PropertyValues,
7
+ TemplateResult,
8
+ nothing,
9
+ } from 'lit';
10
+ import { customElement, property, query, state } from 'lit/decorators.js';
11
+ import { ifDefined } from 'lit/directives/if-defined.js';
12
+
13
+ import type { AnalyticsManagerInterface } from '@internetarchive/analytics-manager';
14
+ import type {
15
+ InfiniteScroller,
16
+ InfiniteScrollerCellProviderInterface,
17
+ } from '@internetarchive/infinite-scroller';
18
+ import {
19
+ Aggregation,
20
+ SearchParams,
21
+ SearchResult,
22
+ SearchServiceInterface,
23
+ SearchType,
24
+ SortDirection,
25
+ SortParam,
26
+ } from '@internetarchive/search-service';
27
+ import type {
28
+ SharedResizeObserverInterface,
29
+ SharedResizeObserverResizeHandlerInterface,
30
+ } from '@internetarchive/shared-resize-observer';
31
+ import '@internetarchive/infinite-scroller';
32
+ import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
33
+ import type { ModalManagerInterface } from '@internetarchive/modal-manager';
34
+ import './tiles/tile-dispatcher';
35
+ import './tiles/collection-browser-loading-tile';
36
+ import './sort-filter-bar/sort-filter-bar';
37
+ import './collection-facets';
38
+ import './circular-activity-indicator';
39
+ import './sort-filter-bar/sort-filter-bar';
40
+ import {
41
+ SelectedFacets,
42
+ SortField,
43
+ SortFieldToMetadataField,
44
+ CollectionBrowserContext,
45
+ defaultSelectedFacets,
46
+ TileModel,
47
+ CollectionDisplayMode,
48
+ FacetOption,
49
+ } from './models';
50
+ import {
51
+ RestorationStateHandlerInterface,
52
+ RestorationStateHandler,
53
+ RestorationState,
54
+ } from './restoration-state-handler';
55
+ import chevronIcon from './assets/img/icons/chevron';
56
+ import { LanguageCodeHandler } from './language-code-handler/language-code-handler';
57
+ import type { PlaceholderType } from './empty-placeholder';
58
+ import './empty-placeholder';
59
+
60
+ import {
61
+ analyticsActions,
62
+ analyticsCategories,
63
+ } from './utils/analytics-events';
64
+
65
+ @customElement('collection-browser')
66
+ export class CollectionBrowser
67
+ extends LitElement
68
+ implements
69
+ InfiniteScrollerCellProviderInterface,
70
+ SharedResizeObserverResizeHandlerInterface
71
+ {
72
+ @property({ type: String }) baseNavigationUrl?: string;
73
+
74
+ @property({ type: String }) baseImageUrl: string = 'https://archive.org';
75
+
76
+ @property({ type: Object }) searchService?: SearchServiceInterface;
77
+
78
+ @property({ type: String }) searchType: SearchType = SearchType.METADATA;
79
+
80
+ @property({ type: String }) baseQuery?: string;
81
+
82
+ @property({ type: String }) displayMode?: CollectionDisplayMode;
83
+
84
+ @property({ type: Object }) sortParam: SortParam | null = null;
85
+
86
+ @property({ type: String }) selectedSort: SortField = SortField.relevance;
87
+
88
+ @property({ type: String }) selectedTitleFilter: string | null = null;
89
+
90
+ @property({ type: String }) selectedCreatorFilter: string | null = null;
91
+
92
+ @property({ type: String }) sortDirection: SortDirection | null = null;
93
+
94
+ @property({ type: String }) dateRangeQueryClause?: string;
95
+
96
+ @property({ type: Number }) pageSize = 50;
97
+
98
+ @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
99
+
100
+ @property({ type: String }) titleQuery?: string;
101
+
102
+ @property({ type: String }) creatorQuery?: string;
103
+
104
+ @property({ type: Number }) currentPage?: number;
105
+
106
+ @property({ type: String }) minSelectedDate?: string;
107
+
108
+ @property({ type: String }) maxSelectedDate?: string;
109
+
110
+ @property({ type: Object }) selectedFacets?: SelectedFacets;
111
+
112
+ @property({ type: Boolean }) showHistogramDatePicker = false;
113
+
114
+ /** describes where this component is being used */
115
+ @property({ type: String, reflect: true }) searchContext: string =
116
+ analyticsCategories.default;
117
+
118
+ @property({ type: Object })
119
+ collectionNameCache?: CollectionNameCacheInterface;
120
+
121
+ @property({ type: String }) pageContext: CollectionBrowserContext = 'search';
122
+
123
+ @property({ type: Object })
124
+ restorationStateHandler: RestorationStateHandlerInterface = new RestorationStateHandler(
125
+ {
126
+ context: this.pageContext,
127
+ }
128
+ );
129
+
130
+ @property({ type: Number }) mobileBreakpoint = 600;
131
+
132
+ @property({ type: Boolean }) loggedIn = false;
133
+
134
+ @property({ type: Object }) modalManager?: ModalManagerInterface = undefined;
135
+
136
+ /**
137
+ * If item management UI active
138
+ */
139
+ @property({ type: Boolean }) isManageView = false;
140
+
141
+ /**
142
+ * The page that the consumer wants to load.
143
+ */
144
+ private initialPageNumber = 1;
145
+
146
+ /**
147
+ * This the the number of pages that we want to show.
148
+ *
149
+ * The data isn't necessarily loaded for all of the pages, but this lets us
150
+ * know how many cells we should render.
151
+ */
152
+ @state() private pagesToRender = this.initialPageNumber;
153
+
154
+ @state() private searchResultsLoading = false;
155
+
156
+ @state() private facetsLoading = false;
157
+
158
+ @state() private fullYearAggregationLoading = false;
159
+
160
+ @state() private aggregations?: Record<string, Aggregation>;
161
+
162
+ @state() private fullYearsHistogramAggregation: Aggregation | undefined;
163
+
164
+ /**
165
+ * The search type of the previous search (i.e., the currently displayed
166
+ * search results), which may differ from the one that is currently selected
167
+ * to be used for the next search.
168
+ */
169
+ @state() private previousSearchType?: SearchType;
170
+
171
+ @state() private totalResults?: number;
172
+
173
+ @state() private mobileView = false;
174
+
175
+ @state() private mobileFacetsVisible = false;
176
+
177
+ @state() private placeholderType: PlaceholderType = null;
178
+
179
+ @query('#content-container') private contentContainer!: HTMLDivElement;
180
+
181
+ private languageCodeHandler = new LanguageCodeHandler();
182
+
183
+ @property({ type: Object, attribute: false })
184
+ private analyticsHandler?: AnalyticsManagerInterface;
185
+
186
+ /**
187
+ * When we're animated scrolling to the page, we don't want to fetch
188
+ * all of the pages as it scrolls so this lets us know if we're scrolling
189
+ */
190
+ private isScrollingToCell = false;
191
+
192
+ /**
193
+ * When we've reached the end of the data, stop trying to fetch more
194
+ */
195
+ private endOfDataReached = false;
196
+
197
+ /**
198
+ * When page width resizes from desktop to mobile, set true to
199
+ * disable expand/collapse transition when loading.
200
+ */
201
+ private isResizeToMobile = false;
202
+
203
+ private placeholderCellTemplate = html`<collection-browser-loading-tile></collection-browser-loading-tile>`;
204
+
205
+ private tileModelAtCellIndex(index: number): TileModel | undefined {
206
+ const pageNumber = Math.floor(index / this.pageSize) + 1;
207
+ const itemIndex = index % this.pageSize;
208
+ const model = this.dataSource[pageNumber]?.[itemIndex];
209
+ /**
210
+ * If we encounter a model we don't have yet and we're not in the middle of an
211
+ * automated scroll, fetch the page and just return undefined.
212
+ * The datasource will be updated once the page is loaded and the cell will be rendered.
213
+ *
214
+ * We disable it during the automated scroll since we may fetch pages for cells the
215
+ * user may never see.
216
+ */
217
+ if (!model && !this.isScrollingToCell) {
218
+ this.fetchPage(pageNumber);
219
+ }
220
+ return model;
221
+ }
222
+
223
+ private get sortFilterQueries(): string {
224
+ const queries = [this.titleQuery, this.creatorQuery];
225
+ return queries.filter(q => q).join(' AND ');
226
+ }
227
+
228
+ // this is the total number of tiles we expect if
229
+ // the data returned is a full page worth
230
+ // this is useful for putting in placeholders for the expected number of tiles
231
+ private get estimatedTileCount(): number {
232
+ return this.pagesToRender * this.pageSize;
233
+ }
234
+
235
+ // this is the actual number of tiles in the datasource,
236
+ // which is useful for removing excess placeholder tiles
237
+ // once we reached the end of the data
238
+ private get actualTileCount(): number {
239
+ return Object.keys(this.dataSource).reduce(
240
+ (acc, page) => acc + this.dataSource[page].length,
241
+ 0
242
+ );
243
+ }
244
+
245
+ /**
246
+ * The results per page so we can paginate.
247
+ *
248
+ * This allows us to start in the middle of the search results and
249
+ * fetch data before or after the current page. If we don't have a key
250
+ * for the previous/next page, we'll fetch the next/previous page to populate it
251
+ */
252
+ private dataSource: Record<string, TileModel[]> = {};
253
+
254
+ @query('infinite-scroller')
255
+ private infiniteScroller!: InfiniteScroller;
256
+
257
+ /**
258
+ * Go to the given page of results
259
+ *
260
+ * @param pageNumber
261
+ */
262
+ goToPage(pageNumber: number) {
263
+ this.initialPageNumber = pageNumber;
264
+ this.pagesToRender = pageNumber;
265
+ this.scrollToPage(pageNumber);
266
+ }
267
+
268
+ clearFilters() {
269
+ this.selectedFacets = defaultSelectedFacets;
270
+ this.sortParam = null;
271
+ this.selectedTitleFilter = null;
272
+ this.selectedCreatorFilter = null;
273
+ this.titleQuery = undefined;
274
+ this.creatorQuery = undefined;
275
+ this.selectedSort = SortField.relevance;
276
+ this.sortDirection = null;
277
+ }
278
+
279
+ /**
280
+ * Manually requests to perform a search, which will only be executed if one of
281
+ * the query, the search type, or the sort has changed.
282
+ */
283
+ requestSearch() {
284
+ this.handleQueryChange();
285
+ }
286
+
287
+ render() {
288
+ this.setPlaceholderType();
289
+ return html`
290
+ <div
291
+ id="content-container"
292
+ class=${this.mobileView ? 'mobile' : 'desktop'}
293
+ >
294
+ ${this.placeholderType
295
+ ? this.emptyPlaceholderTemplate
296
+ : this.collectionBrowserTemplate}
297
+ </div>
298
+ `;
299
+ }
300
+
301
+ private setPlaceholderType() {
302
+ this.placeholderType = null;
303
+ if (!this.baseQuery) {
304
+ this.placeholderType = 'empty-query';
305
+ }
306
+
307
+ if (
308
+ (!this.searchResultsLoading && this.totalResults === 0) ||
309
+ !this.searchService
310
+ ) {
311
+ this.placeholderType = 'null-result';
312
+ }
313
+ }
314
+
315
+ private get emptyPlaceholderTemplate() {
316
+ return html`
317
+ <empty-placeholder
318
+ .placeholderType=${this.placeholderType}
319
+ ?isMobileView=${this.mobileView}
320
+ ></empty-placeholder>
321
+ `;
322
+ }
323
+
324
+ private get collectionBrowserTemplate() {
325
+ return html`<div
326
+ id="left-column"
327
+ class="column${this.isResizeToMobile ? ' preload' : ''}"
328
+ >
329
+ <div id="mobile-header-container">
330
+ ${this.mobileView ? this.mobileFacetsTemplate : nothing}
331
+ <div id="results-total">
332
+ <span id="big-results-count">
333
+ ${this.totalResults !== undefined
334
+ ? this.totalResults.toLocaleString()
335
+ : '-'}
336
+ </span>
337
+ <span id="big-results-label">
338
+ ${this.totalResults === 1 ? 'Result' : 'Results'}
339
+ </span>
340
+ </div>
341
+ </div>
342
+ <div
343
+ id="facets-container"
344
+ class=${!this.mobileView || this.mobileFacetsVisible
345
+ ? 'expanded'
346
+ : ''}
347
+ >
348
+ ${this.facetsTemplate}
349
+ </div>
350
+ </div>
351
+ <div id="right-column" class="column">
352
+ ${this.sortFilterBarTemplate}
353
+ ${this.displayMode === `list-compact`
354
+ ? this.listHeaderTemplate
355
+ : nothing}
356
+ ${this.infiniteScrollerTemplate}
357
+ </div>`;
358
+ }
359
+
360
+ private get infiniteScrollerTemplate() {
361
+ return html`<infinite-scroller
362
+ class="${ifDefined(this.displayMode)}"
363
+ .cellProvider=${this}
364
+ .placeholderCellTemplate=${this.placeholderCellTemplate}
365
+ @scrollThresholdReached=${this.scrollThresholdReached}
366
+ @visibleCellsChanged=${this.visibleCellsChanged}
367
+ ></infinite-scroller>`;
368
+ }
369
+
370
+ private get sortFilterBarTemplate() {
371
+ return html`
372
+ <sort-filter-bar
373
+ .selectedSort=${this.selectedSort}
374
+ .sortDirection=${this.sortDirection}
375
+ .displayMode=${this.displayMode}
376
+ .selectedTitleFilter=${this.selectedTitleFilter}
377
+ .selectedCreatorFilter=${this.selectedCreatorFilter}
378
+ .resizeObserver=${this.resizeObserver}
379
+ @sortChanged=${this.userChangedSort}
380
+ @displayModeChanged=${this.displayModeChanged}
381
+ @titleLetterChanged=${this.titleLetterSelected}
382
+ @creatorLetterChanged=${this.creatorLetterSelected}
383
+ >
384
+ </sort-filter-bar>
385
+ `;
386
+ }
387
+
388
+ private userChangedSort(
389
+ e: CustomEvent<{
390
+ selectedSort: SortField;
391
+ sortDirection: SortDirection | null;
392
+ }>
393
+ ) {
394
+ const { selectedSort, sortDirection } = e.detail;
395
+ this.selectedSort = selectedSort;
396
+ this.sortDirection = sortDirection;
397
+
398
+ if ((this.currentPage ?? 1) > 1) {
399
+ this.goToPage(1);
400
+ }
401
+ this.currentPage = 1;
402
+ }
403
+
404
+ private sendSortByAnalytics(prevSortDirection: SortDirection | null): void {
405
+ const directionCleared = prevSortDirection && !this.sortDirection;
406
+
407
+ this.analyticsHandler?.sendEventNoSampling({
408
+ category: this.searchContext,
409
+ action: analyticsActions.sortBy,
410
+ label: `${this.selectedSort}${
411
+ this.sortDirection || directionCleared ? `-${this.sortDirection}` : ''
412
+ }`,
413
+ });
414
+ }
415
+
416
+ private selectedSortChanged(): void {
417
+ if (this.selectedSort === 'relevance' || this.sortDirection === null) {
418
+ this.sortParam = null;
419
+ return;
420
+ }
421
+ const sortField = SortFieldToMetadataField[this.selectedSort];
422
+
423
+ if (!sortField) return;
424
+ this.sortParam = { field: sortField, direction: this.sortDirection };
425
+ }
426
+
427
+ private displayModeChanged(
428
+ e: CustomEvent<{ displayMode: CollectionDisplayMode }>
429
+ ) {
430
+ this.displayMode = e.detail.displayMode;
431
+
432
+ if (this.displayMode) {
433
+ this.analyticsHandler?.sendEventNoSampling({
434
+ category: this.searchContext,
435
+ action: analyticsActions.displayMode,
436
+ label: this.displayMode,
437
+ });
438
+ }
439
+ }
440
+
441
+ /** Send Analytics when sorting by title's first letter
442
+ * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
443
+ * */
444
+ private sendFilterByTitleAnalytics(prevSelectedLetter: string | null): void {
445
+ if (!prevSelectedLetter && !this.selectedTitleFilter) {
446
+ return;
447
+ }
448
+ const cleared = prevSelectedLetter && this.selectedTitleFilter === null;
449
+
450
+ this.analyticsHandler?.sendEventNoSampling({
451
+ category: this.searchContext,
452
+ action: analyticsActions.filterByTitle,
453
+ label: cleared
454
+ ? `clear-${prevSelectedLetter}`
455
+ : `${prevSelectedLetter || 'start'}-${this.selectedTitleFilter}`,
456
+ });
457
+ }
458
+
459
+ private selectedTitleLetterChanged(): void {
460
+ this.titleQuery = this.selectedTitleFilter
461
+ ? `firstTitle:${this.selectedTitleFilter}`
462
+ : undefined;
463
+ }
464
+
465
+ /** Send Analytics when filtering by creator's first letter
466
+ * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
467
+ * */
468
+ private sendFilterByCreatorAnalytics(
469
+ prevSelectedLetter: string | null
470
+ ): void {
471
+ if (!prevSelectedLetter && !this.selectedCreatorFilter) {
472
+ return;
473
+ }
474
+ const cleared = prevSelectedLetter && this.selectedCreatorFilter === null;
475
+
476
+ this.analyticsHandler?.sendEventNoSampling({
477
+ category: this.searchContext,
478
+ action: analyticsActions.filterByCreator,
479
+ label: cleared
480
+ ? `clear-${prevSelectedLetter}`
481
+ : `${prevSelectedLetter || 'start'}-${this.selectedCreatorFilter}`,
482
+ });
483
+ }
484
+
485
+ private selectedCreatorLetterChanged(): void {
486
+ this.creatorQuery = this.selectedCreatorFilter
487
+ ? `firstCreator:${this.selectedCreatorFilter}`
488
+ : undefined;
489
+ }
490
+
491
+ private titleLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
492
+ this.selectedCreatorFilter = null;
493
+ this.selectedTitleFilter = e.detail.selectedLetter;
494
+ }
495
+
496
+ private creatorLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
497
+ this.selectedTitleFilter = null;
498
+ this.selectedCreatorFilter = e.detail.selectedLetter;
499
+ }
500
+
501
+ private get facetDataLoading(): boolean {
502
+ return this.facetsLoading || this.fullYearAggregationLoading;
503
+ }
504
+
505
+ private get mobileFacetsTemplate() {
506
+ return html`
507
+ <div id="mobile-filter-collapse">
508
+ <h1
509
+ @click=${() => {
510
+ this.isResizeToMobile = false;
511
+ this.mobileFacetsVisible = !this.mobileFacetsVisible;
512
+ }}
513
+ @keyup=${() => {
514
+ this.isResizeToMobile = false;
515
+ this.mobileFacetsVisible = !this.mobileFacetsVisible;
516
+ }}
517
+ >
518
+ <span class="collapser ${this.mobileFacetsVisible ? 'open' : ''}">
519
+ ${chevronIcon}
520
+ </span>
521
+ Filters
522
+ </h1>
523
+ </div>
524
+ `;
525
+ }
526
+
527
+ private get facetsTemplate() {
528
+ return html`
529
+ <collection-facets
530
+ @facetsChanged=${this.facetsChanged}
531
+ @histogramDateRangeUpdated=${this.histogramDateRangeUpdated}
532
+ .searchService=${this.searchService}
533
+ .searchType=${this.searchType}
534
+ .aggregations=${this.aggregations}
535
+ .fullYearsHistogramAggregation=${this.fullYearsHistogramAggregation}
536
+ .moreLinksVisible=${this.previousSearchType !== SearchType.FULLTEXT}
537
+ .minSelectedDate=${this.minSelectedDate}
538
+ .maxSelectedDate=${this.maxSelectedDate}
539
+ .selectedFacets=${this.selectedFacets}
540
+ .collectionNameCache=${this.collectionNameCache}
541
+ .languageCodeHandler=${this.languageCodeHandler}
542
+ .showHistogramDatePicker=${this.showHistogramDatePicker}
543
+ .fullQuery=${this.fullQuery}
544
+ .modalManager=${this.modalManager}
545
+ ?collapsableFacets=${this.mobileView}
546
+ ?facetsLoading=${this.facetDataLoading}
547
+ ?fullYearAggregationLoading=${this.fullYearAggregationLoading}
548
+ .onFacetClick=${this.facetClickHandler}
549
+ .analyticsHandler=${this.analyticsHandler}
550
+ >
551
+ </collection-facets>
552
+ `;
553
+ }
554
+
555
+ private get loadingTemplate() {
556
+ return html`
557
+ <div class="loading-cover">
558
+ <circular-activity-indicator></circular-activity-indicator>
559
+ </div>
560
+ `;
561
+ }
562
+
563
+ private get listHeaderTemplate() {
564
+ return html`
565
+ <div id="list-header">
566
+ <tile-dispatcher
567
+ .tileDisplayMode=${'list-header'}
568
+ .resizeObserver=${this.resizeObserver}
569
+ .sortParam=${this.sortParam}
570
+ .mobileBreakpoint=${this.mobileBreakpoint}
571
+ .loggedIn=${this.loggedIn}
572
+ >
573
+ </tile-dispatcher>
574
+ </div>
575
+ `;
576
+ }
577
+
578
+ private get queryDebuggingTemplate() {
579
+ return html`
580
+ <div>
581
+ <ul>
582
+ <li>Base Query: ${this.baseQuery}</li>
583
+ <li>Facet Query: ${this.facetQuery}</li>
584
+ <li>Sort Filter Query: ${this.sortFilterQueries}</li>
585
+ <li>Date Range Query: ${this.dateRangeQueryClause}</li>
586
+ <li>Sort: ${this.sortParam?.field} ${this.sortParam?.direction}</li>
587
+ <li>Full Query: ${this.fullQuery}</li>
588
+ </ul>
589
+ </div>
590
+ `;
591
+ }
592
+
593
+ private histogramDateRangeUpdated(
594
+ e: CustomEvent<{
595
+ minDate: string;
596
+ maxDate: string;
597
+ }>
598
+ ) {
599
+ const { minDate, maxDate } = e.detail;
600
+ this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
601
+
602
+ if (this.dateRangeQueryClause) {
603
+ this.analyticsHandler?.sendEventNoSampling({
604
+ category: this.searchContext,
605
+ action: analyticsActions.histogramChanged,
606
+ label: this.dateRangeQueryClause,
607
+ });
608
+ }
609
+ }
610
+
611
+ firstUpdated(): void {
612
+ this.setupStateRestorationObserver();
613
+ this.restoreState();
614
+ }
615
+
616
+ updated(changed: PropertyValues) {
617
+ if (
618
+ changed.has('displayMode') ||
619
+ changed.has('baseNavigationUrl') ||
620
+ changed.has('baseImageUrl') ||
621
+ changed.has('loggedIn')
622
+ ) {
623
+ this.infiniteScroller?.reload();
624
+ }
625
+ if (changed.has('baseQuery')) {
626
+ this.emitBaseQueryChanged();
627
+ }
628
+ if (changed.has('searchType')) {
629
+ this.emitSearchTypeChanged();
630
+ }
631
+ if (changed.has('currentPage') || changed.has('displayMode')) {
632
+ this.persistState();
633
+ }
634
+ if (
635
+ changed.has('baseQuery') ||
636
+ changed.has('titleQuery') ||
637
+ changed.has('creatorQuery') ||
638
+ changed.has('dateRangeQueryClause') ||
639
+ changed.has('sortParam') ||
640
+ changed.has('selectedFacets') ||
641
+ changed.has('searchService')
642
+ ) {
643
+ this.handleQueryChange();
644
+ }
645
+ if (changed.has('selectedSort') || changed.has('sortDirection')) {
646
+ const prevSortDirection = changed.get('sortDirection') as SortDirection;
647
+ this.sendSortByAnalytics(prevSortDirection);
648
+ this.selectedSortChanged();
649
+ }
650
+ if (changed.has('selectedTitleFilter')) {
651
+ this.sendFilterByTitleAnalytics(
652
+ changed.get('selectedTitleFilter') as string
653
+ );
654
+ this.selectedTitleLetterChanged();
655
+ }
656
+ if (changed.has('selectedCreatorFilter')) {
657
+ this.sendFilterByCreatorAnalytics(
658
+ changed.get('selectedCreatorFilter') as string
659
+ );
660
+ this.selectedCreatorLetterChanged();
661
+ }
662
+ if (changed.has('pagesToRender')) {
663
+ if (!this.endOfDataReached && this.infiniteScroller) {
664
+ this.infiniteScroller.itemCount = this.estimatedTileCount;
665
+ }
666
+ }
667
+ if (changed.has('resizeObserver')) {
668
+ const oldObserver = changed.get(
669
+ 'resizeObserver'
670
+ ) as SharedResizeObserverInterface;
671
+ if (oldObserver) this.disconnectResizeObserver(oldObserver);
672
+ this.setupResizeObserver();
673
+ }
674
+ }
675
+
676
+ disconnectedCallback(): void {
677
+ if (this.resizeObserver) {
678
+ this.disconnectResizeObserver(this.resizeObserver);
679
+ }
680
+ if (this.boundNavigationHandler) {
681
+ window.removeEventListener('popstate', this.boundNavigationHandler);
682
+ }
683
+ }
684
+
685
+ handleResize(entry: ResizeObserverEntry): void {
686
+ const previousView = this.mobileView;
687
+ if (entry.target === this.contentContainer) {
688
+ this.mobileView = entry.contentRect.width < this.mobileBreakpoint;
689
+ // If changing from desktop to mobile disable transition
690
+ if (this.mobileView && !previousView) {
691
+ this.isResizeToMobile = true;
692
+ }
693
+ }
694
+ }
695
+
696
+ private emitBaseQueryChanged() {
697
+ this.dispatchEvent(
698
+ new CustomEvent<{ baseQuery?: string }>('baseQueryChanged', {
699
+ detail: {
700
+ baseQuery: this.baseQuery,
701
+ },
702
+ })
703
+ );
704
+ }
705
+
706
+ private emitSearchTypeChanged() {
707
+ this.dispatchEvent(
708
+ new CustomEvent<SearchType>('searchTypeChanged', {
709
+ detail: this.searchType,
710
+ })
711
+ );
712
+ }
713
+
714
+ private disconnectResizeObserver(
715
+ resizeObserver: SharedResizeObserverInterface
716
+ ) {
717
+ resizeObserver.removeObserver({
718
+ target: this.contentContainer,
719
+ handler: this,
720
+ });
721
+ }
722
+
723
+ private setupResizeObserver() {
724
+ if (!this.resizeObserver) return;
725
+ this.resizeObserver.addObserver({
726
+ target: this.contentContainer,
727
+ handler: this,
728
+ });
729
+ }
730
+
731
+ /**
732
+ * When the visible cells change from the infinite scroller, we want to emit
733
+ * which page is currently visible so the consumer can update its UI or the URL
734
+ *
735
+ * @param e
736
+ * @returns
737
+ */
738
+ private visibleCellsChanged(
739
+ e: CustomEvent<{ visibleCellIndices: number[] }>
740
+ ) {
741
+ if (this.isScrollingToCell) return;
742
+ const { visibleCellIndices } = e.detail;
743
+ if (visibleCellIndices.length === 0) return;
744
+ const lastVisibleCellIndex =
745
+ visibleCellIndices[visibleCellIndices.length - 1];
746
+ const lastVisibleCellPage =
747
+ Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
748
+ if (this.currentPage !== lastVisibleCellPage) {
749
+ this.currentPage = lastVisibleCellPage;
750
+ }
751
+ const event = new CustomEvent('visiblePageChanged', {
752
+ detail: {
753
+ pageNumber: lastVisibleCellPage,
754
+ },
755
+ });
756
+ this.dispatchEvent(event);
757
+ }
758
+
759
+ // we only want to scroll on the very first query change
760
+ // so this keeps track of whether we've already set the initial query
761
+ private initialQueryChangeHappened = false;
762
+
763
+ private historyPopOccurred = false;
764
+
765
+ // this lets us store the query key so we know if it's actually changed or not
766
+ private previousQueryKey?: string;
767
+
768
+ private async handleQueryChange() {
769
+ // only reset if the query has actually changed
770
+ if (!this.searchService || this.pageFetchQueryKey === this.previousQueryKey)
771
+ return;
772
+ this.previousQueryKey = this.pageFetchQueryKey;
773
+
774
+ this.dataSource = {};
775
+ this.pageFetchesInProgress = {};
776
+ this.endOfDataReached = false;
777
+ this.pagesToRender = this.initialPageNumber;
778
+ if (!this.initialQueryChangeHappened && this.initialPageNumber > 1) {
779
+ this.scrollToPage(this.initialPageNumber);
780
+ }
781
+ this.initialQueryChangeHappened = true;
782
+ // if the query changed as part of a window.history pop event, we don't want to
783
+ // persist the state because it overwrites the forward history
784
+
785
+ if (!this.historyPopOccurred) {
786
+ this.persistState();
787
+ this.historyPopOccurred = false;
788
+ }
789
+
790
+ await Promise.all([
791
+ this.doInitialPageFetch(),
792
+ this.fetchFacets(),
793
+ this.fetchFullYearHistogram(),
794
+ ]);
795
+ }
796
+
797
+ private setupStateRestorationObserver() {
798
+ if (this.boundNavigationHandler) return;
799
+ this.boundNavigationHandler = this.historyNavigationHandler.bind(this);
800
+ // when the user navigates back, we want to update the UI to match the URL
801
+ window.addEventListener('popstate', this.boundNavigationHandler);
802
+ }
803
+
804
+ private boundNavigationHandler?: () => void;
805
+
806
+ private historyNavigationHandler() {
807
+ this.historyPopOccurred = true;
808
+ this.restoreState();
809
+ }
810
+
811
+ private restoreState() {
812
+ const restorationState = this.restorationStateHandler.getRestorationState();
813
+ this.displayMode = restorationState.displayMode;
814
+ if (restorationState.searchType != null)
815
+ this.searchType = restorationState.searchType;
816
+ this.selectedSort = restorationState.selectedSort ?? SortField.relevance;
817
+ this.sortDirection = restorationState.sortDirection ?? null;
818
+ this.selectedTitleFilter = restorationState.selectedTitleFilter ?? null;
819
+ this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
820
+ this.selectedFacets = restorationState.selectedFacets;
821
+ this.baseQuery = restorationState.baseQuery;
822
+ this.titleQuery = restorationState.titleQuery;
823
+ this.creatorQuery = restorationState.creatorQuery;
824
+ this.dateRangeQueryClause = restorationState.dateRangeQueryClause;
825
+ this.sortParam = restorationState.sortParam ?? null;
826
+ this.currentPage = restorationState.currentPage ?? 1;
827
+ this.minSelectedDate = restorationState.minSelectedDate;
828
+ this.maxSelectedDate = restorationState.maxSelectedDate;
829
+ if (this.currentPage > 1) {
830
+ this.goToPage(this.currentPage);
831
+ }
832
+ }
833
+
834
+ private persistState() {
835
+ const restorationState: RestorationState = {
836
+ displayMode: this.displayMode,
837
+ searchType: this.searchType,
838
+ sortParam: this.sortParam ?? undefined,
839
+ selectedSort: this.selectedSort,
840
+ sortDirection: this.sortDirection ?? undefined,
841
+ selectedFacets: this.selectedFacets ?? defaultSelectedFacets,
842
+ baseQuery: this.baseQuery,
843
+ currentPage: this.currentPage,
844
+ dateRangeQueryClause: this.dateRangeQueryClause,
845
+ titleQuery: this.titleQuery,
846
+ creatorQuery: this.creatorQuery,
847
+ minSelectedDate: this.minSelectedDate,
848
+ maxSelectedDate: this.maxSelectedDate,
849
+ selectedTitleFilter: this.selectedTitleFilter ?? undefined,
850
+ selectedCreatorFilter: this.selectedCreatorFilter ?? undefined,
851
+ };
852
+ this.restorationStateHandler.persistState(restorationState);
853
+ }
854
+
855
+ private async doInitialPageFetch() {
856
+ this.searchResultsLoading = true;
857
+ await this.fetchPage(this.initialPageNumber);
858
+ this.searchResultsLoading = false;
859
+ }
860
+
861
+ private get fullQuery(): string | undefined {
862
+ let { fullQueryWithoutDate } = this;
863
+ const { dateRangeQueryClause } = this;
864
+ if (dateRangeQueryClause) {
865
+ fullQueryWithoutDate += ` AND ${dateRangeQueryClause}`;
866
+ }
867
+ return fullQueryWithoutDate;
868
+ }
869
+
870
+ private get fullQueryWithoutDate(): string | undefined {
871
+ if (!this.baseQuery) return undefined;
872
+ let fullQuery = this.baseQuery;
873
+ const { facetQuery, sortFilterQueries } = this;
874
+ if (facetQuery) {
875
+ fullQuery += ` AND ${facetQuery}`;
876
+ }
877
+ if (sortFilterQueries) {
878
+ fullQuery += ` AND ${sortFilterQueries}`;
879
+ }
880
+ return fullQuery;
881
+ }
882
+
883
+ /**
884
+ * Generates a query string for the given facets
885
+ *
886
+ * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
887
+ */
888
+ private get facetQuery(): string | undefined {
889
+ if (!this.selectedFacets) return undefined;
890
+ const facetQuery = [];
891
+ for (const [facetName, facetValues] of Object.entries(
892
+ this.selectedFacets
893
+ )) {
894
+ const facetEntries = Object.entries(facetValues);
895
+ const facetQueryName =
896
+ facetName === 'lending' ? 'lending___status' : facetName;
897
+ // eslint-disable-next-line no-continue
898
+ if (facetEntries.length === 0) continue;
899
+ const facetValuesArray: string[] = [];
900
+ for (const [key, facetData] of facetEntries) {
901
+ const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
902
+
903
+ if (facetName === 'language') {
904
+ const languages =
905
+ this.languageCodeHandler.getCodeArrayFromCodeString(key);
906
+ for (const language of languages) {
907
+ facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
908
+ }
909
+ } else {
910
+ facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
911
+ }
912
+ }
913
+ const valueQuery = facetValuesArray.join(` OR `);
914
+ facetQuery.push(`${facetQueryName}:(${valueQuery})`);
915
+ }
916
+ return facetQuery.length > 0 ? `(${facetQuery.join(' AND ')})` : undefined;
917
+ }
918
+
919
+ facetsChanged(e: CustomEvent<SelectedFacets>) {
920
+ this.selectedFacets = e.detail;
921
+ }
922
+
923
+ facetClickHandler(
924
+ name: FacetOption,
925
+ facetSelected: boolean,
926
+ negative: boolean
927
+ ): void {
928
+ if (negative) {
929
+ this.analyticsHandler?.sendEventNoSampling({
930
+ category: this.searchContext,
931
+ action: facetSelected
932
+ ? analyticsActions.facetNegativeSelected
933
+ : analyticsActions.facetNegativeDeselected,
934
+ label: name,
935
+ });
936
+ } else {
937
+ this.analyticsHandler?.sendEventNoSampling({
938
+ category: this.searchContext,
939
+ action: facetSelected
940
+ ? analyticsActions.facetSelected
941
+ : analyticsActions.facetDeselected,
942
+ label: name,
943
+ });
944
+ }
945
+ }
946
+
947
+ private async fetchFacets() {
948
+ if (!this.fullQuery) return;
949
+
950
+ const params: SearchParams = {
951
+ query: this.fullQuery,
952
+ rows: 0,
953
+ // Fetch a few extra buckets beyond the 6 we show, in case some get suppressed
954
+ aggregationsSize: 10,
955
+ // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
956
+ // The default aggregations for the search_results page type should be what we need here.
957
+ };
958
+
959
+ this.facetsLoading = true;
960
+ this.previousSearchType = this.searchType;
961
+ const results = await this.searchService?.search(params, this.searchType);
962
+ this.facetsLoading = false;
963
+
964
+ this.aggregations = results?.success?.response.aggregations;
965
+ }
966
+
967
+ /**
968
+ * If we haven't changed the query, we don't need to fetch the full year histogram
969
+ *
970
+ * @private
971
+ * @type {string}
972
+ * @memberof CollectionBrowser
973
+ */
974
+ private previousFullQueryNoDate?: string;
975
+
976
+ /**
977
+ * The query key is a string that uniquely identifies the current query
978
+ * without the date range.
979
+ *
980
+ * If this doesn't change, we don't need to re-fetch the histogram date range
981
+ */
982
+ private get fullQueryNoDateKey() {
983
+ return `${this.fullQueryWithoutDate}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
984
+ }
985
+
986
+ /**
987
+ * This method is similar to fetching the facets above,
988
+ * but only fetching the year histogram. There is a subtle difference
989
+ * in how you have to fetch the year histogram where you can't use the
990
+ * advanced JSON syntax like the other aggregations. It's a special
991
+ * case that @ximm put it place.
992
+ */
993
+ private async fetchFullYearHistogram(): Promise<void> {
994
+ const { fullQueryNoDateKey } = this;
995
+ if (
996
+ !this.fullQueryWithoutDate ||
997
+ fullQueryNoDateKey === this.previousFullQueryNoDate
998
+ )
999
+ return;
1000
+ this.previousFullQueryNoDate = fullQueryNoDateKey;
1001
+
1002
+ const aggregations = {
1003
+ simpleParams: ['year'],
1004
+ };
1005
+
1006
+ const params = {
1007
+ query: this.fullQueryWithoutDate,
1008
+ aggregations,
1009
+ rows: 0,
1010
+ };
1011
+
1012
+ this.fullYearAggregationLoading = true;
1013
+ const results = await this.searchService?.search(params, this.searchType);
1014
+ this.fullYearAggregationLoading = false;
1015
+
1016
+ this.fullYearsHistogramAggregation =
1017
+ results?.success?.response?.aggregations?.year_histogram ??
1018
+ results?.success?.response?.aggregations?.['year-histogram']; // Temp fix until PPS FTS key is fixed to use underscore
1019
+ }
1020
+
1021
+ private scrollToPage(pageNumber: number) {
1022
+ const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
1023
+ // without this setTimeout, Safari just pauses until the `fetchPage` is complete
1024
+ // then scrolls to the cell
1025
+ setTimeout(() => {
1026
+ this.isScrollingToCell = true;
1027
+ this.infiniteScroller.scrollToCell(cellIndexToScrollTo, true);
1028
+ // This timeout is to give the scroll animation time to finish
1029
+ // then updating the infinite scroller once we're done scrolling
1030
+ // There's no scroll animation completion callback so we're
1031
+ // giving it 0.5s to finish.
1032
+ setTimeout(() => {
1033
+ this.isScrollingToCell = false;
1034
+ this.infiniteScroller.reload();
1035
+ }, 500);
1036
+ }, 0);
1037
+ }
1038
+
1039
+ /**
1040
+ * The query key is a string that uniquely identifies the current query
1041
+ *
1042
+ * This lets us keep track of queries so we don't persist data that's
1043
+ * no longer relevant.
1044
+ */
1045
+ private get pageFetchQueryKey() {
1046
+ return `${this.fullQuery}-${this.searchType}-${this.sortParam?.field}-${this.sortParam?.direction}`;
1047
+ }
1048
+
1049
+ // this maps the query to the pages being fetched for that query
1050
+ private pageFetchesInProgress: Record<string, Set<number>> = {};
1051
+
1052
+ async fetchPage(pageNumber: number) {
1053
+ if (!this.fullQuery) return;
1054
+
1055
+ // if we already have data, don't fetch again
1056
+ if (this.dataSource[pageNumber]) return;
1057
+
1058
+ if (this.endOfDataReached) return;
1059
+
1060
+ // if a fetch is already in progress for this query and page, don't fetch again
1061
+ const { pageFetchQueryKey } = this;
1062
+ const pageFetches =
1063
+ this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
1064
+ if (pageFetches.has(pageNumber)) return;
1065
+ pageFetches.add(pageNumber);
1066
+ this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
1067
+
1068
+ const sortParams = this.sortParam ? [this.sortParam] : [];
1069
+ const params: SearchParams = {
1070
+ query: this.fullQuery,
1071
+ page: pageNumber,
1072
+ rows: this.pageSize,
1073
+ sort: sortParams,
1074
+ aggregations: { omit: true },
1075
+ };
1076
+ const searchResponse = await this.searchService?.search(
1077
+ params,
1078
+ this.searchType
1079
+ );
1080
+ const success = searchResponse?.success;
1081
+
1082
+ if (!success) return;
1083
+
1084
+ this.totalResults = success.response.totalResults;
1085
+
1086
+ // this is checking to see if the query has changed since the data was fetched
1087
+ // if so, we just want to discard the data since there should be a new query
1088
+ // right behind it
1089
+ const searchQuery = success.request.clientParameters.user_query;
1090
+ const searchSort = success.request.clientParameters.sort;
1091
+ let sortChanged = false;
1092
+ if (!searchSort || searchSort.length === 0) {
1093
+ // if we went from no sort to sort, the sort has changed
1094
+ if (this.sortParam) {
1095
+ sortChanged = true;
1096
+ }
1097
+ } else {
1098
+ // check if the sort has changed
1099
+ for (const sortType of searchSort) {
1100
+ const [field, direction] = sortType.split(':');
1101
+ if (
1102
+ field !== this.sortParam?.field ||
1103
+ direction !== this.sortParam?.direction
1104
+ ) {
1105
+ sortChanged = true;
1106
+ break;
1107
+ }
1108
+ }
1109
+ }
1110
+ const queryChangedSinceFetch =
1111
+ searchQuery !== this.fullQuery || sortChanged;
1112
+ if (queryChangedSinceFetch) return;
1113
+
1114
+ const { results } = success.response;
1115
+ if (results && results.length > 0) {
1116
+ this.preloadCollectionNames(results);
1117
+ this.updateDataSource(pageNumber, results);
1118
+ }
1119
+ if (results.length < this.pageSize) {
1120
+ this.endOfDataReached = true;
1121
+ // this updates the infinite scroller to show the actual size
1122
+ if (this.infiniteScroller) {
1123
+ this.infiniteScroller.itemCount = this.actualTileCount;
1124
+ }
1125
+ }
1126
+ this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber);
1127
+ this.searchResultsLoading = false;
1128
+ }
1129
+
1130
+ private preloadCollectionNames(results: SearchResult[]) {
1131
+ const collectionIds = results
1132
+ .map(result => result.collection?.values)
1133
+ .flat();
1134
+ const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
1135
+ this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
1136
+ }
1137
+
1138
+ /**
1139
+ * This is useful for determining whether we need to reload the scroller.
1140
+ *
1141
+ * When the fetch completes, we need to reload the scroller if the cells for that
1142
+ * page are visible, but if the page is not currenlty visible, we don't need to reload
1143
+ */
1144
+ private get currentVisiblePageNumbers(): number[] {
1145
+ const visibleCells = this.infiniteScroller?.getVisibleCellIndices() ?? [];
1146
+ const visiblePages = new Set<number>();
1147
+ visibleCells.forEach(cellIndex => {
1148
+ const visiblePage = Math.floor(cellIndex / this.pageSize) + 1;
1149
+ visiblePages.add(visiblePage);
1150
+ });
1151
+ return Array.from(visiblePages);
1152
+ }
1153
+
1154
+ /**
1155
+ * Update the datasource from the fetch response
1156
+ *
1157
+ * @param pageNumber
1158
+ * @param results
1159
+ */
1160
+ private updateDataSource(pageNumber: number, results: SearchResult[]) {
1161
+ // copy our existing datasource so when we set it below, it gets set
1162
+ // instead of modifying the existing dataSource since object changes
1163
+ // don't trigger a re-render
1164
+ const datasource = { ...this.dataSource };
1165
+ const tiles: TileModel[] = [];
1166
+ results?.forEach(result => {
1167
+ if (!result.identifier) return;
1168
+
1169
+ let loginRequired = false;
1170
+ let contentWarning = false;
1171
+ // Check if item and item in "modifying" collection, setting above flags
1172
+ if (
1173
+ result.collection?.values.length &&
1174
+ result.mediatype?.value !== 'collection'
1175
+ ) {
1176
+ for (const collection of result.collection?.values ?? []) {
1177
+ if (collection === 'loggedin') {
1178
+ loginRequired = true;
1179
+ if (contentWarning) break;
1180
+ }
1181
+ if (collection === 'no-preview') {
1182
+ contentWarning = true;
1183
+ if (loginRequired) break;
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ tiles.push({
1189
+ averageRating: result.avg_rating?.value,
1190
+ collections: result.collection?.values ?? [],
1191
+ commentCount: result.num_reviews?.value ?? 0,
1192
+ creator: result.creator?.value,
1193
+ creators: result.creator?.values ?? [],
1194
+ dateAdded: result.addeddate?.value,
1195
+ dateArchived: result.publicdate?.value,
1196
+ datePublished: result.date?.value,
1197
+ dateReviewed: result.reviewdate?.value,
1198
+ description: result.description?.value,
1199
+ favCount: result.num_favorites?.value ?? 0,
1200
+ identifier: result.identifier,
1201
+ issue: result.issue?.value,
1202
+ itemCount: result.item_count?.value ?? 0,
1203
+ mediatype: result.mediatype?.value ?? 'data',
1204
+ snippets: result.highlight?.values ?? [],
1205
+ source: result.source?.value,
1206
+ subjects: result.subject?.values ?? [],
1207
+ title: this.etreeTitle(
1208
+ result.title?.value,
1209
+ result.mediatype?.value,
1210
+ result.collection?.values
1211
+ ),
1212
+ volume: result.volume?.value,
1213
+ viewCount: result.downloads?.value ?? 0,
1214
+ weeklyViewCount: result.week?.value,
1215
+ loginRequired,
1216
+ contentWarning,
1217
+ });
1218
+ });
1219
+ datasource[pageNumber] = tiles;
1220
+ this.dataSource = datasource;
1221
+ const visiblePages = this.currentVisiblePageNumbers;
1222
+ const needsReload = visiblePages.includes(pageNumber);
1223
+ if (needsReload) {
1224
+ this.infiniteScroller.reload();
1225
+ }
1226
+ }
1227
+
1228
+ /*
1229
+ * Convert etree titles
1230
+ * "[Creator] Live at [Place] on [Date]" => "[Date]: [Place]"
1231
+ *
1232
+ * Todo: Check collection(s) for etree, need to get as array.
1233
+ * Current search-service only returns first collection as string.
1234
+ */
1235
+ private etreeTitle(
1236
+ title: string | undefined,
1237
+ mediatype: string | undefined,
1238
+ collections: string[] | undefined
1239
+ ): string {
1240
+ if (mediatype === 'etree' || collections?.includes('etree')) {
1241
+ const regex = /^(.*) Live at (.*) on (\d\d\d\d-\d\d-\d\d)$/;
1242
+ const newTitle = title?.replace(regex, '$3: $2');
1243
+ if (newTitle) {
1244
+ return `${newTitle}`;
1245
+ }
1246
+ }
1247
+ return title ?? '';
1248
+ }
1249
+
1250
+ /**
1251
+ * Callback when a result is selected
1252
+ */
1253
+ resultSelected(event: CustomEvent<TileModel>): void {
1254
+ this.analyticsHandler?.sendEventNoSampling({
1255
+ category: this.searchContext,
1256
+ action: analyticsActions.resultSelected,
1257
+ label: event.detail.mediatype,
1258
+ });
1259
+
1260
+ this.analyticsHandler?.sendEventNoSampling({
1261
+ category: this.searchContext,
1262
+ action: analyticsActions.resultSelected,
1263
+ label: `page-${this.currentPage}`,
1264
+ });
1265
+ }
1266
+
1267
+ cellForIndex(index: number): TemplateResult | undefined {
1268
+ const model = this.tileModelAtCellIndex(index);
1269
+ if (!model) return undefined;
1270
+
1271
+ return html`
1272
+ <tile-dispatcher
1273
+ .baseNavigationUrl=${this.baseNavigationUrl}
1274
+ .baseImageUrl=${this.baseImageUrl}
1275
+ .model=${model}
1276
+ .tileDisplayMode=${this.displayMode}
1277
+ .resizeObserver=${this.resizeObserver}
1278
+ .collectionNameCache=${this.collectionNameCache}
1279
+ .sortParam=${this.sortParam}
1280
+ .mobileBreakpoint=${this.mobileBreakpoint}
1281
+ .loggedIn=${this.loggedIn}
1282
+ @resultSelected=${(e: CustomEvent) => this.resultSelected(e)}
1283
+ >
1284
+ </tile-dispatcher>
1285
+ `;
1286
+ }
1287
+
1288
+ /**
1289
+ * When the user scrolls near to the bottom of the page, fetch the next page
1290
+ * increase the number of pages to render and start fetching data for the new page
1291
+ */
1292
+ private scrollThresholdReached() {
1293
+ this.pagesToRender += 1;
1294
+ this.fetchPage(this.pagesToRender);
1295
+ }
1296
+
1297
+ static styles = css`
1298
+ :host {
1299
+ display: block;
1300
+ }
1301
+
1302
+ /**
1303
+ * When page width resizes from desktop to mobile, use this class to
1304
+ * disable expand/collapse transition when loading.
1305
+ */
1306
+ .preload * {
1307
+ transition: none !important;
1308
+ -webkit-transition: none !important;
1309
+ -moz-transition: none !important;
1310
+ -ms-transition: none !important;
1311
+ -o-transition: none !important;
1312
+ }
1313
+
1314
+ #content-container {
1315
+ display: flex;
1316
+ }
1317
+
1318
+ .collapser {
1319
+ display: inline-block;
1320
+ }
1321
+
1322
+ .collapser svg {
1323
+ width: 10px;
1324
+ height: 10px;
1325
+ transition: transform 0.2s ease-out;
1326
+ }
1327
+
1328
+ .collapser.open svg {
1329
+ transform: rotate(90deg);
1330
+ }
1331
+
1332
+ #mobile-filter-collapse h1 {
1333
+ cursor: pointer;
1334
+ }
1335
+
1336
+ #content-container.mobile {
1337
+ display: block;
1338
+ }
1339
+
1340
+ .column {
1341
+ padding-top: 2rem;
1342
+ }
1343
+
1344
+ #right-column {
1345
+ flex: 1;
1346
+ position: relative;
1347
+ border-left: 1px solid rgb(232, 232, 232);
1348
+ padding-left: 1rem;
1349
+ }
1350
+
1351
+ .mobile #right-column {
1352
+ border-left: none;
1353
+ padding: 0;
1354
+ }
1355
+
1356
+ #left-column {
1357
+ width: 18rem;
1358
+ min-width: 18rem; /* Prevents Safari from shrinking col at first draw */
1359
+ padding-right: 12px;
1360
+ padding-right: 2.5rem;
1361
+ }
1362
+
1363
+ .desktop #left-column::-webkit-scrollbar {
1364
+ display: none;
1365
+ }
1366
+
1367
+ .mobile #left-column {
1368
+ width: 100%;
1369
+ padding: 0;
1370
+ }
1371
+
1372
+ .desktop #left-column {
1373
+ top: 0;
1374
+ position: sticky;
1375
+ max-height: 100vh;
1376
+ overflow: scroll;
1377
+ -ms-overflow-style: none; /* hide scrollbar IE and Edge */
1378
+ scrollbar-width: none; /* hide scrollbar Firefox */
1379
+ }
1380
+
1381
+ #mobile-header-container {
1382
+ display: flex;
1383
+ justify-content: space-between;
1384
+ }
1385
+
1386
+ #facets-container {
1387
+ position: relative;
1388
+ max-height: 0;
1389
+ transition: max-height 0.2s ease-in-out;
1390
+ z-index: 1;
1391
+ padding-bottom: 2rem;
1392
+ }
1393
+
1394
+ .mobile #facets-container {
1395
+ overflow: hidden;
1396
+ padding-bottom: 0;
1397
+ }
1398
+
1399
+ #facets-container.expanded {
1400
+ max-height: 2000px;
1401
+ }
1402
+
1403
+ #results-total {
1404
+ display: flex;
1405
+ align-items: baseline;
1406
+ margin-bottom: 5rem;
1407
+ }
1408
+
1409
+ .mobile #results-total {
1410
+ margin-bottom: 0;
1411
+ }
1412
+
1413
+ #big-results-count {
1414
+ font-size: 2.4rem;
1415
+ font-weight: 500;
1416
+ margin-right: 5px;
1417
+ }
1418
+
1419
+ #big-results-label {
1420
+ font-size: 1.4rem;
1421
+ font-weight: 200;
1422
+ }
1423
+
1424
+ #list-header {
1425
+ max-height: 4.2rem;
1426
+ }
1427
+
1428
+ .loading-cover {
1429
+ position: absolute;
1430
+ top: 0;
1431
+ left: 0;
1432
+ width: 100%;
1433
+ height: 100%;
1434
+ display: flex;
1435
+ justify-content: center;
1436
+ z-index: 1;
1437
+ padding-top: 50px;
1438
+ }
1439
+
1440
+ circular-activity-indicator {
1441
+ width: 30px;
1442
+ height: 30px;
1443
+ }
1444
+
1445
+ sort-filter-bar {
1446
+ display: block;
1447
+ margin-bottom: 4rem;
1448
+ }
1449
+
1450
+ infinite-scroller {
1451
+ display: block;
1452
+ --infiniteScrollerRowGap: var(--collectionBrowserRowGap, 1.7rem);
1453
+ --infiniteScrollerColGap: var(--collectionBrowserColGap, 1.7rem);
1454
+ }
1455
+
1456
+ infinite-scroller.list-compact {
1457
+ --infiniteScrollerCellMinWidth: var(
1458
+ --collectionBrowserCellMinWidth,
1459
+ 100%
1460
+ );
1461
+ --infiniteScrollerCellMinHeight: 34px; /* override infinite scroller component */
1462
+ --infiniteScrollerCellMaxHeight: 56px;
1463
+ --infiniteScrollerRowGap: 0px;
1464
+ }
1465
+
1466
+ infinite-scroller.list-detail {
1467
+ --infiniteScrollerCellMinWidth: var(
1468
+ --collectionBrowserCellMinWidth,
1469
+ 100%
1470
+ );
1471
+ --infiniteScrollerCellMinHeight: var(
1472
+ --collectionBrowserCellMinHeight,
1473
+ 5rem
1474
+ );
1475
+ /*
1476
+ 30px in spec, compensating for a -4px margin
1477
+ to align title with top of item image
1478
+ src/tiles/list/tile-list.ts
1479
+ */
1480
+ --infiniteScrollerRowGap: 34px;
1481
+ }
1482
+
1483
+ .mobile infinite-scroller.list-detail {
1484
+ --infiniteScrollerRowGap: 24px;
1485
+ }
1486
+
1487
+ infinite-scroller.grid {
1488
+ --infiniteScrollerCellMinWidth: var(
1489
+ --collectionBrowserCellMinWidth,
1490
+ 18rem
1491
+ );
1492
+ --infiniteScrollerCellMaxWidth: var(--collectionBrowserCellMaxWidth, 1fr);
1493
+ --infiniteScrollerCellMinHeight: var(
1494
+ --collectionBrowserCellMinHeight,
1495
+ 29rem
1496
+ );
1497
+ --infiniteScrollerCellMaxHeight: var(
1498
+ --collectionBrowserCellMaxHeight,
1499
+ 29rem
1500
+ );
1501
+ }
1502
+ `;
1503
+ }