@internetarchive/collection-browser 4.7.1-alpha-webdev9189.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/src/app-root.js +0 -2
  2. package/dist/src/app-root.js.map +1 -1
  3. package/dist/src/assets/img/icons/check.d.ts +2 -0
  4. package/dist/src/assets/img/icons/check.js +5 -0
  5. package/dist/src/assets/img/icons/check.js.map +1 -0
  6. package/dist/src/collection-browser.js +2 -24
  7. package/dist/src/collection-browser.js.map +1 -1
  8. package/dist/src/collection-facets/facet-row-template.d.ts +38 -0
  9. package/dist/src/collection-facets/facet-row-template.js +200 -0
  10. package/dist/src/collection-facets/facet-row-template.js.map +1 -0
  11. package/dist/src/collection-facets/facet-row.js +11 -186
  12. package/dist/src/collection-facets/facet-row.js.map +1 -1
  13. package/dist/src/collection-facets/models.d.ts +7 -2
  14. package/dist/src/collection-facets/models.js +7 -2
  15. package/dist/src/collection-facets/models.js.map +1 -1
  16. package/dist/src/collection-facets/more-facets-content.d.ts +87 -21
  17. package/dist/src/collection-facets/more-facets-content.js +477 -200
  18. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  19. package/dist/src/collection-facets/more-facets-scroller.d.ts +85 -0
  20. package/dist/src/collection-facets/more-facets-scroller.js +428 -0
  21. package/dist/src/collection-facets/more-facets-scroller.js.map +1 -0
  22. package/dist/src/collection-facets/page-window.d.ts +73 -0
  23. package/dist/src/collection-facets/page-window.js +202 -0
  24. package/dist/src/collection-facets/page-window.js.map +1 -0
  25. package/dist/src/collection-facets/toggle-switch.js +1 -0
  26. package/dist/src/collection-facets/toggle-switch.js.map +1 -1
  27. package/dist/src/models.d.ts +4 -0
  28. package/dist/src/models.js +16 -0
  29. package/dist/src/models.js.map +1 -1
  30. package/dist/src/sort-filter-bar/sort-filter-bar.js +0 -1
  31. package/dist/src/sort-filter-bar/sort-filter-bar.js.map +1 -1
  32. package/dist/src/utils/log.d.ts +1 -4
  33. package/dist/src/utils/normalize-filter-text.d.ts +28 -0
  34. package/dist/src/utils/normalize-filter-text.js +35 -0
  35. package/dist/src/utils/normalize-filter-text.js.map +1 -0
  36. package/dist/test/collection-browser.test.js +0 -34
  37. package/dist/test/collection-browser.test.js.map +1 -1
  38. package/dist/test/collection-facets/more-facets-content.test.js +367 -17
  39. package/dist/test/collection-facets/more-facets-content.test.js.map +1 -1
  40. package/dist/test/collection-facets/more-facets-scroller.test.d.ts +1 -0
  41. package/dist/test/collection-facets/more-facets-scroller.test.js +288 -0
  42. package/dist/test/collection-facets/more-facets-scroller.test.js.map +1 -0
  43. package/dist/test/collection-facets/page-window.test.d.ts +1 -0
  44. package/dist/test/collection-facets/page-window.test.js +175 -0
  45. package/dist/test/collection-facets/page-window.test.js.map +1 -0
  46. package/dist/test/mocks/mock-search-responses.d.ts +6 -0
  47. package/dist/test/mocks/mock-search-responses.js +52 -0
  48. package/dist/test/mocks/mock-search-responses.js.map +1 -1
  49. package/dist/test/mocks/mock-search-service.js +2 -1
  50. package/dist/test/mocks/mock-search-service.js.map +1 -1
  51. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +0 -22
  52. package/dist/test/sort-filter-bar/sort-filter-bar.test.js.map +1 -1
  53. package/dist/test/utils/normalize-filter-text.test.d.ts +1 -0
  54. package/dist/test/utils/normalize-filter-text.test.js +69 -0
  55. package/dist/test/utils/normalize-filter-text.test.js.map +1 -0
  56. package/docs/design/column-virtualization.md +214 -0
  57. package/package.json +3 -1
  58. package/src/app-root.ts +0 -2
  59. package/src/assets/img/icons/check.ts +5 -0
  60. package/src/collection-browser.ts +4 -26
  61. package/src/collection-facets/facet-row-template.ts +242 -0
  62. package/src/collection-facets/facet-row.ts +15 -202
  63. package/src/collection-facets/models.ts +8 -2
  64. package/src/collection-facets/more-facets-content.ts +528 -221
  65. package/src/collection-facets/more-facets-scroller.ts +485 -0
  66. package/src/collection-facets/page-window.ts +247 -0
  67. package/src/collection-facets/toggle-switch.ts +1 -0
  68. package/src/models.ts +17 -0
  69. package/src/sort-filter-bar/sort-filter-bar.ts +0 -1
  70. package/src/utils/normalize-filter-text.ts +54 -0
  71. package/test/collection-browser.test.ts +0 -55
  72. package/test/collection-facets/more-facets-content.test.ts +509 -24
  73. package/test/collection-facets/more-facets-scroller.test.ts +401 -0
  74. package/test/collection-facets/page-window.test.ts +208 -0
  75. package/test/mocks/mock-search-responses.ts +56 -0
  76. package/test/mocks/mock-search-service.ts +2 -0
  77. package/test/sort-filter-bar/sort-filter-bar.test.ts +0 -29
  78. package/test/utils/normalize-filter-text.test.ts +87 -0
  79. package/web-test-runner.config.mjs +7 -2
  80. package/dist/src/collection-facets/more-facets-pagination.d.ts +0 -36
  81. package/dist/src/collection-facets/more-facets-pagination.js +0 -260
  82. package/dist/src/collection-facets/more-facets-pagination.js.map +0 -1
  83. package/dist/test/collection-facets/more-facets-pagination.test.d.ts +0 -1
  84. package/dist/test/collection-facets/more-facets-pagination.test.js +0 -126
  85. package/dist/test/collection-facets/more-facets-pagination.test.js.map +0 -1
  86. package/src/collection-facets/more-facets-pagination.ts +0 -294
  87. package/test/collection-facets/more-facets-pagination.test.ts +0 -201
