From 2c0b6d183fbe7e78f96094214715b844eb4db9a4 Mon Sep 17 00:00:00 2001 From: Lokesh Dhakar Date: Fri, 8 May 2026 22:32:14 -0700 Subject: [PATCH 01/31] feat: add mobile-friendly header search modal Replace the legacy inline autocomplete dropdown on the header search bar with an OlDialog-based modal. On mobile it goes fullscreen; on desktop it anchors near the top of the viewport. - OlDialog: native -based modal with focus trap, animations, placement="top", fullscreen-on-mobile, and a custom header slot - OlOptionsPopover: filter trigger + popover for rich single-select options (used by the modal for Availability) - search-modal/SearchModal: takes over autocomplete duties from SearchBar via a new disableAutocomplete option on the legacy bar - focus-utils, slot-utils: shared helpers for shadow-DOM components - Registration guards on OlPopover/OlSelectPopover so they don't double-define when imported through both lit-components and the search-modal webpack consumer --- openlibrary/components/lit/OlDialog.js | 615 +++++++++++++++ .../components/lit/OlOptionsPopover.js | 395 ++++++++++ openlibrary/components/lit/OlPopover.js | 4 +- openlibrary/components/lit/OlSelectPopover.js | 4 +- openlibrary/components/lit/index.js | 2 + .../components/lit/utils/focus-utils.js | 59 ++ .../components/lit/utils/slot-utils.js | 27 + .../plugins/openlibrary/js/SearchBar.js | 10 +- openlibrary/plugins/openlibrary/js/ol.js | 5 +- .../js/search-modal/SearchModal.js | 745 ++++++++++++++++++ .../openlibrary/js/search-modal/constants.js | 64 ++ 11 files changed, 1925 insertions(+), 5 deletions(-) create mode 100644 openlibrary/components/lit/OlDialog.js create mode 100644 openlibrary/components/lit/OlOptionsPopover.js create mode 100644 openlibrary/components/lit/utils/focus-utils.js create mode 100644 openlibrary/components/lit/utils/slot-utils.js create mode 100644 openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js create mode 100644 openlibrary/plugins/openlibrary/js/search-modal/constants.js diff --git a/openlibrary/components/lit/OlDialog.js b/openlibrary/components/lit/OlDialog.js new file mode 100644 index 00000000000..02b8f91ee90 --- /dev/null +++ b/openlibrary/components/lit/OlDialog.js @@ -0,0 +1,615 @@ +import { LitElement, html, css, isServer } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { FOCUSABLE_SELECTOR, getDeepActiveElement, getFocusableFromSlot } from './utils/focus-utils.js'; +import { slotHasContent } from './utils/slot-utils.js'; + +/** + * A modal dialog built on the native `` element. Provides focus trap, + * focus restoration, scroll lock (via `showModal()`), backdrop dismissal, and + * Escape-to-close out of the box. Supports slotted header/body/footer content + * and a fullscreen mode for mobile. + * + * @element ol-dialog + * + * @prop {Boolean} open - Whether the dialog is open. + * @prop {String} label - Title shown in the default header. Also used as the + * accessible name when `withoutHeader` is true. + * @prop {Boolean} withoutHeader - Hide the default header (title + close + * button). The `header` slot still works. + * @prop {String} width - Width preset: `'small'` (400px), `'medium'` (550px, + * default), or `'large'` (800px). Override per-instance via + * `--ol-dialog-width-*` host CSS variables. + * @prop {Boolean} closeOnBackdropClick - Whether clicking the backdrop closes + * the dialog. Default `true`. Attribute: `close-on-backdrop-click`. + * @prop {Boolean} closeOnEscape - Whether pressing Escape closes the dialog. + * Default `true`. Attribute: `close-on-escape`. + * @prop {Boolean} fullscreenOnMobile - At viewports ≤767px, render edge-to-edge + * (full viewport, no border-radius). Attribute: `fullscreen-on-mobile`. + * @prop {String} placement - `'center'` (default) keeps the dialog vertically + * centered like a normal modal. `'top'` anchors it a fixed distance from + * the top of the viewport so the top edge stays put as content grows or + * shrinks (command-palette / search-modal pattern). + * + * @slot - Default slot for the dialog body. + * @slot header - Optional custom header. When filled, replaces the default + * title + close-button row entirely. Useful for search bars, custom + * toolbars, etc. + * @slot footer - Slot for action buttons. Footer region is hidden when empty. + * + * @cssprop --ol-dialog-padding - Padding around body and footer regions. + * Set to `0` for edge-to-edge content (e.g. when slotting a search bar + * or filter row that owns its own padding). + * @cssprop --ol-dialog-border-radius - Corner radius (ignored in fullscreen mode). + * @cssprop --ol-dialog-backdrop-color - Backdrop color. + * @cssprop --ol-dialog-animation-duration - Open/close animation duration. + * @cssprop --ol-dialog-top-offset - Distance from viewport top when + * `placement="top"`. Default `clamp(40px, 8vh, 96px)`. + * + * @fires ol-open - Fires when the dialog starts opening. + * @fires ol-after-open - Fires after the open animation completes. + * @fires ol-close - Fires when the dialog starts closing. Cancelable — + * calling `event.preventDefault()` keeps the dialog open. + * @fires ol-after-close - Fires after the close animation completes. + * + * @example + * + *

Form goes here.

