Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2c0b6d1
feat: add mobile-friendly header search modal
lokesh May 9, 2026
2649af3
feat(search): add availability and language filters to header modal -…
Armansiddiqui9 May 23, 2026
bbcd387
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 23, 2026
0600a21
Availability option mapping resolved
Armansiddiqui9 May 23, 2026
afa2df1
docs(design): document ol-dialog and ol-options-popover components
lokesh May 27, 2026
e2d591d
i18n(search): localize header search modal + filter row strings
lokesh May 27, 2026
4a33f7e
fix(search): OR multiple language filter values instead of ANDing
lokesh May 27, 2026
ac18f7a
chore(search): remove legacy SearchBar and dead header CSS
lokesh May 28, 2026
85c69bd
feat(search): persist availability + language filters across modal & …
lokesh May 28, 2026
432c6d7
Merge remote-tracking branch 'upstream/master' into feat/search-avail…
lokesh May 28, 2026
d8a2a79
fix(search): keyboard focus trap + misc search-modal/filter polish
lokesh May 28, 2026
5956f22
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 28, 2026
7936751
feat(search): standalone barcode button + icon-only mobile pill
lokesh May 28, 2026
c03ba3b
refactor(lit): FocusableHostMixin for shadow-DOM focus trap discovery
lokesh May 28, 2026
082aca5
docs(ai): capture web-component, mobile, and i18n learnings from sear…
lokesh May 28, 2026
0a09bea
refactor(lit): centralize <ol-*> registration on ol-components.js
lokesh May 28, 2026
4a42212
fix(search): accept public_scan/print_disabled on /search.json
lokesh May 28, 2026
5320e6e
fix(search): align modal results with /search availability filter
lokesh May 28, 2026
945c43c
fix(search): restore 3-char autocomplete threshold and "the" skip
lokesh May 29, 2026
2face6e
Merge remote-tracking branch 'upstream/master' into feat/search-avail…
lokesh Jun 2, 2026
32b5675
fix(search): anchor negated editions.fq so "Borrowable" returns results
lokesh Jun 2, 2026
9f0e583
search: clarify availability filter labels, fix mislabeled filters
lokesh Jun 2, 2026
13a658a
feat(search): add OlAvailabilityFilter component for search availability
lokesh Jun 2, 2026
d2024cd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 2, 2026
0767571
feat(search): wrap header modal search field in an inset rounded box
lokesh Jun 2, 2026
aee45a5
fix(popover): soften mobile tray backdrop blur from 2px to 1px
lokesh Jun 2, 2026
4bc41ab
feat(search): surface author suggestions in the search modal
lokesh Jun 2, 2026
61cd50b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 2, 2026
271bad3
feat(search): show loading feedback when a modal result is pressed
lokesh Jun 2, 2026
718e9fa
style(availability-filter): dial back nested option label and icon
lokesh Jun 2, 2026
430dd21
docs(design): simplify options-popover demos away from the availabili…
lokesh Jun 2, 2026
8597066
fix(popover): correct mobile tray scrolling and swipe-dismiss reopen
lokesh Jun 2, 2026
484844f
style(search): hide search placeholder prompt on mobile
lokesh Jun 2, 2026
d38b6d5
fix(search): fix recent searches rendering and add unselected indicat…
Armansiddiqui9 Jun 4, 2026
17b70e5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ When creating PRs, use the template in `.github/pull_request_template.md` for th
These companion docs cover specific areas in depth:

- [CSS](css.md) — BEM naming, selector rules, tokens in practice, bundle sizes, CSS-to-template wiring
- [Design](design.md) — UI design patterns: typography, layout shift prevention, design tokens, animations
- [Web Component Standards](web-components.md) — When to build a component, Lit conventions, accessibility, events
- [Design](design.md) — UI design patterns: typography, layout shift prevention, design tokens, animations, mobile
- [Web Component Standards](web-components.md) — When to build a component, Lit conventions, accessibility, events, focus + shadow DOM
- [Internationalization](i18n.md) — `$_()` in templates, the `data-i18n` bridge for client-rendered strings

## Key File Locations