@@ -0,0 +1,247 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+
3
+ export type PageWindowConfig = {
4
+ /** Width in px of every column. Columns are uniform. */
5
+ colWidth: number;
6
+
7
+ /** Total number of columns in the dataset. */
8
+ total: number;
9
+
10
+ /** Pages kept mounted on each side of the visible ones. Defaults to 1. */
11
+ buffer?: number;
12
+
13
+ /**
14
+ * Called each time the scroller comes to rest (or is re-laid out), with the
15
+ * page it rests on. Fires even when that page hasn't changed.
16
+ */
17
+ onRest?: (page: number) => void;
18
+ };
19
+
20
+ const supportsScrollEnd = 'onscrollend' in window;
21
+
22
+ /**
23
+ * Windowing controller for a horizontal scroller of fixed-width columns that
24
+ * snaps to page boundaries. Tracks which pages should be in the DOM (`first`
25
+ * through `last`) and which page the scroller last came to rest on.
26
+ *
27
+ * All positions are arithmetic on `colWidth`; the only measurement is the
28
+ * scroller's width, which comes from a ResizeObserver.
29
+ *
30
+ * See docs/design/column-virtualization.md before changing this, especially
31
+ * its invariants.
32
+ */
33
+ export class PageWindow implements ReactiveController {
34
+ /** Index of the first mounted page. */
35
+ first = 0;
36
+
37
+ /** Index of the last mounted page, or -1 when nothing is mounted. */
38
+ last = -1;
39
+
40
+ /** How many columns fit in the scroller, derived from its measured width. */
41
+ colsPerPage = 1;
42
+
43
+ /** The page the scroller last came to rest on. */
44
+ currentPage = 0;
45
+
46
+ private scroller?: HTMLElement;
47
+
48
+ private viewportW = 0;
49
+
50
+ private frame = 0;
51
+
52
+ private landFrame = 0;
53
+
54
+ private endTimer = 0;
55
+
56
+ private ro?: ResizeObserver;
57
+
58
+ /** Page to realign to once the host has re-rendered at a new page width. */
59
+ private realignTo?: number;
60
+
61
+ /** Whether to re-check the resting page once the host has re-rendered. */
62
+ private relandAfterUpdate = false;
63
+
64
+ constructor(
65
+ private host: ReactiveControllerHost,
66
+ private cfg: PageWindowConfig,
67
+ ) {
68
+ host.addController(this);
69
+ }
70
+
71
+ get pageWidth(): number {
72
+ return this.cfg.colWidth * this.colsPerPage;
73
+ }
74
+
75
+ get pageCount(): number {
76
+ return Math.ceil(this.cfg.total / this.colsPerPage);
77
+ }
78
+
79
+ get totalWidth(): number {
80
+ return this.cfg.colWidth * this.cfg.total;
81
+ }
82
+
83
+ /**
84
+ * Width of page `i`. Every page is `pageWidth` except a partial last page,
85
+ * which is trimmed so that nothing extends the scroll area past `totalWidth`.
86
+ */
87
+ pageSpan(i: number): number {
88
+ return Math.min(this.pageWidth, this.totalWidth - i * this.pageWidth);
89
+ }
90
+
91
+ /** Updates the number of columns, e.g. when the dataset is filtered. */
92
+ setTotal(total: number): void {
93
+ if (total === this.cfg.total) return;
94
+ this.cfg.total = total;
95
+ this.recompute(true);
96
+ // The sizer hasn't resized yet, so wait for the host to render before
97
+ // reading where the scroller rests.
98
+ this.relandAfterUpdate = true;
99
+ }
100
+
101
+ /** Scrolls so that `page` sits at the start of the viewport. */
102
+ scrollToPage(page: number, behavior: ScrollBehavior = 'auto'): void {
103
+ const target = Math.max(0, Math.min(page, this.pageCount - 1));
104
+ this.scroller?.scrollTo({ left: target * this.pageWidth, behavior });
105
+ }
106
+
107
+ /** Ref callback for the scroll container. */
108
+ attach = (el?: Element): void => {
109
+ const next = el instanceof HTMLElement ? el : undefined;
110
+ if (next === this.scroller) return;
111
+ this.detach();
112
+ if (!next) return;
113
+
114
+ this.scroller = next;
115
+ next.addEventListener('scroll', this.onScroll, { passive: true });
116
+ if (supportsScrollEnd) {
117
+ next.addEventListener('scrollend', this.onScrollEnd);
118
+ } else {
119
+ next.addEventListener('scroll', this.onScrollIdle, { passive: true });
120
+ }
121
+
122
+ this.ro = new ResizeObserver(([e]) => this.onResize(e.contentRect.width));
123
+ this.ro.observe(next);
124
+ };
125
+
126
+ hostUpdated(): void {
127
+ const el = this.scroller;
128
+ if (!el) return;
129
+ // A resize changed the page width. The host has now moved the snap stubs,
130
+ // so put the page that held the anchor column back at the start.
131
+ if (this.realignTo !== undefined) {
132
+ const target = this.realignTo * this.pageWidth;
133
+ // After a layout change WebKit re-snaps to the snap point with the same
134
+ // index as before, which is now a different column. Assigning the
135
+ // position the scroller already has is a no-op that doesn't update that
136
+ // index, so nudge it first.
137
+ if (el.scrollLeft === target) el.scrollLeft = target + 1;
138
+ el.scrollLeft = target;
139
+ this.realignTo = undefined;
140
+ this.relandAfterUpdate = true;
141
+ }
142
+ if (this.relandAfterUpdate && !this.landFrame) {
143
+ this.relandAfterUpdate = false;
144
+ // Next frame, so as not to update the host again from inside its update
145
+ this.landFrame = requestAnimationFrame(() => {
146
+ this.landFrame = 0;
147
+ this.land();
148
+ });
149
+ }
150
+ }
151
+
152
+ hostDisconnected(): void {
153
+ this.detach();
154
+ }
155
+
156
+ private detach(): void {
157
+ const el = this.scroller;
158
+ if (!el) return;
159
+ el.removeEventListener('scroll', this.onScroll);
160
+ el.removeEventListener('scroll', this.onScrollIdle);
161
+ el.removeEventListener('scrollend', this.onScrollEnd);
162
+ this.ro?.disconnect();
163
+ this.ro = undefined;
164
+ if (this.frame) cancelAnimationFrame(this.frame);
165
+ if (this.landFrame) cancelAnimationFrame(this.landFrame);
166
+ this.frame = 0;
167
+ this.landFrame = 0;
168
+ clearTimeout(this.endTimer);
169
+ this.scroller = undefined;
170
+ }
171
+
172
+ private onResize(width: number): void {
173
+ const el = this.scroller;
174
+ if (!el) return;
175
+ this.viewportW = width;
176
+
177
+ const colsPerPage = Math.max(1, Math.floor(width / this.cfg.colWidth));
178
+ if (colsPerPage === this.colsPerPage) {
179
+ this.recompute();
180
+ return;
181
+ }
182
+
183
+ // Columns never move (column c always starts at c * colWidth); only the
184
+ // page boundaries do. Keep the leftmost visible column on screen by
185
+ // realigning to whichever new page contains it.
186
+ const anchorCol = Math.round(el.scrollLeft / this.cfg.colWidth);
187
+ this.colsPerPage = colsPerPage;
188
+ this.realignTo = Math.floor(anchorCol / colsPerPage);
189
+ this.recompute(true);
190
+ }
191
+
192
+ private onScroll = (): void => {
193
+ if (this.frame) return;
194
+ this.frame = requestAnimationFrame(() => {
195
+ this.frame = 0;
196
+ this.recompute();
197
+ });
198
+ };
199
+
200
+ /** Fallback for browsers without `scrollend`: treat 120ms of quiet as rest. */
201
+ private onScrollIdle = (): void => {
202
+ clearTimeout(this.endTimer);
203
+ this.endTimer = window.setTimeout(this.onScrollEnd, 120);
204
+ };
205
+
206
+ private onScrollEnd = (): void => {
207
+ this.land();
208
+ };
209
+
210
+ private recompute(force = false): void {
211
+ const el = this.scroller;
212
+ if (!el) return;
213
+ const pw = this.pageWidth;
214
+ const buf = this.cfg.buffer ?? 1;
215
+ const left = el.scrollLeft;
216
+ const first = Math.max(0, Math.floor(left / pw) - buf);
217
+ // Floor on both edges, so that first and last shift together at each page
218
+ // boundary rather than one just before it and one just after.
219
+ const last = Math.min(
220
+ this.pageCount - 1,
221
+ Math.floor((left + this.viewportW) / pw) + buf,
222
+ );
223
+ // Most scroll events end here: the window only changes at page boundaries
224
+ if (!force && first === this.first && last === this.last) return;
225
+ this.first = first;
226
+ this.last = last;
227
+ this.host.requestUpdate();
228
+ }
229
+
230
+ /** Records which page the scroller is resting on. */
231
+ private land(): void {
232
+ const el = this.scroller;
233
+ if (!el) return;
234
+ const left = el.scrollLeft;
235
+ const maxLeft = this.totalWidth - this.viewportW;
236
+ // A partial last page can't reach its own snap point, so resting at the
237
+ // far end counts as landing on the last page.
238
+ const atEnd = left >= maxLeft - 1;
239
+ const page = atEnd ? this.pageCount - 1 : Math.round(left / this.pageWidth);
240
+ const clamped = Math.max(0, Math.min(page, this.pageCount - 1));
241
+ if (clamped !== this.currentPage) {
242
+ this.currentPage = clamped;
243
+ this.host.requestUpdate();
244
+ }
245
+ this.cfg.onRest?.(clamped);
246
+ }
247
+ }
@@ -62,6 +62,7 @@ export class ToggleSwitch extends LitElement {
62
62
  id="switch-button"
63
63
  class=${this.side}
64
64
  aria-hidden="true"
65
+ tabindex="-1"
65
66
  @click=${this.handleClick}
66
67
  >
67
68
  <div id="knob"></div>
package/src/models.ts CHANGED
@@ -758,6 +758,23 @@ export const facetTitles: Record<FacetOption, string> = {
758
758
  sponsor: 'Sponsor',
759
759
  };
760
760
 
761
+ /**
762
+ * Human-readable plural titles for each facet group, for referring to its values.
763
+ */
764
+ export const facetPluralTitles: Record<FacetOption, string> = {
765
+ subject: 'Subjects',
766
+ lending: 'Availability',
767
+ mediatype: 'Media Types',
768
+ language: 'Languages',
769
+ creator: 'Creators',
770
+ collection: 'Collections',
771
+ year: 'Years',
772
+ clip_type: 'Clip Types',
773
+ program: 'Programs',
774
+ person: 'People',
775
+ sponsor: 'Sponsors',
776
+ };
777
+
761
778
  /**
762
779
  * The default sort type to use for each facet type
763
780
  */
@@ -129,7 +129,6 @@ export class SortFilterBar extends LitElement {
129
129
  : html`<div id="display-style-selector">
130
130
  ${this.displayOptionTemplate}
131
131
  </div>`}
132
- <slot name="sort-bar-end"></slot>
133
132
  </section>
134
133
 
135
134
  ${this.dropdownBackdropVisible ? this.dropdownBackdrop : nothing}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The forms of a piece of text that loose "contains" matching compares. See
3
+ * `normalizeFilterText`.
4
+ */
5
+ export type NormalizedFilterText = {
6
+ /** With word-joining punctuation turned into spaces */
7
+ spaced: string;
8
+
9
+ /** With word-joining punctuation removed */
10
+ joined: string;
11
+ };
12
+
13
+ /** Punctuation that joins words: hyphens and dashes, underscores, slashes */
14
+ const WORD_JOINERS = /[\p{Pd}\p{Pc}/]/gu;
15
+
16
+ /**
17
+ * Reduces text to the forms used for loose "contains" matching: lowercased,
18
+ * with accents and punctuation removed and runs of whitespace collapsed.
19
+ * Apply it to both the filter term and the values being filtered, then
20
+ * compare them with `filterTextIncludes`.
21
+ *
22
+ * Punctuation that joins words goes both ways, since people type those words
23
+ * apart and together: `spaced` turns it into spaces, so `wc tv` matches
24
+ * `WC-TV`, and `joined` removes it, so `wctv` does too. Other punctuation is
25
+ * removed outright, so `aor` matches `A.O.R.` and `dr drew` matches
26
+ * `Dr. Drew`. The two forms only differ for text with word-joining
27
+ * punctuation.
28
+ */
29
+ export function normalizeFilterText(text: string): NormalizedFilterText {
30
+ const base = text
31
+ .normalize('NFKD')
32
+ .replace(/\p{M}/gu, '')
33
+ .toLocaleLowerCase();
34
+ return {
35
+ spaced: removePunctuation(base.replace(WORD_JOINERS, ' ')),
36
+ joined: removePunctuation(base.replace(WORD_JOINERS, '')),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Whether normalized `text` contains the normalized `filter`, in either form
42
+ */
43
+ export function filterTextIncludes(
44
+ text: NormalizedFilterText,
45
+ filter: NormalizedFilterText,
46
+ ): boolean {
47
+ return (
48
+ text.spaced.includes(filter.spaced) || text.joined.includes(filter.joined)
49
+ );
50
+ }
51
+
52
+ function removePunctuation(text: string): string {
53
+ return text.replace(/\p{P}/gu, '').replace(/\s+/g, ' ').trim();
54
+ }
@@ -2482,61 +2482,6 @@ describe('Collection Browser', () => {
2482
2482
  expect(infiniteScroller).not.to.exist;
2483
2483
  });
2484
2484
 
2485
- it('lays out results-aside content beside the result tiles', async () => {
2486
- const searchService = new MockSearchService();
2487
- const el = await fixture<CollectionBrowser>(
2488
- html`<collection-browser .searchService=${searchService}>
2489
- <div slot="results-aside" id="aside" style="width: 200px">Aside</div>
2490
- </collection-browser>`,
2491
- );
2492
-
2493
- el.baseQuery = 'collection:foo';
2494
- await el.updateComplete;
2495
- await el.initialSearchComplete;
2496
-
2497
- const resultsBody = el.shadowRoot?.querySelector('#results-body');
2498
- const asideSlot = resultsBody?.querySelector(
2499
- 'slot[name="results-aside"]',
2500
- ) as HTMLSlotElement;
2501
- const scroller = resultsBody?.querySelector(
2502
- '#results-main > infinite-scroller',
2503
- ) as HTMLElement;
2504
- const aside = el.querySelector('#aside') as HTMLElement;
2505
-
2506
- expect(asideSlot?.assignedElements()).to.deep.equal([aside]);
2507
- expect(scroller).to.exist;
2508
-
2509
- // The aside sits to the right of the tiles, sharing the results row
2510
- const scrollerRect = scroller.getBoundingClientRect();
2511
- const asideRect = aside.getBoundingClientRect();
2512
- expect(asideRect.left).to.be.at.least(scrollerRect.right);
2513
- expect(asideRect.top).to.be.at.most(scrollerRect.bottom);
2514
- });
2515
-
2516
- it('forwards sort-bar-end content into the sort bar', async () => {
2517
- const searchService = new MockSearchService();
2518
- const el = await fixture<CollectionBrowser>(
2519
- html`<collection-browser .searchService=${searchService}>
2520
- <button slot="sort-bar-end" id="end-button">End</button>
2521
- </collection-browser>`,
2522
- );
2523
-
2524
- el.baseQuery = 'collection:foo';
2525
- await el.updateComplete;
2526
- await el.initialSearchComplete;
2527
-
2528
- const sortBar = el.shadowRoot?.querySelector(
2529
- 'sort-filter-bar',
2530
- ) as SortFilterBar;
2531
- const forwardingSlot = sortBar?.querySelector(
2532
- 'slot[name="sort-bar-end"]',
2533
- ) as HTMLSlotElement;
2534
- const button = el.querySelector('#end-button');
2535
-
2536
- expect(forwardingSlot?.slot).to.equal('sort-bar-end');
2537
- expect(forwardingSlot?.assignedElements()).to.deep.equal([button]);
2538
- });
2539
-
2540
2485
  it('fetch larger result on search page for admin user to manage items', async () => {
2541
2486
  const resultsSpy = sinon.spy();
2542
2487
  const searchService = new MockSearchService({