diff --git a/.gitignore b/.gitignore index 36cc77cd5..a55a5b40f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ lerna-debug.log* # We use yarn.lock package-lock.json + +.claude/ diff --git a/package.json b/package.json index 99b388f19..34eb62eb1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "The Internet Archive Collection Browser.", "license": "AGPL-3.0-only", "author": "Internet Archive", - "version": "4.1.0", + "version": "4.2.0-alpha-webdev8164.1", "main": "dist/index.js", "module": "dist/index.js", "scripts": { @@ -29,6 +29,7 @@ "@internetarchive/feature-feedback": "^1.0.0", "@internetarchive/field-parsers": "^1.0.0", "@internetarchive/histogram-date-range": "^1.4.1", + "@internetarchive/ia-clearable-text-input": "^1.1.1", "@internetarchive/ia-dropdown": "^2.0.0", "@internetarchive/iaux-item-metadata": "^1.0.5", "@internetarchive/infinite-scroller": "^1.0.1", diff --git a/src/app-root.ts b/src/app-root.ts index c0e873d87..c1d53aeea 100644 --- a/src/app-root.ts +++ b/src/app-root.ts @@ -950,10 +950,6 @@ export class AppRoot extends LitElement { --modalBorder: 2px solid var(--primaryButtonBGColor, #194880); --modalTitleLineHeight: 4rem; --modalTitleFontSize: 1.8rem; - --modalCornerRadius: 0; - --modalBottomPadding: 0; - --modalBottomMargin: 0; - --modalScrollOffset: 0; --modalCornerRadius: 0.5rem; } modal-manager.expanded-date-picker { diff --git a/src/collection-facets/facets-template.ts b/src/collection-facets/facets-template.ts index 2a53cb8b1..f040521c7 100644 --- a/src/collection-facets/facets-template.ts +++ b/src/collection-facets/facets-template.ts @@ -63,11 +63,16 @@ export class FacetsTemplate extends LitElement { static get styles(): CSSResultGroup { const columnCount = css`var(--facetsColumnCount, 1)`; const columnGap = css`var(--facetsColumnGap, 15px)`; + const columnWidth = css`var(--facetsColumnWidth, auto)`; + const maxHeight = css`var(--facetsMaxHeight, none)`; return css` .facet-rows { column-count: ${columnCount}; column-gap: ${columnGap}; + column-width: ${columnWidth}; + max-height: ${maxHeight}; + column-fill: auto; } a:link, diff --git a/src/collection-facets/more-facets-content.ts b/src/collection-facets/more-facets-content.ts index 1c632504b..ff0c7a3d5 100644 --- a/src/collection-facets/more-facets-content.ts +++ b/src/collection-facets/more-facets-content.ts @@ -7,7 +7,9 @@ import { PropertyValues, TemplateResult, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { when } from 'lit/directives/when.js'; import { Aggregation, Bucket, @@ -40,13 +42,16 @@ import type { TVChannelAliases, } from '../data-source/models'; import '@internetarchive/elements/ia-status-indicator/ia-status-indicator'; -import './more-facets-pagination'; import './facets-template'; import { analyticsActions, analyticsCategories, } from '../utils/analytics-events'; import './toggle-switch'; +import './more-facets-pagination'; +import '@internetarchive/ia-clearable-text-input'; +import arrowLeftIcon from '../assets/img/icons/arrow-left'; +import arrowRightIcon from '../assets/img/icons/arrow-right'; import { srOnlyStyle } from '../styles/sr-only'; import { mergeSelectedFacets, @@ -58,6 +63,12 @@ import { MORE_FACETS__MAX_AGGREGATIONS, } from './models'; +/** + * Threshold for switching from horizontal scroll to pagination. + * If facet count >= this value, use pagination. Otherwise use horizontal scroll. + */ +const PAGINATION_THRESHOLD = 1000; + @customElement('more-facets-content') export class MoreFacetsContent extends LitElement { @property({ type: String }) facetKey?: FacetOption; @@ -126,10 +137,46 @@ export class MoreFacetsContent extends LitElement { getDefaultSelectedFacets(); /** - * Which page of facets we are showing. + * Text entered by the user to filter facet buckets. + * Applied to bucket.key for case-insensitive matching. + */ + @state() private filterText = ''; + + /** + * Current page number for pagination (when facet count >= PAGINATION_THRESHOLD). */ @state() private pageNumber = 1; + /** + * Whether the component is narrow enough to warrant compact pagination. + * Updated via a ResizeObserver-based container query approach. + */ + @state() private isCompactView = false; + + /** Column gap in px — matches the --facetsColumnGap default (never overridden). */ + private static readonly COLUMN_GAP = 15; + + /** Column count derived from the same breakpoint as the CSS media query. */ + private get columnCount(): number { + return this.isCompactView ? 1 : 3; + } + + /** + * Whether the horizontal scroll is at the leftmost position. + */ + @state() private atScrollStart = true; + + /** + * Whether the horizontal scroll is at the rightmost position. + */ + @state() private atScrollEnd = true; + + @query('ia-clearable-text-input') + private filterInput!: HTMLElement; + + @query('.facets-content') + private facetsContentEl!: HTMLElement; + willUpdate(changed: PropertyValues): void { if ( changed.has('aggregations') || @@ -143,6 +190,13 @@ export class MoreFacetsContent extends LitElement { this.facetGroup = this.mergedFacets; } + // Reset to page 1 when filter text changes (only matters for pagination mode) + if (changed.has('filterText')) { + this.pageNumber = 1; + } + } + + updated(changed: PropertyValues): void { // If any of the search properties change, it triggers a facet fetch if ( changed.has('facetKey') || @@ -159,24 +213,196 @@ export class MoreFacetsContent extends LitElement { this.updateSpecificFacets(); } + + // Reset horizontal scroll when filter text changes (e.g., switching from + // horizontal-scroll mode back to pagination mode) + if (changed.has('filterText')) { + const facetsContent = this.shadowRoot?.querySelector('.facets-content'); + if (facetsContent) { + facetsContent.scrollLeft = 0; + } + } + + // Manage scroll listener for horizontal scroll mode arrows. + // Only re-evaluate when properties that affect the displayed content change. + if ( + changed.has('filterText') || + changed.has('aggregations') || + changed.has('facetKey') || + changed.has('sortedBy') || + changed.has('selectedFacets') || + changed.has('unappliedFacetChanges') + ) { + if (!this.usePagination) { + this.attachScrollListener(); + // Refresh scroll state whenever content may have changed (e.g., filtering) + requestAnimationFrame(() => this.updateScrollState()); + } else { + this.removeScrollListener(); + } + } } + private resizeObserver?: ResizeObserver; + firstUpdated(): void { this.setupEscapeListeners(); + this.setupCompactViewObserver(); + this.constrainToScrollContainer(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.removeScrollListener(); + document.removeEventListener('keydown', this.escapeHandler); + } + + private scrollHandler = () => this.updateScrollState(); + + private scrollListenerAttached = false; + + private scrollListenerTarget?: HTMLElement; + + /** + * Attaches a scroll event listener to the facets content element + * to track horizontal scroll position for arrow button states. + */ + private attachScrollListener(): void { + if (this.scrollListenerAttached || !this.facetsContentEl) return; + this.scrollListenerTarget = this.facetsContentEl; + this.scrollListenerTarget.addEventListener('scroll', this.scrollHandler, { + passive: true, + }); + this.scrollListenerAttached = true; + // Defer initial state check until after browser layout, so scrollWidth + // reflects the actual content dimensions. + requestAnimationFrame(() => this.updateScrollState()); + } + + private removeScrollListener(): void { + if (!this.scrollListenerAttached || !this.scrollListenerTarget) return; + this.scrollListenerTarget.removeEventListener('scroll', this.scrollHandler); + this.scrollListenerTarget = undefined; + this.scrollListenerAttached = false; + } + + /** + * Updates the scroll arrow disabled states based on current scroll position. + */ + private updateScrollState(): void { + const el = this.facetsContentEl; + if (!el) return; + this.atScrollStart = el.scrollLeft <= 0; + this.atScrollEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1; + } + + /** + * Calculates the width of one column step (column width + gap) based on + * the CSS multi-column layout of the scroll container. + */ + private getColumnStep(): number { + const el = this.facetsContentEl; + if (!el) return 0; + // Column step = column width + gap = (visible width + gap) / column count + return (el.clientWidth + MoreFacetsContent.COLUMN_GAP) / this.columnCount; + } + + /** + * Snaps a scroll target to the nearest column boundary. + */ + private snapToColumn(target: number): number { + const step = this.getColumnStep(); + if (step <= 0) return target; + return Math.round(target / step) * step; + } + + /** + * Scrolls the facet content left by approximately one page, snapping to + * the nearest column boundary. + */ + private onScrollLeft(): void { + const el = this.facetsContentEl; + if (!el) return; + const rawTarget = el.scrollLeft - el.clientWidth; + const snapped = Math.max(0, this.snapToColumn(rawTarget)); + el.scrollTo({ left: snapped, behavior: 'smooth' }); + } + + /** + * Scrolls the facet content right by approximately one page, snapping to + * the nearest column boundary. + */ + private onScrollRight(): void { + const el = this.facetsContentEl; + if (!el) return; + const maxScroll = el.scrollWidth - el.clientWidth; + const rawTarget = el.scrollLeft + el.clientWidth; + const snapped = Math.min(maxScroll, this.snapToColumn(rawTarget)); + el.scrollTo({ left: snapped, behavior: 'smooth' }); + } + + /** + * Sets up a ResizeObserver to toggle compact pagination based on component width. + */ + private setupCompactViewObserver(): void { + this.resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const compact = entry.contentRect.width <= 560; + if (this.isCompactView !== compact) this.isCompactView = compact; + } + }); + this.resizeObserver.observe(this); + } + + /** + * Constrains the section's max-height to fit within the nearest + * scroll-container ancestor (e.g., the modal's content area). + * This is a safety net for cases where the CSS max-height calculation + * doesn't perfectly match the container's available space. + */ + private constrainToScrollContainer(): void { + requestAnimationFrame(() => { + const section = this.shadowRoot?.querySelector( + 'section#more-facets', + ) as HTMLElement; + if (!section) return; + + // Walk up from the assigned slot to find the nearest overflow container + let el = this.assignedSlot?.parentElement; + while (el) { + const cs = getComputedStyle(el); + if ( + cs.overflowY === 'auto' || + cs.overflowY === 'scroll' || + cs.overflowY === 'hidden' + ) { + const containerBottom = el.getBoundingClientRect().bottom; + const sectionTop = section.getBoundingClientRect().top; + const available = containerBottom - sectionTop; + // Compare against the CSS max-height rather than actual height, + // since content may not have loaded yet at firstUpdated time + const computedMax = parseFloat(getComputedStyle(section).maxHeight); + if (available > 0 && available < computedMax) { + section.style.maxHeight = `${available}px`; + } + return; + } + el = el.parentElement; + } + }); } /** * Close more facets modal on Escape click */ + private escapeHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.modalManager?.closeModal(); + }; + private setupEscapeListeners() { if (this.modalManager) { - document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') { - this.modalManager?.closeModal(); - } - }); - } else { - document.removeEventListener('keydown', () => {}); + document.addEventListener('keydown', this.escapeHandler); } } @@ -215,34 +441,21 @@ export class MoreFacetsContent extends LitElement { rows: 0, // todo - do we want server-side pagination with offset/page/limit flag? }; - const results = await this.searchService?.search(params, this.searchType); - this.aggregations = results?.success?.response.aggregations; - this.facetsLoading = false; + try { + const results = await this.searchService?.search(params, this.searchType); + this.aggregations = results?.success?.response.aggregations; - const collectionTitles = results?.success?.response?.collectionTitles; - if (collectionTitles) { - for (const [id, title] of Object.entries(collectionTitles)) { - this.collectionTitles?.set(id, title); + const collectionTitles = results?.success?.response?.collectionTitles; + if (collectionTitles) { + for (const [id, title] of Object.entries(collectionTitles)) { + this.collectionTitles?.set(id, title); + } } + } finally { + this.facetsLoading = false; } } - /** - * Handler for page number changes from the pagination widget. - */ - private pageNumberClicked(e: CustomEvent<{ page: number }>) { - const page = e?.detail?.page; - if (page) { - this.pageNumber = Number(page); - } - - this.analyticsHandler?.sendEvent({ - category: analyticsCategories.default, - action: analyticsActions.moreFacetsPageChange, - label: `${this.pageNumber}`, - }); - } - /** * Combines the selected facets with the aggregations to create a single list of facets */ @@ -324,7 +537,10 @@ export class MoreFacetsContent extends LitElement { const buckets: FacetBucket[] = Object.entries(selectedFacetsForKey).map( ([value, data]) => { - const displayText: string = value; + const displayText = + (this.facetKey === 'collection' + ? this.collectionTitles?.get(value) + : undefined) ?? value; return { displayText, key: value, @@ -368,17 +584,33 @@ export class MoreFacetsContent extends LitElement { }); } - // Construct the array of facet buckets from the aggregation buckets + // Construct the array of facet buckets from the aggregation buckets, + // using collection display titles where available. const facetBuckets: FacetBucket[] = sortedBuckets.map(bucket => { const bucketKeyStr = `${bucket.key}`; + const displayText = + (this.facetKey === 'collection' + ? this.collectionTitles?.get(bucketKeyStr) + : undefined) ?? bucketKeyStr; return { - displayText: `${bucketKeyStr}`, + displayText, key: `${bucketKeyStr}`, count: bucket.doc_count, state: 'none', }; }); + // For collection facets sorted alphabetically, re-sort by display title + // instead of the raw identifier used by getSortedBuckets. + if ( + this.facetKey === 'collection' && + this.sortedBy === AggregationSortType.ALPHABETICAL + ) { + facetBuckets.sort((a, b) => + (a.displayText ?? a.key).localeCompare(b.displayText ?? b.key), + ); + } + return { title: facetGroupTitle, key: this.facetKey, @@ -387,29 +619,81 @@ export class MoreFacetsContent extends LitElement { } /** - * Returns a FacetGroup representing only the current page of facet buckets to show. + * Returns the facet group with buckets filtered by the current filter text. + * Filters are applied to the full bucket list before pagination. */ - private get facetGroupForCurrentPage(): FacetGroup | undefined { - const { facetGroup } = this; + private get filteredFacetGroup(): FacetGroup | undefined { + const { facetGroup, filterText } = this; if (!facetGroup) return undefined; - // Slice out only the current page of facet buckets - const firstBucketIndexOnPage = (this.pageNumber - 1) * this.facetsPerPage; - const truncatedBuckets = facetGroup.buckets.slice( - firstBucketIndexOnPage, - firstBucketIndexOnPage + this.facetsPerPage, - ); + // If no filter text, return the full group + if (!filterText.trim()) { + return facetGroup; + } + + // Filter buckets by the text the user actually sees. + // For collections, match against the displayed collection title (not the identifier). + // For other facet types, match against the bucket key (which is also the display text). + const lowerFilter = filterText.toLowerCase().trim(); + const filteredBuckets = facetGroup.buckets.filter(bucket => { + const displayText = this.collectionTitles?.get(bucket.key) ?? bucket.key; + return displayText.toLowerCase().includes(lowerFilter); + }); return { ...facetGroup, - buckets: truncatedBuckets, + buckets: filteredBuckets, + }; + } + + /** + * Determines whether to use pagination based on the number of filtered facets. + * Returns true if facet count >= PAGINATION_THRESHOLD, false otherwise. + */ + private get usePagination(): boolean { + const facetCount = this.filteredFacetGroup?.buckets.length ?? 0; + return facetCount >= PAGINATION_THRESHOLD; + } + + /** + * Returns the facet group for the current page. + * If using pagination (>= 1000 facets), slices to show only the current page. + * Otherwise, returns all facets for horizontal scrolling. + */ + private get facetGroupForCurrentPage(): FacetGroup | undefined { + const filteredGroup = this.filteredFacetGroup; + if (!filteredGroup) return undefined; + + // If facet count is below threshold, show all facets with horizontal scroll + if (!this.usePagination) { + return filteredGroup; + } + + // Otherwise, use pagination - slice to current page + const startIndex = (this.pageNumber - 1) * this.facetsPerPage; + const endIndex = startIndex + this.facetsPerPage; + const slicedBuckets = filteredGroup.buckets.slice(startIndex, endIndex); + + return { + ...filteredGroup, + buckets: slicedBuckets, }; } private get moreFacetsTemplate(): TemplateResult { + const facetGroup = this.facetGroupForCurrentPage; + + // Show empty state if filtering returned no results + if ( + this.filterText.trim() && + (!facetGroup || facetGroup.buckets.length === 0) + ) { + return this.emptyFilterResultsTemplate; + } + return html` ) => { @@ -434,50 +718,51 @@ export class MoreFacetsContent extends LitElement { `; } + private get emptyFilterResultsTemplate(): TemplateResult { + return html` +
+