Expand Down
47 changes: 46 additions & 1 deletion docs/ai/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,49 @@ CSS custom properties inherit through the shadow boundary, so design tokens work
| Hover causes flicker | Animate child element, not parent |
| Popover scales from wrong point | Set `transform-origin` to trigger location |
| Sequential tooltips feel slow | Skip delay/animation after first tooltip |
| Hover triggers on mobile | Use `@media (hover: hover) and (pointer: fine)` |
| Hover triggers on mobile | Use `@media (hover: hover) and (pointer: fine)` — see [Mobile](#mobile) |

## Mobile

### Prevent iOS Safari auto-zoom on input focus

iOS Safari auto-zooms the viewport when the user focuses any text input with `font-size < 16px`. The page stays zoomed after the input blurs, which is jarring and breaks fixed-position layout. Fix: set `font-size: 16px` on every text input that can receive focus on mobile.

```css
.search-modal__input {
/* Visually 14px-feeling input, but 16px to dodge iOS auto-zoom. */
font-size: 16px;
}
```

If you need the input to look smaller, scale it visually rather than dropping below 16px (e.g., reduce padding, use `transform: scale()` only on non-text affordances).

### Gate hover styles to hover-capable pointers

Touch devices fire `:hover` on tap and the style sticks until the next tap elsewhere. That makes plain `:hover` rules feel broken on phones — buttons stay highlighted, tooltips linger.

Wrap hover styles in `@media (hover: hover) and (pointer: fine)` so they only apply on devices with a precise hover-capable pointer (mouse, trackpad):

```css
.chip {
background: var(--white);
}

@media (hover: hover) and (pointer: fine) {
.chip:hover {
background: var(--lightest-grey);
}
}
```

Use the same query to decide which affordance to render in markup. For example, the search modal shows a tappable close button on touch devices and an "ESC" pill on hover-capable pointers (where the keyboard is the expected dismiss path). Pick one or the other rather than showing both.

```css
.dismiss-touch { display: block; }
.dismiss-keyboard { display: none; }

@media (hover: hover) and (pointer: fine) {
.dismiss-touch { display: none; }
.dismiss-keyboard { display: block; }
}
```
118 changes: 118 additions & 0 deletions docs/ai/i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Internationalization

How to translate UI strings in Open Library. Server-rendered text uses Templetor's `$_()` directly. Client-rendered text (Lit components, vanilla JS UI, anything that builds DOM in the browser) needs the bridge pattern below.

## Server-rendered strings

In a `.html` template, wrap any user-visible string in `$_()`:

```html
<h2>$_("Recently added")</h2>
<button>$_("Borrow")</button>
```

Strings are extracted to `openlibrary/i18n/messages.pot` by the `generate-pot` pre-commit hook and translated per locale under `openlibrary/i18n/<lang>/`.

## Client-rendered strings: the `data-i18n` bridge

JavaScript modules cannot use Templetor's `$_()` — and the JS-side `ugettext` helper in this codebase is **only a pass-through**, not a real translation call. Strings hardcoded in JS ship in English everywhere.

The working pattern is to render translated strings server-side into a `data-i18n` attribute on the consuming element, then read them in JS at startup. The component module also exports English defaults that double as both the source for `.pot` extraction (via the partial) and the runtime fallback.

### 1. Write a `_i18n.html` partial that emits a JSON dict

Create a small template that returns a JSON object of translated strings. By convention these live next to the feature template and are named `<feature>_i18n.html`.

```html
$def with ()
$# Translated chrome strings for the header search modal (SearchModal.js).
$# Rendered into the trigger's `data-i18n-ui` attribute and read by
$# search-modal/constants.js (searchModalStringsFromElement). The keys and
$# English source strings here must match DEFAULT_SEARCH_MODAL_STRINGS in that
$# same file.
$ search_modal_i18n = {
$ "inputPlaceholder": _("Search books, authors…"),
$ "closeAria": _("Close search"),
$ "noResults": _("No results found"),
$ "removeFilter": _("Remove filter: %s"),
$ }
$json_encode(search_modal_i18n)
```

The leading comment is important: it tells the next contributor which JS file consumes the partial and which constant the English fallback lives in. Keep keys and English text in lockstep across the two files.

### 2. Drop the JSON onto the consuming element

In the parent template, render the partial into a `data-*` attribute on the element your JS module already targets (a trigger button, a container, etc.). Use `$:render_template` (the `$:` prefix is required to skip HTML-escaping of the JSON):

```html
<button
type="button"
class="search-bar-trigger"
aria-label="$_('Search')"
data-i18n="$:render_template('search/availability_i18n')"
data-i18n-ui="$:render_template('search/search_modal_i18n')"
></button>
```

When one element needs two independent payloads, use two attribute names (e.g., `data-i18n` for the option list, `data-i18n-ui` for chrome strings). Don't merge them — they usually correspond to two separate concerns with separate fallbacks.

### 3. Read and merge in JS, with English defaults as the fallback

Keep the canonical English strings in the JS module. They serve three purposes: source-of-truth for the partial's keys, runtime fallback if the attribute is missing or malformed, and what ships when the page is rendered in English.

```js
export const DEFAULT_SEARCH_MODAL_STRINGS = {
inputPlaceholder: 'Search books, authors…',
closeAria: 'Close search',
noResults: 'No results found',
removeFilter: 'Remove filter: %s',
};

export function searchModalStringsFromElement(el) {
let overrides = null;
try {
const raw = el?.dataset?.i18nUi;
if (raw) overrides = JSON.parse(raw);
} catch { /* fall through to defaults */ }
return overrides
? { ...DEFAULT_SEARCH_MODAL_STRINGS, ...overrides }
: DEFAULT_SEARCH_MODAL_STRINGS;
}
```

Spreading the overrides over the defaults means a partial translation (some keys present, others missing) never blanks out a string — the English shows through for any key the locale didn't translate.

Wrap the `JSON.parse` in `try`/`catch` and treat any failure as "fall back to defaults." Don't throw — a bad attribute on one element shouldn't take down the page.

### 4. Use the merged strings to build DOM

```js
import { searchModalStringsFromElement } from './constants.js';

const trigger = document.querySelector('.search-bar-trigger');
const strings = searchModalStringsFromElement(trigger);

input.placeholder = strings.inputPlaceholder;
closeButton.setAttribute('aria-label', strings.closeAria);
```

For strings with placeholders (`%s`), interpolate client-side with `sprintf` from the project's existing helpers — don't reinvent.

## Choosing where strings live

| String source | Where to put it |
| --- | --- |
| Visible text in a `.html` template | `$_("…")` directly in the template |
| Static text in a Lit component's `static styles` content (rare) | Pass in via attribute from the template, where `$_()` is available |
| Dynamic UI strings built in JS (labels, ARIA, status messages) | `_i18n.html` partial → `data-i18n*` attribute → JS reader (this doc) |
| Counts, numbers, prices | Format client-side; don't translate |

If a Lit component is the consumer, the `data-i18n*` attributes go on the host element (the custom-element tag), and the component's reader runs in its `connectedCallback` or constructor.

## Reference implementations

- `openlibrary/templates/search/availability_i18n.html` + `openlibrary/templates/search/search_modal_i18n.html` — the two partials
- `openlibrary/plugins/openlibrary/js/search-modal/constants.js` — `availabilityOptionsFromElement`, `searchModalStringsFromElement`, the English defaults
- `openlibrary/templates/lib/nav_head.html` — how the partials are wired onto the trigger button
- `tests/unit/js/searchModalConstants.test.js` — tests for the reader/merge logic
110 changes: 109 additions & 1 deletion docs/ai/web-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,117 @@ render() {
</ol-card>
```

## Registration

Register the component once at the bottom of its file:

```js
customElements.define('ol-my-widget', OlMyWidget);
```

**`ol-components.js` is the single registration site for every `<ol-*>` custom element.** It is built from `openlibrary/components/lit/index.js` (which re-exports every component, running each `define()` as a side effect) and loaded site-wide from `openlibrary/templates/site/footer.html`.

If you need to drive a Lit component from page JS that webpack bundles (e.g., the search-modal entrypoint), import the component's exported class only if you need the class identifier — and never as a bare side-effect import. Re-running `customElements.define()` from a second bundle throws `NotSupportedError: this name has already been used with this registry`, which surfaces as a blank page with no obvious cause. The component will already be registered by `ol-components.js` before any page-JS handler (jQuery `DOMContentLoaded`) runs.

## Focus and Shadow DOM

Shadow DOM breaks the assumptions most focus-management code makes. The helpers in `openlibrary/components/lit/utils/focus-utils.js` and `FocusableHostMixin` exist to handle the cases below — reach for them rather than rolling your own.

### Make custom elements visible to outer focus traps

A custom element whose only focusable content is a `<button>` inside its shadow root is **invisible** to a focus trap that calls `querySelectorAll(FOCUSABLE_SELECTOR)` on light DOM. Two problems compound:

1. The trap's selector can't see into the shadow root, so the element is skipped entirely.
2. Calling `host.focus()` focuses the *host*, not the inner button — `:focus-visible` lands on nothing.

Apply `FocusableHostMixin` (`openlibrary/components/lit/utils/focusable-host-mixin.js`) to fix both at once. It sets `tabindex="0"` on the host (so traps discover it) and `delegatesFocus: true` on the shadow root (so `host.focus()` forwards to the first focusable inside and `:focus-visible` fires correctly). Override `_focusTarget` if the desired target isn't the first focusable in DOM order.

```js
import { FocusableHostMixin } from './utils/focusable-host-mixin.js';

export class OlMyWidget extends FocusableHostMixin(LitElement) {
get _focusTarget() {
return this.shadowRoot?.querySelector('.default-trigger');
}
}
```

### Filter hidden elements from trap lists

Calling `.focus()` on a `display:none` or `visibility:hidden` element is a silent no-op. But `querySelectorAll(FOCUSABLE_SELECTOR)` still returns it, so the trap thinks focus moved when it didn't — Tab/Shift+Tab appear stuck on the previous element.

Use `el.checkVisibility({ visibilityProperty: true })` to filter (or `isFocusable()` from `focus-utils.js`, which wraps it). This bit us when a `display:none` close button in `SearchModal` kept jamming the dialog's focus trap.

### Walk shadow boundaries when reading active element

`document.activeElement` returns the *host*, not the deeply focused element inside a shadow root. When a trap needs to know "where is focus right now relative to my managed list?", use `getDeepActiveElement()` to drill in, then `findFocusableIndex()` to climb back out across shadow boundaries until it finds a host that the trap recognizes. Both are in `focus-utils.js`.

### Restore focus after Lit re-renders

When a `repeat` directive destroys a node — e.g., an item moves between two groups based on selected state, or a list re-sorts — the browser drops focus to `<body>`. Stash an identifying value, then refocus in `updated()` after the new node mounts:

```js
_onItemToggle(e) {
// Only restore if the checkbox actually owned focus at toggle time
if (this.shadowRoot?.activeElement === e.target) {
this._restoreFocusToValue = e.target.value;
}
this._emitChange(/* ... */);
}

updated(changedProperties) {
super.updated?.(changedProperties);
if (this._restoreFocusToValue !== null && changedProperties.has('selected')) {
const value = this._restoreFocusToValue;
this._restoreFocusToValue = null;
const target = this.shadowRoot?.querySelector(`[data-value="${value}"]`);
target?.focus({ preventScroll: true });
}
}
```

See `OlSelectPopover._onItemToggle` for the reference implementation.

## ARIA on lists

Putting a non-list role like `role="radiogroup"` directly on a `<ul>` **strips the list semantics**. The `<li>` children then become invalid in the accessibility tree (a `<li>` is only valid inside `<ul>`, `<ol>`, or `<menu>`), and accesslint will flag it.

Separate the roles: wrap the list in a `<div role="radiogroup">` and keep the `<ul>` pure.

```js
// Bad — strips list semantics
html`<ul role="radiogroup" aria-label=${label}>
${items.map(item => html`<li>...</li>`)}
</ul>`;

// Good — separate roles
html`<div role="radiogroup" aria-label=${label}>
<ul>${items.map(item => html`<li>...</li>`)}</ul>
</div>`;
```

Related: whitespace inside `<ul>` template literals creates real text nodes that accesslint flags as direct text content inside a list. Keep `<li>` flush against the opening `<ul>` tag — no leading newline.

## Autofocus on mobile

Don't auto-focus a text input when a component opens on a mobile breakpoint — the soft keyboard pops up and shrinks the visible panel area to nothing. Gate the focus call:

```js
_onPopoverOpen() {
if (!window.matchMedia('(max-width: 767px)').matches) {
this.shadowRoot.querySelector('.filter-input')?.focus();
}
}
```

767px matches the breakpoint that `ol-popover` uses to switch into its mobile tray layout — stay consistent with that so behavior matches what the user sees.

(Inputs in this component should also use `font-size: 16px` to prevent iOS Safari's auto-zoom on focus — see [design.md](design.md#mobile).)

## New Component Checklist

1. Create a file in `openlibrary/components/lit/` named after the class (e.g., `OlMyWidget.js`).
2. Register the component by adding an export to `openlibrary/components/lit/index.js`.
3. Add JSDoc to the class with `@element`, `@prop`, `@fires`, and `@example` tags (see `OlPagination.js` for the pattern).
4. Build with `npm run watch:lit-components` and verify the component renders at http://localhost:8080.
4. Guard the `customElements.define()` call with `if (!customElements.get('ol-my-widget'))` — see [Registration](#registration).
5. Build with `npm run watch:lit-components` and verify the component renders at http://localhost:8080.
Loading