diff --git a/docs/ai/README.md b/docs/ai/README.md index 3fbdcf9c266..0e9b0b6eda8 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -168,8 +168,9 @@ When creating PRs, use the template in `.github/pull_request_template.md` for th These companion docs cover specific areas in depth: - [CSS](css.md) — BEM naming, selector rules, tokens in practice, bundle sizes, CSS-to-template wiring -- [Design](design.md) — UI design patterns: typography, layout shift prevention, design tokens, animations -- [Web Component Standards](web-components.md) — When to build a component, Lit conventions, accessibility, events +- [Design](design.md) — UI design patterns: typography, layout shift prevention, design tokens, animations, mobile +- [Web Component Standards](web-components.md) — When to build a component, Lit conventions, accessibility, events, focus + shadow DOM +- [Internationalization](i18n.md) — `$_()` in templates, the `data-i18n` bridge for client-rendered strings ## Key File Locations diff --git a/docs/ai/design.md b/docs/ai/design.md index 974518255ac..e1a7581a68e 100644 --- a/docs/ai/design.md +++ b/docs/ai/design.md @@ -115,4 +115,49 @@ CSS custom properties inherit through the shadow boundary, so design tokens work | Hover causes flicker | Animate child element, not parent | | Popover scales from wrong point | Set `transform-origin` to trigger location | | Sequential tooltips feel slow | Skip delay/animation after first tooltip | -| Hover triggers on mobile | Use `@media (hover: hover) and (pointer: fine)` | +| Hover triggers on mobile | Use `@media (hover: hover) and (pointer: fine)` — see [Mobile](#mobile) | + +## Mobile + +### Prevent iOS Safari auto-zoom on input focus + +iOS Safari auto-zooms the viewport when the user focuses any text input with `font-size < 16px`. The page stays zoomed after the input blurs, which is jarring and breaks fixed-position layout. Fix: set `font-size: 16px` on every text input that can receive focus on mobile. + +```css +.search-modal__input { + /* Visually 14px-feeling input, but 16px to dodge iOS auto-zoom. */ + font-size: 16px; +} +``` + +If you need the input to look smaller, scale it visually rather than dropping below 16px (e.g., reduce padding, use `transform: scale()` only on non-text affordances). + +### Gate hover styles to hover-capable pointers + +Touch devices fire `:hover` on tap and the style sticks until the next tap elsewhere. That makes plain `:hover` rules feel broken on phones — buttons stay highlighted, tooltips linger. + +Wrap hover styles in `@media (hover: hover) and (pointer: fine)` so they only apply on devices with a precise hover-capable pointer (mouse, trackpad): + +```css +.chip { + background: var(--white); +} + +@media (hover: hover) and (pointer: fine) { + .chip:hover { + background: var(--lightest-grey); + } +} +``` + +Use the same query to decide which affordance to render in markup. For example, the search modal shows a tappable close button on touch devices and an "ESC" pill on hover-capable pointers (where the keyboard is the expected dismiss path). Pick one or the other rather than showing both. + +```css +.dismiss-touch { display: block; } +.dismiss-keyboard { display: none; } + +@media (hover: hover) and (pointer: fine) { + .dismiss-touch { display: none; } + .dismiss-keyboard { display: block; } +} +``` diff --git a/docs/ai/i18n.md b/docs/ai/i18n.md new file mode 100644 index 00000000000..c0c4fa4c2c4 --- /dev/null +++ b/docs/ai/i18n.md @@ -0,0 +1,118 @@ +# Internationalization + +How to translate UI strings in Open Library. Server-rendered text uses Templetor's `$_()` directly. Client-rendered text (Lit components, vanilla JS UI, anything that builds DOM in the browser) needs the bridge pattern below. + +## Server-rendered strings + +In a `.html` template, wrap any user-visible string in `$_()`: + +```html +

$_("Recently added")

