Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion openlibrary/components/lit/OlSelectPopover.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
clearLabel: { type: String, attribute: 'clear-label' },
noMatchesLabel: { type: String, attribute: 'no-matches-label' },
_query: { state: true },
loading: { type: Boolean, reflect: true },
};

static styles = css`
Expand Down Expand Up @@ -365,6 +366,45 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
outline: var(--focus-width) solid var(--color-focus-ring);
outline-offset: 2px;
}

/* ── Item count ──────────────────────────────────────────────── */

.item-count {
margin-left: auto;
flex-shrink: 0;
color: var(--accessible-grey);
font-size: var(--font-size-label-medium);
font-variant-numeric: tabular-nums;
}

/* ── Host-driven loading state ───────────────────────────────────────── */

@keyframes ol-sp-spin {
to { transform: rotate(360deg); }
}

.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-inline-sm);
padding: var(--spacing-inset-md);
color: var(--accessible-grey);
font-size: var(--font-size-body-medium);
}
.loading-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--color-border-subtle);
border-top-color: var(--accessible-grey);
border-radius: 50%;
flex-shrink: 0;
animation: ol-sp-spin 0.65s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
.loading-spinner { animation: none; opacity: 0.5; }
}
`;

/** Chevron icon for the default trigger */
Expand Down Expand Up @@ -396,6 +436,7 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
// re-homes the item between the selected/suggestions groups (which
// destroys its DOM node, so its focus is lost).
this._restoreFocusToValue = null;
this.loading = false;
}

/**
Expand Down Expand Up @@ -522,6 +563,13 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
</ul>
` : nothing}
<div class="list-area" id=${this._panelId} @keydown=${this._onListKeydown}>
${this.loading
? html`
<div class="loading-row" role="status" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
<span>Loading…</span>
</div>`
: html`
<ul
class="group group--suggestions"
role="group"
Expand All @@ -531,7 +579,7 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
${filteredSuggestions.length === 0 && query
? html`<li class="empty-state">${this.noMatchesLabel}</li>`
: repeat(filteredSuggestions, it => it.value, it => this._renderItem(it))}
</ul>
</ul>`}
</div>
${hasSelected ? html`
<div class="footer">
Expand Down Expand Up @@ -559,6 +607,9 @@ export class OlSelectPopover extends FocusableHostMixin(LitElement) {
@change=${this._onItemToggle}
/>
<span class="item-label">${item.label}</span>
${item.count !== null && item.count !== undefined
? html`<span class="item-count" aria-hidden="true">${item.count.toLocaleString()}</span>`
: nothing}
</label>
</li>
`;
Expand Down
19 changes: 19 additions & 0 deletions openlibrary/components/lit/custom-elements.json
Original file line number Diff line number Diff line change
Expand Up @@ -3344,6 +3344,17 @@
},
"default": "null"
},
{
"kind": "field",
"name": "loading",
"privacy": "public",
"type": {
"text": "boolean"
},
"default": "false",
"attribute": "loading",
"reflects": true
},
{
"kind": "field",
"name": "shadowRootOptions",
Expand Down Expand Up @@ -3489,6 +3500,14 @@
},
"default": "''",
"fieldName": "_query"
},
{
"name": "loading",
"type": {
"text": "boolean"
},
"default": "false",
"fieldName": "loading"
}
],
"mixins": [
Expand Down
92 changes: 75 additions & 17 deletions openlibrary/plugins/openlibrary/js/SearchFilterBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
* bar — the user gets the filters they last set in this session.
*
* The full language catalogue is fetched lazily on first popover open.
* Context-aware facet counts are fetched
* in parallel with the catalogue whenever there is an active search query.
*/