${msg('No matching values found.')}

+

${msg('Try a different search term.')}

+
+ `; + } + /** - * How many pages of facets to show in the modal pagination widget + * Number of pages for pagination (only used when facet count >= PAGINATION_THRESHOLD). */ private get paginationSize(): number { - if (!this.aggregations || !this.facetKey) return 0; - - // Calculate the appropriate number of pages to show in the modal pagination widget - const length = this.aggregations[this.facetKey]?.buckets.length; - return Math.ceil(length / this.facetsPerPage); + const filteredBuckets = this.filteredFacetGroup?.buckets ?? []; + return Math.ceil(filteredBuckets.length / this.facetsPerPage); } - // render pagination if more then 1 page + /** + * Template for pagination component. + */ private get facetsPaginationTemplate() { - return this.paginationSize > 1 - ? html`` - : nothing; + return html``; } private get footerTemplate() { - if (this.paginationSize > 0) { - return html`${this.facetsPaginationTemplate} - `; - } - - return nothing; + return html` + ${when(this.usePagination, () => this.facetsPaginationTemplate)} + + `; } private sortFacetAggregation(facetSortType: AggregationSortType) { @@ -487,6 +772,44 @@ export class MoreFacetsContent extends LitElement { ); } + /** + * Handler for filter input changes. Updates the filter text and triggers re-render. + */ + private handleFilterInput(e: Event): void { + const input = e.target as HTMLElement & { value: string }; + this.filterText = input.value; + } + + /** + * Handler for when the filter input is cleared via the clear button. + */ + private handleFilterClear(): void { + this.filterText = ''; + } + + /** + * Handler for pagination page number clicks. + * Only used when facet count >= PAGINATION_THRESHOLD. + */ + private pageNumberClicked(e: CustomEvent<{ page: number }>) { + this.pageNumber = e.detail.page; + + // Track page navigation in analytics + this.analyticsHandler?.sendEvent({ + category: analyticsCategories.default, + action: analyticsActions.moreFacetsPageChange, + label: `${this.pageNumber}`, + }); + + this.dispatchEvent( + new CustomEvent('pageChanged', { + detail: this.pageNumber, + bubbles: true, + composed: true, + }), + ); + } + private get modalHeaderTemplate(): TemplateResult { const facetSort = this.sortedBy ?? defaultFacetSort[this.facetKey as FacetOption]; @@ -494,36 +817,103 @@ export class MoreFacetsContent extends LitElement { facetSort === AggregationSortType.COUNT ? 'left' : 'right'; return html`${msg('More facets for:')} - - ${this.facetGroup?.title} - - - ${this.facetKey - ? html`) => { - this.sortFacetAggregation( - Number(e.detail) as AggregationSortType, - ); - }} - >` - : nothing} + ${this.facetGroup?.title} + + + + ${this.facetKey + ? html`) => { + this.sortFacetAggregation( + Number(e.detail) as AggregationSortType, + ); + }} + >` + : nothing} + + + + + + `; } + private get horizontalScrollTemplate(): TemplateResult { + const contentClasses = classMap({ + 'facets-content': true, + 'horizontal-scroll-mode': true, + }); + const showArrows = !this.atScrollStart || !this.atScrollEnd; + + return html`
+ ${when( + showArrows, + () => + html``, + )} +
+
+ ${this.moreFacetsTemplate} +
+
+ ${when( + showArrows, + () => + html``, + )} +
`; + } + render() { + const sectionClasses = classMap({ + 'pagination-mode': this.usePagination, + 'horizontal-scroll-mode': !this.usePagination, + }); + const contentClasses = classMap({ + 'facets-content': true, + 'pagination-mode': this.usePagination, + }); + return html` ${this.facetsLoading ? this.loaderTemplate : html` -
+
${this.modalHeaderTemplate}
-
${this.moreFacetsTemplate}
+ ${this.usePagination + ? html`
+ ${this.moreFacetsTemplate} +
` + : this.horizontalScrollTemplate} ${this.footerTemplate}
`} @@ -546,6 +936,9 @@ export class MoreFacetsContent extends LitElement { // Reset the unapplied changes back to default, now that they have been applied this.unappliedFacetChanges = getDefaultSelectedFacets(); + // Reset filter text + this.filterText = ''; + this.modalManager?.closeModal(); this.analyticsHandler?.sendEvent({ category: analyticsCategories.default, @@ -558,6 +951,9 @@ export class MoreFacetsContent extends LitElement { // Reset the unapplied changes back to default this.unappliedFacetChanges = getDefaultSelectedFacets(); + // Reset filter text + this.filterText = ''; + this.modalManager?.closeModal(); this.analyticsHandler?.sendEvent({ category: analyticsCategories.default, @@ -573,10 +969,26 @@ export class MoreFacetsContent extends LitElement { srOnlyStyle, css` section#more-facets { - overflow: auto; - padding: 10px; /* leaves room for scroll bar to appear without overlaying on content */ + display: flex; + flex-direction: column; + max-height: calc(100vh - 16.5rem - var(--modalBottomMargin, 2.5rem)); + padding: 10px; + box-sizing: border-box; --facetsColumnCount: 3; } + + /* Both modes need a height constraint for proper column flow */ + section#more-facets.horizontal-scroll-mode, + section#more-facets.pagination-mode { + --facetsMaxHeight: 280px; + } + .header-content { + flex-shrink: 0; + position: relative; + z-index: 1; + background: #fff; + } + .header-content .title { display: block; text-align: left; @@ -585,8 +997,22 @@ export class MoreFacetsContent extends LitElement { font-weight: bold; } + .header-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; + padding: 0 10px 8px; + } + + .sort-controls { + display: inline-flex; + align-items: center; + white-space: nowrap; + gap: 5px; + } + .sort-label { - margin-left: 20px; font-size: 1.3rem; } @@ -594,11 +1020,115 @@ export class MoreFacetsContent extends LitElement { font-weight: normal; } + .filter-controls { + display: inline-flex; + align-items: center; + white-space: nowrap; + } + + .filter-label { + font-size: 1.3rem; + } + + .filter-input { + --input-height: 2.5rem; + --input-font-size: 1.3rem; + --input-border-radius: 4px; + --input-padding: 4px 8px; + --input-focused-border-color: ${modalSubmitButton}; + width: 150px; + margin-left: 5px; + } + + .empty-results { + text-align: center; + padding: 40px 20px; + color: #666; + } + + .empty-results .hint { + margin-top: 10px; + } + .facets-content { font-size: 1.2rem; - max-height: 300px; - overflow: auto; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; padding: 10px; + /* Force scrollbar to always be visible */ + scrollbar-width: thin; /* Firefox */ + scrollbar-color: #888 #f1f1f1; /* Firefox - thumb and track colors */ + } + + /* Horizontal scroll mode: horizontal scrolling only */ + .facets-content.horizontal-scroll-mode { + overflow-x: auto; + overflow-y: hidden; + } + + /* Webkit browsers scrollbar styling - always visible */ + .facets-content::-webkit-scrollbar { + width: 12px; /* Vertical scrollbar width */ + height: 12px; /* Horizontal scrollbar height */ + } + + .facets-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 6px; + } + + .facets-content::-webkit-scrollbar-thumb { + background: #888; + border-radius: 6px; + min-height: 30px; /* Ensure thumb is always visible when scrolling is possible */ + } + + .facets-content::-webkit-scrollbar-thumb:hover { + background: #555; + } + + /* Force corner to match track color */ + .facets-content::-webkit-scrollbar-corner { + background: #f1f1f1; + } + + .facets-horizontal-container { + display: inline-block; + min-width: 100%; + /* Allow natural width expansion based on content */ + width: fit-content; + } + + .scroll-nav-container { + display: flex; + align-items: center; + flex: 1 1 auto; + min-height: 0; + } + + .scroll-nav-container .facets-content { + flex: 1 1 auto; + min-width: 0; + } + + .scroll-arrow { + background: none; + border: none; + cursor: pointer; + padding: 5px; + flex-shrink: 0; + } + + .scroll-arrow svg { + height: 14px; + fill: #2c2c2c; + } + + .scroll-arrow:disabled { + opacity: 0.3; + cursor: default; } .facets-loader { --icon-width: 70px; @@ -614,6 +1144,7 @@ export class MoreFacetsContent extends LitElement { width: auto; border-radius: 4px; cursor: pointer; + font-family: inherit; } .btn-cancel { background-color: #2c2c2c; @@ -623,19 +1154,37 @@ export class MoreFacetsContent extends LitElement { background-color: ${modalSubmitButton}; color: white; } + more-facets-pagination { + flex-shrink: 0; + } + .footer { text-align: center; margin-top: 10px; + flex-shrink: 0; } @media (max-width: 560px) { - section#more-facets { - max-height: 450px; - --facetsColumnCount: 1; + section#more-facets.horizontal-scroll-mode, + section#more-facets.pagination-mode { + --facetsColumnCount: 1; /* Single column on mobile */ + --facetsMaxHeight: none; /* Remove fixed height for vertical scrolling */ } - .facets-content { + /* On mobile, always use vertical scrolling regardless of mode */ + .facets-content, + .facets-content.horizontal-scroll-mode { overflow-y: auto; - height: 300px; + overflow-x: hidden; + } + .scroll-nav-container { + display: contents; /* Remove wrapper from layout so section flex-column works */ + } + .scroll-arrow { + display: none; + } + .filter-input { + width: 120px; + --input-font-size: 1.2rem; } } `, diff --git a/src/collection-facets/more-facets-pagination.ts b/src/collection-facets/more-facets-pagination.ts index c5d4c6f2a..aa80e23f7 100644 --- a/src/collection-facets/more-facets-pagination.ts +++ b/src/collection-facets/more-facets-pagination.ts @@ -25,19 +25,27 @@ export class MoreFacetsPagination extends LitElement { @property({ type: Number }) currentPage: number = 1; - @state() pages?: number[] = []; + /** + * When true, shows a more compact set of page numbers + * (only 1 neighbor on each side of the current page). + */ + @property({ type: Boolean }) compact = false; - firstUpdated() { - this.observePageCount(); - } + @state() pages?: number[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any - override updated(changed: Map) { - if (changed.has('size')) { - this.observePageCount(); + override willUpdate(changed: Map) { + if ( + changed.has('size') || + changed.has('compact') || + changed.has('currentPage') + ) { + this.updatePages(); } - if (changed.has('currentPage')) { - this.observePageCount(); + if ( + changed.has('currentPage') && + changed.get('currentPage') !== undefined + ) { this.emitPageClick(); } } @@ -50,9 +58,14 @@ export class MoreFacetsPagination extends LitElement { * - outlier: last page selected, show 1 ... N-2 N-1 _N_ * - outlier: if page count = 7, & selected is either [2, 3, 4, 5, 6], show all pages */ - observePageCount() { + updatePages() { this.pages = []; /* `0` is elipses marker */ + if (this.compact) { + this.updatePagesCompact(); + return; + } + const paginatorMaxPagesToShow = 7; const atMinThreshold = this.size <= paginatorMaxPagesToShow; @@ -166,6 +179,52 @@ export class MoreFacetsPagination extends LitElement { } } + /** + * Compact page calculation: shows first, ..., prev, current, next, ..., last. + * Only 1 neighbor on each side of the current page for minimal width. + */ + private updatePagesCompact() { + if (this.size <= 3) { + this.pages = [...Array(this.size).keys()].map(i => i + 1); + return; + } + + const pages: number[] = []; + + // First page + pages.push(1); + + // Ellipsis after first if current is far enough away + if (this.currentPage > 3) { + pages.push(0); + } + + // Previous page (if not already shown as first) + if (this.currentPage - 1 > 1) { + pages.push(this.currentPage - 1); + } + + // Current page (if not first or last) + if (this.currentPage !== 1 && this.currentPage !== this.size) { + pages.push(this.currentPage); + } + + // Next page (if not already the last) + if (this.currentPage + 1 < this.size) { + pages.push(this.currentPage + 1); + } + + // Ellipsis before last if current is far enough away + if (this.currentPage < this.size - 2) { + pages.push(0); + } + + // Last page + pages.push(this.size); + + this.pages = pages; + } + private get getEllipsisTemplate() { return html`...`; } @@ -250,6 +309,10 @@ export class MoreFacetsPagination extends LitElement { margin-top: 10px; background-color: #eee; text-align: center; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: nowrap; } .facets-pagination button { border: none; @@ -276,6 +339,7 @@ export class MoreFacetsPagination extends LitElement { vertical-align: baseline; display: inline-block; min-width: 2.5rem; + font-family: inherit; } .facets-pagination i { cursor: auto; @@ -288,6 +352,16 @@ export class MoreFacetsPagination extends LitElement { .page-numbers { display: inline-block; } + + @media (max-width: 560px) { + .facets-pagination button, + .facets-pagination i { + margin: 5px 2px; + padding: 3px; + min-width: 2rem; + font-size: 1.2rem; + } + } `, ]; } diff --git a/src/collection-facets/toggle-switch.ts b/src/collection-facets/toggle-switch.ts index 23109e4fc..d05901e2f 100644 --- a/src/collection-facets/toggle-switch.ts +++ b/src/collection-facets/toggle-switch.ts @@ -62,6 +62,7 @@ export class ToggleSwitch extends LitElement { id="switch-button" class=${this.side} aria-hidden="true" + tabindex="-1" @click=${this.handleClick} >
diff --git a/test/collection-facets/more-facets-content.test.ts b/test/collection-facets/more-facets-content.test.ts index a3d299a5c..96428a205 100644 --- a/test/collection-facets/more-facets-content.test.ts +++ b/test/collection-facets/more-facets-content.test.ts @@ -5,7 +5,10 @@ import '../../src/collection-facets/more-facets-content'; import { MockSearchService } from '../mocks/mock-search-service'; import { MockAnalyticsHandler } from '../mocks/mock-analytics-handler'; import type { FacetsTemplate } from '../../src/collection-facets/facets-template'; -import type { SelectedFacets } from '../../src/models'; +import { + getDefaultSelectedFacets, + type SelectedFacets, +} from '../../src/models'; const selectedFacetsGroup = { title: 'Media Type', @@ -54,7 +57,7 @@ describe('More facets content', () => { expect(el.shadowRoot?.querySelector('.facets-loader')).to.exist; }); - it('should render pagination for more facets', async () => { + it('should NOT render pagination when facet count < 1000', async () => { const searchService = new MockSearchService(); const el = await fixture( @@ -64,11 +67,22 @@ describe('More facets content', () => { ); el.facetKey = 'year'; - el.query = 'more-facets'; // Produces a response with 40+ aggregations for multiple pages + el.query = 'more-facets'; // Produces a response with 45 aggregations (< 1000) await el.updateComplete; await aTimeout(50); // Give it a moment to perform the (mock) search query after the initial update - expect(el.shadowRoot?.querySelectorAll('more-facets-pagination')).to.exist; + // Verify pagination component is NOT present (horizontal scroll mode) + expect(el.shadowRoot?.querySelector('more-facets-pagination')).to.not.exist; + + // Verify horizontal scroll mode CSS class is applied + expect( + el.shadowRoot?.querySelector('.facets-content.horizontal-scroll-mode'), + ).to.exist; + + // Verify footer still exists with buttons + expect(el.shadowRoot?.querySelector('.footer')).to.exist; + expect(el.shadowRoot?.querySelector('.btn-cancel')).to.exist; + expect(el.shadowRoot?.querySelector('.btn-submit')).to.exist; }); it('query for more facets content using search service', async () => { @@ -228,4 +242,312 @@ describe('More facets content', () => { expect(mockAnalyticsHandler.callAction).to.equal('applyMoreFacetsModal'); expect(mockAnalyticsHandler.callLabel).to.equal('collection'); }); + + it('should have horizontal scrolling enabled', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'year'; + el.query = 'more-facets'; + await el.updateComplete; + await aTimeout(50); + + const facetsContent = el.shadowRoot?.querySelector( + '.facets-content', + ) as HTMLElement; + const styles = window.getComputedStyle(facetsContent); + + expect(styles.overflowX).to.equal('auto'); + expect(styles.overflowY).to.equal('hidden'); + }); + + it('should have horizontal container wrapper', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'year'; + el.query = 'more-facets'; + await el.updateComplete; + await aTimeout(50); + + const container = el.shadowRoot?.querySelector( + '.facets-horizontal-container', + ); + expect(container).to.exist; + + const facetsTemplate = container?.querySelector('facets-template'); + expect(facetsTemplate).to.exist; + }); + + it('should render pagination when facet count >= 1000', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'subject'; + el.query = 'large-facets'; // Produces a response with 1100 aggregations (>= 1000) + await el.updateComplete; + await aTimeout(50); + + // Verify pagination component IS present + expect(el.shadowRoot?.querySelector('more-facets-pagination')).to.exist; + + // Verify pagination mode CSS class is applied + expect(el.shadowRoot?.querySelector('.facets-content.pagination-mode')).to + .exist; + + // Verify horizontal container wrapper does NOT exist in pagination mode + expect(el.shadowRoot?.querySelector('.facets-horizontal-container')).to.not + .exist; + }); + + it('pagination page change should send analytics event', async () => { + const searchService = new MockSearchService(); + const mockAnalyticsHandler = new MockAnalyticsHandler(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'subject'; + el.query = 'large-facets'; // Produces a response with 1100 aggregations (>= 1000) + await el.updateComplete; + await aTimeout(50); + + // Get the pagination component + const pagination = el.shadowRoot?.querySelector( + 'more-facets-pagination', + ) as any; + expect(pagination).to.exist; + + // Simulate clicking page 2 + pagination.currentPage = 2; + await pagination.updateComplete; + + // Verify analytics event was sent + expect(mockAnalyticsHandler.callCategory).to.equal('collection-browser'); + expect(mockAnalyticsHandler.callAction).to.equal('moreFacetsPageChange'); + expect(mockAnalyticsHandler.callLabel).to.equal('2'); + }); + + it('should render clearable text input for filtering', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + await el.updateComplete; + await aTimeout(50); + + // Verify the clearable text input component is present + const clearableInput = el.shadowRoot?.querySelector( + 'ia-clearable-text-input', + ) as HTMLElement; + expect(clearableInput).to.exist; + }); + + it('should clear filter text when clear event is dispatched', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + await el.updateComplete; + await aTimeout(50); + + // Simulate typing into the clearable input by dispatching input event + const clearableInput = el.shadowRoot?.querySelector( + 'ia-clearable-text-input', + ) as HTMLElement & { value: string }; + expect(clearableInput).to.exist; + + clearableInput.value = 'test'; + clearableInput.dispatchEvent(new Event('input')); + await el.updateComplete; + + // Dispatch clear event + clearableInput.dispatchEvent(new CustomEvent('clear', { detail: 'test' })); + await el.updateComplete; + + // Verify the filter was cleared + expect(clearableInput.value).to.equal(''); + }); + + describe('Modal container height constraint', () => { + // Register a test wrapper element to simulate the modal's scroll container + if (!customElements.get('test-scroll-wrapper')) { + customElements.define( + 'test-scroll-wrapper', + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot!.innerHTML = ` + +
+ `; + } + }, + ); + } + + it('should constrain section height when inside a scroll container', async () => { + const el = await fixture(html` + + + + `); + + const mfc = el.querySelector('more-facets-content') as MoreFacetsContent; + mfc.facetsLoading = false; + await mfc.updateComplete; + + // Wait for the constrainToScrollContainer rAF callback + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)), + ); + + const section = mfc.shadowRoot?.querySelector( + 'section#more-facets', + ) as HTMLElement; + + // The section's inline max-height should be set when it would + // overflow the 300px scroll container + const sectionHeight = section.getBoundingClientRect().height; + const wrapper = el.shadowRoot?.querySelector('.content') as HTMLElement; + const wrapperBottom = wrapper.getBoundingClientRect().bottom; + const sectionTop = section.getBoundingClientRect().top; + const availableSpace = wrapperBottom - sectionTop; + + // The section should not exceed the available space in the container + expect(sectionHeight).to.be.at.most(availableSpace + 1); // +1 for rounding + }); + + it('should not constrain section when no scroll container exists', async () => { + const el = await fixture( + html``, + ); + + el.facetsLoading = false; + await el.updateComplete; + + // Wait for the constrainToScrollContainer rAF callback + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)), + ); + + const section = el.shadowRoot?.querySelector( + 'section#more-facets', + ) as HTMLElement; + + // No inline max-height should be set when there's no scroll container + expect(section.style.maxHeight).to.equal(''); + }); + }); + + describe('Horizontal scroll navigation arrows', () => { + it('should use scroll-nav-container in horizontal scroll mode', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'year'; + el.query = 'more-facets'; // Produces < 1000 aggregations + await el.updateComplete; + await aTimeout(50); + + // Verify scroll navigation container exists in horizontal scroll mode + expect(el.shadowRoot?.querySelector('.scroll-nav-container')).to.exist; + + // Verify horizontal container and facets-content exist inside it + expect( + el.shadowRoot?.querySelector( + '.scroll-nav-container .facets-content.horizontal-scroll-mode', + ), + ).to.exist; + expect( + el.shadowRoot?.querySelector( + '.scroll-nav-container .facets-horizontal-container', + ), + ).to.exist; + }); + + it('should NOT show scroll arrows in pagination mode', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'subject'; + el.query = 'large-facets'; // Produces >= 1000 aggregations + await el.updateComplete; + await aTimeout(50); + + // Verify scroll navigation container does NOT exist + expect(el.shadowRoot?.querySelector('.scroll-nav-container')).to.not + .exist; + expect(el.shadowRoot?.querySelector('.scroll-arrow')).to.not.exist; + }); + + it('should hide scroll arrows when content does not overflow', async () => { + const searchService = new MockSearchService(); + + const el = await fixture( + html``, + ); + + el.facetKey = 'year'; + el.query = 'more-facets'; + await el.updateComplete; + await aTimeout(50); + + // In test environment, there's no real layout so scrollWidth === clientWidth. + // Arrows should be hidden when there's no horizontal overflow. + expect(el.shadowRoot?.querySelector('.scroll-arrow')).to.not.exist; + }); + }); }); diff --git a/test/collection-facets/more-facets-pagination.test.ts b/test/collection-facets/more-facets-pagination.test.ts index 1fcc152cf..c7c8c13b5 100644 --- a/test/collection-facets/more-facets-pagination.test.ts +++ b/test/collection-facets/more-facets-pagination.test.ts @@ -112,7 +112,7 @@ describe('More facets pagination', () => { const fake1 = sinon.fake(); const fake2 = sinon.fake(); - el.observePageCount = fake1; + el.updatePages = fake1; el.emitPageClick = fake2; // select first page button @@ -146,7 +146,7 @@ describe('More facets pagination', () => { const fake1 = sinon.fake(); const fake2 = sinon.fake(); - el.observePageCount = fake1; + el.updatePages = fake1; el.emitPageClick = fake2; // select first page button @@ -182,7 +182,7 @@ describe('More facets pagination', () => { const fake1 = sinon.fake(); const fake2 = sinon.fake(); - el.observePageCount = fake1; + el.updatePages = fake1; el.emitPageClick = fake2; // select first page button @@ -198,4 +198,88 @@ describe('More facets pagination', () => { expect(el.currentPage).to.equal(6); // brings us forward 1 page }); }); + + describe('Compact mode', () => { + it('shows all pages when size <= 3', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + expect(el.pages).to.deep.equal([1, 2, 3]); + }); + + it('shows first, prev, current, next, ..., last for middle page', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + // first, ..., prev, current, next, ..., last + expect(el.pages).to.deep.equal([1, 0, 9, 10, 11, 0, 20]); + }); + + it('shows correct pages when on page 1', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + // first (current), next, ..., last + expect(el.pages).to.deep.equal([1, 2, 0, 20]); + }); + + it('shows correct pages when on last page', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + // first, ..., prev, last (current) + expect(el.pages).to.deep.equal([1, 0, 19, 20]); + }); + + it('shows correct pages when on page 2', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + // first, current, next, ..., last + expect(el.pages).to.deep.equal([1, 2, 3, 0, 20]); + }); + + it('shows correct pages when on second-to-last page', async () => { + const el = await fixture( + html``, + ); + + await el.updateComplete; + // first, ..., prev, current, last + expect(el.pages).to.deep.equal([1, 0, 18, 19, 20]); + }); + }); }); diff --git a/test/mocks/mock-search-responses.ts b/test/mocks/mock-search-responses.ts index 22abdcc99..76e2aa7ca 100644 --- a/test/mocks/mock-search-responses.ts +++ b/test/mocks/mock-search-responses.ts @@ -1349,6 +1349,54 @@ export const getMockSuccessWithManyAggregations: () => Result< }, }); +/** + * Returns a mock response with 1000+ subject aggregation buckets, + * used to test pagination mode in the More Facets dialog. + */ +export const getMockSuccessWithLargeAggregations: () => Result< + SearchResponse, + SearchServiceError +> = () => { + const buckets = Array.from({ length: 1100 }, (_, i) => ({ + key: `subject-${i}`, + doc_count: 1100 - i, + })); + return { + success: { + request: { + kind: 'aggregations' as const, + clientParameters: { + user_query: 'large-facets', + sort: [], + }, + backendRequests: { + primary: { + kind: 'aggregations' as const, + finalized_parameters: { + user_query: 'large-facets', + sort: [], + }, + }, + }, + }, + rawResponse: {}, + sessionContext: {}, + response: { + totalResults: 0, + returnedCount: 0, + results: [], + aggregations: { + subject: new Aggregation({ buckets }), + }, + }, + responseHeader: { + succeeded: true, + query_time: 0, + }, + }, + }; +}; + export const getMockErrorResult: () => Result< SearchResponse, SearchServiceError diff --git a/test/mocks/mock-search-service.ts b/test/mocks/mock-search-service.ts index 71e168334..734a2b0bc 100644 --- a/test/mocks/mock-search-service.ts +++ b/test/mocks/mock-search-service.ts @@ -33,6 +33,7 @@ import { getMockSuccessNoResults, getMockSuccessWithWebArchiveHits, getMockSuccessWithManyAggregations, + getMockSuccessWithLargeAggregations, getMockSuccessTvFields, getMockSuccessArchiveOrgUserResult, getMockSuccessArchiveOrgUserNoBlurResult, @@ -64,6 +65,7 @@ const responses: Record< 'tv-collection': getMockSuccessForTvCollection, 'web-archive': getMockSuccessWithWebArchiveHits, 'more-facets': getMockSuccessWithManyAggregations, + 'large-facets': getMockSuccessWithLargeAggregations, 'many-fields': getMockSuccessManyFields, 'tv-fields': getMockSuccessTvFields, 'no-results': getMockSuccessNoResults, diff --git a/yarn.lock b/yarn.lock index fa6acd98b..848dfc00c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -349,6 +349,14 @@ dependencies: lit "^2.8.0" +"@internetarchive/ia-clearable-text-input@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@internetarchive/ia-clearable-text-input/-/ia-clearable-text-input-1.1.1.tgz#5b5c702d5b67ffb73c81384a53d278bb63b08bfc" + integrity sha512-l3Z6gKfSRURvicHlAjL3V+q/yESYSev34fswfG7ESIiWuF7UuWDKjlLOyY3gFch8eAfqS5AQ6KurRw5JWGD4RA== + dependencies: + "@internetarchive/icon-close" "^1.3.4" + lit "^2.2.7" + "@internetarchive/ia-dropdown@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@internetarchive/ia-dropdown/-/ia-dropdown-2.0.0.tgz" @@ -379,7 +387,7 @@ "@internetarchive/field-parsers" "^1.0.0" typescript-memoize "^1.1.1" -"@internetarchive/icon-close@^1.4.0": +"@internetarchive/icon-close@^1.3.4", "@internetarchive/icon-close@^1.4.0": version "1.4.0" resolved "https://registry.npmjs.org/@internetarchive/icon-close/-/icon-close-1.4.0.tgz" integrity sha512-MDNwjt2PubjhsxwY0Td3Fl2LPLwhTvLT717Eju06ogHFt8SFDRbxY8iU0dIebM9LvT+2k4cAQdtbUEfCVWnRog==