+ +``` + +Strings are extracted to `openlibrary/i18n/messages.pot` by the `generate-pot` pre-commit hook and translated per locale under `openlibrary/i18n//`. + +## Client-rendered strings: the `data-i18n` bridge + +JavaScript modules cannot use Templetor's `$_()` — and the JS-side `ugettext` helper in this codebase is **only a pass-through**, not a real translation call. Strings hardcoded in JS ship in English everywhere. + +The working pattern is to render translated strings server-side into a `data-i18n` attribute on the consuming element, then read them in JS at startup. The component module also exports English defaults that double as both the source for `.pot` extraction (via the partial) and the runtime fallback. + +### 1. Write a `_i18n.html` partial that emits a JSON dict + +Create a small template that returns a JSON object of translated strings. By convention these live next to the feature template and are named `_i18n.html`. + +```html +$def with () +$# Translated chrome strings for the header search modal (SearchModal.js). +$# Rendered into the trigger's `data-i18n-ui` attribute and read by +$# search-modal/constants.js (searchModalStringsFromElement). The keys and +$# English source strings here must match DEFAULT_SEARCH_MODAL_STRINGS in that +$# same file. +$ search_modal_i18n = { +$ "inputPlaceholder": _("Search books, authors…"), +$ "closeAria": _("Close search"), +$ "noResults": _("No results found"), +$ "removeFilter": _("Remove filter: %s"), +$ } +$json_encode(search_modal_i18n) +``` + +The leading comment is important: it tells the next contributor which JS file consumes the partial and which constant the English fallback lives in. Keep keys and English text in lockstep across the two files. + +### 2. Drop the JSON onto the consuming element + +In the parent template, render the partial into a `data-*` attribute on the element your JS module already targets (a trigger button, a container, etc.). Use `$:render_template` (the `$:` prefix is required to skip HTML-escaping of the JSON): + +```html + +``` + +When one element needs two independent payloads, use two attribute names (e.g., `data-i18n` for the option list, `data-i18n-ui` for chrome strings). Don't merge them — they usually correspond to two separate concerns with separate fallbacks. + +### 3. Read and merge in JS, with English defaults as the fallback + +Keep the canonical English strings in the JS module. They serve three purposes: source-of-truth for the partial's keys, runtime fallback if the attribute is missing or malformed, and what ships when the page is rendered in English. + +```js +export const DEFAULT_SEARCH_MODAL_STRINGS = { + inputPlaceholder: 'Search books, authors…', + closeAria: 'Close search', + noResults: 'No results found', + removeFilter: 'Remove filter: %s', +}; + +export function searchModalStringsFromElement(el) { + let overrides = null; + try { + const raw = el?.dataset?.i18nUi; + if (raw) overrides = JSON.parse(raw); + } catch { /* fall through to defaults */ } + return overrides + ? { ...DEFAULT_SEARCH_MODAL_STRINGS, ...overrides } + : DEFAULT_SEARCH_MODAL_STRINGS; +} +``` + +Spreading the overrides over the defaults means a partial translation (some keys present, others missing) never blanks out a string — the English shows through for any key the locale didn't translate. + +Wrap the `JSON.parse` in `try`/`catch` and treat any failure as "fall back to defaults." Don't throw — a bad attribute on one element shouldn't take down the page. + +### 4. Use the merged strings to build DOM + +```js +import { searchModalStringsFromElement } from './constants.js'; + +const trigger = document.querySelector('.search-bar-trigger'); +const strings = searchModalStringsFromElement(trigger); + +input.placeholder = strings.inputPlaceholder; +closeButton.setAttribute('aria-label', strings.closeAria); +``` + +For strings with placeholders (`%s`), interpolate client-side with `sprintf` from the project's existing helpers — don't reinvent. + +## Choosing where strings live + +| String source | Where to put it | +| --- | --- | +| Visible text in a `.html` template | `$_("…")` directly in the template | +| Static text in a Lit component's `static styles` content (rare) | Pass in via attribute from the template, where `$_()` is available | +| Dynamic UI strings built in JS (labels, ARIA, status messages) | `_i18n.html` partial → `data-i18n*` attribute → JS reader (this doc) | +| Counts, numbers, prices | Format client-side; don't translate | + +If a Lit component is the consumer, the `data-i18n*` attributes go on the host element (the custom-element tag), and the component's reader runs in its `connectedCallback` or constructor. + +## Reference implementations + +- `openlibrary/templates/search/availability_i18n.html` + `openlibrary/templates/search/search_modal_i18n.html` — the two partials +- `openlibrary/plugins/openlibrary/js/search-modal/constants.js` — `availabilityOptionsFromElement`, `searchModalStringsFromElement`, the English defaults +- `openlibrary/templates/lib/nav_head.html` — how the partials are wired onto the trigger button +- `tests/unit/js/searchModalConstants.test.js` — tests for the reader/merge logic diff --git a/docs/ai/web-components.md b/docs/ai/web-components.md index c185d40ede1..3abf0503580 100644 --- a/docs/ai/web-components.md +++ b/docs/ai/web-components.md @@ -202,9 +202,117 @@ render() { ``` +## Registration + +Register the component once at the bottom of its file: + +```js +customElements.define('ol-my-widget', OlMyWidget); +``` + +**`ol-components.js` is the single registration site for every `` custom element.** It is built from `openlibrary/components/lit/index.js` (which re-exports every component, running each `define()` as a side effect) and loaded site-wide from `openlibrary/templates/site/footer.html`. + +If you need to drive a Lit component from page JS that webpack bundles (e.g., the search-modal entrypoint), import the component's exported class only if you need the class identifier — and never as a bare side-effect import. Re-running `customElements.define()` from a second bundle throws `NotSupportedError: this name has already been used with this registry`, which surfaces as a blank page with no obvious cause. The component will already be registered by `ol-components.js` before any page-JS handler (jQuery `DOMContentLoaded`) runs. + +## Focus and Shadow DOM + +Shadow DOM breaks the assumptions most focus-management code makes. The helpers in `openlibrary/components/lit/utils/focus-utils.js` and `FocusableHostMixin` exist to handle the cases below — reach for them rather than rolling your own. + +### Make custom elements visible to outer focus traps + +A custom element whose only focusable content is a ` + `; + } + + _renderPanel() { + const items = this.items || []; + const heading = this.heading || (this.label || '').toUpperCase(); + const parents = this._parentMap(items); + + // role="radiogroup" lives on a
(with the keyboard handler), NOT on + // the
    — putting it there strips list semantics and makes the
  • + // children invalid in the accessibility tree (WCAG 1.3.1). + return html` +
    +
    + ${heading ? html`` : nothing} +
      ${repeat(items, it => it.value, it => this._renderItem(it, parents))}
    +
    +
    + `; + } + + _renderItem(item, parents) { + const isSelected = item.value === this.selected; + // In scope = a nested child whose parent is the selected option. Decked + // out visually (hollow check, tint) but never reported as selected. + const inScope = !isSelected && item.nested && parents[item.value] === this.selected; + const cls = [ + 'item', + isSelected ? 'item--selected' : '', + inScope ? 'item--in-scope' : '', + item.nested ? 'item--nested' : '', + ].filter(Boolean).join(' '); + + // No leading whitespace/newline before
  • — template-literal + // whitespace creates real text nodes that accesslint flags as direct + // text content inside
      (WCAG 1.3.1). + return html`
    • + +
    • `; + } + + /** + * The line below the label: the count and description, in that order, + * joined by a middot when both are present. Renders nothing if the item has + * neither. + */ + _renderMeta(item) { + if (!item.count && !item.description) return nothing; + return html`${item.count ? html`${item.count}` : nothing}${item.count && item.description ? html`` : nothing}${item.description ? html`${item.description}` : nothing}`; + } + + _renderIcon(name) { + const paths = OlAvailabilityFilter._icons[name]; + if (!paths) return html``; + return html``; + } + + /** + * The circular selection indicator: a filled check for the selected option, + * a hollow check for an in-scope option, and nothing otherwise (the fixed + * width of `.item-indicator` keeps the counts column-aligned regardless). + */ + _renderIndicator(isSelected, inScope) { + if (isSelected) { + return html``; + } + if (inScope) { + return html``; + } + return nothing; + } + + // ── Event handlers ─────────────────────────────────────────── + + _onTriggerKeydown(e) { + if (e.key === 'ArrowDown' && !this._isOpen) { + e.preventDefault(); + const popover = this.shadowRoot?.querySelector('ol-popover'); + if (!popover) return; + this._pendingFocusFirst = true; + popover.open = true; + } + } + + _onPopoverOpen() { + this._isOpen = true; + this.setAttribute('data-open', ''); + + if (this._pendingFocusFirst) { + this._pendingFocusFirst = false; + this._focusSelectedOrFirst(); + } + } + + _onPopoverClose() { + this._isOpen = false; + this._pendingFocusFirst = false; + this.removeAttribute('data-open'); + } + + _onItemChange(e) { + const value = e.target.value; + if (value === this.selected) return; + this.selected = value; + this.dispatchEvent(new CustomEvent('ol-availability-filter-change', { + bubbles: true, composed: true, + detail: { selected: value }, + })); + // Intentionally stay open: unlike a native
+ *
Results…
+ * + */ +export class OlDialog extends LitElement { + static properties = { + open: { type: Boolean, reflect: true }, + label: { type: String }, + withoutHeader: { type: Boolean, attribute: 'without-header' }, + width: { type: String }, + closeOnBackdropClick: { type: Boolean, attribute: 'close-on-backdrop-click' }, + closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, + fullscreenOnMobile: { type: Boolean, attribute: 'fullscreen-on-mobile', reflect: true }, + placement: { type: String, reflect: true }, + _hasHeaderContent: { state: true }, + _hasFooterContent: { state: true }, + }; + + static styles = css` + :host { + --ol-dialog-width-small: 400px; + --ol-dialog-width-medium: 550px; + --ol-dialog-width-large: 800px; + --ol-dialog-padding: var(--spacing-xl); + --ol-dialog-border-radius: var(--border-radius-overlay); + --ol-dialog-animation-duration: 200ms; + --ol-dialog-backdrop-color: hsla(0, 0%, 0%, 0.25); + --ol-dialog-top-offset: clamp(40px, 8vh, 96px); + + font-family: var(--font-family-body); + } + + dialog { + border: none; + border-radius: var(--ol-dialog-border-radius); + padding: 0; + max-width: 90vw; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 4px 24px var(--boxshadow-black); + } + + dialog:focus { + outline: none; + } + + dialog:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + dialog[open] { + display: flex; + flex-direction: column; + animation: dialog-open var(--ol-dialog-animation-duration) ease-out; + } + + dialog.closing { + animation: dialog-close var(--ol-dialog-animation-duration) ease-in; + } + + dialog::backdrop { + background-color: var(--ol-dialog-backdrop-color); + animation: backdrop-fade-in var(--ol-dialog-animation-duration) ease-out; + } + + dialog.closing::backdrop { + animation: backdrop-fade-out var(--ol-dialog-animation-duration) ease-in; + } + + @keyframes dialog-open { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } + } + + @keyframes dialog-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } + } + + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes backdrop-fade-out { + from { opacity: 1; } + to { opacity: 0; } + } + + @media (prefers-reduced-motion: reduce) { + dialog[open], + dialog.closing, + dialog::backdrop, + dialog.closing::backdrop, + :host([placement="top"]) dialog[open], + :host([placement="top"]) dialog.closing { + animation: none; + } + } + + /* Top-anchored placement: keeps the dialog's top edge fixed as its + own height grows or shrinks (search modal / command palette). */ + :host([placement="top"]) dialog { + margin-block-start: var(--ol-dialog-top-offset); + margin-block-end: auto; + max-height: calc(100dvh - var(--ol-dialog-top-offset) - var(--spacing-xl)); + transform-origin: top center; + } + + :host([placement="top"]) dialog[open] { + animation: dialog-open-top var(--ol-dialog-animation-duration) ease-out; + } + + :host([placement="top"]) dialog.closing { + animation: dialog-close-top var(--ol-dialog-animation-duration) ease-in; + } + + @keyframes dialog-open-top { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes dialog-close-top { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } + } + + /* Width variants */ + :host([width="small"]) dialog { + width: var(--ol-dialog-width-small); + } + + :host([width="medium"]) dialog { + width: var(--ol-dialog-width-medium); + } + + :host([width="large"]) dialog { + width: var(--ol-dialog-width-large); + } + + /* Fullscreen on mobile — overrides width preset and removes chrome. + Also neutralizes placement="top" so the dialog truly fills the + viewport (no top offset, no leftover max-height clamp). */ + @media (max-width: 767px) { + :host([fullscreen-on-mobile]) dialog { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + border-radius: 0; + } + + :host([fullscreen-on-mobile][placement="top"]) dialog { + margin-block-start: 0; + margin-block-end: 0; + max-height: 100dvh; + } + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ol-dialog-padding) var(--ol-dialog-padding) 0 var(--ol-dialog-padding); + } + + .header.hidden { + display: none; + } + + h2.title { + margin: 0; + padding: 0; + font-size: 1.25rem; + font-weight: 600; + } + + .close-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--border-radius-button); + color: inherit; + cursor: pointer; + transition: background-color 150ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .close-button:hover { + background-color: var(--icon-link-grey); + } + } + + .close-button:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + .close-button { + transition: none; + } + } + + .close-button svg { + width: 20px; + height: 20px; + } + + .body { + padding: var(--ol-dialog-padding); + overflow-y: auto; + } + + .footer { + padding: 0 var(--ol-dialog-padding) var(--ol-dialog-padding) var(--ol-dialog-padding); + } + + .footer[hidden] { + display: none; + } + `; + + constructor() { + super(); + this.open = false; + this.label = ''; + this.withoutHeader = false; + this.width = 'medium'; + this.closeOnBackdropClick = true; + this.closeOnEscape = true; + this.fullscreenOnMobile = false; + this.placement = 'center'; + this._hasHeaderContent = false; + this._hasFooterContent = false; + + /** @type {HTMLElement|null} Element that had focus before dialog opened */ + this._previouslyFocusedElement = null; + + this._handleCancel = this._handleCancel.bind(this); + this._handleBackdropClick = this._handleBackdropClick.bind(this); + this._handleHeaderSlotChange = this._handleHeaderSlotChange.bind(this); + this._handleFooterSlotChange = this._handleFooterSlotChange.bind(this); + this._handleKeyDown = this._handleKeyDown.bind(this); + } + + /** Unique ID for ARIA labelledby association */ + get _titleId() { + return `${this.id || 'ol-dialog'}-title`; + } + + /** @returns {HTMLDialogElement} */ + get dialog() { + return this.renderRoot?.querySelector('dialog'); + } + + updated(changedProperties) { + if (changedProperties.has('open')) { + if (this.open) { + this._openDialog(); + } else if (changedProperties.get('open') === true) { + this._closeDialog(); + } + } + } + + _openDialog() { + const dialog = this.dialog; + if (!dialog || dialog.open) return; + + this._previouslyFocusedElement = document.activeElement; + + this.dispatchEvent(new CustomEvent('ol-open', { + bubbles: true, + composed: true, + })); + + dialog.showModal(); + + // Capture phase to intercept Tab before Safari's native handling. + document.addEventListener('keydown', this._handleKeyDown, true); + + this._setInitialFocus(); + + dialog.addEventListener('animationend', () => { + this.dispatchEvent(new CustomEvent('ol-after-open', { + bubbles: true, + composed: true, + })); + }, { once: true }); + } + + /** + * Sets initial focus when dialog opens. + * Priority: [autofocus] > first focusable in body > close button > dialog + */ + _setInitialFocus() { + requestAnimationFrame(() => { + const dialog = this.dialog; + if (!dialog) return; + + const autofocusEl = this.querySelector('[autofocus]'); + if (autofocusEl) { + autofocusEl.focus(); + return; + } + + const firstFocusable = this.querySelector(FOCUSABLE_SELECTOR); + if (firstFocusable) { + firstFocusable.focus(); + return; + } + + const closeButton = this.renderRoot?.querySelector('.close-button'); + if (closeButton && !this.withoutHeader && !this._hasHeaderContent) { + closeButton.focus(); + return; + } + + dialog.focus(); + }); + } + + _closeDialog() { + const dialog = this.dialog; + if (!dialog || !dialog.open) return; + + const closeEvent = new CustomEvent('ol-close', { + bubbles: true, + composed: true, + cancelable: true, + }); + + this.dispatchEvent(closeEvent); + + if (closeEvent.defaultPrevented) { + this.open = true; + return; + } + + document.removeEventListener('keydown', this._handleKeyDown, true); + + dialog.classList.add('closing'); + + dialog.addEventListener('animationend', () => { + dialog.classList.remove('closing'); + dialog.close(); + + this._restoreFocus(); + + this.dispatchEvent(new CustomEvent('ol-after-close', { + bubbles: true, + composed: true, + })); + }, { once: true }); + } + + _restoreFocus() { + if (this._previouslyFocusedElement && typeof this._previouslyFocusedElement.focus === 'function') { + // setTimeout ensures focus happens after dialog is fully closed. + setTimeout(() => { + this._previouslyFocusedElement?.focus(); + this._previouslyFocusedElement = null; + }, 0); + } + } + + /** Native dialog cancel event (Escape key). */ + _handleCancel(event) { + // Prevent default close so we can animate. + event.preventDefault(); + + if (this.closeOnEscape) { + this.open = false; + } + } + + _handleBackdropClick(event) { + if (!this.closeOnBackdropClick) return; + + // Clicks on the ::backdrop register the dialog as the target. + // Clicks on dialog content target a child element. + if (event.target === this.dialog) { + this.open = false; + } + } + + _handleCloseClick() { + this.open = false; + } + + /** + * Returns all focusable elements within the dialog in DOM order: + * header → body → footer. Includes the default close button when no + * custom header is slotted. + * @returns {HTMLElement[]} + */ + _getFocusableElements() { + if (!this.dialog) return []; + + const focusable = []; + + const headerSlot = this.renderRoot?.querySelector('slot[name="header"]'); + const headerSlotted = getFocusableFromSlot(headerSlot); + if (headerSlotted.length > 0) { + focusable.push(...headerSlotted); + } else { + const closeButton = this.renderRoot?.querySelector('.close-button'); + if (closeButton && !this.withoutHeader && isFocusable(closeButton)) { + focusable.push(closeButton); + } + } + + const bodySlot = this.renderRoot?.querySelector('slot:not([name])'); + focusable.push(...getFocusableFromSlot(bodySlot)); + + const footerSlot = this.renderRoot?.querySelector('slot[name="footer"]'); + focusable.push(...getFocusableFromSlot(footerSlot)); + + return focusable; + } + + /** + * Walks up from `el` (across shadow boundaries) looking for an open + * nested overlay inside this dialog — currently any `` + * with the `open` attribute set. Used to skip Tab trapping when a + * sub-overlay is driving its own focus. + * @param {Element|null} el + * @returns {Boolean} + */ + _isInsideOpenOverlay(el) { + let cur = el; + while (cur && cur !== this.dialog) { + // ol-popover reflects `open` to its attribute (see OlPopover.js). + // We match by tagName to avoid an import-time coupling to the + // OlPopover class itself. + if (cur.tagName === 'OL-POPOVER' && cur.hasAttribute('open')) { + return true; + } + const parent = cur.parentNode; + cur = (parent?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && parent.host) + ? parent.host + : cur.parentElement; + } + return false; + } + + /** + * Manual Tab focus trap. Needed because Safari doesn't trap focus across + * shadow DOM boundaries for slotted content. + */ + _handleKeyDown(event) { + if (event.key !== 'Tab') return; + + const activeElement = getDeepActiveElement(); + + // If focus is inside an open nested overlay (e.g. an the + // user just opened from a trigger in this dialog), that overlay owns + // its own focus trap — don't intercept Tab or we'll yank focus back + // out of the popover. + if (this._isInsideOpenOverlay(activeElement)) return; + + const focusable = this._getFocusableElements(); + if (focusable.length === 0) return; + + event.preventDefault(); + + // findFocusableIndex climbs shadow boundaries so a custom-element + // wrapper (e.g. ) that delegates focus inward to + // a deeper button still matches its host entry in the trap list. + const currentIndex = findFocusableIndex(focusable, activeElement); + + let nextIndex; + if (event.shiftKey) { + nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1; + } else { + nextIndex = currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1; + } + + focusable[nextIndex].focus(); + } + + _handleHeaderSlotChange(event) { + this._hasHeaderContent = slotHasContent(event.target); + } + + _handleFooterSlotChange(event) { + this._hasFooterContent = slotHasContent(event.target); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('keydown', this._handleKeyDown, true); + const dialog = this.dialog; + if (dialog) { + dialog.removeEventListener('cancel', this._handleCancel); + dialog.removeEventListener('click', this._handleBackdropClick); + } + } + + firstUpdated() { + const dialog = this.dialog; + if (dialog) { + dialog.addEventListener('cancel', this._handleCancel); + dialog.addEventListener('click', this._handleBackdropClick); + + if (this.open) { + this._openDialog(); + } + } + } + + render() { + const showDefaultHeader = !this.withoutHeader && !this._hasHeaderContent; + const ariaLabel = (this.withoutHeader || this._hasHeaderContent) && this.label ? this.label : undefined; + const ariaLabelledBy = showDefaultHeader && this.label ? this._titleId : undefined; + + return html` + +
+