import {
Expand All @@ -35,6 +37,7 @@ import {
readStoredLanguages,
} from './search-modal/constants.js';
import { fetchLanguageOptions } from './search-modal/languages.js';
import { fetchFacetCounts } from './search-modal/searchFacets.js';
import { trackEvent } from './ol.analytics.js';

// Every query param the availability filter owns, across all of its values.
Expand All @@ -44,6 +47,35 @@ const AVAILABILITY_PARAM_KEYS = [
...new Set(Object.values(AVAILABILITY_TO_PARAMS).flatMap(Object.keys)),
];

// ── Facet field config ─────────────────────────────────────────────────────
//
// Maps each OlSelectPopover to its Solr facet field name (validated server-side
// against WorkSearchScheme.facet_fields).
/** @type {Map<HTMLElement, string>} */
let POPOVER_FIELD_CONFIG;

// ── Merge helpers ──────────────────────────────────────────────────────────

/**
* Merge context-aware facet counts from the API into the item list, then
* sort and filter according to the issue spec:
* @param {Array<{value: string, label: string}>} items - Full catalogue list.
* @param {Array<{value: string, count: number}>} counts - API response.
* @param {string[]} selectedValues - Currently selected item values.
* @returns {Array<{value: string, label: string, count: number}>}
*/
export function mergeFacetCounts(items, counts, selectedValues) {
const countMap = new Map(counts.map(c => [c.label, c.count]));
const selectedSet = new Set(selectedValues);

return items
.map(it => ({ ...it, count: countMap.get(it.label) ?? 0 }))
.filter(it => it.count > 0 || selectedSet.has(it.value))
.sort((a, b) => b.count - a.count);
}

// ── sessionStorage helpers ─────────────────────────────────────────────────

function writeStoredAvailability(value) {
ssSet(SS_AVAILABILITY_KEY, value || DEFAULT_AVAILABILITY);
}
Expand Down Expand Up @@ -127,11 +159,18 @@ export function initSearchFilterBar(container) {
const availabilityEl = container.querySelector('ol-toggle');
const languageEl = container.querySelector('ol-select-popover');

// Build the facet field config now that we have element references.
// Future filters: add an entry here.
POPOVER_FIELD_CONFIG = new Map([
...(languageEl ? [[languageEl, 'language']] : []),
]);

// True when the page has a meaningful search query — facet counts are only
// fetched in this case. An empty or whitespace-only q= is treated as no
// query (popularity-sorted catalogue, no per-query counts).
const hasQuery = (currentParams.get('q') || '').trim().length > 0;

if (availabilityEl) {
// Binary availability: the toggle reads as "on" whenever any
// readable-scoped filter is in the URL (readable / open / borrowable),
// and "off" for the all-books default. Flipping it on applies the broad
// `readable` filter; flipping it off clears every availability param.
availabilityEl.checked =
availabilityFromParams((name) => currentParams.get(name)) !== DEFAULT_AVAILABILITY;
availabilityEl.addEventListener('ol-toggle-change', (e) => {
Expand All @@ -144,8 +183,6 @@ export function initSearchFilterBar(container) {
Object.entries(mapped).forEach(([key, val]) => params.set(key, val));
});
});
// The readable-count sublabel is rendered server-side (work_search.html),
// so it's already present on first paint — nothing to fetch here.
}

if (languageEl) {
Expand All @@ -154,17 +191,38 @@ export function initSearchFilterBar(container) {
languageEl.items = DEFAULT_LANGUAGE_OPTIONS;
languageEl.selected = currentParams.getAll('language');

// Defer fetching the full catalogue list until the popover first opens.
// Most searches never touch the language filter, so this avoids the
// /languages.json request entirely for them. ol-popover-open bubbles
// (composed) out of the popover's shadow root up to this host.
let languagesLoaded = false;
languageEl.addEventListener('ol-popover-open', () => {
if (languagesLoaded) return;
languagesLoaded = true;
fetchLanguageOptions().then((options) => {
languageEl.items = options;
});
// Defer fetching the full catalogue + context-aware counts until the
// popover first opens. Most searches never touch the language filter,
// avoiding both the /languages.json and /search/facets.json requests.
// On subsequent opens of the same dropper (same page load / same query):
// counts are already merged into items; nothing to re-fetch.
let loaded = false;

languageEl.addEventListener('ol-popover-open', async() => {
if (loaded) return;
loaded = true;

const field = POPOVER_FIELD_CONFIG.get(languageEl);
languageEl.loading = true;

try {
// Fetch catalogue and counts in parallel to minimise latency.
const [options, counts] = await Promise.all([
fetchLanguageOptions(),
hasQuery && field
? fetchFacetCounts(field, currentParams)
: Promise.resolve([]),
]);

languageEl.items = (hasQuery && counts.length > 0)
? mergeFacetCounts(options, counts, languageEl.selected || [])
: options;
} catch (_err) {
// Graceful degradation: keep DEFAULT_LANGUAGE_OPTIONS seeded at
// init. Filtering must never break (spec requirement).
} finally {
languageEl.loading = false;
}
});

languageEl.addEventListener('ol-select-popover-change', (e) => {
Expand Down
36 changes: 36 additions & 0 deletions openlibrary/plugins/openlibrary/js/search-modal/searchFacets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Service module — context-aware facet counts.
*
* Sole owner of the /search/facets.json URL and response shape.
* Mirrors the fetchLanguageOptions() pattern in search-modal/languages.js.
*/

const FACETS_URL = '/search/facets.json';

/**
* Fetch context-aware facet value counts for a single Solr field.
*
* @param {string} field - A field name from WorkSearchScheme.facet_fields,
* e.g. 'language', 'author_facet', 'subject_facet'.
* @param {URLSearchParams} searchParams - The current page search params
* (q, availability flags, etc.) forwarded as-is to the backend.
* @returns {Promise<Array<{value: string, count: number}>>}
* Count-descending list of {value, count} pairs (count > 0 only).
*/
export async function fetchFacetCounts(field, searchParams) {
const params = new URLSearchParams(searchParams);
params.set('field', field);

const response = await fetch(`${FACETS_URL}?${params.toString()}`);
if (!response.ok) {
throw new Error(`fetchFacetCounts: HTTP ${response.status} for field="${field}"`);
}

const data = await response.json();

// The API returns either shape for forward-compatibility with multi-field
// Flat array (single-field): [{value, count}, ...]
// Field-keyed map (multi): { "language": [{value, count}], ... }
if (Array.isArray(data)) return data;
return data[field] ?? [];
}
Loading