Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.
Every widget renders through named templates. Each has a built-in version; a file named
template-<name>.ts in your integration replaces it. Listings use templates in your own
markup and CSS classes, so results look like the rest of your site.
Which file wins
Section titled “Which file wins”For each template name, the last one found is used:
- The widget’s built-in template
integration/_global/template-<name>.ts- The widget’s own folder —
integration/search/,integration/autocomplete/, and so on
Category pages use search/ templates, overridden by any in plp/. A _global/ template
named after a widget (template-recommender-…) applies to that widget only.
| Widget | Template names |
|---|---|
| Search results, category pages | search-box, search-header, search-facets-default, search-range-default, search-results-default, search-result-default, search-pagination |
| Autocomplete | autocomplete-box, autocomplete-result-default, autocomplete-sheet, autocomplete-empty |
| Recommenders | recommender-box-default, recommender-result-default, recommender-popup |
Any other name is your own template, rendered from another with include().
Writing one
Section titled “Writing one”A template is lit-html: a default-exported
function of its scope that returns html`…`.
import { html, nothing } from '@/lit';import type { SearchResultScope } from '@/types';
export default ({ r, i, img, price }: SearchResultScope) => html` <a class="product-tile" href=${r.c.url}> ${img(r, i)} <span class="product-tile__name">${r.c.title}</span> ${r.c.price ? html`<b class="product-tile__price">${price(r.c.price)}</b>` : nothing} </a>`;Import lit from @/lit and scope types from @/types. A re-render changes only the
bindings whose value changed, so images, focus and open <details> survive it. Templates
need no eval and work under a strict Content Security Policy.
| Syntax | |
|---|---|
${expr} | Text, escaped |
${cond ? html`…` : nothing} | A condition |
${repeat(list, (x) => x.id, (x) => html`…`)} | A keyed list; list.map(…) for an unkeyed one |
attr=${v}, ?disabled=${b}, .checked=${b} | Attribute, boolean attribute, property |
@click=${s.showMore} | Event handler |
${include('product-tile', { badge })} | Another template, with this scope plus the given properties |
@/lit also exports svg, classMap, styleMap, ifDefined and unsafeHTML.
A template must depend on its scope by value. Luma reuses a result tile’s output while the values it read are unchanged, so an object changed in place, rather than replaced, can keep showing the old value.
Scopes
Section titled “Scopes”| Template | Scope type | Holds |
|---|---|---|
search-* | SearchScope | query, title, total, results, facets, ranges, flags, chips, sorts, more, showMore, and the actions on them |
search-result-default | SearchResultScope | The above, plus r (the result) and i (its position, from 0) |
search-range-default | SearchRangeScope | The above, plus range (one numeric facet) |
autocomplete-* | AutocompleteScope | query, groups, byType(), active, choose(), allHref |
autocomplete-result-default | AutocompleteResultScope | The above, plus r and i |
recommender-* | RecommenderScope | results, and close() in the popup |
recommender-result-default | RecommenderResultScope | The above, plus r and i |
Every field is documented on its type in @/types. The ones a listing template binds most:
| Field | |
|---|---|
facets | Text facets. Each value carries label, checked, href and rank (its place in the API’s order, most frequent first). There are no counts per value. |
ranges | Numeric facets: min, max, from, to, step, and set(from, to) to apply one. Call set when the shopper lets go, not on every move. |
chips | Active filters, each with the link that removes it |
clear | A link that removes every filter, or null |
sorts | Each with label, selected and href |
more, showMore | Whether another page exists, and the handler that appends it |
A control that changes the listing is a link: put its href on an <a>, or as
data-lbx-href on any other element, and bind go(link) as the click handler. Luma then
prefetches the state the control leads to when the shopper hovers or focuses it.
Pagination
Section titled “Pagination”Listings use “show more” only. results holds every page loaded so far; showMore appends
the next one. Bind it on <button data-lbx-more @click=${s.showMore}>: from the keyboard,
focus moves to the first new result. There are no numbered pages, and “show more” does not
change the URL.
Results
Section titled “Results”A result r has:
r.id | Its @id, for example product/sku-1001 |
r.type | Its @type |
r.a | The fields the API returned, as returned — see Fields |
r.c | Computed attributes |
The built-in computed attributes:
| Read from | |
|---|---|
r.c.title | @title |
r.c.url | url, else web_url |
r.c.image | lbx:image, else image_url, else image_link |
r.c.price | lbx:price, else price |
Add or replace them in _global/_widget.ts (every widget) or a widget’s own _widget.ts:
import type { LumaConfigType } from '@/types';
export default (c: LumaConfigType) => { c._global.computedAttributes = { onSale: (r) => Number(r.a.list_price) > Number(r.a.price), };};Helpers
Section titled “Helpers”In every scope:
| Helper | |
|---|---|
t(key, params) | Translated text. The widget’s own keys first (search.sort), then the bare key. |
price(n) | A number as a price in the site’s locale and currency; empty for a missing price |
plain(v) | A field’s text — its first value when it is a list — for attributes and alt text |
first(v) | The first value of a list, or the value itself |
include(name, props) | Another template, with this scope plus props |
img(r, i, opts) | A complete <img> in a placeholder box |
image(r, i, opts) | The same image’s attributes, for your own markup — a <picture>, your own classes |
Images
Section titled “Images”img() and image() build every attribute of a product image from images in
_global/_config.ts:
images: { srcset: { match: '^(https://images\\.example\\.com/.+\\.jpg)$', pattern: '$1?w={w}', widths: [240, 480, 960] }, sizes: '(min-width: 1200px) 240px, (min-width: 768px) 30vw, 50vw', width: 400, height: 400, firstRow: { '0': 2, '768': 3, '1200': 4 },},| Key | Default | |
|---|---|---|
srcset | — | How to build srcset from the image URL: match, a regular expression on it; then either pattern with {w} for each of widths (an image resizer), or candidates, a map of descriptor to replacement ({ "240w": "/thumb_$1", "480w": "/$1" }). A URL that does not match gets no srcset. |
sizes | — | The sizes attribute: the tile image’s rendered width per viewport |
width, height | 400 × 400 | The image box’s size. It reserves the space before the image loads, so nothing shifts. |
firstRow | { "0": 2, "768": 3, "1200": 4 } | Images in the first row load eagerly at high priority and are preloaded; the rest load lazily. A number, or columns per viewport min-width. |
placeholder | Neutral skeleton | Catalog fields holding a dominant colour (color) or a tiny image URL (image) to show in the box while the image loads. false for none. |
preload | true | Preload the first row’s images |
fade | true | Lazy images fade in, except under reduced motion |
warm | 0 | For results fetched ahead of a click, load the first N images at low priority |
opts on img() and image() overrides one image: src, sizes, eager, alt,
class, title.
Styles
Section titled “Styles”A widget’s styles go in style-<name>.css in its folder and load with the widget. Luma’s
built-in CSS sits in @layer lbx, so a rule of yours without a layer always wins over it.
style-host.css is different: it hides your own UI that the widget replaces, and applies
only while Luma’s is shown. Target only elements of the page it replaces — on a category
page, the listing, not the breadcrumbs.
Accessibility
Section titled “Accessibility”Luma’s built-in templates meet WCAG 2.2 AA: combobox semantics on the autocomplete input,
labelled controls, managed focus in dialogs, aria-busy while a listing renders. A template
override takes on that responsibility for its own markup:
- Use real
<a>and<button>elements for controls. - Label inputs — the facet search field with
t('facetSearch', { facet }). - Bind facet group toggles as
<button aria-expanded=${open} aria-controls=${id}>. - Give images an
alt—plain(r.c.title)if the catalog has nothing better.
yarn luma a11y checks an integration’s widgets, by keyboard too — see
Tools and debugging.
See also
Section titled “See also”- The integration folder — where templates sit
- Fields — choosing what the API returns for
r.a
Was this page helpful?
Thanks.