${this.label}

+ +
+ +
+ +
+
+ +
+
+ `; + } +} + +customElements.define('ol-dialog', OlDialog); diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js new file mode 100644 index 00000000000..833491a1289 --- /dev/null +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -0,0 +1,421 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { FocusableHostMixin } from './utils/focusable-host-mixin.js'; +import './OlPopover.js'; + +let _idCounter = 0; + +/** + * A trigger button paired with a popover containing a single-select list of + * rich options. Each option can show a label, a description, and a count + * (e.g. "Readable Books Only — Primary older digitized, preserved, physical + * books — ~4.6M"). Used for filters with a small fixed set of mutually + * exclusive choices. + * + * Composes `` for animation, focus trap, mobile tray, and + * Escape/outside-click dismissal. Use `` instead when + * the user can pick multiple values or filter a long list. + * + * @element ol-options-popover + * + * @prop {Array} items - List of `{ value, label, description?, count?, + * nested? }` objects. Settable as JSON attribute or property. `nested: true` + * indents the option to show it's a subset of the option above it. + * @prop {String} selected - Currently selected `value`, or empty string for + * no selection. Reflects to attribute. + * @prop {String} label - Default trigger button text (e.g. "Availability"). + * @prop {String} heading - Heading shown above the options list (default: + * uppercased `label`). + * + * @attr aria-label - Accessible name for the popover dialog. Falls back to + * `label` if unset. + * + * @fires ol-options-popover-change - Fires when the selection changes. + * detail: { selected: String } + * + * @slot trigger - Optional custom trigger element. When omitted, a styled + * default button renders with `label` and a chevron icon. + * + * @example + * + */ +export class OlOptionsPopover extends FocusableHostMixin(LitElement) { + static properties = { + items: { type: Array }, + selected: { type: String, reflect: true }, + label: { type: String }, + heading: { type: String }, + }; + + static styles = css` + :host { + display: inline-block; + font-family: var(--font-family-body); + } + + /* ── Default trigger ─────────────────────────────────────── */ + + .default-trigger { + display: inline-flex; + align-items: center; + gap: var(--spacing-inline-sm); + padding: var(--spacing-inset-xs) var(--spacing-inset-sm); + background: var(--white); + border: 1px solid var(--color-border-subtle); + border-radius: var(--border-radius-button); + color: var(--darker-grey); + font: inherit; + font-size: 14px; + line-height: 1.4; + cursor: pointer; + white-space: nowrap; + } + + @media (hover: hover) and (pointer: fine) { + .default-trigger:hover { + background: var(--lightest-grey); + } + } + + .default-trigger:active { + transform: scale(0.97); + } + + .default-trigger:focus { + outline: none; + } + + .default-trigger:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .trigger-chevron { + display: inline-block; + width: 16px; + height: 16px; + transition: transform 150ms ease-out; + flex-shrink: 0; + } + + :host([data-open]) .trigger-chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .trigger-chevron { + transition: none; + } + } + + /* ── Panel layout ────────────────────────────────────────── */ + + .panel { + display: flex; + flex-direction: column; + min-width: 280px; + max-width: min(90vw, 400px); + max-height: min(70vh, 480px); + } + + .group { + list-style: none; + margin: 0; + padding: var(--spacing-inset-xs) 0; + overflow-y: auto; + } + + .group-heading { + margin: 0; + padding: var(--spacing-inset-sm) var(--spacing-inset-md) var(--spacing-inset-xs); + color: var(--accessible-grey); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + /* ── Items ───────────────────────────────────────────────── */ + + .item { + font-size: 14px; + } + + .item-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-inline-md); + padding: var(--spacing-inset-sm) var(--spacing-inset-md); + cursor: pointer; + user-select: none; + } + + /* Nested options are a subset of the option above them; indent the + whole row so the hierarchy reads at a glance. */ + .item--nested .item-row { + padding-left: var(--spacing-inset-xl); + } + + @media (hover: hover) and (pointer: fine) { + .item-row:hover { + background: var(--lightest-grey); + } + } + + .item-row:focus-within { + outline: none; + background: var(--lightest-grey); + } + + .item--selected .item-row { + background: hsla(202, 96%, 37%, 0.08); + } + + .item--selected .item-row:focus-within, + .item--selected .item-row:hover { + background: hsla(202, 96%, 37%, 0.12); + } + + .item-radio { + flex-shrink: 0; + width: 16px; + height: 16px; + margin: 2px 0 0; + accent-color: var(--primary-blue); + cursor: pointer; + } + + .item-radio:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + border-radius: 50%; + } + + .item-content { + flex: 1; + min-width: 0; + } + + .item-label { + display: block; + color: var(--darker-grey); + font-weight: 500; + } + + .item--selected .item-label { + color: var(--link-blue); + font-weight: 600; + } + + .item-description { + display: block; + margin-top: 2px; + color: var(--accessible-grey); + font-size: 12px; + line-height: 1.35; + } + + .item--selected .item-description { + color: var(--link-blue); + } + + .item-count { + flex-shrink: 0; + margin-left: var(--spacing-inline-md); + color: var(--accessible-grey); + font-size: 13px; + font-variant-numeric: tabular-nums; + } + `; + + /** Chevron icon for the default trigger */ + static _chevronIcon = html``; + + constructor() { + super(); + this.items = []; + this.selected = ''; + this.label = ''; + this.heading = ''; + this._panelId = `ol-options-popover-${++_idCounter}`; + this._radioName = `ol-options-popover-radio-${_idCounter}`; + this._isOpen = false; + this._pendingFocusFirst = false; + } + + /** + * Send focus to the default-trigger button rather than the first + * focusable in shadow order (which could be a slotted user-provided + * trigger — but the default-trigger is the one we want when it's there). + */ + get _focusTarget() { + return this.shadowRoot?.querySelector('.default-trigger') + ?? this.querySelector('[slot="trigger"]') + ?? null; + } + + render() { + return html` + + ${this._renderDefaultTrigger()} + ${this._renderPanel()} + + `; + } + + _renderDefaultTrigger() { + // Trigger always shows the filter category (e.g. "Availability"); the + // current selection is communicated by the consumer (e.g. via a chip + // row above the popover). Consumers needing the selection in the + // trigger itself can override via the `trigger` slot. + const selectedItem = (this.items || []).find(it => it.value === this.selected); + return html` + + `; + } + + _renderPanel() { + const items = this.items || []; + const heading = this.heading || (this.label || '').toUpperCase(); + + // FIX (WCAG 1.3.1): role="radiogroup" must NOT be on the
    because + // that strips list semantics and makes
  • children invalid in the + // accessibility tree. Separate the roles: a
    owns radiogroup + + // keyboard handler, the
      stays a pure list. + return html` +
      +
      + ${heading ? html`` : nothing} +
        ${repeat(items, it => it.value, it => this._renderItem(it))}
      +
      +
      + `; + } + + _renderItem(item) { + const isSelected = item.value === this.selected; + // FIX (WCAG 1.3.1): no leading whitespace/newline before
    • — Lit + // template literal whitespace creates real text nodes that accesslint + // flags as direct text content inside
        . + return html`
      • + +
      • `; + } + + // ── Event handlers ─────────────────────────────────────────── + + _onTriggerKeydown(e) { + if (e.key === 'ArrowDown' && !this._isOpen) { + e.preventDefault(); + const popover = this.shadowRoot?.querySelector('ol-popover'); + if (!popover) return; + this._pendingFocusFirst = true; + popover.open = true; + } + } + + _onPopoverOpen() { + this._isOpen = true; + this.setAttribute('data-open', ''); + + if (this._pendingFocusFirst) { + this._pendingFocusFirst = false; + this._focusSelectedOrFirst(); + } + } + + _onPopoverClose() { + this._isOpen = false; + this._pendingFocusFirst = false; + this.removeAttribute('data-open'); + } + + _onItemChange(e) { + const value = e.target.value; + if (value !== this.selected) { + this.selected = value; + this.dispatchEvent(new CustomEvent('ol-options-popover-change', { + bubbles: true, composed: true, + detail: { selected: value }, + })); + } + // Close on selection to match native '); - url = `${url + (url.indexOf('?') > -1 ? '&' : '?')}has_fulltext=true`; - } - - $form.attr('action', url); - } -} - - /** * @typedef {Object} PersistentValue.Options * @property {String?} [default] @@ -122,22 +94,3 @@ export const mode = new PersistentValue('mode', { return isValidMode ? mode : DEFAULT_MODE; } }); - -/** Manages interactions of the search mode radio buttons */ -export class SearchModeSelector { - /** - * @param {JQuery} radioButtons - */ - constructor(radioButtons) { - this.$radioButtons = radioButtons; - this.change(newMode => mode.write(newMode)); - } - - /** - * Listen for changes - * @param {Function} handler - */ - change(handler) { - this.$radioButtons.on('change', event => handler($(event.target).val())); - } -} diff --git a/openlibrary/plugins/openlibrary/js/index.js b/openlibrary/plugins/openlibrary/js/index.js index 2ef67c01142..b62e8460921 100644 --- a/openlibrary/plugins/openlibrary/js/index.js +++ b/openlibrary/plugins/openlibrary/js/index.js @@ -335,6 +335,12 @@ jQuery(function() { .then((module) => module.initSearchFacets(searchFacets)); } + const searchFilterBar = document.querySelector('.search-filter-row'); + if (searchFilterBar) { + import(/* webpackChunkName: "search-filter-bar" */ './SearchFilterBar') + .then((module) => module.initSearchFilterBar(searchFilterBar)); + } + // Conditionally load Integrated Librarian Environment if (document.getElementsByClassName('show-librarian-tools').length) { import(/* webpackChunkName: "ile" */ './ile') diff --git a/openlibrary/plugins/openlibrary/js/ol.js b/openlibrary/plugins/openlibrary/js/ol.js index 3341ef10ad9..a0b5e342ee7 100644 --- a/openlibrary/plugins/openlibrary/js/ol.js +++ b/openlibrary/plugins/openlibrary/js/ol.js @@ -1,7 +1,6 @@ import { getJsonFromUrl } from './Browser'; -import { SearchBar } from './SearchBar'; -import { SearchPage } from './SearchPage'; -import { SearchModeSelector, mode as searchMode } from './SearchUtils'; +import { initSearchModal } from './search-modal/SearchModal'; +import { mode as searchMode } from './SearchUtils'; /* Sets the key in the website cookie to the specified value @@ -15,12 +14,8 @@ export default function init() { if (urlParams.mode) { searchMode.write(urlParams.mode); } - new SearchBar($('header#header-bar .search-component'), urlParams); - - if ($('.siteSearch.olform').length) { - // Only applies to search results page (as of writing) - new SearchPage($('.siteSearch.olform'), new SearchModeSelector($('.search-mode'))); - } + const $searchComponent = $('header#header-bar .search-component'); + initSearchModal($searchComponent.find('.search-bar-trigger')[0]); initBorrowAndReadLinks(); initWebsiteTranslationOptions(); diff --git a/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js new file mode 100644 index 00000000000..d97d7027f29 --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js @@ -0,0 +1,1020 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +// The custom elements this modal uses (ol-dialog, ol-availability-filter, +// ol-select-popover, ol-chip, ol-chip-group) are registered by the site-wide +// Lit bundle: build/lit-components/production/ol-components.js, loaded from +// openlibrary/templates/site/footer.html. Do NOT re-import those component +// modules here — re-running customElements.define() throws NotSupportedError. +import { debounce } from '../nonjquery_utils.js'; +import { sprintf } from '../i18n.js'; +import { mode as searchMode } from '../SearchUtils.js'; +import { + AVAILABILITY_OPTIONS, + AVAILABILITY_TO_PARAMS, + DEFAULT_AVAILABILITY, + DEFAULT_LANGUAGE_OPTIONS, + DEFAULT_SEARCH_MODAL_STRINGS, + SS_AVAILABILITY_KEY, + SS_LANGUAGES_KEY, + availabilityOptionsFromElement, + readStoredLanguages, + searchModalStringsFromElement, +} from './constants.js'; +import { fetchLanguageOptions } from './languages.js'; +import { deriveAuthors } from './authorSuggestion.js'; + +// `editions` is requested not to render it, but to opt /search.json into the +// edition-level block-join (see WorkSearchScheme.q_to_solr_params). Without it, +// availability filters like "Readable Books Only" (public_scan/print_disabled) +// only match the work-level `ebook_access` aggregate, so the modal would surface +// works the /search page hides — e.g. a work whose only query-matching edition +// is non-readable. Requesting `editions` makes the modal match /search exactly. +// `author_key` rides along with `author_name` so each result's author can link +// to the author page (and so deriveAuthors() can surface author rows for the +// top results whose author the query names). +const SEARCH_FIELDS = ['key', 'cover_i', 'title', 'subtitle', 'author_name', 'author_key', 'first_publish_year', 'editions']; + +const RESULTS_LIMIT = 10; +// Matches the legacy SearchBar autocomplete threshold: fire the header +// autocomplete only at 3+ chars (see _shouldAutocomplete for the "the" skip). +const MIN_QUERY_LENGTH = 3; +const COVER_PLACEHOLDER = '/static/images/icons/avatar_book-sm.png'; + +// The bare common-word "the" matches almost everything and isn't worth a Solr +// round-trip, so the legacy SearchBar skipped it for autocomplete. Navigation +// to /search is still allowed for it (handled by the length-only gates). +const AUTOCOMPLETE_STOPWORDS = new Set(['the']); + +function ssGet(key) { try { return sessionStorage.getItem(key); } catch { return null; } } +function ssSet(key, value) { try { sessionStorage.setItem(key, value); } catch { /* ignore */ } } + +export class SearchModal extends LitElement { + static properties = { + open: { type: Boolean, reflect: true }, + _query: { state: true }, + _availability: { state: true }, + _languages: { state: true }, + _results: { state: true }, + _authorSuggestions: { state: true }, + _numFound: { state: true }, + _loading: { state: true }, + _hasSearched: { state: true }, + _languageItems: { state: true }, + _langsLoading: { state: true }, + _navigatingKey: { state: true }, + }; + + static styles = css` + :host { + font-family: var(--font-family-body); + color: var(--darker-grey); + } + + /* ── Search input row ──────────────────────────────────────── */ + + .bar { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-subtle); + } + + /* Wraps the icon + input (+ ESC pill). Transparent on desktop so the + bar reads as one flat row; becomes an inset rounded box on mobile. */ + .search-field { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: var(--spacing-sm); + } + + .search-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--accessible-grey); + } + + .search-input { + flex: 1; + min-width: 0; + padding: var(--spacing-sm) 0; + background: transparent; + border: none; + color: inherit; + font: inherit; + font-size: 17px; + line-height: 1.4; + } + + .search-input::placeholder { color: var(--accessible-grey); } + .search-input:focus { outline: none; } + + /* Drop the native type="search" clear affordance — the modal has its + own close control and an empty field is cleared by deleting text. */ + .search-input::-webkit-search-cancel-button, + .search-input::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; + } + + .esc-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + padding: var(--spacing-2xs) var(--spacing-sm); + background: var(--white); + border: 1px solid var(--color-border-subtle); + border-radius: var(--border-radius-button); + color: var(--accessible-grey); + font: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + cursor: pointer; + white-space: nowrap; + transition: background-color 150ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .esc-pill:hover { background: var(--lightest-grey); } + } + + .esc-pill:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + @media (hover: none) and (pointer: coarse) { .esc-pill { display: none; } } + @media (prefers-reduced-motion: reduce) { .esc-pill { transition: none; } } + + /* ── Close button (mobile) ─────────────────────────────────── */ + + /* Touch devices don't have an Esc key, so the ESC pill (above) is + replaced by an explicit close affordance in the same slot. */ + .close-btn { + flex-shrink: 0; + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-right: calc(var(--spacing-sm) * -1); + background: transparent; + border: none; + border-radius: var(--border-radius-button); + color: var(--accessible-grey); + cursor: pointer; + transition: background-color 150ms ease; + } + + .close-btn svg { + width: 22px; + height: 22px; + } + + .close-btn:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + @media (hover: none) and (pointer: coarse) { .close-btn { display: inline-flex; } } + @media (prefers-reduced-motion: reduce) { .close-btn { transition: none; } } + + /* ── Active filter chip row ────────────────────────────────── */ + + .chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-subtle); + } + + /* The chip group takes the row's width so "Clear all" is pushed to + the far end by its margin-left: auto. */ + .chips ol-chip-group { + flex: 1; + } + + .clear-all { + margin-left: auto; + padding: 3px var(--spacing-sm); + background: transparent; + border: 1px solid transparent; + border-radius: var(--border-radius-button); + color: var(--darker-grey); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 150ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .clear-all:hover { background: var(--lightest-grey); } + } + + .clear-all:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + .clear-all { transition: none; } + } + + /* ── Filter button row ─────────────────────────────────────── */ + + .filters { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-lg) var(--spacing-sm); + border-bottom: 1px solid var(--color-border-subtle); + } + + /* ── Results ───────────────────────────────────────────────── */ + + .results { + flex: 1; + min-height: 80px; + max-height: 320px; + overflow-y: auto; + padding: var(--spacing-xs) 0; + } + + .results-heading { + margin: 0; + padding: var(--spacing-sm) var(--spacing-lg) var(--spacing-2xs); + color: var(--accessible-grey); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .results-list { + list-style: none; + margin: 0; + padding: 0; + } + + /* Sets the author suggestion apart from the "Top results" works below. */ + .author-suggestion { + margin-bottom: var(--spacing-2xs); + padding-bottom: var(--spacing-2xs); + border-bottom: 1px solid var(--color-border-subtle); + } + + .result { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-lg); + color: inherit; + text-decoration: none; + transition: + background-color 100ms ease, + opacity 160ms ease, + transform 100ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .result:hover { background: var(--lightest-grey); } + } + + /* Both the author suggestion and the work rows are single anchors, so + the same focus highlight covers the whole row. */ + .result:focus-visible { + outline: none; + background: var(--lightest-grey); + box-shadow: inset 2px 0 0 var(--color-focus-ring); + } + + @media (prefers-reduced-motion: reduce) { .result { transition: none; } } + + .result__cover-link { + position: relative; + display: flex; + flex-shrink: 0; + } + + .result__cover { + flex-shrink: 0; + width: 36px; + height: 50px; + object-fit: cover; + background: var(--lightest-grey); + border-radius: var(--border-radius-thumbnail); + } + + /* Circular author avatar. The person glyph sits underneath as the + always-present fallback; the photo (when the author has one) is + layered over it and covers the circle. If the photo 404s, it's hidden + to reveal the glyph — so there's never a broken-image flash. */ + .result__avatar { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + overflow: hidden; + color: var(--accessible-grey); + background: var(--lightest-grey); + border-radius: 50%; + } + + .result__avatar svg { width: 20px; height: 20px; } + + .result__avatar-photo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .result__meta { + flex: 1; + min-width: 0; + font-size: 14px; + line-height: 1.35; + } + + .result__title { + display: block; + overflow: hidden; + color: var(--darker-grey); + font-weight: 600; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + } + + .result__author { + display: block; + overflow: hidden; + color: var(--accessible-grey); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .result__year { + display: block; + color: var(--accessible-grey); + font-size: 12px; + font-weight: 400; + } + + .empty, .placeholder, .loading { + padding: var(--spacing-lg) var(--spacing-lg); + color: var(--accessible-grey); + font-size: 14px; + text-align: center; + } + + /* ── Navigating (pressed result → page loading) ────────────── */ + + /* Pressing a result navigates the whole window, and the next page can + take a moment to start painting. During that gap the chosen row + holds full opacity while the rest dim back, its cover darkens under + a spinner, and the row scales down — matching the header search + field's press feedback (scale 0.985). */ + .results.is-navigating .result { opacity: 0.4; } + + .results.is-navigating .result.is-target { + opacity: 1; + background: var(--lightest-grey); + transform: scale(0.985); + } + + .result.is-target .result__cover, + .result.is-target .result__avatar-photo { + filter: brightness(0.5); + } + + /* Spinner centered over the thumbnail. Mirrors the loading + spinner — a currentcolor ring with one transparent edge spun by + keyframes — but white here to read over the darkened cover. */ + .result__spinner { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; + } + + .result__spinner::before { + content: ""; + box-sizing: border-box; + width: 18px; + height: 18px; + border: 2px solid var(--white); + border-right-color: transparent; + border-radius: 50%; + } + + .result.is-target .result__spinner { opacity: 1; } + + .result.is-target .result__spinner::before { + animation: ol-search-result-spin 0.7s linear infinite; + } + + @keyframes ol-search-result-spin { + to { transform: rotate(360deg); } + } + + @media (prefers-reduced-motion: reduce) { + .results.is-navigating .result.is-target { transform: none; } + .result.is-target .result__spinner::before { animation-duration: 2s; } + } + + /* ── Footer ────────────────────────────────────────────────── */ + + .footer { + display: flex; + justify-content: flex-end; + padding: var(--spacing-sm) var(--spacing-lg); + border-top: 1px solid var(--color-border-subtle); + } + + .see-all { + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--primary-blue); + border: 1px solid var(--primary-blue); + border-radius: var(--border-radius-button); + color: var(--white); + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: filter 150ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .see-all:hover { filter: brightness(1.08); } + } + + .see-all:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .see-all:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + @media (prefers-reduced-motion: reduce) { .see-all { transition: none; } } + + /* ── Mobile overrides ──────────────────────────────────────── */ + + @media (max-width: 767px) { + .search-input { font-size: 16px; } + .results { max-height: none; flex: 1; } + + /* The "Start typing to search…" prompt reads as confusing on a + phone where the keyboard is already up — drop it on mobile. */ + .placeholder { display: none; } + .filters { padding: var(--spacing-xs) var(--spacing-md) var(--spacing-sm); } + .footer { + position: sticky; + bottom: 0; + background: var(--white); + border-top: 1px solid var(--color-border-subtle); + } + + /* Inset, rounded search field with the close (X) sitting outside it. */ + .bar { + padding: var(--spacing-md); + border-bottom: none; + } + .search-field { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-subtle); + border-radius: var(--border-radius-xl); + } + .close-btn { margin-right: 0; } + } + `; + + constructor() { + super(); + this.open = false; + this._query = ''; + this._results = []; + this._authorSuggestions = []; + this._numFound = null; + this._loading = false; + this._hasSearched = false; + this._langsLoading = false; + this._navigatingKey = null; + + // Availability options. Defaults to the built-in English list; the + // localized list (from the trigger's data-i18n) is set in + // initSearchModal before the modal first renders. + this._availabilityOptions = AVAILABILITY_OPTIONS; + + // Chrome strings (labels, placeholders, status messages). Defaults to + // English; the translated set (from the trigger's data-i18n-ui) is set + // in initSearchModal before the modal first renders. + this._i18n = DEFAULT_SEARCH_MODAL_STRINGS; + + // Curated set shown instantly; replaced by the real catalogue list + // (translated names, volume-ranked) once _loadAllLanguages() resolves. + this._languageItems = DEFAULT_LANGUAGE_OPTIONS; + + this._availability = ssGet(SS_AVAILABILITY_KEY) || DEFAULT_AVAILABILITY; + this._languages = readStoredLanguages(); + + this._debouncedFetch = debounce(() => this._fetchResults(), 250, false); + this._activeFetchKey = null; + this._allLangsLoaded = false; + } + + connectedCallback() { + super.connectedCallback(); + // The back button can restore this page (and modal) from the bfcache + // with a row still flagged as navigating — clear it so its spinner + // doesn't linger on a page the user has returned to. + this._onPageShow = () => { this._navigatingKey = null; }; + window.addEventListener('pageshow', this._onPageShow); + } + + disconnectedCallback() { + window.removeEventListener('pageshow', this._onPageShow); + super.disconnectedCallback(); + } + + attachToTrigger(trigger) { + if (!trigger) return; + // The trigger is a +
    + + + + ${hasFilters ? this._renderChips() : nothing} + ${this._renderFilters()} + ${this._renderResults()} + + + + `; + } + + _renderChips() { + // Each active filter is a selected : the `selected` state + // gives it the built-in close icon (and the default blue fill), + // and clicking it (ol-chip-select) removes the filter. We use the + // default chip here — no `variant=` — so the modal row matches the + // /search filter bar. + const chips = []; + + if (this._availability !== DEFAULT_AVAILABILITY) { + const opt = this._availabilityOptions.find(o => o.value === this._availability); + if (opt) chips.push({ + key: `availability:${opt.value}`, + label: opt.label, + onRemove: () => this._setAvailability(DEFAULT_AVAILABILITY), + }); + } + + for (const value of this._languages) { + // Fall back to the raw code when the language isn't in our current + // item list. This happens for codes that aren't in + // DEFAULT_LANGUAGE_OPTIONS until /languages.json resolves; without + // a fallback the chip would silently disappear, leaving the user + // unable to dismiss an active filter. + const opt = this._languageItems.find(o => o.value === value); + chips.push({ + key: `language:${value}`, + label: opt?.label || value, + onRemove: () => this._removeLanguage(value), + }); + } + + return html` +
    + + ${repeat(chips, c => c.key, c => html` + ${c.label} + `)} + + ${chips.length >= 2 ? html` + + ` : nothing} +
    + `; + } + + _renderFilters() { + return html` +
    + + +
    + `; + } + + _renderResults() { + if (!this._shouldAutocomplete()) { + return html`
    ${this._i18n.startTyping}
    `; + } + + if (this._loading && this._results.length === 0) { + return html`
    ${this._i18n.searching}
    `; + } + + if (this._results.length === 0 && this._hasSearched) { + return html`
    ${this._i18n.noResults}
    `; + } + + return html` +
    + ${this._authorSuggestions.length ? html` +
      + ${repeat(this._authorSuggestions, a => a.key, a => this._renderAuthorSuggestion(a))} +
    + ` : nothing} +

    ${this._i18n.topResults}

    +
      ${repeat(this._results, r => r.key, r => this._renderResult(r))}
    +
    + `; + } + + // A "go to the author page" row shown above the works for each top-result + // author the query names (see deriveAuthors). The whole row is a single link + // to the author page, so it's a plain anchor (no nested-link concern). + _renderAuthorSuggestion(author) { + const href = `/authors/${author.key}`; + return html`
  • + this._onResultPress(e, href)} + > + + ${SearchModal._personIcon} + + + + + ${author.name} + ${this._i18n.authorLabel} + + +
  • `; + } + + // The whole row is a single link to the work — the author is surfaced in + // its own suggestion row, so there's no separate author link here (which + // also keeps this a plain anchor with no nested-link concern). + _renderResult(work) { + const author = work.author_name?.[0] || ''; + const year = work.first_publish_year || ''; + const cover = work.cover_i + ? `https://covers.openlibrary.org/b/id/${work.cover_i}-S.jpg` + : COVER_PLACEHOLDER; + return html`
  • + this._onResultPress(e, work.key)} + > + + + + + + ${work.title || this._i18n.untitled} + ${author ? html`${author}` : nothing} + ${year ? html`${year}` : nothing} + + +
  • `; + } + + // The footer button shows the actual hit count once a search lands + // (e.g. "See all 1,234 results"); the bare "See all results" label is + // used before any results are in (initial open, query under MIN_QUERY_LENGTH, + // or fetch error). + _seeAllLabel() { + const n = this._numFound; + if (typeof n !== 'number' || n <= 0) return this._i18n.seeAll; + const template = n === 1 ? this._i18n.seeAllOne : this._i18n.seeAllMany; + return sprintf(template, n.toLocaleString()); + } + + // ── Event handlers ─────────────────────────────────────────────────── + + _onDialogOpened() { + this.renderRoot.querySelector('.search-input')?.focus(); + } + + _onDialogClosed() { + this.open = false; + this._navigatingKey = null; + } + + // A result is a native anchor, so pressing it navigates the whole window. + // The new page can take a beat to start painting; flag the chosen row so it + // shows its loading treatment (cover spinner, dimmed siblings) during that + // gap. Modified clicks (open in new tab/window) don't navigate this page — + // leave them untreated. + _onResultPress(e, key) { + if (e.defaultPrevented || e.button !== 0) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + this._navigatingKey = key; + } + + // The author photo is requested with ?default=false, so a missing photo + // 404s and fires this — hide the to reveal the person glyph beneath. + _onAvatarError(e) { e.target.hidden = true; } + + _onQueryInput(e) { + this._query = e.target.value; + this._navigatingKey = null; + if (!this._shouldAutocomplete()) { + this._results = []; + this._authorSuggestions = []; + this._numFound = null; + this._loading = false; + this._hasSearched = false; + return; + } + this._loading = true; + this._debouncedFetch(); + } + + _onInputKeydown(e) { + if (e.key === 'Enter' && this._query.trim().length >= MIN_QUERY_LENGTH) { + e.preventDefault(); + this._onSeeAllResults(); + } + } + + _onAvailabilityChange(e) { this._setAvailability(e.detail.selected); } + + _onLanguagesChange(e) { + this._languages = [...e.detail.selected]; + ssSet(SS_LANGUAGES_KEY, JSON.stringify(this._languages)); + this._refetchIfActive(); + } + + _setAvailability(value) { + this._availability = value; + ssSet(SS_AVAILABILITY_KEY, value); + this._refetchIfActive(); + } + + _removeLanguage(value) { + this._languages = this._languages.filter(v => v !== value); + ssSet(SS_LANGUAGES_KEY, JSON.stringify(this._languages)); + this._refetchIfActive(); + } + + _clearAllFilters() { + this._availability = DEFAULT_AVAILABILITY; + this._languages = []; + ssSet(SS_AVAILABILITY_KEY, DEFAULT_AVAILABILITY); + ssSet(SS_LANGUAGES_KEY, JSON.stringify([])); + this._refetchIfActive(); + } + + _refetchIfActive() { + if (this._shouldAutocomplete()) { + this._loading = true; + this._debouncedFetch(); + } + } + + _onSeeAllResults() { + const url = this._buildSearchUrl(); + if (url) window.location.assign(url); + } + + // ── Data layer ─────────────────────────────────────────────────────── + + // Whether the current query should trigger the header autocomplete. Mirrors + // the legacy SearchBar gate: long enough, and not a bare autocomplete stopword. + _shouldAutocomplete() { + const trimmed = this._query.trim(); + return trimmed.length >= MIN_QUERY_LENGTH && !AUTOCOMPLETE_STOPWORDS.has(trimmed.toLowerCase()); + } + + _fetchResults() { + const trimmed = this._query.trim(); + if (!this._shouldAutocomplete()) return; + + const url = this._buildSearchJsonUrl(trimmed); + const fetchKey = url; + this._activeFetchKey = fetchKey; + + fetch(url) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`Search failed: ${r.status}`))) + .then(data => { + if (this._activeFetchKey !== fetchKey) return; + this._results = data.docs || []; + this._authorSuggestions = deriveAuthors(this._results, trimmed); + this._numFound = typeof data.numFound === 'number' ? data.numFound : null; + this._loading = false; + this._hasSearched = true; + }) + .catch(() => { + if (this._activeFetchKey !== fetchKey) return; + this._results = []; + this._authorSuggestions = []; + this._numFound = null; + this._loading = false; + this._hasSearched = true; + }); + } + + _buildSearchJsonUrl(query) { + const params = new URLSearchParams(); + params.set('q', query); + params.set('limit', String(RESULTS_LIMIT)); + params.set('fields', SEARCH_FIELDS.join(',')); + params.set('_spellcheck_count', '0'); + params.set('mode', searchMode.read()); + this._appendFilterParams(params); + return `/search.json?${params.toString()}`; + } + + _buildSearchUrl() { + const trimmed = this._query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) return null; + + const params = new URLSearchParams(); + params.set('q', trimmed); + params.set('mode', searchMode.read()); + this._appendFilterParams(params); + return `/search?${params.toString()}`; + } + + _appendFilterParams(params) { + const availParams = AVAILABILITY_TO_PARAMS[this._availability] || {}; + for (const [key, value] of Object.entries(availParams)) { + params.append(key, value); + } + for (const lang of this._languages) { + params.append('language', lang); + } + } + + // ── Static SVGs ────────────────────────────────────────────────────── + + static _searchIcon = html``; + + static _closeIcon = html``; + + static _personIcon = html``; +} + +customElements.define('ol-search-modal', SearchModal); + +/** + * Mounts a single SearchModal and wires it to the header search trigger button. + * Idempotent – safe to call multiple times with the same element. + * @param {HTMLButtonElement} trigger + * @returns {SearchModal|null} + */ +export function initSearchModal(trigger) { + if (!trigger || trigger.dataset.olSearchModalAttached === 'true') { + return null; + } + + const modal = document.createElement('ol-search-modal'); + + // Translated strings are rendered into the trigger's data-i18n + // (availability options) and data-i18n-ui (chrome) attributes by + // search/availability_i18n.html and search/search_modal_i18n.html. Apply + // them before the modal mounts so the first render is already localized. + modal._availabilityOptions = availabilityOptionsFromElement(trigger); + modal._i18n = searchModalStringsFromElement(trigger); + + document.body.appendChild(modal); + modal.attachToTrigger(trigger); + trigger.dataset.olSearchModalAttached = 'true'; + return modal; +} diff --git a/openlibrary/plugins/openlibrary/js/search-modal/authorSuggestion.js b/openlibrary/plugins/openlibrary/js/search-modal/authorSuggestion.js new file mode 100644 index 00000000000..1f0042f27eb --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/authorSuggestion.js @@ -0,0 +1,70 @@ +/** + * Author suggestions ("B-zero") for the header search modal. + * + * No extra request: when the query names the author of one of the top works + * /search.json already returned, we surface a row linking straight to that + * author's page. This covers the common "type a name → I want that author" case + * (the old Author facet) without a second Solr round-trip. + * + * Matching the query against each top result's author is self-protecting: a + * title search ("dune") returns that author's works, but the title isn't part + * of their name, so nothing is surfaced. Only queries that actually name an + * author produce a row. + * + * Pure functions only (no lit/DOM) so they're unit-testable on their own. + */ + +/** Only the top few results are relevant enough to surface their author. */ +export const AUTHOR_SCAN_LIMIT = 5; + +/** At most this many author rows, so an ambiguous one-word query ("smith") can't flood the list. */ +export const AUTHOR_SUGGESTION_MAX = 3; + +/** Lowercase and strip diacritics so "garcia" matches "García". */ +function fold(s) { + return (s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +/** + * True when the query names the author: a substring match either way, or a + * shared word (3+ chars, to skip initials/particles like "de"). + * + * @param {string} query + * @param {string} name + * @returns {boolean} + */ +export function queryMatchesName(query, name) { + const q = fold(query).trim(); + const full = fold(name).trim(); + if (!q || !full) return false; + if (full.includes(q) || q.includes(full)) return true; + const nameTokens = new Set(full.split(/\s+/).filter(Boolean)); + return q.split(/\s+/).some(token => token.length >= 3 && nameTokens.has(token)); +} + +/** + * Given the work docs from /search.json and the query, return the authors to + * suggest — those of the top results whose name the query matches, in rank + * order, deduped by key and capped. Empty when the query names none of them. + * + * @param {Array<{author_key?: string[], author_name?: string[]}>} docs + * @param {string} query + * @returns {Array<{key: string, name: string}>} + */ +export function deriveAuthors(docs, query) { + if (!Array.isArray(docs) || docs.length === 0) return []; + + const seen = new Set(); + const authors = []; + for (const doc of docs.slice(0, AUTHOR_SCAN_LIMIT)) { + const key = doc.author_key?.[0]; + const name = doc.author_name?.[0]; + if (!key || !name || seen.has(key)) continue; + if (queryMatchesName(query, name)) { + seen.add(key); + authors.push({ key, name }); + if (authors.length >= AUTHOR_SUGGESTION_MAX) break; + } + } + return authors; +} diff --git a/openlibrary/plugins/openlibrary/js/search-modal/constants.js b/openlibrary/plugins/openlibrary/js/search-modal/constants.js new file mode 100644 index 00000000000..496c93fad5b --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/constants.js @@ -0,0 +1,240 @@ +/** + * Filter options for the header search modal. + */ + +// Counts are rounded from production openlibrary.org facets (NOT fetched live) +// — they give the user a sense of scale without an extra round-trip and stay +// stable across renders. Last verified 2026-06-02: all=23.2M, readable=4.62M +// (public+borrowable), open/public=1.86M, borrowable=2.75M. The nested counts +// sum to their parent exactly (1.86M + 2.75M = 4.62M); print-disabled-only +// scans are excluded from `has_fulltext` for non-print-disabled patrons. Bump +// these when the corpus shifts materially. +// `nested: true` marks an option as a subset of the broader option above it +// ("Readable online"), so the filter indents it and marks it in-scope when the +// parent is selected. `icon` names a glyph in OlAvailabilityFilter._icons. +export const AVAILABILITY_OPTIONS = [ + { + value: 'all', + label: 'All books', + description: 'Including print-only books with no digital copy', + count: '23M', + icon: 'book', + }, + { + value: 'readable', + label: 'Readable online', + description: 'Anything you can read in your browser', + count: '4.6M', + icon: 'globe', + }, + { + value: 'open', + label: 'Free to read now', + description: 'Public domain & openly licensed', + count: '1.9M', + nested: true, + icon: 'unlock', + }, + { + value: 'borrowable', + label: 'Borrow online', + description: 'Digital loan - one reader at a time, may have a waitlist', + count: '2.8M', + nested: true, + icon: 'clock', + }, +]; + +export const DEFAULT_AVAILABILITY = 'all'; + +/** + * Returns a copy of AVAILABILITY_OPTIONS with each option's `label` and + * `description` replaced by the translated strings in `i18nStrings` (keyed by + * the option's `value`). The English strings above are the source/fallback: + * any value the translations omit keeps its built-in text, so a missing or + * partial translation never blanks out an option. + * + * The translated strings are rendered server-side via search/availability_i18n + * (Templetor `$_()`), since the JS-side `ugettext` is only a pass-through. + * + * @param {Object|null} i18nStrings + * @returns {typeof AVAILABILITY_OPTIONS} + */ +export function localizeAvailabilityOptions(i18nStrings) { + if (!i18nStrings) return AVAILABILITY_OPTIONS; + return AVAILABILITY_OPTIONS.map((opt) => { + const t = i18nStrings[opt.value]; + if (!t) return opt; + return { + ...opt, + label: t.label || opt.label, + description: t.description || opt.description, + }; + }); +} + +/** + * Reads translated availability strings from an element's `data-i18n` JSON + * attribute (rendered by search/availability_i18n.html) and returns the + * localized option list. Falls back to the English defaults when the attribute + * is absent or malformed. + * + * @param {Element|null} el + * @returns {typeof AVAILABILITY_OPTIONS} + */ +export function availabilityOptionsFromElement(el) { + let i18nStrings = null; + try { + const raw = el && el.dataset ? el.dataset.i18n : null; + if (raw) i18nStrings = JSON.parse(raw); + } catch { /* fall back to the English defaults below */ } + return localizeAvailabilityOptions(i18nStrings); +} + +/** + * English source/fallback for the header search modal's chrome strings (labels, + * placeholders, aria-labels, status messages). Rendered server-side by + * search/search_modal_i18n.html via `$_()`; this object is what ships when no + * translation is present. `%s` in `removeFilter` is filled in at runtime with + * the filter label (see SearchModal._renderChips). + */ +export const DEFAULT_SEARCH_MODAL_STRINGS = { + dialogAria: 'Search Open Library', + inputPlaceholder: 'Search books and authors…', + inputAria: 'Search', + closeAria: 'Close search', + seeAll: 'See all results', + seeAllOne: 'See all %s result', + seeAllMany: 'See all %s results', + activeFiltersAria: 'Active filters', + removeFilter: 'Remove filter: %s', + clearAll: 'Clear all', + filtersAria: 'Search filters', + availabilityLabel: 'Availability', + languageLabel: 'Language', + languagePlaceholder: 'Search languages…', + languageHeading: 'Languages', + startTyping: 'Start typing to search…', + searching: 'Searching…', + noResults: 'No results found', + topResults: 'Top results', + untitled: 'Untitled', + authorLabel: 'Author', +}; + +/** + * Reads translated modal chrome strings from an element's `data-i18n-ui` JSON + * attribute (rendered by search/search_modal_i18n.html) and merges them over + * DEFAULT_SEARCH_MODAL_STRINGS, so any key the translations omit keeps its + * English text. Falls back to the full English set when the attribute is absent + * or malformed. + * + * @param {Element|null} el + * @returns {typeof DEFAULT_SEARCH_MODAL_STRINGS} + */ +export function searchModalStringsFromElement(el) { + let overrides = null; + try { + const raw = el && el.dataset ? el.dataset.i18nUi : null; + if (raw) overrides = JSON.parse(raw); + } catch { /* fall back to the English defaults below */ } + return overrides + ? { ...DEFAULT_SEARCH_MODAL_STRINGS, ...overrides } + : DEFAULT_SEARCH_MODAL_STRINGS; +} + +/** + * Maps an availability value to the `/search` query params that express it. + * Shared by the header modal and the search-page filter row so both produce + * identical filters. The param names match WorkSearchScheme.facet_rewrites + * (`public_scan`, `print_disabled`, `has_fulltext`). + */ +export const AVAILABILITY_TO_PARAMS = { + all: {}, + // "Readable online" — everything a patron can read without special access: + // ebook_access:[borrowable TO *] via has_fulltext (public + borrowable). + readable: { has_fulltext: 'true' }, + // "Borrow online" — readable but not public: borrowable scans only. + borrowable: { has_fulltext: 'true', public_scan: 'false' }, + // "Free to read now" — public-domain / open-access scans (ebook_access:public). + open: { public_scan: 'true' }, +}; + +/** + * Inverse of AVAILABILITY_TO_PARAMS: given a param lookup (a function or object + * returning the current value of a param name), returns the matching + * availability value. Falls back to DEFAULT_AVAILABILITY when nothing matches. + * + * @param {(name: string) => string|null|undefined} get - param accessor + * @returns {string} + */ +export function availabilityFromParams(get) { + const matches = (params) => + Object.entries(params).every(([k, v]) => String(get(k) ?? '') === v); + // Check specific (multi-param) values before less specific ones; skip `all` + // (the empty default) so it only wins when nothing else matches. + for (const value of ['borrowable', 'readable', 'open']) { + if (matches(AVAILABILITY_TO_PARAMS[value])) return value; + } + return DEFAULT_AVAILABILITY; +} + +/** + * The 20 default languages shown immediately when the Language popover + * opens (before any API response arrives, or if the fetch fails). + * + * Chosen by OL catalogue volume – the languages most likely to be useful + * to a patron on the first click. Sorted by global speaker population / + * OL catalog representation. + * + * fetchLanguageOptions() (languages.js) fetches the full OL language + * catalogue from /languages.json – translated names, volume-ranked – and + * replaces this list so every language in the catalogue becomes searchable. + * This array is the instant-render and fetch-failure fallback only. + */ +export const DEFAULT_LANGUAGE_OPTIONS = [ + { value: 'eng', label: 'English' }, + { value: 'fre', label: 'French' }, + { value: 'ger', label: 'German' }, + { value: 'spa', label: 'Spanish' }, + { value: 'por', label: 'Portuguese' }, + { value: 'ita', label: 'Italian' }, + { value: 'rus', label: 'Russian' }, + { value: 'chi', label: 'Chinese' }, + { value: 'jpn', label: 'Japanese' }, + { value: 'ara', label: 'Arabic' }, + { value: 'dut', label: 'Dutch' }, + { value: 'pol', label: 'Polish' }, + { value: 'swe', label: 'Swedish' }, + { value: 'tur', label: 'Turkish' }, + { value: 'hin', label: 'Hindi' }, + { value: 'kor', label: 'Korean' }, + { value: 'lat', label: 'Latin' }, + { value: 'per', label: 'Persian' }, + { value: 'heb', label: 'Hebrew' }, + { value: 'ben', label: 'Bengali' }, +]; + +/** + * sessionStorage keys for per-session filter persistence. + */ +export const SS_AVAILABILITY_KEY = 'ol-header-search-availability'; +export const SS_LANGUAGES_KEY = 'ol-header-search-languages'; + +/** + * Read the language list from sessionStorage. Guards against missing values, + * unparseable JSON, and values that parse to a non-array (e.g. a previously + * stored object or string), any of which would otherwise leave callers with a + * non-iterable or character-iterable value. + * + * @returns {string[]} + */ +export function readStoredLanguages() { + let raw = null; + try { raw = sessionStorage.getItem(SS_LANGUAGES_KEY); } catch { return []; } + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { return []; } +} diff --git a/openlibrary/plugins/openlibrary/js/search-modal/languages.js b/openlibrary/plugins/openlibrary/js/search-modal/languages.js new file mode 100644 index 00000000000..6959d83ce01 --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/languages.js @@ -0,0 +1,43 @@ +/** + * Fetches the real language options for the search filters. + * + * Uses the `/languages.json` endpoint (openlibrary/fastapi/languages.py), which + * returns languages ordered by OL catalogue volume with names already + * translated to the patron's UI language. This is the single source of truth — + * preferred over a hardcoded code→label map. + */ + +import { DEFAULT_LANGUAGE_OPTIONS } from './constants.js'; + +const ENDPOINT = '/languages.json'; + +/** + * @typedef {{ value: string, label: string }} LanguageOption + */ + +/** + * Fetch language options as `{ value: marc_code, label: name }`, sorted by + * catalogue volume. Falls back to DEFAULT_LANGUAGE_OPTIONS if the request + * fails so the popover is never empty. + * + * @param {Object} [opts] + * @param {number} [opts.limit=500] - max languages to request (covers ~all). + * @param {number} [opts.timeout=8000] - abort the fetch after this many ms. + * @returns {Promise} + */ +export async function fetchLanguageOptions({ limit = 500, timeout = 8000 } = {}) { + try { + const res = await fetch( + `${ENDPOINT}?limit=${limit}&sort=count`, + { signal: AbortSignal.timeout?.(timeout) } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const options = (Array.isArray(data) ? data : []) + .filter(lang => lang && lang.marc_code && lang.name) + .map(lang => ({ value: lang.marc_code, label: lang.name })); + return options.length ? options : DEFAULT_LANGUAGE_OPTIONS; + } catch { + return DEFAULT_LANGUAGE_OPTIONS; + } +} diff --git a/openlibrary/plugins/worksearch/code.py b/openlibrary/plugins/worksearch/code.py index 70d49031069..d0b57db336e 100644 --- a/openlibrary/plugins/worksearch/code.py +++ b/openlibrary/plugins/worksearch/code.py @@ -120,6 +120,84 @@ def get_facet_map() -> tuple[tuple[str, str]]: ) +# Server-side mirror of AVAILABILITY_TO_PARAMS in +# openlibrary/plugins/openlibrary/js/search-modal/constants.js. Keep in sync. +# The keys are the user-facing availability "value" the header modal and the +# search-page filter row use; the values are the Solr filter params they +# materialize as in the URL. +AVAILABILITY_TO_PARAMS: dict[str, dict[str, str]] = { + "all": {}, + # "Readable online" — readable without special access: ebook_access:[borrowable TO *] + # via has_fulltext (public + borrowable). + "readable": {"has_fulltext": "true"}, + # "Borrow online" — readable but not public: borrowable scans only. + "borrowable": {"has_fulltext": "true", "public_scan": "false"}, + # "Free to read now" — public-domain / open-access scans (ebook_access:public). + "open": {"public_scan": "true"}, +} + +# Every URL param that any availability value can set. Used to clear the +# availability filter (chip removal) without touching unrelated params. +AVAILABILITY_PARAM_KEYS: tuple[str, ...] = tuple({key for params in AVAILABILITY_TO_PARAMS.values() for key in params}) + + +def _param_first(param: dict, key: str) -> str: + """web.input can give back either a string or list depending on the field's + default. The availability params come in as scalar strings, but we + defensively unwrap lists too so a future change to the input declaration + doesn't silently break this comparison.""" + val = param.get(key) + if isinstance(val, list): + return val[0] if val else "" + return "" if val is None else str(val) + + +@public +def get_active_availability(param: dict) -> str: + """Return the availability value ('all'/'readable'/'borrowable'/'open') + currently active for `param`. Mirrors availabilityFromParams() in + search-modal/constants.js: check the more specific multi-param values + first, fall back to 'all'.""" + + def matches(expected: dict) -> bool: + return all(_param_first(param, k) == v for k, v in expected.items()) + + for value in ("borrowable", "readable", "open"): + if matches(AVAILABILITY_TO_PARAMS[value]): + return value + return "all" + + +@public +def get_availability_label(value: str) -> str: + """Translated chip label for an availability value. Keep in sync with the + label text in AVAILABILITY_OPTIONS (search-modal/constants.js).""" + return { + "all": _("All books"), + "readable": _("Readable online"), + "borrowable": _("Borrow online"), + "open": _("Free to read now"), + }.get(value, value) + + +@public +def get_availability_param_keys() -> tuple[str, ...]: + return AVAILABILITY_PARAM_KEYS + + +@public +def get_request_lang() -> str: + """The request's UI language, safe to call from templates rendered on + either the legacy web.py server or the FastAPI server. The Templetor + global `get_lang()` reads `web.ctx.lang` directly, which isn't populated + by FastAPI — partials rendered there would AttributeError. Reading from + the unified `req_context` works on both. Falls back to 'en'.""" + try: + return req_context.get().lang or "en" + except LookupError: + return "en" + + async def get_solr_works_async(work_keys: set[str], fields: Iterable[str] | None = None, editions=False) -> dict[str, web.storage]: from openlibrary.plugins.worksearch.search import get_solr @@ -313,7 +391,18 @@ def _prepare_solr_query_params( # noqa: PLR0912 values = param[field] if isinstance(values, str): values = [values] - params += [("fq", f'{field}:"{val}"') for val in values if val] + non_empty = [val for val in values if val] + if field == "language" and len(non_empty) > 1: + # Multiple languages are additive: a work in any of the selected + # languages should match. Emitting one fq per value would make Solr + # AND them, requiring a work to be in every selected language at + # once. Keep the field name first (language:("a" OR "b")) so + # editions.fq rewriting, which splits on the first ':', still + # resolves the field correctly. + or_clause = " OR ".join(f'"{val}"' for val in non_empty) + params.append(("fq", f"{field}:({or_clause})")) + else: + params += [("fq", f'{field}:"{val}"') for val in non_empty] # Many fields in solr use the convention of `*_facet` both # as a facet key and as the explicit search query key. @@ -724,6 +813,11 @@ def GET(self): "person", "time", "editions.sort", + # Availability filters. These are defined in WorkSearchScheme.facet_rewrites + # (mapping to ebook_access:* Solr clauses) but are not facet_fields, so they + # must be whitelisted here for the availability filter to take effect. + "public_scan", + "print_disabled", } | WorkSearchScheme.facet_fields: if web_input.get(p): param[p] = web_input[p] diff --git a/openlibrary/plugins/worksearch/schemes/works.py b/openlibrary/plugins/worksearch/schemes/works.py index 1bc3f25fec8..28d671d39b7 100644 --- a/openlibrary/plugins/worksearch/schemes/works.py +++ b/openlibrary/plugins/worksearch/schemes/works.py @@ -498,8 +498,24 @@ def convert_work_query_to_edition_query(work_query: str) -> str: if param_name != "fq" or param_value.startswith("type:"): continue field_name, field_val = param_value.split(":", 1) + # facet_rewrites can produce negated fq values like + # '-ebook_access:public' (from public_scan=false). The leading + # '-' is Solr negation syntax, not part of the field name — + # strip it before lookup and re-apply to the rewritten field. + negate = field_name.startswith("-") + if negate: + field_name = field_name[1:] if ed_field := convert_work_field_to_edition_field(field_name): - editions_fq.append(f"{ed_field}:{field_val}") + if negate: + # A pure-negative query matches nothing inside the + # block-join `filters=$editions.fq` local param (it gets + # no top-level `*:*` fixup the way a real `fq` does), so + # an unguarded `-ebook_access:public` zeroed out the + # "Borrow online" filter (public_scan=false). Anchor + # with `*:*` so it subtracts instead of matching nothing. + editions_fq.append(f"(*:* -{ed_field}:{field_val})") + else: + editions_fq.append(f"{ed_field}:{field_val}") for fq in editions_fq: new_params.append(("editions.fq", fq)) diff --git a/openlibrary/plugins/worksearch/tests/test_worksearch.py b/openlibrary/plugins/worksearch/tests/test_worksearch.py index 55857dc1e24..57eedf741b1 100644 --- a/openlibrary/plugins/worksearch/tests/test_worksearch.py +++ b/openlibrary/plugins/worksearch/tests/test_worksearch.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import web from openlibrary.plugins.worksearch.code import ( @@ -6,6 +8,7 @@ process_facet, ) from openlibrary.plugins.worksearch.schemes.works import WorkSearchScheme +from openlibrary.utils.request_context import RequestContextVars, req_context def test_process_facet(): @@ -92,3 +95,52 @@ def test_prepare_solr_query_params_first_publish_year_string(): # Check that the fq param for first_publish_year is correctly added fq_params = [p for p in params if p[0] == "fq"] assert ("fq", 'first_publish_year:"1997"') in fq_params + + +def _editions_fq_for(param: dict) -> list[str]: + """Run a Solr-editions query for `param` and return its editions.fq clauses. + + Sets req_context because the has_fulltext facet_rewrite resolves + get_fulltext_min() off it; requesting the `editions` field opts the query + into the block-join path that builds editions.fq. convert_iso_to_marc is + stubbed since it reaches for the `site` ContextVar (irrelevant here).""" + token = req_context.set( + RequestContextVars( + x_forwarded_for=None, + user_agent=None, + lang="en", + solr_editions=True, + print_disabled=False, + ) + ) + try: + scheme = WorkSearchScheme(solr_editions=True) + with patch( + "openlibrary.plugins.worksearch.schemes.works.convert_iso_to_marc", + return_value="eng", + ): + params, _ = _prepare_solr_query_params(scheme, param, fields="key,editions") + finally: + req_context.reset(token) + return [v for k, v in params if k == "editions.fq"] + + +def test_prepare_solr_query_params_borrowable_editions_fq_anchors_negation(): + """ "Borrowable Only" maps to has_fulltext=true + public_scan=false, the + latter rewriting to a negated `-ebook_access:public`. A bare pure-negative + clause matches nothing inside the block-join `filters=$editions.fq` local + param (no top-level `*:*` fixup), which made the filter return zero results. + It must be anchored as `(*:* -ebook_access:public)`.""" + editions_fq = _editions_fq_for({"q": "harry potter", "has_fulltext": "true", "public_scan": "false"}) + assert "(*:* -ebook_access:public)" in editions_fq + # The unanchored form (the bug) must never be emitted. + assert "-ebook_access:public" not in editions_fq + + +def test_prepare_solr_query_params_open_access_editions_fq_positive_unwrapped(): + """A positive availability clause ("Free to read now" → public_scan=true → + ebook_access:public) must pass through to editions.fq unwrapped — the + negation guard should only touch negated clauses.""" + editions_fq = _editions_fq_for({"q": "harry potter", "public_scan": "true"}) + assert "ebook_access:public" in editions_fq + assert "(*:* -ebook_access:public)" not in editions_fq diff --git a/openlibrary/templates/design.html b/openlibrary/templates/design.html index 76da1524240..376e36ba1eb 100644 --- a/openlibrary/templates/design.html +++ b/openlibrary/templates/design.html @@ -15,6 +15,8 @@

    $_("Web Component Library")

  • $_("Pagination")
  • $_("Popover")
  • Select Popover
  • +
  • Options Popover
  • +
  • Dialog
  • $_("Read More")
  • $_("Chip")
  • $_("Button")
  • @@ -120,6 +122,10 @@

    $_("Arrows mode (first page, has next)")

    $:render_template("design/select-popover") + $:render_template("design/options-popover") + + $:render_template("design/dialog") +

    $_("Read More")

    $_("The ol-read-more component provides expandable/collapsible content with two truncation modes: height-based and line-based.")

    @@ -297,6 +303,35 @@

    $_("Interactive toggle")

    }); + +
    +

    $_("Domain variants")

    +

    $:_("Use %(variant)s to color a chip by the kind of thing it represents (language, subject, genre, author, place, or a neutral facet). The chip maps the variant to a soft-tint palette internally.", variant='variant')

    +
    <ol-chip variant="language">
    +  English
    +</ol-chip>
    +
    + $_("English") + $_("Science Fiction") + $_("Memoir") + $_("Ursula K. Le Guin") + $_("San Francisco") + $_("Read now") +
    +
    + +
    +

    $_("Removable filter pills")

    +

    $:_("Combine %(variant)s with %(selected)s to make a removable, category-colored filter pill. The selected state adds a close icon; listen for %(event)s to remove the filter.", variant='variant', selected='selected', event='ol-chip-select')

    +
    <ol-chip variant="language" selected>
    +  English
    +</ol-chip>
    +
    + $_("Read now") + $_("English") + $_("French") +
    +
    $:render_template("design/button") diff --git a/openlibrary/templates/design/dialog.html b/openlibrary/templates/design/dialog.html new file mode 100644 index 00000000000..71ac522a4f6 --- /dev/null +++ b/openlibrary/templates/design/dialog.html @@ -0,0 +1,196 @@ +$def with() + +
    +

    Dialog

    +

    The ol-dialog component is a modal built on the native <dialog> element. It provides a focus trap, focus restoration, scroll lock, backdrop dismissal, and Escape-to-close out of the box. There are no show/hide methods — toggle the open attribute (or set the .open property) to open and close it. Backdrop and Escape dismissal are on by default; set the .closeOnBackdropClick / .closeOnEscape properties to false to require an explicit action.

    + +
    +

    Confirmation dialog

    +

    A small dialog with a title, body, and footer actions. Opening it traps focus inside; closing it restores focus to the trigger. The default header renders a title and a close button automatically.

    +
    <ol-dialog label="Delete this list?" width="small">
    +  <p>This can't be undone.</p>
    +  <div slot="footer">
    +    <button value="cancel">Cancel</button>
    +    <button value="delete">Delete list</button>
    +  </div>
    +</ol-dialog>
    +
    + + +

    Deleting “Want to Read” removes it permanently. This can't be undone.

    +
    + + +
    +
    +

    + Last action: (none) +

    +
    + +
    + +
    +

    Width presets

    +

    Use width to pick a preset: small (400px), medium (550px, the default), or large (800px). The dialog never exceeds 90vw, so presets stay responsive. Override a single instance with the --ol-dialog-width-* custom properties.

    +
    <ol-dialog width="large" label="…">…</ol-dialog>
    +
    + + + + +

    This dialog is using the medium width preset. Resize the window to see how it stays within 90vw on narrow screens.

    +
    +
    + +
    + +
    +

    Form dialog

    +

    A medium dialog hosting a short form. The focus trap keeps Tab cycling between the fields, footer buttons, and close button, which makes it well suited to quick edit flows like adding a note to your reading log.

    +
    <ol-dialog label="Add a note" width="medium">
    +  <label for="note">Your note</label>
    +  <textarea id="note"></textarea>
    +  <div slot="footer">
    +    <button value="cancel">Cancel</button>
    +    <button value="save">Save note</button>
    +  </div>
    +</ol-dialog>
    +
    + + + + +
    + + +
    +
    +
    + +
    + +
    +

    Command palette / search modal

    +

    Combine placement="top", without-header, fullscreen-on-mobile, and --ol-dialog-padding: 0 to slot a search bar into the header region and let results grow downward while the top edge stays put. This is the pattern behind the site header's search modal. The ol-after-open event is a good place to focus the input.

    +
    <ol-dialog
    +  placement="top"
    +  without-header
    +  fullscreen-on-mobile
    +  label="Search the catalog"
    +  style="--ol-dialog-padding: 0">
    +  <div slot="header"><input type="search" /></div>
    +  <ul>…results…</ul>
    +</ol-dialog>
    +
    + + +
    + +
    +
      +
    • The Left Hand of Darkness — Ursula K. Le Guin
    • +
    • A Wizard of Earthsea — Ursula K. Le Guin
    • +
    • The Dispossessed — Ursula K. Le Guin
    • +
    +
    +
    + +
    +
    diff --git a/openlibrary/templates/design/options-popover.html b/openlibrary/templates/design/options-popover.html new file mode 100644 index 00000000000..ee2fa195d29 --- /dev/null +++ b/openlibrary/templates/design/options-popover.html @@ -0,0 +1,109 @@ +$def with() + +
    +

    Options Popover

    +

    The ol-options-popover component pairs a trigger button with a popover containing a single-select list of rich options. Each option can carry a label, an optional description, and an optional count, making it a good fit for filters with a small fixed set of mutually exclusive choices. Use ol-select-popover instead when the user can pick multiple values or filter a long list.

    + +
    +

    Single-select menu

    +

    With just a value and label per item, the rows render as a tidy single-select menu — useful for a sort order or layout switcher. The list heading defaults to the uppercased label; override it with heading. The selected value is exposed via the selected attribute and the ol-options-popover-change event.

    +
    <ol-options-popover
    +  label="Sort by"
    +  heading="SORT ORDER"
    +  selected="relevance"
    +  items='[{"value":"relevance","label":"Relevance"}, ...]'>
    +</ol-options-popover>
    +
    + +

    + Selected: relevance +

    +
    + +
    + +
    +

    Options with descriptions and counts

    +

    Add an optional description and count to give each option more context. Both render only when present, so plain and rich rows can mix in the same list.

    +
    <ol-options-popover
    +  label="Genre"
    +  selected="fiction"
    +  items='[
    +    {"value":"fiction","label":"Fiction","description":"Novels and short stories","count":"1,024"},
    +    {"value":"nonfiction","label":"Nonfiction","description":"Fact-based works","count":"892"}
    +  ]'>
    +</ol-options-popover>
    +
    + +
    + +
    + +
    +

    Custom trigger via slot

    +

    Provide your own trigger element with slot="trigger" when the default button doesn't fit. The component still handles open/close, ARIA wiring, and keyboard navigation (arrow keys, Home/End, and Escape).

    +
    <ol-options-popover label="Sort by" items="...">
    +  <button slot="trigger">Sort results</button>
    +</ol-options-popover>
    +
    + + + +
    + +
    +
    diff --git a/openlibrary/templates/lib/nav_head.html b/openlibrary/templates/lib/nav_head.html index fe56adf5ea7..5ea51f4c87d 100644 --- a/openlibrary/templates/lib/nav_head.html +++ b/openlibrary/templates/lib/nav_head.html @@ -87,41 +87,30 @@
    - -
    -
      -
    -
    + $# data-i18n carries the translated availability-filter strings and + $# data-i18n-ui the modal's chrome strings; SearchModal.js reads both from + $# this trigger element. The visual "Search" label is hidden on + $# mobile/touch widths (the trigger renders as a compact icon-only + $# pill there) and shown at desktop to give the full wide search bar. + $# `aria-label` keeps the accessible name in both modes. +
    + $# Barcode scan needs a camera, so it's only surfaced on touch devices + $# (hidden on hover-capable pointers via CSS). It sits outside + $# .search-bar-component so the search-page hide rule (which targets the + $# pill only) keeps the scanner reachable from the search results page. + + +
    $if not ctx.user: diff --git a/openlibrary/templates/search/availability_i18n.html b/openlibrary/templates/search/availability_i18n.html new file mode 100644 index 00000000000..8a6e89f217e --- /dev/null +++ b/openlibrary/templates/search/availability_i18n.html @@ -0,0 +1,15 @@ +$def with () +$# Translated availability-filter labels/descriptions for the header search +$# modal (lib/nav_head.html) and the search-results filter row (work_search.html). +$# Rendered into a `data-i18n` attribute and read client-side by +$# search-modal/constants.js (availabilityOptionsFromElement). The English source +$# strings here must match the fallback AVAILABILITY_OPTIONS in that same file. +$# Counts are not translated — they're rendered separately by the popover and +$# carried in AVAILABILITY_OPTIONS, not here. +$ availability_i18n = { +$ "all": {"label": _("All books"), "description": _("Including print-only books with no digital copy")}, +$ "readable": {"label": _("Readable online"), "description": _("Anything you can read in your browser")}, +$ "open": {"label": _("Free to read now"), "description": _("Public domain & openly licensed")}, +$ "borrowable": {"label": _("Borrow online"), "description": _("Digital loan - one reader at a time, may have a waitlist")}, +$ } +$json_encode(availability_i18n) diff --git a/openlibrary/templates/search/search_modal_i18n.html b/openlibrary/templates/search/search_modal_i18n.html new file mode 100644 index 00000000000..ffb05ae5e93 --- /dev/null +++ b/openlibrary/templates/search/search_modal_i18n.html @@ -0,0 +1,32 @@ +$def with () +$# Translated chrome strings for the header search modal (SearchModal.js): +$# labels, placeholders, aria-labels and status messages. Rendered into the +$# trigger's `data-i18n-ui` attribute and read client-side by +$# search-modal/constants.js (searchModalStringsFromElement). The keys and +$# English source strings here must match DEFAULT_SEARCH_MODAL_STRINGS in that +$# same file. `%s` in "Remove filter:" is filled in client-side with the filter +$# label. (Availability option labels live in search/availability_i18n.html.) +$ search_modal_i18n = { +$ "dialogAria": _("Search Open Library"), +$ "inputPlaceholder": _("Search books and authors…"), +$ "inputAria": _("Search"), +$ "closeAria": _("Close search"), +$ "seeAll": _("See all results"), +$ "seeAllOne": _("See all %s result"), +$ "seeAllMany": _("See all %s results"), +$ "activeFiltersAria": _("Active filters"), +$ "removeFilter": _("Remove filter: %s"), +$ "clearAll": _("Clear all"), +$ "filtersAria": _("Search filters"), +$ "availabilityLabel": _("Availability"), +$ "languageLabel": _("Language"), +$ "languagePlaceholder": _("Search languages…"), +$ "languageHeading": _("Languages"), +$ "startTyping": _("Start typing to search…"), +$ "searching": _("Searching…"), +$ "noResults": _("No results found"), +$ "topResults": _("Top results"), +$ "untitled": _("Untitled"), +$ "authorLabel": _("Author"), +$ } +$json_encode(search_modal_i18n) diff --git a/openlibrary/templates/search/searchbox.html b/openlibrary/templates/search/searchbox.html index 0c3ad6ae7d0..88f56ac7d59 100644 --- a/openlibrary/templates/search/searchbox.html +++ b/openlibrary/templates/search/searchbox.html @@ -1,9 +1,9 @@ -$def with (q, placeholder="") +$def with (q, placeholder="", autofocus=False) $ placeholder = placeholder or _('Search')