+ Dialog
+ The ol-dialog component is a modal built on the native <dialog> element. It provides a focus trap, focus restoration, scroll lock, backdrop dismissal, and Escape-to-close out of the box. There are no show/hide methods — toggle the open attribute (or set the .open property) to open and close it. Backdrop and Escape dismissal are on by default; set the .closeOnBackdropClick / .closeOnEscape properties to false to require an explicit action.
+
+
+ Confirmation dialog
+ A small dialog with a title, body, and footer actions. Opening it traps focus inside; closing it restores focus to the trigger. The default header renders a title and a close button automatically.
+ <ol-dialog label="Delete this list?" width="small">
+ <p>This can't be undone.</p>
+ <div slot="footer">
+ <button value="cancel">Cancel</button>
+ <button value="delete">Delete list</button>
+ </div>
+</ol-dialog>
+
+
Delete list…
+
+ Deleting “Want to Read” removes it permanently. This can't be undone.
+
+ Cancel
+ Delete list
+
+
+
+ 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>
+
+
Small
+
Medium
+
Large
+
+ 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>
+
+
Add a note…
+
+ Your note
+
+
+ Cancel
+ Save note
+
+
+
+
+
+
+
+ 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>
+
+
Search…
+
+
+
+
+
+ The Left Hand of Darkness — Ursula K. Le Guin
+ A Wizard of Earthsea — Ursula K. Le Guin
+ The Dispossessed — Ursula K. Le Guin
+
+
+
+
+
+
diff --git a/openlibrary/templates/design/options-popover.html b/openlibrary/templates/design/options-popover.html
new file mode 100644
index 00000000000..ee2fa195d29
--- /dev/null
+++ b/openlibrary/templates/design/options-popover.html
@@ -0,0 +1,109 @@
+$def with()
+
+
+ Options Popover
+ The ol-options-popover component pairs a trigger button with a popover containing a single-select list of rich options. Each option can carry a label, an optional description, and an optional count, making it a good fit for filters with a small fixed set of mutually exclusive choices. Use ol-select-popover instead when the user can pick multiple values or filter a long list.
+
+
+ Single-select menu
+ With just a value and label per item, the rows render as a tidy single-select menu — useful for a sort order or layout switcher. The list heading defaults to the uppercased label; override it with heading. The selected value is exposed via the selected attribute and the ol-options-popover-change event.
+ <ol-options-popover
+ label="Sort by"
+ heading="SORT ORDER"
+ selected="relevance"
+ items='[{"value":"relevance","label":"Relevance"}, ...]'>
+</ol-options-popover>
+
+
+
+ Selected: relevance
+
+
+
+
+
+
+ Options with descriptions and counts
+ Add an optional description and count to give each option more context. Both render only when present, so plain and rich rows can mix in the same list.
+ <ol-options-popover
+ label="Genre"
+ selected="fiction"
+ items='[
+ {"value":"fiction","label":"Fiction","description":"Novels and short stories","count":"1,024"},
+ {"value":"nonfiction","label":"Nonfiction","description":"Fact-based works","count":"892"}
+ ]'>
+</ol-options-popover>
+
+
+
+
+
+ Custom trigger via slot
+ Provide your own trigger element with slot="trigger" when the default button doesn't fit. The component still handles open/close, ARIA wiring, and keyboard navigation (arrow keys, Home/End, and Escape).
+ <ol-options-popover label="Sort by" items="...">
+ <button slot="trigger">Sort results</button>
+</ol-options-popover>
+
+
+
+
diff --git a/openlibrary/templates/lib/nav_head.html b/openlibrary/templates/lib/nav_head.html
index fe56adf5ea7..5ea51f4c87d 100644
--- a/openlibrary/templates/lib/nav_head.html
+++ b/openlibrary/templates/lib/nav_head.html
@@ -87,41 +87,30 @@
-
+
diff --git a/openlibrary/templates/search/work_search_selected_facets.html b/openlibrary/templates/search/work_search_selected_facets.html
index 2935115d350..ff593366b80 100644
--- a/openlibrary/templates/search/work_search_selected_facets.html
+++ b/openlibrary/templates/search/work_search_selected_facets.html
@@ -3,51 +3,141 @@
$code:
fulltext_names = {'true': 'Ebooks', 'false': 'Exclude ebooks'}
facet_map = get_facet_map()
+ # get_language_name() needs the request's UI language to pick the
+ # translated language name. Use get_request_lang() instead of get_lang(),
+ # since this template is rendered both by the web.py server (work_search.html)
+ # and by the FastAPI partials endpoint (partials.py), and FastAPI doesn't
+ # populate web.ctx.lang — get_request_lang() reads the unified req_context.
+ user_lang = get_request_lang()
facet_tooltips = {
'first_publish_year': _('First published in'),
'language': _('Written in'),
'publisher_facet': _('Published by'),
'author_key': _('Author'),
}
+ # Facets the chips bar handles itself rather than via the facet_map loop.
+ # `has_fulltext` / `public_scan` / `print_disabled` are collapsed into a
+ # single Availability chip (see get_active_availability), and the language
+ # chips are rendered up-front so they don't depend on facet_counts being
+ # loaded (it isn't on the initial work_search.html render — facet=False).
+ special_handled = {'has_fulltext', 'public_scan_b', 'language'}
+
def del_facet_url(k, v):
if k != 'has_fulltext':
return changequery(page=None, _path=path, query=dict(query), **{k:[i for i in param.get(k, []) if i != v]})
else:
return changequery(page=None, _path=path, query=dict(query), **{k:None})
+ def clear_availability_url():
+ # Clear every URL param any availability value can set, in one go.
+ cleared = {k: None for k in get_availability_param_keys()}
+ return changequery(page=None, _path=path, query=dict(query), **cleared)
+
+ active_availability = get_active_availability(param) if param else 'all'
+ selected_languages = list(param.get('language', [])) if param else []
+
+ # 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 other_chips
+ )
+
$if param and not search_response.error:
$ title = []
$if q_param:
$title.append(q_param)
- $if 'has_fulltext' in param:
- $title.append(_('eBook'))
- $if 'public_scan_b' in param:
- $title.append(_('Classic eBook'))
+ $if active_availability != 'all':
+ $title.append(get_availability_label(active_availability))
+ $for lang_code in selected_languages:
+ $title.append(get_language_name('/languages/' + lang_code, user_lang))
- $if any(header in param for header, label in facet_map):
+ $if show_chips_bar:
- $for header, label in facet_map:
- $ counts = search_response.facet_counts[header]
- $for k, display, count in counts:
- $if k not in param.get(header, []):
- $continue
+ $if active_availability != 'all':
+ $ avail_label = get_availability_label(active_availability)
+
$avail_label
+
+ $for lang_code in selected_languages:
+ $ lang_label = get_language_name('/languages/' + lang_code, user_lang)
+
$lang_label
- $if header not in ['has_fulltext', 'public_scan_b']:
- $title.append(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 header == 'has_fulltext':
+ $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
+ $# already represents has_fulltext — skip the legacy chip to avoid
+ $# showing two chips for the same param ("Borrowable" + "Ebooks").
+ $if 'has_fulltext' in param and active_availability == 'all':
+ $ counts = search_response.facet_counts.get('has_fulltext', [])
+ $for k, display, count in counts:
+ $if k not in param.get('has_fulltext', []):
+ $continue
$ chip_display = fulltext_names.get(k, '')
- $elif header == 'public_scan_b':
+
$chip_display
+
+ $if 'public_scan_b' in param:
+ $ counts = search_response.facet_counts.get('public_scan_b', [])
+ $for k, display, count in counts:
+ $if k not in param.get('public_scan_b', []):
+ $continue
$# TODO: Consider removing public_scan_b handling. No UI exposes
$# 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")
- $else:
- $ chip_display = display
- $ chip_title = facet_tooltips.get(header, '')
- $if chip_title:
-
$chip_display
- $else:
-
$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 412e6e1991a..cb65cf846c9 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')
@@ -17,13 +19,7 @@
$_("Search Books")
+
+ $# Option lists and current selections are wired client-side by
+ $# SearchFilterBar.js, which seeds `selected` from the URL query so the
+ $# popovers stay in sync with the sidebar facets across page loads.
+ $# data-i18n carries the translated availability-filter strings;
+ $# SearchFilterBar.js reads it from this container element.
+
+
+ $# Availability and language chips are rendered server-side on the first
+ $# paint (they don't need facet_counts to resolve a display name); the
+ $# rest of the chips (author, subject, etc.) appear once the async
+ $# facets request populates the bar. See search.js initSearchFacets.
+ $# `param` carries the current request's params; passing it as `query`
+ $# lets the chip's clear-URL (changequery) preserve q and other filters.
+ $# The partials path (partials.py) does the same via parsed_qs.
- $if search_response.facet_counts:
- $:render_template('search/work_search_selected_facets', param, search_response, q_param)
+ $:render_template('search/work_search_selected_facets', param, search_response, q_param, query=param)
diff --git a/static/css/components/header-bar--desktop.css b/static/css/components/header-bar--desktop.css
index 8ef9a3ffed5..bf34aadcf14 100644
--- a/static/css/components/header-bar--desktop.css
+++ b/static/css/components/header-bar--desktop.css
@@ -39,12 +39,31 @@
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-component .search-bar {
- max-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 {
diff --git a/static/css/components/header-bar--tablet.css b/static/css/components/header-bar--tablet.css
index 00903f179bd..7388eb060a4 100644
--- a/static/css/components/header-bar--tablet.css
+++ b/static/css/components/header-bar--tablet.css
@@ -25,19 +25,6 @@
order: 3;
}
-.header-bar .search-component .search-bar-component {
- width: 300px;
-}
-
-.header-bar .search-component .search-justify-right {
- min-width: 210px;
-}
-
-.header-bar .search-component .search-bar {
- width: auto;
- max-width: 310px;
-}
-
.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 7527b34f6c0..1a03427d478 100644
--- a/static/css/components/header-bar.css
+++ b/static/css/components/header-bar.css
@@ -396,228 +396,28 @@
/* 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;
height: 47px;
- margin-right: -5px;
- text-align: right;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
-}
-
-/* stylelint-disable selector-max-specificity */
-/* stylelint-disable max-nesting-depth */
-.header-bar .search-component.expanded {
- -webkit-box-flex: 1;
- -ms-flex: 1;
- flex: 1;
- margin-left: 5px;
- -ms-flex-item-align: center;
- align-self: center;
-}
-
-.header-bar .search-component.expanded .search-bar-component {
- width: 95%;
- background-color: var(--grey-fafafa);
- border: 1px solid var(--dark-beige);
-}
-
-.header-bar .search-component.expanded .search-bar {
- width: auto;
-}
-
-.header-bar .search-component.expanded .search-bar .search-bar-input {
+ /* Holds the icon-only search pill and the standalone barcode button as
+ two distinct controls aligned to the right of the header. */
display: flex;
-}
-
-.header-bar .search-component.expanded .search-bar-input input[type="text"] {
- display: block;
- width: 100%;
-}
-
-.header-bar .search-component.expanded .search-bar-submit {
- cursor: pointer;
-}
-
-.header-bar .search-component.expanded .vertical-separator {
- display: block;
-}
-
-.header-bar .search-component.expanded .search-by-barcode-submit {
- display: block;
-}
-
-.header-bar .search-component.expanded .search-facet-selector {
- display: block;
-}
-
-.header-bar .search-component.expanded .search-facet {
- display: block;
-}
-
-.header-bar .search-component .search-bar {
- width: 45px;
-}
-
-.header-bar .search-component .search-bar-submit {
- background: url(/static/images/search-icon.svg) center no-repeat;
- width: 35px;
- height: 45px;
- border-top-left-radius: 0.3em;
- border-bottom-left-radius: 0.3em;
-}
-
-.header-bar .search-component .vertical-separator {
- border-left: 1px solid var(--light-grey);
- height: 34px;
- margin: auto 5px auto 0;
-}
-
-.header-bar .search-component .search-by-barcode-submit {
- background: url(/static/images/icons/barcode_scanner.svg) center no-repeat;
- background-size: 26px;
- width: 28px;
- margin-right: 8px;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--spacing-inline-sm);
}
.header-bar .search-component .search-bar-component {
- display: inline-block;
- border-radius: 6px;
- position: relative;
-}
-
-.header-bar .search-component .search-bar-advanced-btn {
- text-align: center;
-}
-
-.header-bar .search-component .search-bar-input input[type="text"] {
- max-width: 100%;
-}
-/* stylelint-enable selector-max-specificity */
-
-.header-bar .search-component .search-dropdown {
- position: relative;
-}
-
-/* stylelint-disable selector-max-specificity */
-.header-bar .search-component .search-dropdown .search-results li {
- text-align: left;
- padding: var(--spacing-inset-sm);
- border-top: 1px solid var(--lighter-grey);
- font-size: var(--font-size-label-large);
- transition: background-color 0.2s;
-}
-
-.header-bar .search-component .search-dropdown .search-results li:last-child {
- border-radius: 0 0 6px 6px;
-}
-
-.header-bar .search-component .search-dropdown .search-results li:hover {
- background-color: var(--white);
-}
-
-.header-bar .search-component .search-dropdown .search-results li a {
display: flex;
- text-decoration: none;
- color: var(--dark-grey);
-}
-
-.header-bar .search-component .search-dropdown .search-results li a img {
- width: 40px;
- min-width: 40px;
- height: 60px;
- min-height: 60px;
- background-color: var(--lightest-grey);
- margin-right: var(--spacing-inline-lg);
- object-fit: cover;
- border-radius: var(--border-radius-thumbnail);
-}
-
-.header-bar .search-component .search-dropdown .search-results li a .book-desc {
- font-weight: 300;
- text-decoration: none;
-}
-
-.header-bar
- .search-component
- .search-dropdown
- .search-results
- li
- a
- .book-desc
- .book-title {
- font-weight: bold;
-}
-
-.header-bar
- .search-component
- .search-dropdown
- .search-results
- li
- a
- .book-desc
- .book-author {
- color: var(--dark-blue);
-}
-/* stylelint-enable selector-max-specificity */
-
-.header-bar .search-component .search-facet-selector {
- display: none;
- margin-top: var(--spacing-stack-xs);
- padding: 13px 10px 0;
- font-size: var(--font-size-label-large);
-}
-
-.header-bar .search-component .search-facet {
- display: none;
+ align-items: stretch;
+ background-color: var(--grey-fafafa);
+ border: 1px solid var(--dark-beige);
+ border-radius: 6px;
position: relative;
- border-radius: 3px 0 0 3px;
- background-color: hsla(48, 33%, 83%, 0.32);
- border: none;
- border-right: 1px solid var(--lighter-grey);
- font-weight: 500;
- color: var(--grey);
-}
-
-/* stylelint-disable-next-line selector-max-specificity */
-.header-bar .search-component .search-facet select {
- font-size: inherit;
- font-family: inherit;
- font-weight: inherit;
- color: inherit;
- background: none;
- border: none;
- cursor: pointer;
- max-width: 100%;
- -moz-appearance: none;
- appearance: none;
- -webkit-appearance: none;
-}
-
-.header-bar .search-component .search-facet-value {
- display: block;
- min-width: 20px;
- padding-right: var(--spacing-inset-sm);
-}
-
-/* stylelint-disable-next-line selector-max-specificity */
-.header-bar .search-component .search-facet-value::after {
- position: absolute;
- top: 50%;
- right: 4px;
- content: "";
- width: 0;
- height: 0;
- border-left: 4px solid transparent;
- border-right: 4px solid transparent;
- border-top: 4px solid currentColor;
-}
-
-div.search-facet:focus-within {
- outline: 2px solid;
}
.hamburger-component summary {
@@ -677,80 +477,15 @@ div.search-facet:focus-within {
line-height: 0;
}
-.search-component .search-bar-advanced-btn {
- margin: 10px;
- border: 0 none;
- width: 40px;
- padding: 0;
- cursor: pointer;
- display: none;
-}
-
-.search-component .search-bar {
- max-width: none;
- min-width: none;
- -webkit-transition: all 0.2s;
- -o-transition: all 0.2s;
- transition: all 0.2s;
- border-radius: var(--border-radius-input);
- -webkit-box-flex: 1;
- -ms-flex: 1 0 0%;
- flex: 1;
- display: flex;
-}
-
-.search-component .search-bar-input {
- min-width: 50px;
- width: 100%;
- display: flex;
-}
-
-/* stylelint-disable selector-max-specificity */
-.search-component .search-bar-input input[type="text"] {
- min-width: 0;
- margin: 3px;
- -webkit-box-flex: 1;
- -ms-flex: 1;
- flex: 1;
- padding: 5px 0 2px 3px;
- font-size: 1em;
- font-weight: 500;
- border: 0 none;
- outline: none;
- background: var(--grey-fafafa);
- color: var(--grey);
- -webkit-transition: all 0.5s;
- -o-transition: all 0.5s;
- transition: all 0.5s;
- display: none;
-}
-
-.search-component .search-bar-input input[type="submit"] {
- border: none;
- cursor: pointer;
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
-}
-
-.search-component .search-bar-input .vertical-separator {
- display: none;
-}
-
-.search-component .search-bar-input .search-by-barcode-submit {
- display: none;
-}
-/* stylelint-enable selector-max-specificity */
-
@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;
}
@@ -785,24 +520,11 @@ div.search-facet:focus-within {
border-top-left-radius: 0;
}
- .header-bar .search-component {
- margin-right: 0;
- }
-
.header-bar .hamburger-component summary {
margin-left: var(--spacing-inline-lg);
}
}
-/* If JS is enabled, we are going to inject a new
- * element but use the option control to toggle it.
- * to prepare for this change and ensure the search-facet
- * is the right size hide the select element */
-.client-js .search-facet select {
- opacity: 0;
- position: absolute;
-}
-
/* We should review this media query to determine if it is in fact needed. */
@media only screen and (min-width: 35.5em) {
.header-bar .logo-component {
@@ -822,51 +544,6 @@ div.search-facet:focus-within {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
-
- .header-bar .search-bar-input input {
- width: 100%;
- }
-
- .header-bar .search-component .search-bar {
- width: auto;
- max-width: 205px;
- }
-
- .header-bar .search-component .search-bar-advanced-btn {
- display: block;
- }
-
- .header-bar .search-component .search-bar-component {
- background-color: var(--grey-fafafa);
- border: 1px solid var(--dark-beige);
- width: 207px;
- }
-
- .header-bar .search-component .search-bar-input {
- display: flex;
- }
-
- /* stylelint-disable selector-max-specificity */
- .header-bar .search-component .search-bar-input input[type="text"] {
- display: block;
- }
-
- .header-bar .search-component .search-bar-input .vertical-separator {
- display: block;
- }
-
- .header-bar .search-component .search-bar-input .search-by-barcode-submit {
- display: block;
- }
- /* stylelint-enable selector-max-specificity */
-
- .header-bar .search-component .search-facet {
- display: block;
- }
-
- .header-bar .search-component .search-facet-selector {
- display: block;
- }
}
@media only screen and (max-width: 960px) /* @width-breakpoint-desktop */ {
@@ -893,15 +570,132 @@ div.search-facet:focus-within {
height: 44px;
}
- .header-bar .search-component .search-bar-submit {
- height: 42px;
+ .header-bar.mobile {
+ padding: var(--spacing-inset-sm) 0;
+ }
+}
+
+/* ── Search trigger button ───────────────────────────────────────────────
+ 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: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+ min-height: 38px;
+ padding: 0 var(--spacing-inset-sm);
+ background: transparent;
+ border: 0;
+ border-radius: var(--border-radius-input);
+ color: var(--grey);
+ font-family: inherit;
+ font-size: 1em;
+ cursor: pointer;
+ 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 {
+ display: none;
+}
+
+.search-bar-trigger__icon {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ background: url(/static/images/search-icon.svg) center / contain no-repeat;
+}
+
+/* ── Barcode scanner button ──────────────────────────────────────────────
+ 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;
+ 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) {
+ .search-by-barcode:hover {
+ opacity: 1;
+ background-color: var(--lightest-grey);
}
+}
- .header-bar .search-component .search-facet-selector {
- margin-top: 1px;
+@media (hover: hover) and (pointer: fine) {
+ .search-by-barcode {
+ display: none;
}
+}
- .header-bar.mobile {
- padding: var(--spacing-inset-sm) 0;
+@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);
+ }
+}
+
+.search-bar-trigger:focus-visible {
+ outline: 2px solid var(--color-focus-ring);
+ outline-offset: 2px;
+}
+
+/* Press feedback scales the whole field, including its border, rather than
+ just the inner button — otherwise the border stays put and detaches. */
+.search-bar-component {
+ transition: transform 0.1s ease;
+}
+
+.search-bar-component:has(.search-bar-trigger:active) {
+ transform: scale(0.985);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .search-bar-trigger {
+ transition: none;
+ }
+
+ .search-bar-component {
+ transition: none;
+ }
+
+ .search-bar-component:has(.search-bar-trigger:active) {
+ transform: none;
}
}
diff --git a/static/css/legacy.css b/static/css/legacy.css
index bf3b3094fcc..711fbac5d80 100644
--- a/static/css/legacy.css
+++ b/static/css/legacy.css
@@ -1766,19 +1766,6 @@ div#subjectLists p {
order: 3;
}
- .header-bar .search-component .search-bar-component {
- width: 300px;
- }
-
- .header-bar .search-component .search-justify-right {
- min-width: 210px;
- }
-
- .header-bar .search-component .search-bar {
- width: auto;
- max-width: 310px;
- }
-
.header-bar .header-dropdown .account__icon {
width: 45px;
height: 45px;
@@ -1877,12 +1864,27 @@ 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-component .search-bar {
- max-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 {
diff --git a/static/css/page-user.css b/static/css/page-user.css
index f0a854bc66a..bc307c57aa0 100644
--- a/static/css/page-user.css
+++ b/static/css/page-user.css
@@ -67,6 +67,30 @@ div.siteSearch.darker {
background-color: var(--grey-f3f3f3);
padding-right: 22px;
}
+/* Availability + language filter popovers below the search input. */
+.search-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm);
+ align-items: center;
+ margin: var(--spacing-md) 0;
+}
+
+/* On the search page, enlarge the in-page search box to match the header
+ 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-bar-component {
+ visibility: hidden;
+}
/* stylelint-disable selector-max-specificity */
div#revertLink,
div#editHistory {
diff --git a/static/css/tokens/colors.css b/static/css/tokens/colors.css
index 894172b4da0..38be7d962ab 100644
--- a/static/css/tokens/colors.css
+++ b/static/css/tokens/colors.css
@@ -125,4 +125,39 @@
--color-border-error: hsl(8, 78%, 49%);
--color-border-subtle: hsl(0, 0%, 87%);
--color-focus-ring: hsl(202, 96%, 37%);
+
+ /* Chip / tag variants — soft-tint palette (pale background, saturated text
+ & border) used to color a chip by the *kind* of thing it represents.
+ Consumed via the `variant` attribute on
; see OLChip.js. Each
+ variant defines bg / fg / border and a slightly darker bg for hover. */
+ --color-chip-language-bg: hsl(128, 45%, 94%);
+ --color-chip-language-bg-hover: hsl(128, 45%, 90%);
+ --color-chip-language-fg: hsl(130, 50%, 26%);
+ --color-chip-language-border: hsl(128, 38%, 80%);
+
+ --color-chip-subject-bg: hsl(202, 80%, 95%);
+ --color-chip-subject-bg-hover: hsl(202, 80%, 91%);
+ --color-chip-subject-fg: hsl(202, 96%, 28%);
+ --color-chip-subject-border: hsl(202, 70%, 83%);
+
+ --color-chip-genre-bg: hsl(283, 55%, 96%);
+ --color-chip-genre-bg-hover: hsl(283, 55%, 93%);
+ --color-chip-genre-fg: hsl(283, 60%, 40%);
+ --color-chip-genre-border: hsl(283, 45%, 86%);
+
+ --color-chip-author-bg: hsl(32, 90%, 94%);
+ --color-chip-author-bg-hover: hsl(32, 90%, 90%);
+ --color-chip-author-fg: hsl(23, 80%, 38%);
+ --color-chip-author-border: hsl(32, 80%, 82%);
+
+ --color-chip-place-bg: hsl(184, 55%, 93%);
+ --color-chip-place-bg-hover: hsl(184, 55%, 89%);
+ --color-chip-place-fg: hsl(184, 100%, 22%);
+ --color-chip-place-border: hsl(184, 45%, 77%);
+
+ /* Neutral facet chip (e.g. availability) — no category color. */
+ --color-chip-neutral-bg: hsl(0, 0%, 95%);
+ --color-chip-neutral-bg-hover: hsl(0, 0%, 92%);
+ --color-chip-neutral-fg: hsl(0, 0%, 20%);
+ --color-chip-neutral-border: hsl(0, 0%, 85%);
}
diff --git a/tests/unit/js/SearchBar.test.js b/tests/unit/js/SearchBar.test.js
deleted file mode 100644
index 65f6edb9c68..00000000000
--- a/tests/unit/js/SearchBar.test.js
+++ /dev/null
@@ -1,288 +0,0 @@
-import sinon from 'sinon';
-import { SearchBar } from '../../../openlibrary/plugins/openlibrary/js/SearchBar';
-import * as SearchUtils from '../../../openlibrary/plugins/openlibrary/js/SearchUtils';
-import * as nonjquery_utils from '../../../openlibrary/plugins/openlibrary/js/nonjquery_utils.js';
-
-describe('SearchBar', () => {
- const DUMMY_COMPONENT_HTML = `
- `;
-
- describe('initFromUrlParams', () => {
- /** @type {SearchBar} */
- let sb;
- beforeEach(() => {
- sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- sinon.stub(sb, 'getCurUrl').returns(new URL('https://openlibrary.org/search'));
- });
- afterEach(() => localStorage.clear());
- test('Does not throw on empty params', () => {
- sb.initFromUrlParams({});
- });
-
- test('Updates facet from params', () => {
- expect(sb.facet.read()).not.toBe('title');
- sb.initFromUrlParams({facet: 'title'});
- expect(sb.facet.read()).toBe('title');
- });
-
- test('Ignore invalid facets', () => {
- const originalValue = sb.facet.read();
- sb.initFromUrlParams({facet: 'spam'});
- expect(sb.facet.read()).toBe(originalValue);
- });
-
- test('Sets input value from q param', () => {
- sb.initFromUrlParams({q: 'Harry Potter'});
- expect(sb.$input.val()).toBe('Harry Potter');
- });
-
- test('Remove title prefix from q param', () => {
- sb.initFromUrlParams({q: 'title:"Harry Potter"', facet: 'title'});
- expect(sb.$input.val()).toBe('Harry Potter');
- sb.initFromUrlParams({q: 'title: "Harry"', facet: 'title'});
- expect(sb.$input.val()).toBe('Harry');
- });
-
- test('Persists value in url param', () => {
- expect(localStorage.getItem('facet')).not.toBe('title');
- sb.initFromUrlParams({facet: 'title'});
- expect(localStorage.getItem('facet')).toBe('title');
- });
- });
-
- describe('submitForm', () => {
- let sb;
- beforeEach(() => {
- sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- });
- afterEach(() => localStorage.clear());
-
- test('Queries are marshalled before submit for titles', () => {
- sb.initFromUrlParams({facet: 'title'});
- const spy = sinon.spy(SearchBar, 'marshalBookSearchQuery');
- sb.submitForm();
- expect(spy.callCount).toBe(1);
- spy.restore();
- });
-
- test('Form action is updated on submit', () => {
- sb.initFromUrlParams({facet: 'title'});
- const originalAction = sb.$form[0].action;
- sb.submitForm();
- expect(sb.$form[0].action).not.toBe(originalAction);
- });
-
- test('Special inputs are added to the form on submit', () => {
- const spy = sinon.spy(SearchUtils, 'addModeInputsToForm');
- sb.submitForm();
- expect(spy.callCount).toBe(1);
- });
- });
-
- describe('toggleCollapsibleModeForSmallScreens', () => {
- /** @type {SearchBar?} */
- let sb;
- beforeEach(() => sb = new SearchBar($(DUMMY_COMPONENT_HTML)));
- afterEach(() => localStorage.clear());
-
- test('Only enters collapsible mode if not already there', () => {
- sb.inCollapsibleMode = true;
- const spy = sinon.spy(sb, 'enableCollapsibleMode');
- sb.toggleCollapsibleModeForSmallScreens(100);
- expect(spy.callCount).toBe(0);
- });
-
- test('Only exits collapsible mode if not already exited', () => {
- sb.inCollapsibleMode = false;
- const spy = sinon.spy(sb, 'disableCollapsibleMode');
- sb.toggleCollapsibleModeForSmallScreens(1000);
- expect(spy.callCount).toBe(0);
- });
- });
-
- describe('marshalBookSearchQuery', () => {
- const fn = SearchBar.marshalBookSearchQuery;
- test('Empty string', () => {
- expect(fn('')).toBe('');
- });
-
- test('Adds title prefix to plain strings', () => {
- expect(fn('Harry Potter')).toBe('title: "Harry Potter"');
- });
-
- test('Does not add title prefix to lucene-style queries', () => {
- expect(fn('author:"Harry Potter"')).toBe('author:"Harry Potter"');
- expect(fn('"Harry Potter"')).toBe('"Harry Potter"');
- });
- });
-
- describe('Misc', () => {
- const sandbox = sinon.createSandbox();
- afterEach(() => {
- sandbox.restore();
- localStorage.clear();
- });
-
- test('When localStorage empty, defaults to facet=all', () => {
- localStorage.clear();
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- expect(sb.facet.read()).toBe('all');
- });
-
- test('Facet persists between page loads', () => {
- localStorage.setItem('facet', 'title');
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- expect(sb.facet.read()).toBe('title');
- const sb2 = new SearchBar($(DUMMY_COMPONENT_HTML));
- expect(sb2.facet.read()).toBe('title');
- });
-
- test('Advanced facet triggers redirect', () => {
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- const navigateToStub = sandbox.stub(sb, 'navigateTo');
- const event = Object.assign(new $.Event(), { target: { value: 'advanced' } });
- sb.handleFacetSelectChange(event);
- expect(navigateToStub.callCount).toBe(1);
- expect(navigateToStub.args[0]).toEqual(['/advancedsearch']);
- });
-
- for (const facet of ['title', 'author', 'all']) {
- test(`Facet "${facet}" searches tigger autocomplete`, () => {
- // Stub debounce to avoid have to manipulate time (!)
- sandbox.stub(nonjquery_utils, 'debounce').callsFake(fn => fn);
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML), { facet });
- const getJSONStub = sandbox.stub($, 'getJSON');
-
- sb.$input.val('Harry');
- sb.$input.triggerHandler('focus');
- expect(getJSONStub.callCount).toBe(1);
- });
- }
-
- test('Title searches tigger autocomplete even if containing title: prefix', () => {
- // Stub debounce to avoid have to manipulate time (!)
- sandbox.stub(nonjquery_utils, 'debounce').callsFake(fn => fn);
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML), {facet: 'title'});
- const getJSONStub = sandbox.stub($, 'getJSON');
- sb.$input.val('title:"Harry"');
- sb.$input.triggerHandler('focus');
- expect(getJSONStub.callCount).toBe(1);
- });
-
- test('Focussing on input when empty does not trigger autocomplete', () => {
- // Stub debounce to avoid have to manipulate time (!)
- sandbox.stub(nonjquery_utils, 'debounce').callsFake(fn => fn);
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML), {facet: 'title'});
- const getJSONStub = sandbox.stub($, 'getJSON');
- sb.$input.val('');
- sb.$input.triggerHandler('focus');
- expect(getJSONStub.callCount).toBe(0);
- });
-
- for (const facet of ['lists', 'subject', 'text']) {
- test(`Facet "${facet}" does not tigger autocomplete`, () => {
- // Stub debounce to avoid have to manipulate time (!)
- sandbox.stub(nonjquery_utils, 'debounce').callsFake(fn => fn);
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
- const getJSONStub = sandbox.stub($, 'getJSON');
-
- sb.$input.val('foo bar');
- sb.facet.write(facet);
- sb.$input.triggerHandler('focus');
- expect(getJSONStub.callCount).toBe(0);
- });
- }
-
- test('Tabbing out of search input clears autocomplete results', () => {
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
-
- // Spy on the clearAutocompletionResults method
- const clearResultsSpy = sandbox.spy(sb, 'clearAutocompletionResults');
-
- // Simulate tab keydown event on the form
- const tabEvent = $.Event('keydown', { key: 'Tab' });
- sb.$form.trigger(tabEvent);
-
- // Verify clearAutocompletionResults was called
- expect(clearResultsSpy.callCount).toBe(1);
- });
-
- test('Autocomplete rendering behavior depends on existing results', () => {
- sandbox.stub(nonjquery_utils, 'debounce').callsFake(fn => fn);
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML), { facet: 'title' });
- const renderSpy = sandbox.spy(sb, 'renderAutocompletionResults');
-
- // Should render when results are empty
- sb.$input.triggerHandler('focus');
- expect(renderSpy.callCount).toBe(1, 'Should render when no results exist');
-
- renderSpy.resetHistory();
-
- // Should not render when results exist
- sb.$results.append('Some result ');
- sb.$input.triggerHandler('focus');
- expect(renderSpy.callCount).toBe(0, 'Should not render when results exist');
- });
-
- test('Tabbing from search result focuses search submit button and clears results', () => {
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
-
- // Add a dummy result and focus on it
- sb.$results.append('Test Result ');
- const $resultItem = sb.$results.children().first();
- $resultItem.trigger('focus');
-
- // Spy on the clearAutocompletionResults method
- const clearResultsSpy = sandbox.spy(sb, 'clearAutocompletionResults');
-
- // Spy on the focus trigger for search submit
- const focusSpy = sandbox.spy(sb.$searchSubmit, 'trigger');
-
- // Simulate tab keydown event on the result item
- const tabEvent = $.Event('keydown', { key: 'Tab', shiftKey: false });
- $resultItem.trigger(tabEvent);
-
- // Verify clearAutocompletionResults was called
- expect(clearResultsSpy.callCount).toBe(1, 'Should clear autocomplete results');
-
- // Verify search submit was focused
- expect(focusSpy.calledWith('focus')).toBe(true, 'Should focus search submit button');
-
- // Verify event default was prevented
- expect(tabEvent.isDefaultPrevented()).toBe(true, 'Should prevent default tab behavior');
- });
-
- test('Shift+tabbing from search result focuses facet select and clears results', () => {
- const sb = new SearchBar($(DUMMY_COMPONENT_HTML));
-
- // Add a dummy result and focus on it
- sb.$results.append('Test Result ');
- const $resultItem = sb.$results.children().first();
- $resultItem.trigger('focus');
-
- // Spy on the clearAutocompletionResults method
- const clearResultsSpy = sandbox.spy(sb, 'clearAutocompletionResults');
-
- // Spy on the focus trigger for facet select
- const focusSpy = sandbox.spy(sb.$facetSelect, 'trigger');
-
- // Simulate shift+tab keydown event on the result item
- const shiftTabEvent = $.Event('keydown', { key: 'Tab', shiftKey: true });
- $resultItem.trigger(shiftTabEvent);
-
- // Verify clearAutocompletionResults was called
- expect(clearResultsSpy.callCount).toBe(1, 'Should clear autocomplete results');
-
- // Verify facet select was focused
- expect(focusSpy.calledWith('focus')).toBe(true, 'Should focus facet select');
-
- // Verify event default was prevented
- expect(shiftTabEvent.isDefaultPrevented()).toBe(true, 'Should prevent default tab behavior');
- });
- });
-});
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/focusableHostMixin.test.js b/tests/unit/js/focusableHostMixin.test.js
new file mode 100644
index 00000000000..7472ffaa50b
--- /dev/null
+++ b/tests/unit/js/focusableHostMixin.test.js
@@ -0,0 +1,109 @@
+import { FocusableHostMixin } from '../../../openlibrary/components/lit/utils/focusable-host-mixin.js';
+
+// We test the mixin against a stand-in for LitElement: a plain HTMLElement
+// subclass that satisfies the parts of the contract the mixin reads from
+// (`shadowRootOptions`, `connectedCallback`, `focus`). This keeps the test
+// independent of a Lit transform in Jest.
+class MockBase extends HTMLElement {
+ static shadowRootOptions = { mode: 'open' };
+
+ constructor() {
+ super();
+ this.attachShadow(this.constructor.shadowRootOptions);
+ }
+}
+
+// Each test scenario gets its own tag, since `customElements.define` is
+// global and one-shot per name.
+function defineFocusableElement(tagName, { renderHTML = '', focusTargetSelector = null } = {}) {
+ const cls = class extends FocusableHostMixin(MockBase) {
+ connectedCallback() {
+ super.connectedCallback();
+ if (!this.shadowRoot.innerHTML) this.shadowRoot.innerHTML = renderHTML;
+ }
+ get _focusTarget() {
+ return focusTargetSelector
+ ? this.shadowRoot.querySelector(focusTargetSelector)
+ : null;
+ }
+ };
+ customElements.define(tagName, cls);
+ return cls;
+}
+
+defineFocusableElement('mixin-test-default', {
+ renderHTML: 'trigger other ',
+});
+
+defineFocusableElement('mixin-test-with-target', {
+ renderHTML: 'trigger other ',
+ 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();
+ });
+});
diff --git a/tests/unit/js/searchModalAuthor.test.js b/tests/unit/js/searchModalAuthor.test.js
new file mode 100644
index 00000000000..cb955607ba7
--- /dev/null
+++ b/tests/unit/js/searchModalAuthor.test.js
@@ -0,0 +1,109 @@
+import {
+ AUTHOR_SUGGESTION_MAX,
+ deriveAuthors,
+ queryMatchesName,
+} from '../../../openlibrary/plugins/openlibrary/js/search-modal/authorSuggestion';
+
+/** Build a /search.json-style work doc with a single primary author. */
+function work(key, authorName, authorKey) {
+ return {
+ key,
+ title: `Work ${key}`,
+ author_name: authorName ? [authorName] : undefined,
+ author_key: authorKey ? [authorKey] : undefined,
+ };
+}
+
+describe('queryMatchesName', () => {
+ test('matches a surname substring of the full name', () => {
+ expect(queryMatchesName('asimov', 'Isaac Asimov')).toBe(true);
+ });
+
+ test('matches the full name typed out', () => {
+ expect(queryMatchesName('octavia butler', 'Octavia E. Butler')).toBe(true);
+ });
+
+ test('matches on a shared whole word', () => {
+ expect(queryMatchesName('le guin', 'Ursula K. Le Guin')).toBe(true);
+ });
+
+ test('does not match a title that happens to skew to one author', () => {
+ expect(queryMatchesName('dune', 'Frank Herbert')).toBe(false);
+ expect(queryMatchesName('the great gatsby', 'F. Scott Fitzgerald')).toBe(false);
+ });
+
+ test('ignores short shared tokens like particles/initials', () => {
+ expect(queryMatchesName('the de la', 'Walter de la Mare')).toBe(false);
+ });
+
+ test('folds diacritics', () => {
+ expect(queryMatchesName('gabriel garcia marquez', 'Gabriel García Márquez')).toBe(true);
+ });
+
+ test('is empty-safe', () => {
+ expect(queryMatchesName('', 'Isaac Asimov')).toBe(false);
+ expect(queryMatchesName('asimov', '')).toBe(false);
+ expect(queryMatchesName(undefined, undefined)).toBe(false);
+ });
+});
+
+describe('deriveAuthors', () => {
+ test('surfaces a named author even with a single book in the results', () => {
+ const docs = [
+ work('/works/OL1W', 'Octavia E. Butler', 'OL11A'),
+ work('/works/OL2W', 'Someone Else', 'OL22A'),
+ ];
+ expect(deriveAuthors(docs, 'octavia butler')).toEqual([{ key: 'OL11A', name: 'Octavia E. Butler' }]);
+ });
+
+ test('surfaces multiple distinct authors for an ambiguous given name', () => {
+ const docs = [
+ work('/works/OL1W', 'Stephen King', 'OL19A'),
+ work('/works/OL2W', 'Stephen Hawking', 'OL20A'),
+ work('/works/OL3W', 'Stephen King', 'OL19A'), // dupe key → collapsed
+ ];
+ expect(deriveAuthors(docs, 'stephen')).toEqual([
+ { key: 'OL19A', name: 'Stephen King' },
+ { key: 'OL20A', name: 'Stephen Hawking' },
+ ]);
+ });
+
+ test('dedupes a prolific author to a single row', () => {
+ const docs = Array.from({ length: 4 }, (_, i) => work(`/works/OL${i}W`, 'Isaac Asimov', 'OL34221A'));
+ expect(deriveAuthors(docs, 'asimov')).toEqual([{ key: 'OL34221A', name: 'Isaac Asimov' }]);
+ });
+
+ test(`caps the number of author rows at ${AUTHOR_SUGGESTION_MAX}`, () => {
+ const docs = [
+ work('/works/OL1W', 'John Smith', 'OL1A'),
+ work('/works/OL2W', 'Jane Smith', 'OL2A'),
+ work('/works/OL3W', 'Adam Smith', 'OL3A'),
+ work('/works/OL4W', 'Zadie Smith', 'OL4A'),
+ ];
+ expect(deriveAuthors(docs, 'smith')).toHaveLength(AUTHOR_SUGGESTION_MAX);
+ });
+
+ test('only scans the top results, not the whole page', () => {
+ // Asimov is the 6th result — past the scan window — so no row.
+ const docs = [
+ ...Array.from({ length: 5 }, (_, i) => work(`/works/OL${i}W`, 'Filler Writer', `OL9${i}A`)),
+ work('/works/OLaW', 'Isaac Asimov', 'OL34221A'),
+ ];
+ expect(deriveAuthors(docs, 'asimov')).toEqual([]);
+ });
+
+ test('returns nothing for a title search even when results skew to one author', () => {
+ const docs = Array.from({ length: 5 }, (_, i) => work(`/works/OL${i}W`, 'Frank Herbert', 'OL79034A'));
+ expect(deriveAuthors(docs, 'dune')).toEqual([]);
+ });
+
+ test('ignores docs missing an author key (can not link to a page)', () => {
+ const docs = [work('/works/OL1W', 'Isaac Asimov', undefined)];
+ expect(deriveAuthors(docs, 'asimov')).toEqual([]);
+ });
+
+ test('is empty-safe', () => {
+ expect(deriveAuthors([], 'asimov')).toEqual([]);
+ expect(deriveAuthors(null, 'asimov')).toEqual([]);
+ });
+});
diff --git a/tests/unit/js/searchModalConstants.test.js b/tests/unit/js/searchModalConstants.test.js
new file mode 100644
index 00000000000..1aa49b9cabc
--- /dev/null
+++ b/tests/unit/js/searchModalConstants.test.js
@@ -0,0 +1,112 @@
+import {
+ AVAILABILITY_OPTIONS,
+ DEFAULT_SEARCH_MODAL_STRINGS,
+ availabilityFromParams,
+ availabilityOptionsFromElement,
+ localizeAvailabilityOptions,
+ searchModalStringsFromElement,
+} from '../../../openlibrary/plugins/openlibrary/js/search-modal/constants';
+
+describe('localizeAvailabilityOptions', () => {
+ test('returns the English defaults when given no translations', () => {
+ expect(localizeAvailabilityOptions(null)).toBe(AVAILABILITY_OPTIONS);
+ });
+
+ test('overrides label/description by option value', () => {
+ const localized = localizeAvailabilityOptions({
+ readable: { label: 'Lire maintenant', description: 'Lecture libre' },
+ });
+ const readable = localized.find((o) => o.value === 'readable');
+ 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 books');
+ // ...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', () => {
+ const localized = localizeAvailabilityOptions({
+ readable: { label: 'Lire maintenant' },
+ });
+ const readable = localized.find((o) => o.value === 'readable');
+ expect(readable.label).toBe('Lire maintenant');
+ expect(readable.description).toBe('Anything you can read in your browser');
+ });
+
+ test('does not mutate the shared defaults', () => {
+ localizeAvailabilityOptions({ all: { label: 'Tout' } });
+ expect(AVAILABILITY_OPTIONS.find((o) => o.value === 'all').label).toBe('All books');
+ });
+});
+
+describe('availabilityOptionsFromElement', () => {
+ const elWith = (i18n) => ({ dataset: i18n === undefined ? {} : { i18n } });
+
+ test('parses the data-i18n attribute and localizes', () => {
+ const el = elWith(JSON.stringify({ open: { label: 'Aperçu' } }));
+ expect(availabilityOptionsFromElement(el).find((o) => o.value === 'open').label).toBe('Aperçu');
+ });
+
+ test('falls back to defaults when the attribute is absent', () => {
+ expect(availabilityOptionsFromElement(elWith())).toBe(AVAILABILITY_OPTIONS);
+ });
+
+ test('falls back to defaults on malformed JSON', () => {
+ expect(availabilityOptionsFromElement(elWith('{not json'))).toBe(AVAILABILITY_OPTIONS);
+ });
+
+ test('tolerates a null element', () => {
+ expect(availabilityOptionsFromElement(null)).toBe(AVAILABILITY_OPTIONS);
+ });
+});
+
+describe('searchModalStringsFromElement', () => {
+ const elWith = (i18nUi) => ({ dataset: i18nUi === undefined ? {} : { i18nUi } });
+
+ test('parses data-i18n-ui and merges over the English defaults', () => {
+ const el = elWith(JSON.stringify({ seeAll: 'Voir tout', noResults: 'Aucun résultat' }));
+ const s = searchModalStringsFromElement(el);
+ expect(s.seeAll).toBe('Voir tout');
+ expect(s.noResults).toBe('Aucun résultat');
+ // Untranslated keys keep their English text.
+ expect(s.clearAll).toBe('Clear all');
+ });
+
+ test('preserves the %s placeholder in removeFilter', () => {
+ const el = elWith(JSON.stringify({ removeFilter: 'Retirer le filtre : %s' }));
+ expect(searchModalStringsFromElement(el).removeFilter).toBe('Retirer le filtre : %s');
+ });
+
+ test('falls back to the full English set when the attribute is absent', () => {
+ expect(searchModalStringsFromElement(elWith())).toBe(DEFAULT_SEARCH_MODAL_STRINGS);
+ });
+
+ test('falls back to defaults on malformed JSON', () => {
+ expect(searchModalStringsFromElement(elWith('{nope'))).toBe(DEFAULT_SEARCH_MODAL_STRINGS);
+ });
+
+ test('tolerates a null element', () => {
+ expect(searchModalStringsFromElement(null)).toBe(DEFAULT_SEARCH_MODAL_STRINGS);
+ });
+
+ test('does not mutate the shared defaults', () => {
+ searchModalStringsFromElement(elWith(JSON.stringify({ seeAll: 'X' })));
+ expect(DEFAULT_SEARCH_MODAL_STRINGS.seeAll).toBe('See all results');
+ });
+});
+
+describe('availabilityFromParams', () => {
+ const fromObj = (obj) => availabilityFromParams((name) => obj[name]);
+
+ test('maps params back to their availability value', () => {
+ expect(fromObj({ has_fulltext: 'true' })).toBe('readable');
+ expect(fromObj({ has_fulltext: 'true', public_scan: 'false' })).toBe('borrowable');
+ expect(fromObj({ public_scan: 'true' })).toBe('open');
+ });
+
+ test('falls back to the default when nothing matches', () => {
+ expect(fromObj({})).toBe('all');
+ });
+});
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000000..ba5d5e6c13d
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,8 @@
+version = 1
+revision = 3
+requires-python = ">=3.14.5, <3.14.6"
+
+[[package]]
+name = "openlibrary"
+version = "1.0.0"
+source = { virtual = "." }