+ * + *
+ * + * @example + * + * + *
+ *
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 */ + @media (max-width: 767px) { + :host([fullscreen-on-mobile]) dialog { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + border-radius: 0; + } + } + + .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 && !closeButton.disabled) { + 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; + } + + /** + * 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 focusable = this._getFocusableElements(); + if (focusable.length === 0) return; + + event.preventDefault(); + + const activeElement = getDeepActiveElement(); + const currentIndex = focusable.indexOf(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}

+ +
+ +
+ +
+
+ +
+
+ `; + } +} + +// SSR-safe registration. Guarded against double-registration when both the +// lit-components bundle and a webpack consumer (e.g. SearchModal) import the +// component module. +if (!isServer && !customElements.get('ol-dialog')) { + 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..a5d9794bfc0 --- /dev/null +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -0,0 +1,395 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.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? }` + * objects. Settable as JSON attribute or property. + * @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 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; + font-weight: 500; + 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; + } + + @media (hover: hover) and (pointer: fine) { + .item-row:hover { + background: var(--icon-link-grey); + } + } + + .item-row:focus-within { + outline: none; + background: var(--icon-link-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 { + color: var(--darker-grey); + font-weight: 500; + } + + .item--selected .item-label { + color: var(--link-blue); + font-weight: 600; + } + + .item-description { + margin-top: 2px; + color: var(--accessible-grey); + font-size: 13px; + 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; + } + + 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(); + + return html` +
+
    + ${heading ? html`` : nothing} + ${repeat(items, it => it.value, it => this._renderItem(it))} +
+
+ `; + } + + _renderItem(item) { + const isSelected = item.value === this.selected; + 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) return; + this.selected = value; + this.dispatchEvent(new CustomEvent('ol-options-popover-change', { + bubbles: true, composed: true, + detail: { selected: value }, + })); + } + + _onListKeydown(e) { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') { + return; + } + const radios = Array.from(this.shadowRoot.querySelectorAll('.item-radio')); + if (radios.length === 0) return; + + const active = this.shadowRoot.activeElement; + const idx = radios.indexOf(active); + + let next; + if (e.key === 'ArrowDown') { + next = idx === -1 ? 0 : Math.min(idx + 1, radios.length - 1); + } else if (e.key === 'ArrowUp') { + next = idx === -1 ? radios.length - 1 : Math.max(idx - 1, 0); + } else if (e.key === 'Home') { + next = 0; + } else if (e.key === 'End') { + next = radios.length - 1; + } + e.preventDefault(); + radios[next].focus(); + } + + _focusSelectedOrFirst() { + const radios = Array.from(this.shadowRoot.querySelectorAll('.item-radio')); + if (radios.length === 0) return; + const selectedRadio = radios.find(r => r.value === this.selected); + (selectedRadio || radios[0]).focus(); + } +} + +if (!customElements.get('ol-options-popover')) { + customElements.define('ol-options-popover', OlOptionsPopover); +} diff --git a/openlibrary/components/lit/OlPopover.js b/openlibrary/components/lit/OlPopover.js index 4b27d5c11de..6c03e3005d2 100644 --- a/openlibrary/components/lit/OlPopover.js +++ b/openlibrary/components/lit/OlPopover.js @@ -841,4 +841,6 @@ export class OlPopover extends LitElement { } } -customElements.define('ol-popover', OlPopover); +if (!customElements.get('ol-popover')) { + customElements.define('ol-popover', OlPopover); +} diff --git a/openlibrary/components/lit/OlSelectPopover.js b/openlibrary/components/lit/OlSelectPopover.js index c2eeb9ae25b..4e903cde22e 100644 --- a/openlibrary/components/lit/OlSelectPopover.js +++ b/openlibrary/components/lit/OlSelectPopover.js @@ -609,4 +609,6 @@ export class OlSelectPopover extends LitElement { } } -customElements.define('ol-select-popover', OlSelectPopover); +if (!customElements.get('ol-select-popover')) { + customElements.define('ol-select-popover', OlSelectPopover); +} diff --git a/openlibrary/components/lit/index.js b/openlibrary/components/lit/index.js index 90adfae20ab..62fefec0dec 100644 --- a/openlibrary/components/lit/index.js +++ b/openlibrary/components/lit/index.js @@ -9,8 +9,10 @@ export { OLReadMore } from './OLReadMore.js'; export { OlPagination } from './OlPagination.js'; export { OLMarkdownEditor } from './OLMarkdownEditor.js'; +export { OlDialog } from './OlDialog.js'; export { OlPopover } from './OlPopover.js'; export { OlSelectPopover } from './OlSelectPopover.js'; +export { OlOptionsPopover } from './OlOptionsPopover.js'; export { OLChip } from './OLChip.js'; export { OLChipGroup } from './OLChipGroup.js'; export { OLButton } from './OLButton.js'; diff --git a/openlibrary/components/lit/utils/focus-utils.js b/openlibrary/components/lit/utils/focus-utils.js new file mode 100644 index 00000000000..dbd586a208f --- /dev/null +++ b/openlibrary/components/lit/utils/focus-utils.js @@ -0,0 +1,59 @@ +/** + * Focus management utilities for web components with shadow DOM. + */ + +/** + * CSS selector for commonly focusable elements. + * Excludes elements with tabindex="-1" which are programmatically focusable only. + */ +export const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +/** + * Gets the currently focused element, traversing through shadow DOM boundaries. + * Useful when you need to find the actual focused element inside shadow roots. + * + * @returns {Element|null} The deepest active element, or null if nothing is focused + * + * @example + * // If focus is inside a web component's shadow DOM: + * // document.activeElement might return the host element, + * // but getDeepActiveElement() returns the actual focused input/button inside. + * const focused = getDeepActiveElement(); + */ +export function getDeepActiveElement() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active; +} + +/** + * Gets all focusable elements from a slot's assigned content. + * Handles both elements that are directly focusable and their focusable descendants. + * + * @param {HTMLSlotElement} slot - The slot element to get focusable elements from + * @returns {HTMLElement[]} Array of focusable elements in DOM order + * + * @example + * const slot = this.renderRoot.querySelector('slot'); + * const focusable = getFocusableFromSlot(slot); + */ +export function getFocusableFromSlot(slot) { + if (!slot) return []; + + const focusable = []; + const assignedElements = slot.assignedElements({ flatten: true }); + + for (const el of assignedElements) { + // Check if the element itself is focusable + if (el.matches?.(FOCUSABLE_SELECTOR)) { + focusable.push(el); + } + // Find focusable descendants + focusable.push(...el.querySelectorAll(FOCUSABLE_SELECTOR)); + } + + // Filter out disabled elements + return focusable.filter(el => !el.disabled); +} diff --git a/openlibrary/components/lit/utils/slot-utils.js b/openlibrary/components/lit/utils/slot-utils.js new file mode 100644 index 00000000000..072a407817e --- /dev/null +++ b/openlibrary/components/lit/utils/slot-utils.js @@ -0,0 +1,27 @@ +/** + * Slot utilities for web components. + */ + +/** + * Checks if a slot has meaningful content (elements or non-empty text). + * Useful for conditionally showing/hiding slot wrapper elements. + * + * @param {HTMLSlotElement} slot - The slot element to check + * @returns {boolean} True if the slot has content + * + * @example + * // In a slotchange handler: + * _handleSlotChange(event) { + * this.hasContent = slotHasContent(event.target); + * } + * + */ +export function slotHasContent(slot) { + if (!slot) return false; + const assignedNodes = slot.assignedNodes({ flatten: true }); + return assignedNodes.some(node => { + if (node.nodeType === Node.ELEMENT_NODE) return true; + if (node.nodeType === Node.TEXT_NODE) return node.textContent.trim() !== ''; + return false; + }); +} diff --git a/openlibrary/plugins/openlibrary/js/SearchBar.js b/openlibrary/plugins/openlibrary/js/SearchBar.js index e64fdc91950..70afef309b6 100644 --- a/openlibrary/plugins/openlibrary/js/SearchBar.js +++ b/openlibrary/plugins/openlibrary/js/SearchBar.js @@ -72,8 +72,12 @@ export class SearchBar { /** * @param {JQuery} $component * @param {Object?} urlParams + * @param {Object?} options + * @param {Boolean} [options.disableAutocomplete] Skip the legacy inline + * autocomplete dropdown. Set when SearchModal is taking over + * autocomplete duties for this trigger. */ - constructor($component, urlParams={}) { + constructor($component, urlParams={}, options={}) { /** UI Elements */ this.$component = $component; this.$form = this.$component.find('form.search-bar-input'); @@ -166,7 +170,9 @@ export class SearchBar { } }); - this.initAutocompletionLogic(); + if (!options.disableAutocomplete) { + this.initAutocompletionLogic(); + } } /** @type {String} The endpoint of the active facet */ diff --git a/openlibrary/plugins/openlibrary/js/ol.js b/openlibrary/plugins/openlibrary/js/ol.js index 3341ef10ad9..2d6c02cb927 100644 --- a/openlibrary/plugins/openlibrary/js/ol.js +++ b/openlibrary/plugins/openlibrary/js/ol.js @@ -1,6 +1,7 @@ import { getJsonFromUrl } from './Browser'; import { SearchBar } from './SearchBar'; import { SearchPage } from './SearchPage'; +import { initSearchModal } from './search-modal/SearchModal'; import { SearchModeSelector, mode as searchMode } from './SearchUtils'; /* @@ -15,7 +16,9 @@ export default function init() { if (urlParams.mode) { searchMode.write(urlParams.mode); } - new SearchBar($('header#header-bar .search-component'), urlParams); + const $searchComponent = $('header#header-bar .search-component'); + new SearchBar($searchComponent, urlParams, { disableAutocomplete: true }); + initSearchModal($searchComponent.find('form.search-bar-input input[type="text"]')[0]); if ($('.siteSearch.olform').length) { // Only applies to search results page (as of writing) 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..4e9c205ff95 --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js @@ -0,0 +1,745 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import '../../../../components/lit/OlDialog.js'; +import '../../../../components/lit/OlOptionsPopover.js'; +import '../../../../components/lit/OlSelectPopover.js'; +import { debounce } from '../nonjquery_utils.js'; +import { mode as searchMode } from '../SearchUtils.js'; +import { AVAILABILITY_OPTIONS, DEFAULT_AVAILABILITY, LANGUAGE_OPTIONS } from './constants.js'; + +/** + * Maps availability filter values to /search query params. The backend + * recognizes `has_fulltext` and `public_scan` as facet-rewrite inputs + * (see `openlibrary/plugins/worksearch/schemes/works.py` `facet_rewrites`), + * which Solr translates to `ebook_access:[borrowable TO *]` and + * `ebook_access:public` respectively. There's no public param for + * "borrowable only" specifically, so we splice an `ebook_access:borrowable` + * Solr clause directly into the `q` (see `_buildSearchJsonUrl`). + */ +const AVAILABILITY_TO_PARAMS = { + all: {}, + readable: { has_fulltext: 'true' }, + borrowable: {}, // handled via q clause below + open: { public_scan: 'true' }, +}; + +/** Extra Solr `q` clause to AND into the user query for certain availability values. */ +const AVAILABILITY_TO_Q_CLAUSE = { + borrowable: 'ebook_access:borrowable', +}; + +/** + * Solr fields requested for autocomplete results. Deliberately omits + * `editions` even though the legacy SearchBar requests it: when + * `editions:[subquery]` is in the field list, the worksearch backend + * rewrites the work query to additionally require a matching edition + * (see `WorkSearchScheme.process_user_query()` — the parent-block-join + * filter). That silently drops works that match the user query only via + * work-level fields like `series_name` or `subject` (e.g. `q=narnia` + * loses every Narnia book because no edition contains "narnia"). For + * autocomplete, completeness of the result set matters more than the + * edition-level cover fallback. + */ +const SEARCH_FIELDS = [ + 'key', + 'cover_i', + 'title', + 'subtitle', + 'author_name', +]; + +const RESULTS_LIMIT = 10; +const MIN_QUERY_LENGTH = 2; +const COVER_PLACEHOLDER = '/static/images/icons/avatar_book-sm.png'; + +/** + * Header search modal. Replaces the legacy inline autocomplete dropdown. + * + * Mounts itself dynamically (no template change required) and attaches to + * a trigger input via `attachToTrigger()`. Composes ``, + * ``, ``, and `` — owns + * search query state, filter state, the /search.json fetch, and the URL + * built when the user clicks "See all results". + * + * Per the building-components doc, this is an orchestrator (knows about + * the search API and URL shape), not a design-system component. + * + * @element ol-search-modal + * + * @example + * import { initSearchModal } from './SearchModal.js'; + * initSearchModal(document.querySelector('header .search-bar-input input')); + */ +export class SearchModal extends LitElement { + static properties = { + open: { type: Boolean, reflect: true }, + _query: { state: true }, + _availability: { state: true }, + _languages: { state: true }, + _results: { state: true }, + _loading: { state: true }, + _hasSearched: { state: true }, + }; + + static styles = css` + :host { + font-family: var(--font-family-body); + color: var(--darker-grey); + } + + /* ── Header (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); + } + + .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: 18px; + line-height: 1.4; + } + + .search-input::placeholder { + color: var(--accessible-grey); + } + + .search-input:focus { + outline: none; + } + + .esc-pill { + 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; + } + + @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; + } + + /* Hide ESC pill on touch devices — they have no ESC key */ + @media (hover: none) and (pointer: coarse) { + .esc-pill { + display: none; + } + } + + /* ── Selected filters chip row ───────────────────────────── */ + + .chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-subtle); + } + + .chip-pill { + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs); + padding: var(--spacing-2xs) var(--spacing-sm); + background: hsla(202, 96%, 37%, 0.08); + border: 1px solid hsla(202, 96%, 37%, 0.2); + border-radius: var(--border-radius-pill); + color: var(--link-blue); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + } + + @media (hover: hover) and (pointer: fine) { + .chip-pill:hover { + background: hsla(202, 96%, 37%, 0.12); + } + } + + .chip-pill:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .chip-pill__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: currentColor; + opacity: 0.7; + } + + .chip-pill__remove svg { + width: 100%; + height: 100%; + } + + .clear-all { + margin-left: auto; + padding: var(--spacing-2xs) var(--spacing-sm); + background: transparent; + border: 1px solid transparent; + border-radius: var(--border-radius-button); + color: var(--dark-red); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + @media (hover: hover) and (pointer: fine) { + .clear-all:hover { + background: hsla(8, 70%, 44%, 0.08); + } + } + + .clear-all:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + /* ── Filter row ──────────────────────────────────────────── */ + + .filters { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-subtle); + } + + /* ── Results region ──────────────────────────────────────── */ + + .results { + flex: 1; + min-height: 120px; + max-height: 50vh; + overflow-y: auto; + padding: var(--spacing-sm) 0; + } + + .results-heading { + margin: 0; + padding: var(--spacing-sm) var(--spacing-lg) var(--spacing-2xs); + color: var(--accessible-grey); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .results-list { + list-style: none; + margin: 0; + padding: 0; + } + + .result { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-lg); + color: inherit; + text-decoration: none; + } + + @media (hover: hover) and (pointer: fine) { + .result:hover { + background: var(--icon-link-grey); + } + } + + .result:focus-visible { + outline: none; + background: var(--icon-link-grey); + box-shadow: inset 2px 0 0 var(--color-focus-ring); + } + + .result__cover { + flex-shrink: 0; + width: 40px; + height: 56px; + object-fit: cover; + background: var(--lightest-grey); + border-radius: var(--border-radius-thumbnail); + } + + .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-overflow: ellipsis; + white-space: nowrap; + } + + .result__author { + display: block; + overflow: hidden; + color: var(--accessible-grey); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .empty, + .placeholder, + .loading { + padding: var(--spacing-xl) var(--spacing-lg); + color: var(--accessible-grey); + font-size: 14px; + text-align: center; + } + + /* ── Footer ──────────────────────────────────────────────── */ + + .footer { + display: flex; + justify-content: flex-end; + padding: var(--spacing-md) 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; + } + + @media (hover: hover) and (pointer: fine) { + .see-all:hover { + filter: brightness(1.1); + } + } + + .see-all:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .see-all:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `; + + constructor() { + super(); + this.open = false; + this._query = ''; + this._availability = DEFAULT_AVAILABILITY; + this._languages = []; + this._results = []; + this._loading = false; + this._hasSearched = false; + + this._debouncedFetch = debounce(() => this._fetchResults(), 250, false); + this._activeFetchKey = null; + } + + /** + * Wires a header search input to open this modal on focus or click. + * Suppresses the input's own focus side-effects by immediately moving + * focus into the modal once it's open. + * @param {HTMLInputElement} input + */ + attachToTrigger(input) { + if (!input) return; + const openModal = (e) => { + if (this.open) return; + // Prevent the original input from holding focus — focus moves + // into the modal's own input on open. + e.preventDefault?.(); + input.blur(); + this._openModal(); + }; + input.addEventListener('focus', openModal); + input.addEventListener('click', openModal); + } + + _openModal() { + this.open = true; + } + + _closeModal() { + this.open = false; + } + + // ── Render ─────────────────────────────────────────────────── + + render() { + const hasFilters = this._availability !== DEFAULT_AVAILABILITY || this._languages.length > 0; + + return html` + +
    + ${SearchModal._searchIcon} + + +
    + + ${hasFilters ? this._renderChips() : nothing} + ${this._renderFilters()} + ${this._renderResults()} + + +
    + `; + } + + _renderChips() { + const chips = []; + + if (this._availability !== DEFAULT_AVAILABILITY) { + const opt = AVAILABILITY_OPTIONS.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) { + const opt = LANGUAGE_OPTIONS.find(o => o.value === value); + if (!opt) continue; + chips.push({ + key: `language:${value}`, + label: opt.label, + onRemove: () => this._removeLanguage(value), + }); + } + + return html` +
    + ${repeat(chips, c => c.key, c => html` + + `)} + +
    + `; + } + + _renderFilters() { + return html` +
    + + +
    + `; + } + + _renderResults() { + const trimmed = this._query.trim(); + + if (trimmed.length < MIN_QUERY_LENGTH) { + return html`
    Start typing to search…
    `; + } + + if (this._loading && this._results.length === 0) { + return html`
    Searching…
    `; + } + + if (this._results.length === 0 && this._hasSearched) { + return html`
    No matches
    `; + } + + return html` +
    +

    Top results

    +
      + ${repeat(this._results, r => r.key, r => this._renderResult(r))} +
    +
    + `; + } + + _renderResult(work) { + const author = work.author_name?.[0] || ''; + const cover = work.cover_i + ? `https://covers.openlibrary.org/b/id/${work.cover_i}-S.jpg` + : COVER_PLACEHOLDER; + return html` +
  • + + + + ${work.title || 'Untitled'} + ${author ? html`${author}` : nothing} + + +
  • + `; + } + + // ── Event handlers ─────────────────────────────────────────── + + _onDialogOpened() { + const input = this.renderRoot.querySelector('.search-input'); + input?.focus(); + } + + _onDialogClosed() { + this.open = false; + } + + _onQueryInput(e) { + this._query = e.target.value; + if (this._query.trim().length < MIN_QUERY_LENGTH) { + this._results = []; + 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]; + this._refetchIfActive(); + } + + _setAvailability(value) { + this._availability = value; + this._refetchIfActive(); + } + + _removeLanguage(value) { + this._languages = this._languages.filter(v => v !== value); + this._refetchIfActive(); + } + + _clearAllFilters() { + this._availability = DEFAULT_AVAILABILITY; + this._languages = []; + this._refetchIfActive(); + } + + _refetchIfActive() { + if (this._query.trim().length >= MIN_QUERY_LENGTH) { + this._loading = true; + this._debouncedFetch(); + } + } + + _onSeeAllResults() { + const url = this._buildSearchUrl(); + if (url) window.location.assign(url); + } + + // ── Data layer ─────────────────────────────────────────────── + + _fetchResults() { + const trimmed = this._query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) 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._loading = false; + this._hasSearched = true; + }) + .catch(() => { + if (this._activeFetchKey !== fetchKey) return; + this._results = []; + this._loading = false; + this._hasSearched = true; + }); + } + + _buildSearchJsonUrl(query) { + const params = new URLSearchParams(); + params.set('q', this._composeQ(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', this._composeQ(trimmed)); + params.set('mode', searchMode.read()); + this._appendFilterParams(params); + return `/search?${params.toString()}`; + } + + /** + * Combines the user's query with any availability-driven Solr clauses. + * Wraps the user query in parens so AND'd clauses don't change precedence. + */ + _composeQ(userQuery) { + const clause = AVAILABILITY_TO_Q_CLAUSE[this._availability]; + return clause ? `(${userQuery}) AND ${clause}` : userQuery; + } + + /** + * Appends filter params to the URLSearchParams. Multi-value filters like + * `language` are serialized as repeated keys (`language=eng&language=spa`) + * because that's what the /search backend expects. + */ + _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``; +} + +customElements.define('ol-search-modal', SearchModal); + +/** + * Mounts a single SearchModal instance and wires it to a header trigger. + * Idempotent — calling more than once with the same trigger has no effect. + * @param {HTMLInputElement} triggerInput + * @returns {SearchModal} + */ +export function initSearchModal(triggerInput) { + if (!triggerInput || triggerInput.dataset.olSearchModalAttached === 'true') { + return null; + } + const modal = document.createElement('ol-search-modal'); + document.body.appendChild(modal); + modal.attachToTrigger(triggerInput); + triggerInput.dataset.olSearchModalAttached = 'true'; + return modal; +} 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..37d45442ece --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/search-modal/constants.js @@ -0,0 +1,64 @@ +/** + * Filter options for the header search modal. + * + * Counts are placeholder strings for v1; wire to live counts later. + * Availability `value` strings map to /search.json query params in + * `SearchModal._buildSearchUrl()`. + */ + +export const AVAILABILITY_OPTIONS = [ + { + value: 'all', + label: 'Full Card Catalog', + description: 'Info on every book published', + count: '~50M', + }, + { + value: 'readable', + label: 'Readable Books Only', + description: 'Older digitized, preserved, physical books', + count: '~4.6M', + }, + { + value: 'borrowable', + label: 'Borrowable Only', + description: 'From Internet Archive\'s lending library', + count: '~2.7M', + }, + { + value: 'open', + label: 'Open Access Only', + description: 'From Trusted Book Providers', + count: '~1.8M', + }, +]; + +export const DEFAULT_AVAILABILITY = 'all'; + +/** + * Top languages by Open Library catalog volume. Curated short list keeps the + * popover usable; uncommon languages remain reachable via Advanced Search. + * `value` is the ISO 639-2 code passed to /search?language=. + */ +export const LANGUAGE_OPTIONS = [ + { value: 'eng', label: 'English' }, + { value: 'spa', label: 'Spanish' }, + { value: 'fre', label: 'French' }, + { value: 'ger', label: 'German' }, + { value: 'ita', label: 'Italian' }, + { value: 'por', label: 'Portuguese' }, + { value: 'rus', label: 'Russian' }, + { value: 'jpn', label: 'Japanese' }, + { value: 'chi', label: 'Chinese' }, + { value: 'ara', label: 'Arabic' }, + { value: 'hin', label: 'Hindi' }, + { value: 'kor', label: 'Korean' }, + { value: 'dut', label: 'Dutch' }, + { value: 'pol', label: 'Polish' }, + { value: 'swe', label: 'Swedish' }, + { value: 'tur', label: 'Turkish' }, + { value: 'cze', label: 'Czech' }, + { value: 'gre', label: 'Greek' }, + { value: 'heb', label: 'Hebrew' }, + { value: 'lat', label: 'Latin' }, +]; From 2649af381f8e787e565612ee8800bf65f68e1189 Mon Sep 17 00:00:00 2001 From: ARMAN S Date: Sat, 23 May 2026 16:13:39 +0530 Subject: [PATCH 02/31] feat(search): add availability and language filters to header modal - Builds on lokesh's draft #12690. --- .../components/lit/OlOptionsPopover.js | 23 +- .../js/search-modal/SearchModal.js | 442 ++++++++++-------- .../openlibrary/js/search-modal/constants.js | 133 +++++- static/css/components/header-bar.css | 20 + 4 files changed, 399 insertions(+), 219 deletions(-) diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js index a5d9794bfc0..c1fea10d460 100644 --- a/openlibrary/components/lit/OlOptionsPopover.js +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -281,26 +281,30 @@ export class OlOptionsPopover extends LitElement { 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))} -
        + ${heading ? html`` : nothing} +
          ${repeat(items, it => it.value, it => this._renderItem(it))}
        +
      `; } _renderItem(item) { const isSelected = item.value === this.selected; - return html` -
    • + // 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 ─────────────────────────────────────────── diff --git a/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js index 4e9c205ff95..aab2d640325 100644 --- a/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js +++ b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js @@ -5,71 +5,34 @@ import '../../../../components/lit/OlOptionsPopover.js'; import '../../../../components/lit/OlSelectPopover.js'; import { debounce } from '../nonjquery_utils.js'; import { mode as searchMode } from '../SearchUtils.js'; -import { AVAILABILITY_OPTIONS, DEFAULT_AVAILABILITY, LANGUAGE_OPTIONS } from './constants.js'; +import { + AVAILABILITY_OPTIONS, + DEFAULT_AVAILABILITY, + LANGUAGE_OPTIONS, + SS_AVAILABILITY_KEY, + SS_LANGUAGES_KEY, +} from './constants.js'; -/** - * Maps availability filter values to /search query params. The backend - * recognizes `has_fulltext` and `public_scan` as facet-rewrite inputs - * (see `openlibrary/plugins/worksearch/schemes/works.py` `facet_rewrites`), - * which Solr translates to `ebook_access:[borrowable TO *]` and - * `ebook_access:public` respectively. There's no public param for - * "borrowable only" specifically, so we splice an `ebook_access:borrowable` - * Solr clause directly into the `q` (see `_buildSearchJsonUrl`). - */ const AVAILABILITY_TO_PARAMS = { all: {}, readable: { has_fulltext: 'true' }, - borrowable: {}, // handled via q clause below + borrowable: {}, open: { public_scan: 'true' }, }; -/** Extra Solr `q` clause to AND into the user query for certain availability values. */ const AVAILABILITY_TO_Q_CLAUSE = { borrowable: 'ebook_access:borrowable', }; -/** - * Solr fields requested for autocomplete results. Deliberately omits - * `editions` even though the legacy SearchBar requests it: when - * `editions:[subquery]` is in the field list, the worksearch backend - * rewrites the work query to additionally require a matching edition - * (see `WorkSearchScheme.process_user_query()` — the parent-block-join - * filter). That silently drops works that match the user query only via - * work-level fields like `series_name` or `subject` (e.g. `q=narnia` - * loses every Narnia book because no edition contains "narnia"). For - * autocomplete, completeness of the result set matters more than the - * edition-level cover fallback. - */ -const SEARCH_FIELDS = [ - 'key', - 'cover_i', - 'title', - 'subtitle', - 'author_name', -]; - -const RESULTS_LIMIT = 10; -const MIN_QUERY_LENGTH = 2; +const SEARCH_FIELDS = ['key', 'cover_i', 'title', 'subtitle', 'author_name']; + +const RESULTS_LIMIT = 10; +const MIN_QUERY_LENGTH = 2; const COVER_PLACEHOLDER = '/static/images/icons/avatar_book-sm.png'; -/** - * Header search modal. Replaces the legacy inline autocomplete dropdown. - * - * Mounts itself dynamically (no template change required) and attaches to - * a trigger input via `attachToTrigger()`. Composes ``, - * ``, ``, and `` — owns - * search query state, filter state, the /search.json fetch, and the URL - * built when the user clicks "See all results". - * - * Per the building-components doc, this is an orchestrator (knows about - * the search API and URL shape), not a design-system component. - * - * @element ol-search-modal - * - * @example - * import { initSearchModal } from './SearchModal.js'; - * initSearchModal(document.querySelector('header .search-bar-input input')); - */ +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 }, @@ -79,6 +42,9 @@ export class SearchModal extends LitElement { _results: { state: true }, _loading: { state: true }, _hasSearched: { state: true }, + _languageItems: { state: true }, + _langsLoading: { state: true }, + barcodeHref: { type: String, attribute: 'barcode-href' }, }; static styles = css` @@ -87,7 +53,7 @@ export class SearchModal extends LitElement { color: var(--darker-grey); } - /* ── Header (search input row) ───────────────────────────── */ + /* ── Search input row ──────────────────────────────────────── */ .bar { display: flex; @@ -112,19 +78,15 @@ export class SearchModal extends LitElement { border: none; color: inherit; font: inherit; - font-size: 18px; + font-size: 17px; line-height: 1.4; } - .search-input::placeholder { - color: var(--accessible-grey); - } - - .search-input:focus { - outline: none; - } + .search-input::placeholder { color: var(--accessible-grey); } + .search-input:focus { outline: none; } .esc-pill { + flex-shrink: 0; display: inline-flex; align-items: center; padding: var(--spacing-2xs) var(--spacing-sm); @@ -138,12 +100,11 @@ export class SearchModal extends LitElement { 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:hover { background: var(--lightest-grey); } } .esc-pill:focus-visible { @@ -151,43 +112,65 @@ export class SearchModal extends LitElement { outline-offset: 2px; } - /* Hide ESC pill on touch devices — they have no ESC key */ - @media (hover: none) and (pointer: coarse) { - .esc-pill { - display: none; - } + @media (hover: none) and (pointer: coarse) { .esc-pill { display: none; } } + @media (prefers-reduced-motion: reduce) { .esc-pill { transition: none; } } + + /* ── Barcode button ────────────────────────────────────────── */ + + .barcode-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--border-radius-button); + opacity: 0.55; + transition: opacity 150ms ease; + text-decoration: none; + } + + @media (hover: hover) and (pointer: fine) { + .barcode-btn:hover { opacity: 0.9; } + } + + .barcode-btn:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + opacity: 0.9; } - /* ── Selected filters chip row ───────────────────────────── */ + @media (prefers-reduced-motion: reduce) { .barcode-btn { transition: none; } } + + /* ── Active filter chip row ────────────────────────────────── */ .chips { display: flex; flex-wrap: wrap; align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-lg); + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-lg); border-bottom: 1px solid var(--color-border-subtle); } .chip-pill { display: inline-flex; align-items: center; - gap: var(--spacing-2xs); - padding: var(--spacing-2xs) var(--spacing-sm); - background: hsla(202, 96%, 37%, 0.08); - border: 1px solid hsla(202, 96%, 37%, 0.2); + gap: 4px; + padding: 3px var(--spacing-sm); + background: hsla(202, 96%, 37%, 0.07); + border: 1px solid hsla(202, 96%, 37%, 0.22); border-radius: var(--border-radius-pill); color: var(--link-blue); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; + transition: background-color 150ms ease; } @media (hover: hover) and (pointer: fine) { - .chip-pill:hover { - background: hsla(202, 96%, 37%, 0.12); - } + .chip-pill:hover { background: hsla(202, 96%, 37%, 0.13); } } .chip-pill:focus-visible { @@ -201,18 +184,14 @@ export class SearchModal extends LitElement { justify-content: center; width: 14px; height: 14px; - color: currentColor; - opacity: 0.7; + opacity: 0.65; } - .chip-pill__remove svg { - width: 100%; - height: 100%; - } + .chip-pill__remove svg { width: 100%; height: 100%; } .clear-all { margin-left: auto; - padding: var(--spacing-2xs) var(--spacing-sm); + padding: 3px var(--spacing-sm); background: transparent; border: 1px solid transparent; border-radius: var(--border-radius-button); @@ -221,12 +200,11 @@ export class SearchModal extends LitElement { font-size: 13px; font-weight: 600; cursor: pointer; + transition: background-color 150ms ease; } @media (hover: hover) and (pointer: fine) { - .clear-all:hover { - background: hsla(8, 70%, 44%, 0.08); - } + .clear-all:hover { background: hsla(8, 70%, 44%, 0.07); } } .clear-all:focus-visible { @@ -234,33 +212,37 @@ export class SearchModal extends LitElement { outline-offset: 2px; } - /* ── Filter row ──────────────────────────────────────────── */ + @media (prefers-reduced-motion: reduce) { + .chip-pill, .clear-all { transition: none; } + } + + /* ── Filter button row ─────────────────────────────────────── */ .filters { display: flex; flex-wrap: wrap; - gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-lg); + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-lg) var(--spacing-sm); border-bottom: 1px solid var(--color-border-subtle); } - /* ── Results region ──────────────────────────────────────── */ + /* ── Results ───────────────────────────────────────────────── */ .results { flex: 1; - min-height: 120px; - max-height: 50vh; + min-height: 80px; + max-height: 320px; overflow-y: auto; - padding: var(--spacing-sm) 0; + 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: 12px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.06em; text-transform: uppercase; } @@ -277,12 +259,11 @@ export class SearchModal extends LitElement { padding: var(--spacing-sm) var(--spacing-lg); color: inherit; text-decoration: none; + transition: background-color 100ms ease; } @media (hover: hover) and (pointer: fine) { - .result:hover { - background: var(--icon-link-grey); - } + .result:hover { background: var(--icon-link-grey); } } .result:focus-visible { @@ -291,10 +272,12 @@ export class SearchModal extends LitElement { box-shadow: inset 2px 0 0 var(--color-focus-ring); } + @media (prefers-reduced-motion: reduce) { .result { transition: none; } } + .result__cover { flex-shrink: 0; - width: 40px; - height: 56px; + width: 36px; + height: 50px; object-fit: cover; background: var(--lightest-grey); border-radius: var(--border-radius-thumbnail); @@ -325,21 +308,19 @@ export class SearchModal extends LitElement { white-space: nowrap; } - .empty, - .placeholder, - .loading { - padding: var(--spacing-xl) var(--spacing-lg); + .empty, .placeholder, .loading { + padding: var(--spacing-lg) var(--spacing-lg); color: var(--accessible-grey); font-size: 14px; text-align: center; } - /* ── Footer ──────────────────────────────────────────────── */ + /* ── Footer ────────────────────────────────────────────────── */ .footer { display: flex; justify-content: flex-end; - padding: var(--spacing-md) var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-lg); border-top: 1px solid var(--color-border-subtle); } @@ -353,12 +334,11 @@ export class SearchModal extends LitElement { font-size: 14px; font-weight: 600; cursor: pointer; + transition: filter 150ms ease; } @media (hover: hover) and (pointer: fine) { - .see-all:hover { - filter: brightness(1.1); - } + .see-all:hover { filter: brightness(1.08); } } .see-all:focus-visible { @@ -367,37 +347,55 @@ export class SearchModal extends LitElement { } .see-all:disabled { - opacity: 0.5; + 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; } + .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); + } + } `; constructor() { super(); - this.open = false; - this._query = ''; - this._availability = DEFAULT_AVAILABILITY; - this._languages = []; - this._results = []; - this._loading = false; - this._hasSearched = false; + this.open = false; + this._query = ''; + this._results = []; + this._loading = false; + this._hasSearched = false; + this._langsLoading = false; + this.barcodeHref = ''; + + this._languageItems = LANGUAGE_OPTIONS; + + this._availability = ssGet(SS_AVAILABILITY_KEY) || DEFAULT_AVAILABILITY; + + const storedLangs = ssGet(SS_LANGUAGES_KEY); + this._languages = storedLangs + ? (() => { try { return JSON.parse(storedLangs); } catch { return []; } })() + : []; this._debouncedFetch = debounce(() => this._fetchResults(), 250, false); this._activeFetchKey = null; + this._allLangsLoaded = false; } - /** - * Wires a header search input to open this modal on focus or click. - * Suppresses the input's own focus side-effects by immediately moving - * focus into the modal once it's open. - * @param {HTMLInputElement} input - */ attachToTrigger(input) { if (!input) return; const openModal = (e) => { if (this.open) return; - // Prevent the original input from holding focus — focus moves - // into the modal's own input on open. e.preventDefault?.(); input.blur(); this._openModal(); @@ -408,16 +406,65 @@ export class SearchModal extends LitElement { _openModal() { this.open = true; + if (!this._allLangsLoaded && !this._langsLoading) { + this._loadAllLanguages(); + } } - _closeModal() { - this.open = false; + _closeModal() { this.open = false; } + + async _loadAllLanguages() { + this._langsLoading = true; + try { + const res = await fetch( + '/search.json?q=*&facets=true&limit=0&facet=language', + { signal: AbortSignal.timeout?.(8000) } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + const raw = data?.facet_counts?.facet_fields?.language || []; + + const codes = []; + for (let i = 0; i < raw.length; i += 2) { + if (typeof raw[i] === 'string') codes.push(raw[i]); + } + + const staticMap = new Map( + LANGUAGE_OPTIONS.map(o => [o.value, o.label]) + ); + + const merged = codes + .map(code => ({ + value: code, + label: staticMap.get(code) || _titleCase(code), + })) + .filter(o => o.value); + + const seen = new Set(); + const deduped = merged.filter(o => { + if (seen.has(o.value)) return false; + seen.add(o.value); + return true; + }); + deduped.sort((a, b) => a.label.localeCompare(b.label)); + + this._languageItems = deduped; + this._allLangsLoaded = true; + } catch { + this._allLangsLoaded = true; + } finally { + this._langsLoading = false; + } } - // ── Render ─────────────────────────────────────────────────── + // ── Render ──────────────────────────────────────────────────────────── render() { - const hasFilters = this._availability !== DEFAULT_AVAILABILITY || this._languages.length > 0; + const hasFilters = ( + this._availability !== DEFAULT_AVAILABILITY || + this._languages.length > 0 + ); return html` @@ -436,12 +489,27 @@ export class SearchModal extends LitElement { + ${this.barcodeHref ? html` + + + + ` : nothing} + +

        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..8ae4966ed2b --- /dev/null +++ b/openlibrary/templates/design/options-popover.html @@ -0,0 +1,108 @@ +$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, a description, and a 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.

        + +
        +

        Availability filter

        +

        The canonical use case from the search bar. Each item is a { value, label, description, count } object. The selected value is exposed via the selected attribute and the ol-options-popover-change event; the trigger itself stays fixed (the selection is usually surfaced separately, e.g. in a chip row).

        +
        <ol-options-popover
        +  label="Availability"
        +  selected="all"
        +  items='[
        +    {"value":"all","label":"Full Card Catalog","description":"Info on every book","count":"~50M"},
        +    {"value":"readable","label":"Readable Books Only","description":"Older digitized, preserved","count":"~4.6M"}
        +  ]'>
        +</ol-options-popover>
        +
        + +

        + Selected: all +

        +
        + +
        + +
        +

        Compact options (no description or count)

        +

        Description and count are optional. With just value and label 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.

        +
        <ol-options-popover
        +  label="Sort by"
        +  heading="SORT ORDER"
        +  selected="relevance"
        +  items='[{"value":"relevance","label":"Relevance"}, ...]'>
        +</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="Availability" items="...">
        +  <button slot="trigger">Filter availability</button>
        +</ol-options-popover>
        +
        + + + +
        + +
        +
        From e2d591df1e44f10555568b6245f45b09513fc9ac Mon Sep 17 00:00:00 2001 From: Lokesh Dhakar Date: Wed, 27 May 2026 11:02:31 -0700 Subject: [PATCH 06/31] i18n(search): localize header search modal + filter row strings Wire the header search modal and the /search filter row through the data-i18n pattern so their UI strings are translatable instead of hardcoded English: - availability option labels/descriptions via a shared search/availability_i18n.html, read by availabilityOptionsFromElement - modal chrome strings (placeholders, aria-labels, status messages) via search/search_modal_i18n.html, read by searchModalStringsFromElement - English fallbacks (AVAILABILITY_OPTIONS, DEFAULT_SEARCH_MODAL_STRINGS) kept in search-modal/constants.js; interpolated chip label uses sprintf - unit tests for the new helpers (searchModalConstants.test.js) Bundled with the surrounding search-filter feature work on this branch (OLChip/popover components, header-bar + token CSS, worksearch wiring). --- openlibrary/components/lit/OLChip.js | 107 ++++++-- .../components/lit/OlOptionsPopover.js | 7 +- openlibrary/components/lit/OlPagination.js | 2 - openlibrary/components/lit/OlSelectPopover.js | 1 - openlibrary/i18n/messages.pot | 224 +++++++++++++---- .../plugins/openlibrary/js/SearchFilterBar.js | 91 +++++++ openlibrary/plugins/openlibrary/js/index.js | 6 + openlibrary/plugins/openlibrary/js/ol.js | 12 +- .../js/search-modal/SearchModal.js | 233 +++++++----------- .../openlibrary/js/search-modal/constants.js | 222 ++++++++++------- .../openlibrary/js/search-modal/languages.js | 43 ++++ openlibrary/plugins/worksearch/code.py | 5 + openlibrary/templates/design.html | 29 +++ openlibrary/templates/lib/nav_head.html | 53 ++-- .../templates/search/availability_i18n.html | 13 + .../templates/search/search_modal_i18n.html | 31 +++ openlibrary/templates/search/searchbox.html | 4 +- openlibrary/templates/work_search.html | 31 ++- static/css/components/header-bar.css | 96 ++++++++ static/css/page-user.css | 19 ++ static/css/tokens/colors.css | 35 +++ tests/unit/js/searchModalConstants.test.js | 111 +++++++++ 22 files changed, 1021 insertions(+), 354 deletions(-) create mode 100644 openlibrary/plugins/openlibrary/js/SearchFilterBar.js create mode 100644 openlibrary/plugins/openlibrary/js/search-modal/languages.js create mode 100644 openlibrary/templates/search/availability_i18n.html create mode 100644 openlibrary/templates/search/search_modal_i18n.html create mode 100644 tests/unit/js/searchModalConstants.test.js diff --git a/openlibrary/components/lit/OLChip.js b/openlibrary/components/lit/OLChip.js index fc1acbd3807..262d7fe089a 100644 --- a/openlibrary/components/lit/OLChip.js +++ b/openlibrary/components/lit/OLChip.js @@ -8,6 +8,11 @@ import { LitElement, html, css, nothing } from 'lit'; * * @property {Boolean} selected - Whether the chip is in a selected state * @property {String} size - Chip size: "small" or "medium" (default) + * @property {String} variant - Domain category that tints the chip: + * "language" | "subject" | "genre" | "author" | "place" | "neutral". + * Omit for the default (white / solid-blue-when-selected) chip. The chip + * maps the variant to a soft-tint palette internally (see colors.css); a + * variant chip keeps its tint when `selected` and just gains a close icon. * @property {String} href - When set, the chip renders as a link * @property {String} count - Optional count displayed to the right of the label * @property {String} accessibleLabel - Override aria-label on the inner interactive element @@ -21,12 +26,17 @@ import { LitElement, html, css, nothing } from 'lit'; * History * * @example + * + * English + * + * @example * Fiction */ export class OLChip extends LitElement { static properties = { selected: { type: Boolean, reflect: true }, size: { type: String, reflect: true }, + variant: { type: String, reflect: true }, href: { type: String }, count: { type: String }, accessibleLabel: { type: String, attribute: 'accessible-label' }, @@ -38,6 +48,15 @@ export class OLChip extends LitElement { --chip-padding-inline: 12px; --chip-icon-size: 14px; --chip-icon-gap: 4px; + + /* Color slots. Default = idle, unselected neutral chip; overridden + below by [selected] and by each domain [variant]. */ + --_chip-bg: var(--white); + --_chip-fg: var(--dark-grey); + --_chip-border: var(--color-border-subtle); + --_chip-bg-hover: var(--lightest-grey); + --_chip-count-fg: #777; + display: inline-block; } @@ -52,13 +71,13 @@ export class OLChip extends LitElement { display: inline-flex; align-items: center; padding: var(--chip-padding-block) var(--chip-padding-inline); - border: var(--border-width) solid var(--color-border-subtle); + border: var(--border-width) solid var(--_chip-border); border-radius: var(--border-radius-chip); font-family: var(--font-family-button); font-size: var(--font-size-body-medium); line-height: var(--line-height-chip); - background: var(--white); - color: var(--dark-grey); + background: var(--_chip-bg); + color: var(--_chip-fg); cursor: pointer; user-select: none; text-decoration: none; @@ -66,7 +85,7 @@ export class OLChip extends LitElement { @media (hover: hover) and (pointer: fine) { .chip:hover { - background: var(--lightest-grey); + background: var(--_chip-bg-hover); } } @@ -79,21 +98,78 @@ export class OLChip extends LitElement { box-shadow: var(--box-shadow-focus); } - /* Selected state */ - :host([selected]) .chip { - padding-inline-start: calc(var(--chip-padding-inline) + var(--chip-icon-size) + var(--chip-icon-gap)); - background: var(--primary-blue); - border-color: var(--primary-blue); - color: var(--white); + /* Default selected (no domain variant): solid primary-blue fill. */ + :host([selected]:not([variant])) { + --_chip-bg: var(--primary-blue); + --_chip-fg: var(--white); + --_chip-border: var(--primary-blue); + --_chip-bg-hover: var(--primary-blue); + --_chip-count-fg: #c6e1f0; } @media (hover: hover) and (pointer: fine) { - :host([selected]) .chip:hover { - background: var(--primary-blue); + :host([selected]:not([variant])) .chip:hover { filter: brightness(1.1); } } + /* Selected chips reserve room for the leading close icon (all variants). */ + :host([selected]) .chip { + padding-inline-start: calc(var(--chip-padding-inline) + var(--chip-icon-size) + var(--chip-icon-gap)); + } + + /* ── Domain variants: soft category-colored tint ────────────────── + The tint is identical whether or not the chip is selected; the + [selected] rule above only reserves space for the close icon, so a + selected variant chip reads as a removable, category-colored pill. */ + :host([variant="language"]) { + --_chip-bg: var(--color-chip-language-bg); + --_chip-fg: var(--color-chip-language-fg); + --_chip-border: var(--color-chip-language-border); + --_chip-bg-hover: var(--color-chip-language-bg-hover); + --_chip-count-fg: var(--color-chip-language-fg); + } + + :host([variant="subject"]) { + --_chip-bg: var(--color-chip-subject-bg); + --_chip-fg: var(--color-chip-subject-fg); + --_chip-border: var(--color-chip-subject-border); + --_chip-bg-hover: var(--color-chip-subject-bg-hover); + --_chip-count-fg: var(--color-chip-subject-fg); + } + + :host([variant="genre"]) { + --_chip-bg: var(--color-chip-genre-bg); + --_chip-fg: var(--color-chip-genre-fg); + --_chip-border: var(--color-chip-genre-border); + --_chip-bg-hover: var(--color-chip-genre-bg-hover); + --_chip-count-fg: var(--color-chip-genre-fg); + } + + :host([variant="author"]) { + --_chip-bg: var(--color-chip-author-bg); + --_chip-fg: var(--color-chip-author-fg); + --_chip-border: var(--color-chip-author-border); + --_chip-bg-hover: var(--color-chip-author-bg-hover); + --_chip-count-fg: var(--color-chip-author-fg); + } + + :host([variant="place"]) { + --_chip-bg: var(--color-chip-place-bg); + --_chip-fg: var(--color-chip-place-fg); + --_chip-border: var(--color-chip-place-border); + --_chip-bg-hover: var(--color-chip-place-bg-hover); + --_chip-count-fg: var(--color-chip-place-fg); + } + + :host([variant="neutral"]) { + --_chip-bg: var(--color-chip-neutral-bg); + --_chip-fg: var(--color-chip-neutral-fg); + --_chip-border: var(--color-chip-neutral-border); + --_chip-bg-hover: var(--color-chip-neutral-bg-hover); + --_chip-count-fg: var(--accessible-grey); + } + /* Small size */ :host([size="small"]) .chip { font-size: var(--font-size-label-medium); @@ -117,20 +193,17 @@ export class OLChip extends LitElement { /* Count */ .count { margin-inline-start: 4px; - color: #777; + color: var(--_chip-count-fg); font-size: 0.85em; font-variant-numeric: tabular-nums; } - - :host([selected]) .count { - color: #c6e1f0; - } `; constructor() { super(); this.selected = false; this.size = 'medium'; + this.variant = null; this.href = null; this.count = null; this.accessibleLabel = null; diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js index c1fea10d460..5a4da83a592 100644 --- a/openlibrary/components/lit/OlOptionsPopover.js +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -71,7 +71,6 @@ export class OlOptionsPopover extends LitElement { color: var(--darker-grey); font: inherit; font-size: 14px; - font-weight: 500; line-height: 1.4; cursor: pointer; white-space: nowrap; @@ -158,13 +157,13 @@ export class OlOptionsPopover extends LitElement { @media (hover: hover) and (pointer: fine) { .item-row:hover { - background: var(--icon-link-grey); + background: var(--lightest-grey); } } .item-row:focus-within { outline: none; - background: var(--icon-link-grey); + background: var(--lightest-grey); } .item--selected .item-row { @@ -197,6 +196,7 @@ export class OlOptionsPopover extends LitElement { } .item-label { + display: block; color: var(--darker-grey); font-weight: 500; } @@ -207,6 +207,7 @@ export class OlOptionsPopover extends LitElement { } .item-description { + display: block; margin-top: 2px; color: var(--accessible-grey); font-size: 13px; diff --git a/openlibrary/components/lit/OlPagination.js b/openlibrary/components/lit/OlPagination.js index 298152caa33..920bcbe6b99 100644 --- a/openlibrary/components/lit/OlPagination.js +++ b/openlibrary/components/lit/OlPagination.js @@ -87,7 +87,6 @@ export class OlPagination extends LitElement { align-items: center; justify-content: center; padding: var(--spacing-inset-xs) var(--spacing-inset-sm); - border: 1px solid transparent; border-radius: var(--border-radius-button); background: transparent; color: var(--darker-grey, #444); @@ -116,7 +115,6 @@ export class OlPagination extends LitElement { } .pagination-item[aria-current="page"] { - border-color: var(--lighter-grey, #ddd); background-color: var(--lightest-grey, #eee); cursor: default; user-select: none; diff --git a/openlibrary/components/lit/OlSelectPopover.js b/openlibrary/components/lit/OlSelectPopover.js index 4e903cde22e..eebeadd8409 100644 --- a/openlibrary/components/lit/OlSelectPopover.js +++ b/openlibrary/components/lit/OlSelectPopover.js @@ -103,7 +103,6 @@ export class OlSelectPopover extends LitElement { color: var(--darker-grey); font: inherit; font-size: 14px; - font-weight: 500; line-height: 1.4; cursor: pointer; white-space: nowrap; diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot index c63482a64e5..ebcb4e984eb 100644 --- a/openlibrary/i18n/messages.pot +++ b/openlibrary/i18n/messages.pot @@ -538,6 +538,58 @@ msgstr "" msgid "Toggle me too" msgstr "" +#: design.html +msgid "Domain variants" +msgstr "" + +#: design.html +#, python-format +msgid "" +"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." +msgstr "" + +#: design.html openlibrary/plugins/openlibrary/code.py +msgid "English" +msgstr "" + +#: design.html openlibrary/plugins/openlibrary/home.py +msgid "Science Fiction" +msgstr "" + +#: design.html +msgid "Memoir" +msgstr "" + +#: design.html +msgid "Ursula K. Le Guin" +msgstr "" + +#: design.html +msgid "San Francisco" +msgstr "" + +#: design.html +msgid "Read now" +msgstr "" + +#: design.html +msgid "Removable filter pills" +msgstr "" + +#: design.html +#, python-format +msgid "" +"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." +msgstr "" + +#: design.html openlibrary/plugins/openlibrary/code.py +msgid "French" +msgstr "" + #: design.html msgid "" "The ol-chip-group component is a flex-wrap container that provides " @@ -1113,13 +1165,13 @@ msgstr "" msgid "PR" msgstr "" -#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html +#: books/add.html books/edit.html books/edit/edition.html #: search/advancedsearch.html status.html type/about/edit.html #: type/page/edit.html type/template/edit.html msgid "Title" msgstr "" -#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html +#: books/add.html books/edit.html books/edit/edition.html #: openlibrary/plugins/worksearch/code.py search/advancedsearch.html #: search/work_search_selected_facets.html status.html msgid "Author" @@ -1386,20 +1438,39 @@ msgstr "" msgid "Keywords" msgstr "" -#: RecentChanges.html recentchanges/index.html work_search.html -msgid "Everything" +#: work_search.html +msgid "This is only visible to super librarians." msgstr "" #: work_search.html -msgid "Ebooks" +msgid "Solr Editions" msgstr "" +#: search/search_modal_i18n.html type/work/editions_datatable.html #: work_search.html -msgid "This is only visible to super librarians." +msgid "Availability" msgstr "" #: work_search.html -msgid "Solr Editions" +msgid "Filter by availability" +msgstr "" + +#: openlibrary/plugins/worksearch/code.py search/search_modal_i18n.html +#: type/edition/view.html type/work/view.html work_search.html +msgid "Language" +msgstr "" + +#: search/search_modal_i18n.html work_search.html +msgid "Search languages…" +msgstr "" + +#: books/edit/edition.html languages/index.html search/search_modal_i18n.html +#: work_search.html +msgid "Languages" +msgstr "" + +#: work_search.html +msgid "Filter by language" msgstr "" #: work_search.html @@ -1458,7 +1529,7 @@ msgstr "" msgid "Role:" msgstr "" -#: about/index.html lib/nav_head.html type/tag/index.html +#: about/index.html search/availability_i18n.html type/tag/index.html msgid "All" msgstr "" @@ -3421,7 +3492,8 @@ msgstr "" #: authors/index.html lib/nav_head.html lists/home.html publishers/index.html #: publishers/notfound.html publishers/view.html search/advancedsearch.html -#: search/publishers.html search/searchbox.html type/local_id/view.html +#: search/publishers.html search/search_modal_i18n.html search/searchbox.html +#: type/local_id/view.html msgid "Search" msgstr "" @@ -4361,10 +4433,6 @@ msgstr "" msgid "For example: edited by David Anderson" msgstr "" -#: books/edit/edition.html languages/index.html -msgid "Languages" -msgstr "" - #: books/edit/edition.html msgid "What language is this edition written in?" msgstr "" @@ -5660,18 +5728,6 @@ msgstr "" msgid "The Internet Archive's Open Library: One page for every book" msgstr "" -#: lib/nav_head.html -msgid "Text" -msgstr "" - -#: lib/nav_head.html search/advancedsearch.html -msgid "Subject" -msgstr "" - -#: lib/nav_head.html -msgid "Advanced" -msgstr "" - #: lib/nav_head.html msgid "Search by barcode" msgstr "" @@ -6502,6 +6558,10 @@ msgstr "" msgid "By Bots" msgstr "" +#: RecentChanges.html recentchanges/index.html +msgid "Everything" +msgstr "" + #: recentchanges/render.html msgid "Admin view" msgstr "" @@ -6616,6 +6676,10 @@ msgstr "" msgid "ISBN" msgstr "" +#: search/advancedsearch.html +msgid "Subject" +msgstr "" + #: search/advancedsearch.html msgid "Place" msgstr "" @@ -6653,6 +6717,34 @@ msgstr "" msgid "No authors directly matched your search" msgstr "" +#: search/availability_i18n.html +msgid "Every book in the catalog" +msgstr "" + +#: search/availability_i18n.html +msgid "Read now (free)" +msgstr "" + +#: search/availability_i18n.html +msgid "Fully readable – public domain & open access" +msgstr "" + +#: search/availability_i18n.html +msgid "Borrowable" +msgstr "" + +#: search/availability_i18n.html +msgid "Borrow via Internet Archive's lending library" +msgstr "" + +#: search/availability_i18n.html +msgid "Preview only" +msgstr "" + +#: search/availability_i18n.html +msgid "Limited preview available" +msgstr "" + #: search/inside.html #, python-format msgid "Search Open Library for %s" @@ -6709,6 +6801,67 @@ msgstr "" msgid "Publishers Search" msgstr "" +#: search/search_modal_i18n.html +msgid "Search Open Library" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Search books, authors…" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Scan a barcode" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Scan barcode" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Close search" +msgstr "" + +#: search/search_modal_i18n.html +msgid "See all results" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Active filters" +msgstr "" + +#: search/search_modal_i18n.html +#, python-format +msgid "Remove filter: %s" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Clear all" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Search filters" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Start typing to search…" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Searching…" +msgstr "" + +#: search/search_modal_i18n.html +msgid "No results found" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Top results" +msgstr "" + +#: search/search_modal_i18n.html +msgid "Untitled" +msgstr "" + #: search/snippets.html #, python-format msgid "On leaf number %(page_num)d:" @@ -7264,11 +7417,6 @@ msgstr "" msgid "Search for other books from %(publisher)s" msgstr "" -#: openlibrary/plugins/worksearch/code.py type/edition/view.html -#: type/work/view.html -msgid "Language" -msgstr "" - #: type/edition/view.html type/work/view.html msgid "Pages" msgstr "" @@ -7889,10 +8037,6 @@ msgstr "" msgid "Edition" msgstr "" -#: type/work/editions_datatable.html -msgid "Availability" -msgstr "" - #: type/work/editions_datatable.html msgid "No editions available" msgstr "" @@ -8496,18 +8640,10 @@ msgstr "" msgid "German" msgstr "" -#: openlibrary/plugins/openlibrary/code.py -msgid "English" -msgstr "" - #: openlibrary/plugins/openlibrary/code.py msgid "Spanish" msgstr "" -#: openlibrary/plugins/openlibrary/code.py -msgid "French" -msgstr "" - #: openlibrary/plugins/openlibrary/code.py msgid "Hindi" msgstr "" @@ -8552,10 +8688,6 @@ msgstr "" msgid "Art" msgstr "" -#: openlibrary/plugins/openlibrary/home.py -msgid "Science Fiction" -msgstr "" - #: openlibrary/plugins/openlibrary/home.py msgid "Fantasy" msgstr "" diff --git a/openlibrary/plugins/openlibrary/js/SearchFilterBar.js b/openlibrary/plugins/openlibrary/js/SearchFilterBar.js new file mode 100644 index 00000000000..810a55a3255 --- /dev/null +++ b/openlibrary/plugins/openlibrary/js/SearchFilterBar.js @@ -0,0 +1,91 @@ +/** + * Wires the availability + language filter popovers on the search results page + * (openlibrary/templates/work_search.html). The popovers render empty from the + * template; this module seeds them with options and the current selection (read + * from the URL query), and navigates to an updated /search URL when a filter + * changes. The full language catalogue is fetched lazily, only once the + * language popover is first opened. + * + * Filter values live entirely in the URL query string, so the language popover + * and the sidebar language facet (which reloads the page with a new `language=` + * param when clicked) stay in sync automatically through a full page load. + */ + +import { + AVAILABILITY_TO_PARAMS, + DEFAULT_LANGUAGE_OPTIONS, + availabilityFromParams, + availabilityOptionsFromElement, +} from './search-modal/constants.js'; +import { fetchLanguageOptions } from './search-modal/languages.js'; + +// Every query param the availability filter owns, across all of its values. +// Cleared before applying a new value so stale availability filters don't +// accumulate in the URL. +const AVAILABILITY_PARAM_KEYS = [ + ...new Set(Object.values(AVAILABILITY_TO_PARAMS).flatMap(Object.keys)), +]; + +/** + * Navigate to /search with the current query string mutated by `mutate`. + * Pagination is reset because the result set changes. + * @param {(params: URLSearchParams) => void} mutate + */ +function navigateWithParams(mutate) { + const params = new URLSearchParams(window.location.search); + mutate(params); + params.delete('page'); + window.location.assign(`/search?${params.toString()}`); +} + +/** + * Fill in option lists and wire change handlers for the filter row. + * @param {HTMLElement} container - the `.search-filter-row` element + */ +export function initSearchFilterBar(container) { + const availabilityEl = container.querySelector('ol-options-popover'); + const languageEl = container.querySelector('ol-select-popover'); + const currentParams = new URLSearchParams(window.location.search); + + if (availabilityEl) { + // Translated labels/descriptions are rendered into the container's + // data-i18n attribute (search/availability_i18n.html); fall back to + // English defaults if it's absent. + availabilityEl.items = availabilityOptionsFromElement(container); + availabilityEl.selected = availabilityFromParams((name) => currentParams.get(name)); + availabilityEl.addEventListener('ol-options-popover-change', (e) => { + const mapped = AVAILABILITY_TO_PARAMS[e.detail.selected] || {}; + navigateWithParams((params) => { + AVAILABILITY_PARAM_KEYS.forEach((key) => params.delete(key)); + Object.entries(mapped).forEach(([key, value]) => params.set(key, value)); + }); + }); + } + + if (languageEl) { + // Seed with the curated defaults so a pre-selected language renders its + // label immediately, without waiting on (or requiring) the network. + languageEl.items = DEFAULT_LANGUAGE_OPTIONS; + languageEl.selected = currentParams.getAll('language'); + + // Defer fetching the full catalogue list until the popover first opens. + // Most searches never touch the language filter, so this avoids the + // /languages.json request entirely for them. ol-popover-open bubbles + // (composed) out of the popover's shadow root up to this host. + let languagesLoaded = false; + languageEl.addEventListener('ol-popover-open', () => { + if (languagesLoaded) return; + languagesLoaded = true; + fetchLanguageOptions().then((options) => { + languageEl.items = options; + }); + }); + + languageEl.addEventListener('ol-select-popover-change', (e) => { + navigateWithParams((params) => { + params.delete('language'); + e.detail.selected.forEach((code) => params.append('language', code)); + }); + }); + } +} diff --git a/openlibrary/plugins/openlibrary/js/index.js b/openlibrary/plugins/openlibrary/js/index.js index 47c204bd2b8..03e436564d5 100644 --- a/openlibrary/plugins/openlibrary/js/index.js +++ b/openlibrary/plugins/openlibrary/js/index.js @@ -314,6 +314,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 2d6c02cb927..a0b5e342ee7 100644 --- a/openlibrary/plugins/openlibrary/js/ol.js +++ b/openlibrary/plugins/openlibrary/js/ol.js @@ -1,8 +1,6 @@ import { getJsonFromUrl } from './Browser'; -import { SearchBar } from './SearchBar'; -import { SearchPage } from './SearchPage'; import { initSearchModal } from './search-modal/SearchModal'; -import { SearchModeSelector, mode as searchMode } from './SearchUtils'; +import { mode as searchMode } from './SearchUtils'; /* Sets the key in the website cookie to the specified value @@ -17,13 +15,7 @@ export default function init() { searchMode.write(urlParams.mode); } const $searchComponent = $('header#header-bar .search-component'); - new SearchBar($searchComponent, urlParams, { disableAutocomplete: true }); - initSearchModal($searchComponent.find('form.search-bar-input input[type="text"]')[0]); - - if ($('.siteSearch.olform').length) { - // Only applies to search results page (as of writing) - new SearchPage($('.siteSearch.olform'), new SearchModeSelector($('.search-mode'))); - } + 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 index fd81ae1a64d..88766abfae4 100644 --- a/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js +++ b/openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js @@ -3,22 +3,23 @@ import { repeat } from 'lit/directives/repeat.js'; import '../../../../components/lit/OlDialog.js'; import '../../../../components/lit/OlOptionsPopover.js'; import '../../../../components/lit/OlSelectPopover.js'; +import '../../../../components/lit/OLChip.js'; +import '../../../../components/lit/OLChipGroup.js'; 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, - LANGUAGE_OPTIONS, + DEFAULT_LANGUAGE_OPTIONS, + DEFAULT_SEARCH_MODAL_STRINGS, SS_AVAILABILITY_KEY, SS_LANGUAGES_KEY, + availabilityOptionsFromElement, + searchModalStringsFromElement, } from './constants.js'; - -const AVAILABILITY_TO_PARAMS = { - all: {}, - readable: { public_scan: 'true' }, - borrowable: { has_fulltext: 'true', public_scan: 'false' }, - open: { print_disabled: 'true' }, -}; +import { fetchLanguageOptions } from './languages.js'; const AVAILABILITY_TO_Q_CLAUSE = {}; @@ -138,7 +139,9 @@ export class SearchModal extends LitElement { opacity: 0.9; } - @media (prefers-reduced-motion: reduce) { .barcode-btn { transition: none; } } + /* Scanning needs a camera — only surface it on touch devices. */ + @media (hover: hover) and (pointer: fine) { .barcode-btn { display: none; } } + @media (prefers-reduced-motion: reduce) { .barcode-btn { transition: none; } } /* ── Active filter chip row ────────────────────────────────── */ @@ -151,49 +154,19 @@ export class SearchModal extends LitElement { border-bottom: 1px solid var(--color-border-subtle); } - .chip-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px var(--spacing-sm); - background: hsla(202, 96%, 37%, 0.07); - border: 1px solid hsla(202, 96%, 37%, 0.22); - border-radius: var(--border-radius-pill); - color: var(--link-blue); - font: inherit; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background-color 150ms ease; - } - - @media (hover: hover) and (pointer: fine) { - .chip-pill:hover { background: hsla(202, 96%, 37%, 0.13); } - } - - .chip-pill:focus-visible { - outline: 2px solid var(--color-focus-ring); - outline-offset: 2px; - } - - .chip-pill__remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - opacity: 0.65; + /* 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; } - .chip-pill__remove svg { width: 100%; height: 100%; } - .clear-all { margin-left: auto; padding: 3px var(--spacing-sm); background: transparent; border: 1px solid transparent; border-radius: var(--border-radius-button); - color: var(--dark-red); + color: var(--darker-grey); font: inherit; font-size: 13px; font-weight: 600; @@ -202,7 +175,7 @@ export class SearchModal extends LitElement { } @media (hover: hover) and (pointer: fine) { - .clear-all:hover { background: hsla(8, 70%, 44%, 0.07); } + .clear-all:hover { background: var(--lightest-grey); } } .clear-all:focus-visible { @@ -211,7 +184,7 @@ export class SearchModal extends LitElement { } @media (prefers-reduced-motion: reduce) { - .chip-pill, .clear-all { transition: none; } + .clear-all { transition: none; } } /* ── Filter button row ─────────────────────────────────────── */ @@ -376,7 +349,19 @@ export class SearchModal extends LitElement { this._langsLoading = false; this.barcodeHref = ''; - this._languageItems = LANGUAGE_OPTIONS; + // 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; @@ -390,16 +375,15 @@ export class SearchModal extends LitElement { this._allLangsLoaded = false; } - attachToTrigger(input) { - if (!input) return; - const openModal = (e) => { + attachToTrigger(trigger) { + if (!trigger) return; + // The trigger is a @@ -526,20 +475,25 @@ export class SearchModal extends LitElement { class="see-all" ?disabled=${this._query.trim().length < MIN_QUERY_LENGTH} @click=${this._onSeeAllResults} - >See all results + >${this._i18n.seeAll}
        `; } _renderChips() { + // Each active filter is a selected : the `selected` state + // gives it the built-in close icon, and clicking it (ol-chip-select) + // removes the filter. `variant` tints the chip by category — neutral + // grey for the availability facet, green for languages. const chips = []; if (this._availability !== DEFAULT_AVAILABILITY) { - const opt = AVAILABILITY_OPTIONS.find(o => o.value === this._availability); + const opt = this._availabilityOptions.find(o => o.value === this._availability); if (opt) chips.push({ key: `availability:${opt.value}`, label: opt.label, + variant: 'neutral', onRemove: () => this._setAvailability(DEFAULT_AVAILABILITY), }); } @@ -550,48 +504,46 @@ export class SearchModal extends LitElement { chips.push({ key: `language:${value}`, label: opt.label, + variant: 'language', onRemove: () => this._removeLanguage(value), }); } return html` -
        - ${repeat(chips, c => c.key, c => html` - - `)} +
        + + ${repeat(chips, c => c.key, c => html` + ${c.label} + `)} + + >${this._i18n.clearAll}
        `; } _renderFilters() { return html` -
        +
        Start typing to search…
        `; + return html`
        ${this._i18n.startTyping}
        `; } if (this._loading && this._results.length === 0) { - return html`
        Searching…
        `; + return html`
        ${this._i18n.searching}
        `; } if (this._results.length === 0 && this._hasSearched) { - return html`
        No results found
        `; + return html`
        ${this._i18n.noResults}
        `; } return html`
        -

        Top results

        +

        ${this._i18n.topResults}

          ${repeat(this._results, r => r.key, r => this._renderResult(r))}
        `; @@ -632,7 +584,7 @@ export class SearchModal extends LitElement { - ${work.title || 'Untitled'} + ${work.title || this._i18n.untitled} ${author ? html`${author}` : nothing} @@ -772,36 +724,37 @@ export class SearchModal extends LitElement { // ── Static SVGs ────────────────────────────────────────────────────── static _searchIcon = html``; - - static _closeIcon = html``; } customElements.define('ol-search-modal', SearchModal); /** - * Mounts a single SearchModal and wires it to a header trigger input. + * Mounts a single SearchModal and wires it to the header search trigger button. * Idempotent – safe to call multiple times with the same element. - * @param {HTMLInputElement} triggerInput + * @param {HTMLButtonElement} trigger * @returns {SearchModal|null} */ -export function initSearchModal(triggerInput) { - if (!triggerInput || triggerInput.dataset.olSearchModalAttached === 'true') { +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); + const barcodeLink = document.querySelector('#barcode_scanner_link'); if (barcodeLink) { modal.barcodeHref = barcodeLink.getAttribute('href') || ''; } document.body.appendChild(modal); - modal.attachToTrigger(triggerInput); - triggerInput.dataset.olSearchModalAttached = 'true'; + modal.attachToTrigger(trigger); + trigger.dataset.olSearchModalAttached = 'true'; return modal; } - -function _titleCase(str) { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : str; -} diff --git a/openlibrary/plugins/openlibrary/js/search-modal/constants.js b/openlibrary/plugins/openlibrary/js/search-modal/constants.js index 0df467c2a63..b04ed121c6d 100644 --- a/openlibrary/plugins/openlibrary/js/search-modal/constants.js +++ b/openlibrary/plugins/openlibrary/js/search-modal/constants.js @@ -7,30 +7,153 @@ export const AVAILABILITY_OPTIONS = [ value: 'all', label: 'All', description: 'Every book in the catalog', - count: '~50M', }, { value: 'readable', label: 'Read now (free)', description: 'Fully readable – public domain & open access', - count: '~4.6M', }, { value: 'borrowable', label: 'Borrowable', description: 'Borrow via Internet Archive\'s lending library', - count: '~2.7M', }, { value: 'open', label: 'Preview only', description: 'Limited preview available', - count: '~1.8M', }, ]; 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, authors…', + inputAria: 'Search', + scanAria: 'Scan a barcode', + scanTitle: 'Scan barcode', + closeAria: 'Close search', + seeAll: 'See all 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', +}; + +/** + * 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: { public_scan: 'true' }, + borrowable: { has_fulltext: 'true', public_scan: 'false' }, + open: { print_disabled: '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). @@ -39,9 +162,10 @@ export const DEFAULT_AVAILABILITY = 'all'; * to a patron on the first click. Sorted by global speaker population / * OL catalog representation. * - * SearchModal._loadAllLanguages() fetches the full OL language catalogue - * and replaces this list with a merged, sorted set so every language in - * the OL catalogue becomes searchable. + * 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' }, @@ -66,90 +190,6 @@ export const DEFAULT_LANGUAGE_OPTIONS = [ { value: 'ben', label: 'Bengali' }, ]; -/** - * Full static language list – retained as a label-lookup map used by - * SearchModal._loadAllLanguages() when merging OL API results. - * - * Any language code returned by the OL facet API that appears here gets - * a human-readable label; unknown codes fall back to title-cased code. - * - * Values are ISO 639-2/B codes as expected by the /search?language= param. - */ -export const LANGUAGE_OPTIONS = [ - // ── Tier 1 – highest OL catalog volume ────────────────────────────── - { 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: 'lat', label: 'Latin' }, - // ── Tier 2 – well-represented in OL ───────────────────────────────── - { value: 'hin', label: 'Hindi' }, - { value: 'kor', label: 'Korean' }, - { value: 'cze', label: 'Czech' }, - { value: 'gre', label: 'Greek' }, - { value: 'heb', label: 'Hebrew' }, - { value: 'dan', label: 'Danish' }, - { value: 'nor', label: 'Norwegian' }, - { value: 'fin', label: 'Finnish' }, - { value: 'hun', label: 'Hungarian' }, - { value: 'rum', label: 'Romanian' }, - { value: 'ukr', label: 'Ukrainian' }, - { value: 'bul', label: 'Bulgarian' }, - { value: 'hrv', label: 'Croatian' }, - { value: 'cat', label: 'Catalan' }, - { value: 'vie', label: 'Vietnamese' }, - { value: 'tha', label: 'Thai' }, - { value: 'ind', label: 'Indonesian' }, - { value: 'may', label: 'Malay' }, - { value: 'per', label: 'Persian' }, - { value: 'ben', label: 'Bengali' }, - { value: 'tam', label: 'Tamil' }, - { value: 'tel', label: 'Telugu' }, - { value: 'mar', label: 'Marathi' }, - { value: 'urd', label: 'Urdu' }, - { value: 'pan', label: 'Punjabi' }, - { value: 'guj', label: 'Gujarati' }, - { value: 'mal', label: 'Malayalam' }, - { value: 'kan', label: 'Kannada' }, - // ── Tier 3 – smaller but present collections ───────────────────────── - { value: 'afr', label: 'Afrikaans' }, - { value: 'alb', label: 'Albanian' }, - { value: 'arm', label: 'Armenian' }, - { value: 'aze', label: 'Azerbaijani' }, - { value: 'baq', label: 'Basque' }, - { value: 'bel', label: 'Belarusian' }, - { value: 'bos', label: 'Bosnian' }, - { value: 'est', label: 'Estonian' }, - { value: 'geo', label: 'Georgian' }, - { value: 'ice', label: 'Icelandic' }, - { value: 'kaz', label: 'Kazakh' }, - { value: 'lav', label: 'Latvian' }, - { value: 'lit', label: 'Lithuanian' }, - { value: 'mac', label: 'Macedonian' }, - { value: 'mlt', label: 'Maltese' }, - { value: 'mon', label: 'Mongolian' }, - { value: 'nep', label: 'Nepali' }, - { value: 'sin', label: 'Sinhala' }, - { value: 'slv', label: 'Slovenian' }, - { value: 'slo', label: 'Slovak' }, - { value: 'srp', label: 'Serbian' }, - { value: 'swa', label: 'Swahili' }, - { value: 'tgl', label: 'Tagalog' }, - { value: 'uzb', label: 'Uzbek' }, - { value: 'wel', label: 'Welsh' }, - { value: 'yid', label: 'Yiddish' }, -]; - /** * sessionStorage keys for per-session filter persistence. */ 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 5c2254642fc..5b23242f6b5 100644 --- a/openlibrary/plugins/worksearch/code.py +++ b/openlibrary/plugins/worksearch/code.py @@ -721,6 +721,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/templates/design.html b/openlibrary/templates/design.html index 2e545e6be07..376e36ba1eb 100644 --- a/openlibrary/templates/design.html +++ b/openlibrary/templates/design.html @@ -303,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/lib/nav_head.html b/openlibrary/templates/lib/nav_head.html index fe56adf5ea7..585c39a913f 100644 --- a/openlibrary/templates/lib/nav_head.html +++ b/openlibrary/templates/lib/nav_head.html @@ -87,40 +87,25 @@
        - -
        -
          -
        -
        + $# 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. + + $# Kept (hidden) so the search modal can read the barcode-scanner URL. +
        diff --git a/openlibrary/templates/search/availability_i18n.html b/openlibrary/templates/search/availability_i18n.html new file mode 100644 index 00000000000..9cb42c2bd80 --- /dev/null +++ b/openlibrary/templates/search/availability_i18n.html @@ -0,0 +1,13 @@ +$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. +$ availability_i18n = { +$ "all": {"label": _("All"), "description": _("Every book in the catalog")}, +$ "readable": {"label": _("Read now (free)"), "description": _("Fully readable – public domain & open access")}, +$ "borrowable": {"label": _("Borrowable"), "description": _("Borrow via Internet Archive's lending library")}, +$ "open": {"label": _("Preview only"), "description": _("Limited preview available")}, +$ } +$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..9d3a39b9300 --- /dev/null +++ b/openlibrary/templates/search/search_modal_i18n.html @@ -0,0 +1,31 @@ +$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, authors…"), +$ "inputAria": _("Search"), +$ "scanAria": _("Scan a barcode"), +$ "scanTitle": _("Scan barcode"), +$ "closeAria": _("Close search"), +$ "seeAll": _("See all 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"), +$ } +$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') ${hasFilters ? this._renderChips() : nothing} @@ -475,7 +472,7 @@ export class SearchModal extends LitElement { class="see-all" ?disabled=${this._query.trim().length < MIN_QUERY_LENGTH} @click=${this._onSeeAllResults} - >${this._i18n.seeAll} + >${this._seeAllLabel()}
        `; @@ -483,9 +480,10 @@ export class SearchModal extends LitElement { _renderChips() { // Each active filter is a selected : the `selected` state - // gives it the built-in close icon, and clicking it (ol-chip-select) - // removes the filter. `variant` tints the chip by category — neutral - // grey for the availability facet, green for languages. + // 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) { @@ -493,18 +491,20 @@ export class SearchModal extends LitElement { if (opt) chips.push({ key: `availability:${opt.value}`, label: opt.label, - variant: 'neutral', 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); - if (!opt) continue; chips.push({ key: `language:${value}`, - label: opt.label, - variant: 'language', + label: opt?.label || value, onRemove: () => this._removeLanguage(value), }); } @@ -516,17 +516,18 @@ export class SearchModal extends LitElement { ${c.label} `)} - + ${chips.length >= 2 ? html` + + ` : nothing}
        `; } @@ -577,6 +578,7 @@ export class SearchModal extends LitElement { _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; @@ -586,11 +588,23 @@ export class SearchModal extends LitElement { ${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() { @@ -603,6 +617,7 @@ export class SearchModal extends LitElement { this._query = e.target.value; if (this._query.trim().length < MIN_QUERY_LENGTH) { this._results = []; + this._numFound = null; this._loading = false; this._hasSearched = false; return; @@ -673,12 +688,14 @@ export class SearchModal extends LitElement { .then(data => { if (this._activeFetchKey !== fetchKey) return; this._results = data.docs || []; + this._numFound = typeof data.numFound === 'number' ? data.numFound : null; this._loading = false; this._hasSearched = true; }) .catch(() => { if (this._activeFetchKey !== fetchKey) return; this._results = []; + this._numFound = null; this._loading = false; this._hasSearched = true; }); @@ -686,7 +703,7 @@ export class SearchModal extends LitElement { _buildSearchJsonUrl(query) { const params = new URLSearchParams(); - params.set('q', this._composeQ(query)); + params.set('q', query); params.set('limit', String(RESULTS_LIMIT)); params.set('fields', SEARCH_FIELDS.join(',')); params.set('_spellcheck_count', '0'); @@ -700,17 +717,12 @@ export class SearchModal extends LitElement { if (trimmed.length < MIN_QUERY_LENGTH) return null; const params = new URLSearchParams(); - params.set('q', this._composeQ(trimmed)); + params.set('q', trimmed); params.set('mode', searchMode.read()); this._appendFilterParams(params); return `/search?${params.toString()}`; } - _composeQ(userQuery) { - const clause = AVAILABILITY_TO_Q_CLAUSE[this._availability]; - return clause ? `(${userQuery}) AND ${clause}` : userQuery; - } - _appendFilterParams(params) { const availParams = AVAILABILITY_TO_PARAMS[this._availability] || {}; for (const [key, value] of Object.entries(availParams)) { @@ -724,6 +736,8 @@ export class SearchModal extends LitElement { // ── Static SVGs ────────────────────────────────────────────────────── static _searchIcon = html``; + + static _closeIcon = html``; } customElements.define('ol-search-modal', SearchModal); @@ -748,11 +762,6 @@ export function initSearchModal(trigger) { modal._availabilityOptions = availabilityOptionsFromElement(trigger); modal._i18n = searchModalStringsFromElement(trigger); - const barcodeLink = document.querySelector('#barcode_scanner_link'); - if (barcodeLink) { - modal.barcodeHref = barcodeLink.getAttribute('href') || ''; - } - document.body.appendChild(modal); modal.attachToTrigger(trigger); trigger.dataset.olSearchModalAttached = 'true'; diff --git a/openlibrary/plugins/openlibrary/js/search-modal/constants.js b/openlibrary/plugins/openlibrary/js/search-modal/constants.js index b04ed121c6d..f1f2dff0f7f 100644 --- a/openlibrary/plugins/openlibrary/js/search-modal/constants.js +++ b/openlibrary/plugins/openlibrary/js/search-modal/constants.js @@ -2,26 +2,33 @@ * Filter options for the header search modal. */ +// Counts are hand-curated round numbers (NOT live Solr facets) — they give the +// user a sense of scale without an extra round-trip and stay stable across +// renders. Bump them when the underlying corpus shifts materially. export const AVAILABILITY_OPTIONS = [ { value: 'all', - label: 'All', - description: 'Every book in the catalog', + label: 'Full Card Catalog', + description: 'Info on every book published', + count: '~50M', }, { value: 'readable', - label: 'Read now (free)', - description: 'Fully readable – public domain & open access', + label: 'Readable Books Only', + description: 'Primarily older digitized, preserved, physical books', + count: '~4.6M', }, { value: 'borrowable', - label: 'Borrowable', - description: 'Borrow via Internet Archive\'s lending library', + label: 'Borrowable Only', + description: 'From Internet Archive\'s lending library', + count: '~2.7M', }, { value: 'open', - label: 'Preview only', - description: 'Limited preview available', + label: 'Open Access Only', + description: 'From Trusted Book Providers', + count: '~1.8M', }, ]; @@ -82,10 +89,10 @@ export const DEFAULT_SEARCH_MODAL_STRINGS = { dialogAria: 'Search Open Library', inputPlaceholder: 'Search books, authors…', inputAria: 'Search', - scanAria: 'Scan a barcode', - scanTitle: 'Scan barcode', 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', @@ -195,3 +202,21 @@ export const DEFAULT_LANGUAGE_OPTIONS = [ */ 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/worksearch/code.py b/openlibrary/plugins/worksearch/code.py index 720d4348ea1..a2a517f7117 100644 --- a/openlibrary/plugins/worksearch/code.py +++ b/openlibrary/plugins/worksearch/code.py @@ -166,10 +166,10 @@ 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"), - "readable": _("Read now (free)"), - "borrowable": _("Borrowable"), - "open": _("Preview only"), + "all": _("Full Card Catalog"), + "readable": _("Readable Books Only"), + "borrowable": _("Borrowable Only"), + "open": _("Open Access Only"), }.get(value, value) diff --git a/openlibrary/templates/design/options-popover.html b/openlibrary/templates/design/options-popover.html index 8ae4966ed2b..87b7f6c509f 100644 --- a/openlibrary/templates/design/options-popover.html +++ b/openlibrary/templates/design/options-popover.html @@ -31,10 +31,10 @@

        Availability filter

        var el = document.getElementById('demo-options-availability'); var out = document.getElementById('demo-options-availability-out'); el.items = [ - { value: 'all', label: 'Full Card Catalog', description: 'Info on every book in our catalog', count: '~50M' }, - { value: 'readable', label: 'Readable Books Only', description: 'Older digitized, preserved, physical books', count: '~4.6M' }, - { value: 'borrowable', label: 'Borrowable Books', description: 'Books you can check out for free', count: '~2M' }, - { value: 'open', label: 'Always Available', description: 'Public domain books, free to read forever', count: '~1.2M' } + { value: 'all', label: 'Full Card Catalog', description: 'Info on every book published', count: '~50M' }, + { value: 'readable', label: 'Readable Books Only', description: 'Primarily older digitized, preserved, physical books', count: '~4.6M' }, + { value: 'borrowable', label: 'Borrowable Only', description: 'From Internet Archive\'s lending library', count: '~2.7M' }, + { value: 'open', label: 'Open Access Only', description: 'From Trusted Book Providers', count: '~1.8M' } ]; el.addEventListener('ol-options-popover-change', function(e) { out.textContent = e.detail.selected; @@ -100,7 +100,7 @@

        Custom trigger via slot

        el.items = [ { value: 'all', label: 'Full Card Catalog', count: '~50M' }, { value: 'readable', label: 'Readable Books Only', count: '~4.6M' }, - { value: 'borrowable', label: 'Borrowable Books', count: '~2M' } + { value: 'borrowable', label: 'Borrowable Only', count: '~2.7M' } ]; })(); diff --git a/openlibrary/templates/lib/nav_head.html b/openlibrary/templates/lib/nav_head.html index 585c39a913f..f4fc2da5fa7 100644 --- a/openlibrary/templates/lib/nav_head.html +++ b/openlibrary/templates/lib/nav_head.html @@ -94,17 +94,16 @@ $_('Search') - $# Kept (hidden) so the search modal can read the barcode-scanner URL. + $# Barcode scan needs a camera, so it's only surfaced on touch devices + $# (hidden on hover-capable pointers via CSS). diff --git a/openlibrary/templates/search/availability_i18n.html b/openlibrary/templates/search/availability_i18n.html index 9cb42c2bd80..2edd79055f4 100644 --- a/openlibrary/templates/search/availability_i18n.html +++ b/openlibrary/templates/search/availability_i18n.html @@ -4,10 +4,12 @@ $# 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"), "description": _("Every book in the catalog")}, -$ "readable": {"label": _("Read now (free)"), "description": _("Fully readable – public domain & open access")}, -$ "borrowable": {"label": _("Borrowable"), "description": _("Borrow via Internet Archive's lending library")}, -$ "open": {"label": _("Preview only"), "description": _("Limited preview available")}, +$ "all": {"label": _("Full Card Catalog"), "description": _("Info on every book published")}, +$ "readable": {"label": _("Readable Books Only"), "description": _("Primarily older digitized, preserved, physical books")}, +$ "borrowable": {"label": _("Borrowable Only"), "description": _("From Internet Archive's lending library")}, +$ "open": {"label": _("Open Access Only"), "description": _("From Trusted Book Providers")}, $ } $json_encode(availability_i18n) diff --git a/openlibrary/templates/search/search_modal_i18n.html b/openlibrary/templates/search/search_modal_i18n.html index 9d3a39b9300..ad9d5d09f41 100644 --- a/openlibrary/templates/search/search_modal_i18n.html +++ b/openlibrary/templates/search/search_modal_i18n.html @@ -10,10 +10,10 @@ $ "dialogAria": _("Search Open Library"), $ "inputPlaceholder": _("Search books, authors…"), $ "inputAria": _("Search"), -$ "scanAria": _("Scan a barcode"), -$ "scanTitle": _("Scan barcode"), $ "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"), diff --git a/openlibrary/templates/search/work_search_selected_facets.html b/openlibrary/templates/search/work_search_selected_facets.html index b0d4a39f06b..ff593366b80 100644 --- a/openlibrary/templates/search/work_search_selected_facets.html +++ b/openlibrary/templates/search/work_search_selected_facets.html @@ -35,17 +35,39 @@ active_availability = get_active_availability(param) if param else 'all' selected_languages = list(param.get('language', [])) if param else [] - # The chips bar is also surfaced for any facet header that has a value in - # the URL (author_key, subject_facet, etc.). We render those only when - # facet_counts has loaded — see the loop further down. - has_other_facet_filter = any( - header in param and header not in special_handled - for header, _label in facet_map - ) + + # Build the (header, value, display) tuples for the non-special facet chips + # (subject_facet, author_key, etc.). For most facets the raw URL value is + # already a usable display name, so we render the chip even when + # facet_counts is empty (e.g. a zero-result search, or a value outside + # Solr's facet.limit top-N). `author_key` is the exception: its raw value + # is an OL ID like "OL12345A" — we keep gating it on facet_counts + # resolving a display name rather than rendering the bare ID. + def build_other_chips(): + if not param: + return [] + facet_counts = search_response.facet_counts or {} + chips = [] + for header, _label in facet_map: + if header in special_handled: + continue + selected = param.get(header, []) + if not selected: + continue + display_by_key = {k: d for k, d, _ in facet_counts.get(header, [])} + for v in selected: + if header == 'author_key' and v not in display_by_key: + # Wait for the async sidebar request to resolve the name + # so we don't render the bare OL ID on the chip. + continue + chips.append((header, v, display_by_key.get(v, v))) + return chips + + other_chips = build_other_chips() show_chips_bar = ( active_availability != 'all' or selected_languages - or (search_response.facet_counts and has_other_facet_filter) + or other_chips ) $if param and not search_response.error: @@ -64,7 +86,6 @@ $lang_label - $# Other facets (author, subject, year, etc.) depend on facet_counts to - $# resolve the display name — only render them once the async sidebar - $# request has populated facet_counts. - $if search_response.facet_counts: - $for header, label in facet_map: - $if header in special_handled: - $continue - $ counts = search_response.facet_counts[header] - $for k, display, count in counts: - $if k not in param.get(header, []): - $continue - - $title.append(display) - - $ chip_display = display - $ chip_title = facet_tooltips.get(header, '') - $if chip_title: - $chip_display - $else: - $chip_display + $# Other facets (author, subject, year, etc.). Most render immediately + $# off the URL params — facet_counts is used only to prettify the + $# display label where available. `author_key` is the exception: see + $# build_other_chips() above. + $for header, v, chip_display in other_chips: + $title.append(chip_display) + $ chip_title = facet_tooltips.get(header, '') + $if chip_title: + $chip_display + $else: + $chip_display + $if search_response.facet_counts: $# Legacy non-UI facets: only render when facet_counts is loaded $# (these aren't sticky filters owned by the new filter row). $# When an availability value is active the new Availability chip @@ -115,7 +127,7 @@ $if k not in param.get('has_fulltext', []): $continue $ chip_display = fulltext_names.get(k, '') - $chip_display + $chip_display $if 'public_scan_b' in param: $ counts = search_response.facet_counts.get('public_scan_b', []) @@ -126,6 +138,6 @@ $# this facet (the sidebar skips it), so it only triggers via $# manually crafted URLs like /search?public_scan_b=true. $ chip_display = _("Only Classic eBooks") if display == 'true' else _("Classic eBooks hidden") - $chip_display + $chip_display $var title: $_('%(title)s - search', title=', '.join(title)) diff --git a/openlibrary/templates/work_search.html b/openlibrary/templates/work_search.html index eb7047fdccb..897419a52c7 100644 --- a/openlibrary/templates/work_search.html +++ b/openlibrary/templates/work_search.html @@ -1,8 +1,6 @@ $def with (q_param, search_response, get_doc, param, page, rows, has_solr_editions_enabled) $ layout = get_remembered_layout() -$ bodyclass = ctx.setdefault('bodyclass', []) -$ bodyclass.append('search-page')
        diff --git a/static/css/components/header-bar.css b/static/css/components/header-bar.css index 46dfd2640d6..fe13739d234 100644 --- a/static/css/components/header-bar.css +++ b/static/css/components/header-bar.css @@ -396,7 +396,6 @@ /* stylelint-enable max-nesting-depth, selector-max-specificity */ .header-bar .search-component { - width: 45px; -webkit-box-ordinal-group: 3; -ms-flex-order: 2; order: 2; @@ -409,7 +408,10 @@ } .header-bar .search-component .search-bar-component { - display: inline-block; + display: flex; + align-items: stretch; + background-color: var(--grey-fafafa); + border: 1px solid var(--dark-beige); border-radius: 6px; position: relative; } @@ -544,8 +546,6 @@ } .header-bar .search-component .search-bar-component { - background-color: var(--grey-fafafa); - border: 1px solid var(--dark-beige); width: 240px; } } @@ -581,15 +581,17 @@ /* ── Search trigger button ─────────────────────────────────────────────── The header search is a button that opens the search modal. It mimics the - look of the old text field but behaves like a button: pointer cursor, - hover background, and a slight press-down scale. On small screens only the - icon shows; the "Search" label appears once the box has room (>=35.5em). */ + look of a text field but behaves like a button: pointer cursor, hover + background, and a slight press-down scale. The same faux-input rendering + applies at every viewport — on mobile it's squeezed into whatever space + the header has left after the logo and hamburger. */ .search-bar-trigger { + flex: 1; + min-width: 0; display: flex; align-items: center; - justify-content: center; + justify-content: space-between; gap: var(--spacing-inline-sm); - width: 100%; min-height: 38px; padding: 0 var(--spacing-inset-md); background: transparent; @@ -603,7 +605,6 @@ } .search-bar-trigger__label { - display: none; flex: 1; text-align: left; white-space: nowrap; @@ -618,6 +619,46 @@ background: url(/static/images/search-icon.svg) center / contain no-repeat; } +/* ── Barcode scanner button ────────────────────────────────────────────── + Sits to the right of the search trigger. Only surfaced on touch devices + since scanning needs a camera. */ +.search-by-barcode { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + min-height: 38px; + border-radius: var(--border-radius-input); + color: var(--grey); + opacity: 0.7; + transition: + background-color 0.15s ease, + opacity 0.15s ease; +} + +.search-by-barcode img { + display: block; +} + +.search-by-barcode:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + opacity: 1; +} + +@media (hover: hover) and (pointer: fine) { + .search-by-barcode { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .search-by-barcode { + transition: none; + } +} + @media (hover: hover) and (pointer: fine) { .search-bar-trigger:hover { background-color: var(--lightest-grey); @@ -652,13 +693,3 @@ transform: none; } } - -@media only screen and (min-width: 35.5em) { - .search-bar-trigger { - justify-content: space-between; - } - - .search-bar-trigger__label { - display: block; - } -} diff --git a/tests/unit/js/focusUtils.test.js b/tests/unit/js/focusUtils.test.js new file mode 100644 index 00000000000..c611e5b3eb8 --- /dev/null +++ b/tests/unit/js/focusUtils.test.js @@ -0,0 +1,158 @@ +import { + FOCUSABLE_SELECTOR, + findFocusableIndex, + getDeepActiveElement, + getFocusableFromSlot, + isFocusable, +} from '../../../openlibrary/components/lit/utils/focus-utils.js'; + +// jsdom (used by jest-environment-jsdom 26) implements neither layout nor +// `Element.checkVisibility`. We mock checkVisibility on individual elements +// in the visibility tests below — the runtime helper prefers it when present. + +function makeButton(label, { disabled = false, hidden = false } = {}) { + const btn = document.createElement('button'); + btn.textContent = label; + if (disabled) btn.disabled = true; + // Simulate display:none / visibility:hidden via the standard API. + btn.checkVisibility = () => !hidden; + return btn; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('isFocusable', () => { + test('returns true for a plain enabled element with no visibility hook', () => { + const btn = document.createElement('button'); + expect(isFocusable(btn)).toBe(true); + }); + + test('returns false for disabled elements', () => { + const btn = makeButton('go', { disabled: true }); + expect(isFocusable(btn)).toBe(false); + }); + + test('returns false when checkVisibility reports the element is not rendered', () => { + const btn = makeButton('go', { hidden: true }); + expect(isFocusable(btn)).toBe(false); + }); + + test('returns true when checkVisibility reports the element is rendered', () => { + const btn = makeButton('go'); + expect(isFocusable(btn)).toBe(true); + }); +}); + +describe('getFocusableFromSlot', () => { + test('returns [] when the slot is null', () => { + expect(getFocusableFromSlot(null)).toEqual([]); + }); + + test('includes directly focusable assigned elements and their focusable descendants', () => { + const button = makeButton('one'); + const wrapper = document.createElement('div'); + const inner = makeButton('two'); + wrapper.appendChild(inner); + + const slot = { + assignedElements: () => [button, wrapper], + }; + + expect(getFocusableFromSlot(slot)).toEqual([button, inner]); + }); + + test('omits assigned elements that are disabled or hidden — the bug', () => { + // This is the regression that produced the "stuck on Escape / Clear + // all" report: the focus trap kept hidden buttons in its tab list and + // `.focus()` on them was a silent no-op. + const visible = makeButton('visible'); + const hidden = makeButton('hidden', { hidden: true }); + const disabled = makeButton('disabled', { disabled: true }); + + const slot = { assignedElements: () => [visible, hidden, disabled] }; + + expect(getFocusableFromSlot(slot)).toEqual([visible]); + }); + + test('also drops hidden focusable descendants of a wrapper', () => { + const wrapper = document.createElement('div'); + const visible = makeButton('visible'); + const hidden = makeButton('hidden', { hidden: true }); + wrapper.append(visible, hidden); + + const slot = { assignedElements: () => [wrapper] }; + + expect(getFocusableFromSlot(slot)).toEqual([visible]); + }); + + test('matches the documented FOCUSABLE_SELECTOR (button, input, a[href], …)', () => { + // A meta-test: a regression in the selector string would silently break + // every focus trap built on top of this util. + expect(FOCUSABLE_SELECTOR).toMatch(/button/); + expect(FOCUSABLE_SELECTOR).toMatch(/input/); + expect(FOCUSABLE_SELECTOR).toMatch(/\[href\]/); + expect(FOCUSABLE_SELECTOR).toMatch(/tabindex/); + }); +}); + +describe('getDeepActiveElement', () => { + test('returns document.activeElement when there are no shadow roots in the focus chain', () => { + const btn = document.createElement('button'); + document.body.appendChild(btn); + btn.focus(); + expect(getDeepActiveElement()).toBe(btn); + }); + + test('descends into shadow roots to find the actually focused element', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = host.attachShadow({ mode: 'open' }); + const inner = document.createElement('button'); + root.appendChild(inner); + inner.focus(); + + // document.activeElement points to the shadow host; the deep helper + // must drill through to the inner button. + expect(getDeepActiveElement()).toBe(inner); + }); +}); + +describe('findFocusableIndex', () => { + test('returns the index when activeElement is itself in the list', () => { + const a = document.createElement('button'); + const b = document.createElement('button'); + expect(findFocusableIndex([a, b], b)).toBe(1); + }); + + test('returns -1 when activeElement is unrelated to the focusable list', () => { + const a = document.createElement('button'); + const orphan = document.createElement('button'); + expect(findFocusableIndex([a], orphan)).toBe(-1); + }); + + test('climbs the parent chain to find an ancestor that is in the list', () => { + // Mirrors a wrapper-with-deep-focus pattern (e.g. light-DOM trigger + // inside a div that's tracked in the trap). + const wrapper = document.createElement('div'); + const inner = document.createElement('button'); + wrapper.appendChild(inner); + document.body.appendChild(wrapper); + + expect(findFocusableIndex([wrapper], inner)).toBe(0); + }); + + test('crosses a shadow boundary to find the host in the list', () => { + // This is the case that matters for ol-options-popover / ol-select-popover: + // the trap tracks the custom-element host, but the actually focused + // element is a button inside its shadow root. + const host = document.createElement('div'); + document.body.appendChild(host); + const root = host.attachShadow({ mode: 'open' }); + const inner = document.createElement('button'); + root.appendChild(inner); + + expect(findFocusableIndex([host], inner)).toBe(0); + }); +}); diff --git a/tests/unit/js/searchModalConstants.test.js b/tests/unit/js/searchModalConstants.test.js index 438f1ec633c..2041878ebee 100644 --- a/tests/unit/js/searchModalConstants.test.js +++ b/tests/unit/js/searchModalConstants.test.js @@ -20,9 +20,10 @@ describe('localizeAvailabilityOptions', () => { expect(readable.label).toBe('Lire maintenant'); expect(readable.description).toBe('Lecture libre'); // Untranslated values keep their English text... - expect(localized.find((o) => o.value === 'all').label).toBe('All'); + expect(localized.find((o) => o.value === 'all').label).toBe('Full Card Catalog'); // ...and the non-translatable fields are preserved. expect(readable.value).toBe('readable'); + expect(readable.count).toBe('~4.6M'); }); test('falls back per-field when a translation omits one', () => { @@ -31,12 +32,12 @@ describe('localizeAvailabilityOptions', () => { }); const readable = localized.find((o) => o.value === 'readable'); expect(readable.label).toBe('Lire maintenant'); - expect(readable.description).toBe('Fully readable – public domain & open access'); + expect(readable.description).toBe('Primarily older digitized, preserved, physical books'); }); test('does not mutate the shared defaults', () => { localizeAvailabilityOptions({ all: { label: 'Tout' } }); - expect(AVAILABILITY_OPTIONS.find((o) => o.value === 'all').label).toBe('All'); + expect(AVAILABILITY_OPTIONS.find((o) => o.value === 'all').label).toBe('Full Card Catalog'); }); }); From 5956f224ad95193f14135b0f8969cee1c38b37bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 18:18:37 +0000 Subject: [PATCH 11/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- openlibrary/i18n/messages.pot | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot index 62c7c084ca5..fd82c2a3af1 100644 --- a/openlibrary/i18n/messages.pot +++ b/openlibrary/i18n/messages.pot @@ -1529,8 +1529,7 @@ msgstr "" msgid "Role:" msgstr "" -#: about/index.html openlibrary/plugins/worksearch/code.py -#: search/availability_i18n.html type/tag/index.html +#: about/index.html type/tag/index.html msgid "All" msgstr "" From 79367515f000e85312fb38111b16d9a43a69dec3 Mon Sep 17 00:00:00 2001 From: Lokesh Dhakar Date: Thu, 28 May 2026 12:35:46 -0700 Subject: [PATCH 12/31] feat(search): standalone barcode button + icon-only mobile pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the header search area so the barcode scanner reads as its own button on touch devices and stays reachable from /search. - Move the barcode link out of .search-bar-component to be a sibling of it under .search-component, so the search-page hide rule (which now targets only the pill) leaves the scanner reachable on touch widths. - Make .search-component a flex container that lays out the pill and the barcode side-by-side with a gap. - Drop the visible 'Search' label on mobile/touch — the trigger renders as a compact icon-only pill. Desktop (>=960px) restores the wide 'Search Q' bar with the label, via overrides in header-bar--desktop.css and mirrored in legacy.css. - Add .search-page bodyclass on work_search.html and update the hide rule to scope to .search-bar-component only. - Drop the vestigial margin-right: -5px on .search-component (a 2021 nudge that no longer matches the current hamburger sizing). --- openlibrary/templates/lib/nav_head.html | 29 +++++---- openlibrary/templates/work_search.html | 2 + static/css/components/header-bar--desktop.css | 23 +++++++ static/css/components/header-bar--tablet.css | 4 -- static/css/components/header-bar.css | 64 ++++++++++--------- static/css/legacy.css | 23 +++++-- static/css/page-user.css | 10 +-- 7 files changed, 102 insertions(+), 53 deletions(-) diff --git a/openlibrary/templates/lib/nav_head.html b/openlibrary/templates/lib/nav_head.html index f4fc2da5fa7..5ea51f4c87d 100644 --- a/openlibrary/templates/lib/nav_head.html +++ b/openlibrary/templates/lib/nav_head.html @@ -89,23 +89,28 @@
        $# 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. + $# 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). - - -
        + $# 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/work_search.html b/openlibrary/templates/work_search.html index 897419a52c7..eb7047fdccb 100644 --- a/openlibrary/templates/work_search.html +++ b/openlibrary/templates/work_search.html @@ -1,6 +1,8 @@ $def with (q_param, search_response, get_doc, param, page, rows, has_solr_editions_enabled) $ layout = get_remembered_layout() +$ bodyclass = ctx.setdefault('bodyclass', []) +$ bodyclass.append('search-page')
        diff --git a/static/css/components/header-bar--desktop.css b/static/css/components/header-bar--desktop.css index baa4e606e7e..bf34aadcf14 100644 --- a/static/css/components/header-bar--desktop.css +++ b/static/css/components/header-bar--desktop.css @@ -39,10 +39,33 @@ white-space: nowrap; } +/* ── Desktop search trigger ───────────────────────────────────────────── + At desktop widths we restore the full-width "Search 🔍" bar: the pill + expands to fill the search-component slot, the trigger stretches to + fill the pill, the label is shown, and the magnifier sits at the + right edge. Mobile/touch widths keep the compact icon-only pill from + the base file. */ .header-bar .search-component .search-bar-component { width: 100%; } +.header-bar .search-bar-trigger { + flex: 1; + min-width: 0; + justify-content: space-between; + gap: var(--spacing-inline-sm); + padding: 0 var(--spacing-inset-md); +} + +.header-bar .search-bar-trigger__label { + display: block; + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .header-bar .hamburger-component { -webkit-box-ordinal-group: 5; -ms-flex-order: 6; diff --git a/static/css/components/header-bar--tablet.css b/static/css/components/header-bar--tablet.css index b9da85a59c1..7388eb060a4 100644 --- a/static/css/components/header-bar--tablet.css +++ b/static/css/components/header-bar--tablet.css @@ -25,10 +25,6 @@ order: 3; } -.header-bar .search-component .search-bar-component { - width: 300px; -} - .header-bar .header-dropdown .account__icon { width: 45px; height: 45px; diff --git a/static/css/components/header-bar.css b/static/css/components/header-bar.css index fe13739d234..1a03427d478 100644 --- a/static/css/components/header-bar.css +++ b/static/css/components/header-bar.css @@ -400,11 +400,15 @@ -ms-flex-order: 2; order: 2; height: 47px; - margin-right: -5px; - text-align: right; -webkit-box-flex: 1; -ms-flex: 1; flex: 1; + /* Holds the icon-only search pill and the standalone barcode button as + two distinct controls aligned to the right of the header. */ + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-inline-sm); } .header-bar .search-component .search-bar-component { @@ -474,14 +478,14 @@ } @media only screen and (max-width: 25em) { - .search-bar-component { + .search-component { margin-right: 15px; } } /* We should review this media query to determine if it is in fact needed. */ @media only screen and (min-width: 25em) { - .header-bar .search-bar-component { + .header-bar .search-component { margin-right: 15px; } @@ -516,10 +520,6 @@ border-top-left-radius: 0; } - .header-bar .search-component { - margin-right: 0; - } - .header-bar .hamburger-component summary { margin-left: var(--spacing-inline-lg); } @@ -544,10 +544,6 @@ border-top-right-radius: 0; border-top-left-radius: 0; } - - .header-bar .search-component .search-bar-component { - width: 240px; - } } @media only screen and (max-width: 960px) /* @width-breakpoint-desktop */ { @@ -580,20 +576,23 @@ } /* ── Search trigger button ─────────────────────────────────────────────── - The header search is a button that opens the search modal. It mimics the - look of a text field but behaves like a button: pointer cursor, hover - background, and a slight press-down scale. The same faux-input rendering - applies at every viewport — on mobile it's squeezed into whatever space - the header has left after the logo and hamburger. */ + The header search is a button that opens the search modal. The + faux-input aesthetic (background, border, radius) lives on the + `.search-bar-component` wrapper; this button sits inside the pill. + + On mobile/touch widths the trigger renders as a compact icon-only pill + (the "Search" label is hidden). On desktop (header-bar--desktop.css) + it expands to fill the slot and the label is shown beside the icon — + the original wide "Search 🔍" bar. `aria-label="Search"` on the button + supplies the accessible name in both modes. */ .search-bar-trigger { - flex: 1; - min-width: 0; + flex: 0 0 auto; display: flex; align-items: center; - justify-content: space-between; - gap: var(--spacing-inline-sm); + justify-content: center; + min-width: 44px; min-height: 38px; - padding: 0 var(--spacing-inset-md); + padding: 0 var(--spacing-inset-sm); background: transparent; border: 0; border-radius: var(--border-radius-input); @@ -604,12 +603,10 @@ transition: background-color 0.15s ease; } +/* Hidden on mobile/touch; the desktop file flips this back to `block` + alongside the wider trigger layout. */ .search-bar-trigger__label { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: none; } .search-bar-trigger__icon { @@ -620,8 +617,10 @@ } /* ── Barcode scanner button ────────────────────────────────────────────── - Sits to the right of the search trigger. Only surfaced on touch devices - since scanning needs a camera. */ + Standalone control that sits next to the search pill in the header + (not nested inside it), so the `.search-page` rule that hides the pill + still leaves the scanner reachable from the search results page. Only + surfaced on touch devices since scanning needs a camera. */ .search-by-barcode { flex-shrink: 0; display: inline-flex; @@ -647,6 +646,13 @@ opacity: 1; } +@media (hover: hover) { + .search-by-barcode:hover { + opacity: 1; + background-color: var(--lightest-grey); + } +} + @media (hover: hover) and (pointer: fine) { .search-by-barcode { display: none; diff --git a/static/css/legacy.css b/static/css/legacy.css index f78b09ccc32..711fbac5d80 100644 --- a/static/css/legacy.css +++ b/static/css/legacy.css @@ -1766,10 +1766,6 @@ div#subjectLists p { order: 3; } - .header-bar .search-component .search-bar-component { - width: 300px; - } - .header-bar .header-dropdown .account__icon { width: 45px; height: 45px; @@ -1868,10 +1864,29 @@ div#subjectLists p { white-space: nowrap; } + /* Mirrors header-bar--desktop.css: restores the wide "Search 🔍" bar + at desktop. Mobile/touch widths keep the icon-only pill. */ .header-bar .search-component .search-bar-component { width: 100%; } + .header-bar .search-bar-trigger { + flex: 1; + min-width: 0; + justify-content: space-between; + gap: var(--spacing-inline-sm); + padding: 0 var(--spacing-inset-md); + } + + .header-bar .search-bar-trigger__label { + display: block; + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .header-bar .hamburger-component { -webkit-box-ordinal-group: 5; -ms-flex-order: 6; diff --git a/static/css/page-user.css b/static/css/page-user.css index f9576ee9528..bc307c57aa0 100644 --- a/static/css/page-user.css +++ b/static/css/page-user.css @@ -77,16 +77,18 @@ div.siteSearch.darker { } /* On the search page, enlarge the in-page search box to match the header - search bar, then hide the now-redundant header search since the same - input is already visible in the page content. Use `visibility: hidden` - (not `display: none`) so the search-component keeps its flex slot — + search bar, then hide the now-redundant header search pill since the + same input is already visible in the page content. Hide only the pill + (not the surrounding .search-component) so the barcode-scanner sibling + stays reachable from the search page on touch devices. Use + `visibility: hidden` (not `display: none`) so the pill keeps its slot — otherwise the also-`flex: 1` navigation-component absorbs the freed space and shifts My Books / Browse out of their normal positions. */ .search-page .searchbox { height: 47px; border-radius: 6px; } -.search-page .header-bar .search-component { +.search-page .header-bar .search-bar-component { visibility: hidden; } /* stylelint-disable selector-max-specificity */ From c03ba3bd536744d1ace075c977cfd19d039471bc Mon Sep 17 00:00:00 2001 From: Lokesh Dhakar Date: Thu, 28 May 2026 12:36:06 -0700 Subject: [PATCH 13/31] refactor(lit): FocusableHostMixin for shadow-DOM focus trap discovery Adds a mixin that lets shadow-DOM custom elements participate in outer focus traps as single tabbable leaves: - Sets tabindex="0" on the host so light-DOM focus-trap queries pick it up, and turns on delegatesFocus so .focus() and :focus-visible reach the inner control. - Apply the mixin to OLChip, OlOptionsPopover, and OlSelectPopover so they no longer need ad-hoc focus shims. - OlDialog skips its Tab trap when focus is inside an open nested , letting the popover's own trap drive focus. - Adds focusableHostMixin.test.js covering the mixin contract. --- openlibrary/components/lit/OLChip.js | 3 +- openlibrary/components/lit/OlDialog.js | 34 +++++- .../components/lit/OlOptionsPopover.js | 30 ++--- openlibrary/components/lit/OlSelectPopover.js | 61 ++++++---- .../lit/utils/focusable-host-mixin.js | 72 ++++++++++++ tests/unit/js/focusableHostMixin.test.js | 109 ++++++++++++++++++ 6 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 openlibrary/components/lit/utils/focusable-host-mixin.js create mode 100644 tests/unit/js/focusableHostMixin.test.js diff --git a/openlibrary/components/lit/OLChip.js b/openlibrary/components/lit/OLChip.js index 262d7fe089a..2441fbc10db 100644 --- a/openlibrary/components/lit/OLChip.js +++ b/openlibrary/components/lit/OLChip.js @@ -1,4 +1,5 @@ import { LitElement, html, css, nothing } from 'lit'; +import { FocusableHostMixin } from './utils/focusable-host-mixin.js'; /** * OLChip - A pill-shaped interactive chip web component @@ -32,7 +33,7 @@ import { LitElement, html, css, nothing } from 'lit'; * @example * Fiction */ -export class OLChip extends LitElement { +export class OLChip extends FocusableHostMixin(LitElement) { static properties = { selected: { type: Boolean, reflect: true }, size: { type: String, reflect: true }, diff --git a/openlibrary/components/lit/OlDialog.js b/openlibrary/components/lit/OlDialog.js index dc27531d035..d598b38ab03 100644 --- a/openlibrary/components/lit/OlDialog.js +++ b/openlibrary/components/lit/OlDialog.js @@ -513,6 +513,31 @@ export class OlDialog extends LitElement { 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. @@ -520,12 +545,19 @@ export class OlDialog extends LitElement { _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(); - const activeElement = getDeepActiveElement(); // 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. diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js index 65ddd6d325f..4bd939b5355 100644 --- a/openlibrary/components/lit/OlOptionsPopover.js +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -1,6 +1,7 @@ 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; @@ -44,7 +45,7 @@ let _idCounter = 0; * ]' * > */ -export class OlOptionsPopover extends LitElement { +export class OlOptionsPopover extends FocusableHostMixin(LitElement) { static properties = { items: { type: Array }, selected: { type: String, reflect: true }, @@ -242,28 +243,15 @@ export class OlOptionsPopover extends LitElement { this._pendingFocusFirst = false; } - connectedCallback() { - super.connectedCallback(); - // The default trigger button lives in shadow DOM, so an outer focus - // trap (e.g. ) can't discover it via querySelectorAll. - // Exposing the host as tabbable + delegating focus inward lets the - // trap include this component in its tab order. - if (!this.hasAttribute('tabindex')) { - this.setAttribute('tabindex', '0'); - } - } - /** - * Forward focus to the internal trigger so the focus ring lands on the - * actual button rather than the (invisible) host. Falls back to the host - * itself when called before the first render. - * @override + * 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). */ - focus(options) { - const trigger = this.shadowRoot?.querySelector('.default-trigger') - ?? this.querySelector('[slot="trigger"]'); - if (trigger?.focus) trigger.focus(options); - else HTMLElement.prototype.focus.call(this, options); + get _focusTarget() { + return this.shadowRoot?.querySelector('.default-trigger') + ?? this.querySelector('[slot="trigger"]') + ?? null; } render() { diff --git a/openlibrary/components/lit/OlSelectPopover.js b/openlibrary/components/lit/OlSelectPopover.js index b84df0f9d56..a7d6f1163f8 100644 --- a/openlibrary/components/lit/OlSelectPopover.js +++ b/openlibrary/components/lit/OlSelectPopover.js @@ -1,6 +1,7 @@ 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; @@ -69,7 +70,7 @@ let _idCounter = 0; * @ol-select-popover-change=${e => updateUrl(e.detail.selected)} * > */ -export class OlSelectPopover extends LitElement { +export class OlSelectPopover extends FocusableHostMixin(LitElement) { static properties = { items: { type: Array }, selected: { type: Array, reflect: true }, @@ -363,29 +364,39 @@ export class OlSelectPopover extends LitElement { // One-shot flag set by ArrowDown on the trigger to focus into the list // after the popover opens (vs. just focusing the filter on plain click). this._pendingFocusFirst = false; - } - - connectedCallback() { - super.connectedCallback(); - // The default trigger button lives in shadow DOM, so an outer focus - // trap (e.g. ) can't discover it via querySelectorAll. - // Exposing the host as tabbable + delegating focus inward lets the - // trap include this component in its tab order. - if (!this.hasAttribute('tabindex')) { - this.setAttribute('tabindex', '0'); - } + // Item value to refocus after the next render — set by a toggle that + // re-homes the item between the selected/suggestions groups (which + // destroys its DOM node, so its focus is lost). + this._restoreFocusToValue = null; } /** - * Forward focus to the internal trigger so the focus ring lands on the - * actual button rather than the (invisible) host. - * @override + * Send focus to the default-trigger button rather than the first + * focusable in shadow order. */ - focus(options) { - const trigger = this.shadowRoot?.querySelector('.default-trigger') - ?? this.querySelector('[slot="trigger"]'); - if (trigger?.focus) trigger.focus(options); - else HTMLElement.prototype.focus.call(this, options); + get _focusTarget() { + return this.shadowRoot?.querySelector('.default-trigger') + ?? this.querySelector('[slot="trigger"]') + ?? null; + } + + updated(changedProperties) { + super.updated?.(changedProperties); + // Restore focus to the checkbox of an item that just moved between + // the selected/suggestions groups (see _onItemToggle). Lit binds the + // checkbox value via `.value=` (the JS property, not the attribute), + // so we match by property at lookup time. + if (this._restoreFocusToValue !== null && changedProperties.has('selected')) { + const value = this._restoreFocusToValue; + this._restoreFocusToValue = null; + const checkboxes = this.shadowRoot?.querySelectorAll('.item-checkbox') ?? []; + for (const cb of checkboxes) { + if (cb.value === value) { + cb.focus({ preventScroll: true }); + break; + } + } + } } render() { @@ -568,6 +579,16 @@ export class OlSelectPopover extends LitElement { const nextSelected = (this.items || []) .map(it => it.value) .filter(v => current.has(v)); + + // The toggled item is about to move between the "selected" and + // "suggestions" groups, which destroys its checkbox DOM node — focus + // would fall back to . Only restore if the checkbox actually + // owned focus at toggle time (skips the mouse-click-without-focus + // path on Safari). + if (this.shadowRoot?.activeElement === e.target) { + this._restoreFocusToValue = value; + } + this._emitChange(nextSelected, checked ? value : null, checked ? null : value); } diff --git a/openlibrary/components/lit/utils/focusable-host-mixin.js b/openlibrary/components/lit/utils/focusable-host-mixin.js new file mode 100644 index 00000000000..79f9aedd269 --- /dev/null +++ b/openlibrary/components/lit/utils/focusable-host-mixin.js @@ -0,0 +1,72 @@ +/** + * FocusableHostMixin — makes a LitElement custom element behave as a single + * focusable leaf in the document tab order, even when its actual focusable + * element lives in its shadow DOM. + * + * Why this exists + * --------------- + * A consumer-level component (e.g. , ) usually + * renders a ', +}); + +defineFocusableElement('mixin-test-with-target', { + renderHTML: '', + focusTargetSelector: '.trigger', +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('FocusableHostMixin', () => { + test('sets tabindex="0" on the host so an outer focus trap discovers it', () => { + const el = document.createElement('mixin-test-default'); + document.body.appendChild(el); + + expect(el.getAttribute('tabindex')).toBe('0'); + + const wrapper = document.createElement('div'); + wrapper.appendChild(el); + document.body.appendChild(wrapper); + + const discovered = wrapper.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + expect([...discovered]).toContain(el); + }); + + test('does not overwrite a consumer-provided tabindex', () => { + const el = document.createElement('mixin-test-default'); + el.setAttribute('tabindex', '-1'); + document.body.appendChild(el); + + expect(el.getAttribute('tabindex')).toBe('-1'); + }); + + test('exposes shadowRootOptions with delegatesFocus: true and preserves base options', () => { + const Ctor = customElements.get('mixin-test-default'); + expect(Ctor.shadowRootOptions.delegatesFocus).toBe(true); + // Carries the base's mode forward — important guard against a + // future refactor that clobbers other options. + expect(Ctor.shadowRootOptions.mode).toBe('open'); + }); + + test('focus() forwards to _focusTarget when the override returns an element', () => { + const el = document.createElement('mixin-test-with-target'); + document.body.appendChild(el); + + const trigger = el.shadowRoot.querySelector('.trigger'); + const spy = jest.spyOn(trigger, 'focus'); + + el.focus({ preventScroll: true }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ preventScroll: true }); + }); + + test('focus() falls back to HTMLElement.focus when _focusTarget is null', () => { + const el = document.createElement('mixin-test-default'); + document.body.appendChild(el); + + const trigger = el.shadowRoot.querySelector('.trigger'); + const other = el.shadowRoot.querySelector('.other'); + const triggerSpy = jest.spyOn(trigger, 'focus'); + const otherSpy = jest.spyOn(other, 'focus'); + + expect(() => el.focus()).not.toThrow(); + // We don't programmatically focus a specific inner element — that's + // the delegatesFocus opt-in's job at the browser layer (untestable + // in jsdom; verified above via the shadowRootOptions assertion). + expect(triggerSpy).not.toHaveBeenCalled(); + expect(otherSpy).not.toHaveBeenCalled(); + }); +}); From 082aca5f46708bb115af91aae9badcef8a4bee61 Mon Sep 17 00:00:00 2001 From: Lokesh Dhakar Date: Thu, 28 May 2026 12:40:41 -0700 Subject: [PATCH 14/31] docs(ai): capture web-component, mobile, and i18n learnings from search-modal work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls lessons from the header search-modal PR into the AI-agent docs so the next agent (or human) starting in this area doesn't re-discover them. web-components.md - Registration: the customElements.get() guard idiom — needed because some components are imported through both the lit-components bundle and a downstream webpack consumer, and a second define() call throws. - Focus and Shadow DOM: when to reach for FocusableHostMixin, why hidden elements have to be filtered out of trap lists (.focus() is a silent no-op), the deep-active-element + shadow-boundary walk for finding the current trap index, and the stash-and-restore pattern for keeping focus alive across Lit repeat() re-renders. - ARIA on lists: role="radiogroup" on a