@luigisbox/luma 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ before 1.0, a minor version may break the integration API.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.7.1 — 2026-11-02
9
+
10
+ ### Fixed
11
+
12
+ - iOS Safari: swiping Back from a product to a listing showed a blank page
13
+ for a moment; the swipe now shows the listing.
14
+
15
+ ## 0.7.0 — 2026-10-29
16
+
17
+ ### Added
18
+
19
+ - `variants: { fields }` on a listing (search, plp; plp inherits it): each
20
+ result gets its variant group as `r.variants` (`siblings`, `total`) from
21
+ Luigi's Box. One request per page of results, never waited for: the
22
+ tiles are drawn again when it arrives. Off by default, built only where
23
+ a folder's `_config.ts` sets it.
24
+ - Light-DOM autocomplete full screen on a phone: `autocomplete.fullScreen`
25
+ (`true`, or a media query such as `'(max-width: 767px)'`). A tap on the
26
+ site's input opens it over the whole screen, with the integration's own
27
+ markup and CSS. Its template renders its own input, marked
28
+ `data-lbx-full-screen-input`, and gets `fullScreen`, `state`, `tabs`,
29
+ `value`, `back()` and `clear()`. Its CSS places it, keyed on
30
+ `[data-lbx-full-screen]`, with `--lbx-top` and `--lbx-cover` from Luma.
31
+ Off by default.
32
+
33
+ ### Changed
34
+
35
+ - The listing reads the result set's key from `request_key` (`guid` is its
36
+ deprecated name).
37
+ - Autocomplete templates: `sheet` is now `fullScreen`, and the full-screen
38
+ input's marker `data-lbx-sheet-input` (in an `autocomplete-sheet`
39
+ override) is now `data-lbx-full-screen-input`. Rename both in templates
40
+ copied from 0.6.0.
41
+
42
+ ### Fixed
43
+
44
+ - Full-screen autocomplete on a phone: focus leaving it for the page behind
45
+ (Tab past its last control, iOS's next-field arrow) closes it. Focus was
46
+ left on a control it hid.
47
+
8
48
  ## 0.6.0 — 2026-10-20
9
49
 
10
50
  ### Changed
package/README.md CHANGED
@@ -10,9 +10,9 @@ This package is what an integration is built with. Each integration lives in
10
10
  its own repository and depends on `@luigisbox/luma`
11
11
  ([Integration repository](#integration-repository)).
12
12
 
13
- > **Pre-release.** Until 1.0, a minor version (0.7.0) may change the
13
+ > **Pre-release.** Until 1.0, a minor version (0.8.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.6.1) only fixes. Depend on `^0.6.0` to take fixes without breaking
15
+ > (0.7.2) only fixes. Depend on `^0.7.1` to take fixes without breaking
16
16
  > changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
17
17
 
18
18
  ## Core principles
@@ -391,6 +391,59 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
391
391
  and twice.
392
392
  - **Pagination** is "show more" only: templates get `results`, `more` and
393
393
  `showMore`; there are no numbered pages.
394
+ - **Variant groups** (`variants`, a listing's config, off by default): where
395
+ the surface collapses a product's variants into one result (one tile for a
396
+ shoe in eight sizes), `r.variants` holds the group's other products
397
+ (`siblings`, as results, at most 100) and its size with this one
398
+ (`total`). Luma asks Luigi's Box for them once per page of results, as
399
+ soon as the page is here, and draws the tiles again when they arrive: a
400
+ tile shows without them first (`r.variants` is undefined), and stays so if
401
+ the request fails; a listing restored on Back has them at once. Keep the
402
+ tile's box the same with and without them (a reserved row, an overlay, or
403
+ shown on hover and focus, `:hover, :focus-within`), or the rows below move
404
+ when they arrive; draw them inside the tile's own markup, not a different
405
+ template branch. `fields` names what each sibling carries; keep it to what
406
+ the tile shows.
407
+
408
+ ```ts
409
+ // integration/search/_config.ts (plp inherits it): the product's own size in `fields` too
410
+ export default { /* … */ fields: ['@title', 'web_url', 'size'], variants: { fields: ['size'] } };
411
+ // integration/_global/_init.ts: the sizes in stock, the product's and its siblings'
412
+ export default (config) => {
413
+ config._global.computedAttributes = {
414
+ ...config._global.computedAttributes,
415
+ sizes: (r) => [...new Set([r.a.size, ...(r.variants?.siblings ?? []).map((s) => s.a.size)].flat().filter(Boolean))],
416
+ };
417
+ };
418
+ ```
419
+ - **A light-DOM autocomplete full screen on a phone**
420
+ (`autocomplete.dom: 'light'` with `fullScreen: true`, or a media query of
421
+ the site's own: `fullScreen: '(max-width: 767px)'`): a tap on the site's
422
+ input opens the autocomplete over the whole screen instead of a dropdown
423
+ under the input, and `autocomplete-box` gets `fullScreen: true`
424
+ (`state: 'idle'` until a phrase is long enough). The template renders its
425
+ own input, marked `data-lbx-full-screen-input` (`.value=${s.value}`): Luma
426
+ moves the focus into it and makes what is typed there the query and the
427
+ site's input's value. `back()` closes it (as Escape and the browser's
428
+ Back do) and `clear()` empties its input; `tabs` are one per type with
429
+ results. Keep the input one element in every state (not drawn anew for
430
+ results), and the tabs `role="tab"` siblings in their `role="tablist"`:
431
+ the arrow keys move between them. The arrow keys go through the options
432
+ it shows (the open tab's, whether the others are not drawn or hidden by
433
+ its CSS). While it is open the dropdown carries `data-lbx-full-screen` (a
434
+ phone in landscape too), and the integration's CSS places it full screen
435
+ there: `[data-lbx-full-screen] { inset: 0; top: var(--lbx-top, 0px) }`
436
+ (Luma sets `--lbx-top` to the visual viewport's top, which iOS moves when
437
+ it pans the page for the keyboard, and `--lbx-cover` to what Safari's
438
+ bars and the keyboard cover of its bottom: end the scrolling list with
439
+ that much space and give it `scroll-padding-bottom: var(--lbx-cover, 0px)`,
440
+ so its last options are not hidden). Focus leaving it for the page it
441
+ covers closes it. Without `fullScreen`, a light-DOM dropdown on a phone
442
+ stays a dropdown. The build includes this only where a file of the
443
+ integration's repository (a script or `.html` file, not in `node_modules`,
444
+ `dist` or a hidden folder) has `data-lbx-full-screen-input`. The house
445
+ design is always full screen on a phone; its templates get the same
446
+ `fullScreen`.
394
447
  - **No results under filters**: the scope still holds the selected groups,
395
448
  their `chips` and `clear`. A template that leaves the facets out when
396
449
  `total` is 0 must keep them (or at least `clear`) while `chips.length` is
@@ -411,7 +464,9 @@ On by default unless noted; each falls back to what the page would do anyway.
411
464
  `emptyValues`, `prerenderWhileTyping` and `contentVisibility`, off by
412
465
  default, are in the build only where a file of the integration's repository
413
466
  (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) names them, as `_config.ts` or `_init.ts` does when
414
- it sets them; a switch on by default that no file names is built as on.
467
+ it sets them; `variants` only where a folder's `_config.ts` sets it (set it
468
+ there, not in `_init.ts`); a switch on by default that no file names is
469
+ built as on.
415
470
 
416
471
  | Config | What it does | Turn it off when |
417
472
  |---|---|---|
@@ -419,9 +474,10 @@ it sets them; a switch on by default that no file names is built as on.
419
474
  | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
420
475
  | `search.pageWhileTyping` (`'prefetch'`, `'prerender'`, `false`) | the search page of the typed phrase is fetched ahead (Chrome, Edge; Firefox and Safari as they ship it), so Enter from another page skips the site's server render; `'prerender'` renders it ahead as well (Chrome, Edge) | the site cannot take a document request per typed phrase; `'prerender'` only where its tags wait for `prerenderingchange` |
421
476
  | `search.queryUrl` (an `_init.ts` hook, off unless set) | where the site's server redirects its search form to the query's page (`/search?q=x` to `/search/x/`), names that page: Enter, the autocomplete's "show all" and a picked query load it directly, without the redirect, and the result fetched while typing is shown there | the form's other fields decide the page, or the site must handle every search itself |
422
- | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
477
+ | `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page (not while the page is in the browser's back/forward cache, which restores it from memory), unless the site set it | |
423
478
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
424
479
  | `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
480
+ | `variants: { fields }` (opt-in) | each tile's result gets its variant group (`r.variants`, "Templates" above): one request to Luigi's Box per page of results, never waited for | the surface does not collapse variants (every group is a group of one) |
425
481
  | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus; in a drawer with `facetsApply: 'staged'`, the selection it holds is fetched once the changes pause, so its apply button shows the results without waiting | |
426
482
  | `_global.speculation` | product pages are prerendered on hover (`'prerender'`, `'prefetch'`, `false`) | the site's server or its product-page scripts can't take a render per unfollowed hover |
427
483
 
package/build/vite.mjs CHANGED
@@ -562,11 +562,12 @@ export const featureDefines = (integrationDir) => {
562
562
  __LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
563
563
  __LBX_TRANSLATIONS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_translations.ts')))),
564
564
  __LBX_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_init.ts')))),
565
- // Only an integration's templates make a cart button, a filter drawer or a menu (Luma's own have none).
565
+ // Only an integration's templates make a cart button, a filter drawer, a menu or a light-DOM phone sheet's input (Luma's own have none; its house sheet is not light DOM).
566
566
  __LBX_CART__: has(() => inFiles('data-lbx-cart')),
567
567
  __LBX_DRAWER__: has(() => inFiles('data-lbx-drawer')),
568
568
  __LBX_MENU__: has(() => inFiles('data-lbx-menu')),
569
569
  __LBX_DROPDOWN__: has(() => inFiles('data-lbx-dropdown')),
570
+ __LBX_LIGHT_FULL_SCREEN__: has(() => inFiles('data-lbx-full-screen-input')),
570
571
  // Hooks only the integration's code sets (_init.ts, _widget.ts: functions, never in _config.ts).
571
572
  __LBX_AC_HOOKS__: has(() => inFiles('onOpen', 'onClose', 'onSelect', 'onReady')),
572
573
  __LBX_ON_RENDER__: has(() => inFiles('onRender')),
@@ -577,6 +578,8 @@ export const featureDefines = (integrationDir) => {
577
578
  __LBX_EMPTY_VALUES__: has(() => inFiles('emptyValues')),
578
579
  __LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
579
580
  __LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
581
+ // A listing's variant groups: the key in a folder's _config.ts (the word alone is common: a catalog field, a comment).
582
+ __LBX_VARIANTS__: has((d) => d.some((f) => /\bvariants\b["']?\s*[:,}]/.test(existsSync(resolve(integrationDir, f, '_config.ts')) ? readFileSync(resolve(integrationDir, f, '_config.ts'), 'utf8') : ''))),
580
583
  // Options on by default: one no file names is on.
581
584
  ...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
582
585
  };
@@ -139,7 +139,13 @@ guessed alt text, and forced colours and the OS setting are honoured.
139
139
  through the list puts the on-screen keyboard away: focus moves from the
140
140
  input to the sheet (`tabindex="-1"`), Escape still closes it, a tap on the
141
141
  input brings the keyboard back; Tab is the browser's; targets are 44 px; Escape and the
142
- browser's Back close it.
142
+ browser's Back close it, and so does focus leaving it for the page it
143
+ covers (Tab past its last control, Shift+Tab from its first, iOS's
144
+ next-field arrow): it is never left on a control the sheet hides (2.4.11).
145
+ A light-DOM integration's full-screen autocomplete (`fullScreen`) gets the same focus, keys,
146
+ `aria-activedescendant` on its input and Back; a swipe moves the focus to
147
+ the host (`tabindex="-1"`, `role="group"` named as the input); its roles,
148
+ names and targets are its template's (§5).
143
149
  - **Forced colours**: the selection in `Highlight`/`HighlightText` with an
144
150
  outline, the panel (and the sheet) keep a `CanvasText` border, pills,
145
151
  chips and "Show all products" an outline.
@@ -164,6 +170,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
164
170
  link marked `tabindex="-1"`); a render that removed the focused control (a
165
171
  chip, the last "show more") moves it to the region, not `<body>`. A mouse
166
172
  click keeps focus where it is.
173
+ - **Variant groups landing** (`variants`): the tiles are drawn again, in
174
+ steps, with nothing announced and `aria-busy` untouched (the results did
175
+ not change); focus stays where it is, and in a tile whose template drew
176
+ other markup with its group, goes to the region, not `<body>`.
167
177
  - **Menus** (`[data-lbx-menu]` with `[data-lbx-toggle]`, the integration's
168
178
  markup): `aria-expanded` on the menu's button (the toggle with
169
179
  `aria-haspopup`/`aria-expanded`, else the first toggle) in every state;
@@ -330,7 +340,20 @@ drawer. The markup is the integration's, so:
330
340
  `aria-selected`, `tabindex="-1"`; sections as `role="group"` named by their
331
341
  heading; wrappers in between `role="presentation"`; nothing but options and
332
342
  groups inside the listbox (a pointer-only "show all" duplicate of Enter:
333
- `aria-hidden="true"`, no tab stop). Decorative pictures `alt=""`.
343
+ `aria-hidden="true"`, no tab stop). Decorative pictures `alt=""`. Full screen on a
344
+ phone (`fullScreen`): the input marked `data-lbx-full-screen-input` is the combobox
345
+ (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`,
346
+ `aria-controls` naming the listbox, a name: `inputLabel`); its back and
347
+ clear controls are buttons with names (`t('closeSearch')`, `t('clear')`)
348
+ calling `back()` and `clear()`, 44 px; type tabs a `tablist` of
349
+ `role="tab"` buttons with `aria-selected` (Luma moves between them with the
350
+ arrow keys: siblings in the tablist; the arrows go through the options
351
+ it shows, the open tab's); the input one element in every state
352
+ (drawn anew, it loses the focus and the keyboard); the input's font at
353
+ least 16 px (iOS zooms into a smaller one); the scrolling list ends with
354
+ `var(--lbx-cover)` of space and has it as `scroll-padding-bottom`, so an
355
+ option the keys bring into view is not under the keyboard or Safari's
356
+ bars (2.4.11).
334
357
  - **Listings**: filter groups as `fieldset` + `legend` (or `role="group"`
335
358
  with a name); native checkboxes, or links with `role="checkbox"` and
336
359
  `aria-checked` (never `aria-checked` on a plain link); chips named by what
@@ -345,7 +368,11 @@ drawer. The markup is the integration's, so:
345
368
  a `role="button"` div (Luma adds the keys) but a `<button>` is better.
346
369
  - **Tiles**: one link name (the picture `alt=""` when the title is in the same
347
370
  link); a struck-through old price says so (`<span class="lbx-sr-only">Was
348
- </span>`), and the current one "Now" next to it.
371
+ </span>`), and the current one "Now" next to it. A variant group
372
+ (`r.variants`) is drawn inside the tile's own markup, not a different
373
+ template branch (focus in the tile stays on its link); shown on hover, it
374
+ shows on focus too (`:hover, :focus-within`), and sibling links have a
375
+ name and 24 px targets.
349
376
  - **Colour**: text at 4.5:1, states not by colour alone, 24 px targets; adopt
350
377
  the high-contrast mode (§4) for the colours your CSS sets.
351
378
  - **Don't** add `aria-live` to counts (Luma announces), set `tabindex="0"` on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/api/ai.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  // Also runs in Node (the edge render service): there the
9
9
  // storefront origin is sent explicitly and nothing touches the DOM.
10
10
 
11
- import { PLP, RECOMMENDER, SEARCH } from '../core/features';
11
+ import { PLP, RECOMMENDER, SEARCH, VARIANTS } from '../core/features';
12
12
  import { health } from '../core/health';
13
13
  import type { GlobalConfigType } from '../types';
14
- import type { Api, FacetData, Hit, Page } from '.';
14
+ import type { Api, FacetData, Hit, Page, VariantGroup } from '.';
15
15
  import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './token';
16
16
 
17
17
  export { untilAborted };
@@ -78,7 +78,8 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
78
78
  return_fields: r.fields?.length ? r.fields : undefined,
79
79
  });
80
80
  const j = await post(r.endpoint, { channel_id: g.channelId, surface_id: r.surface }, body, signal);
81
- return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
81
+ // `guid` is the deprecated name of `request_key` (it will change meaning).
82
+ return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.request_key ?? j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
82
83
  },
83
84
 
84
85
  // A listing's and a recommender's (core/features.ts): not in a build without one.
@@ -106,5 +107,13 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
106
107
  )
107
108
  ).hits ?? []
108
109
  : unbuilt,
110
+
111
+ // A listing's `variants` (core/features.ts): not in a build whose integration names none.
112
+ ...(VARIANTS
113
+ ? {
114
+ variants: async (ids: string[], key: string, fields: string[] | undefined, signal?: AbortSignal): Promise<Record<string, VariantGroup>> =>
115
+ (await post('variants', { channel_id: g.channelId }, compact({ ids, request_key: key, return_fields: fields?.length ? fields : undefined }), signal)).variants ?? {},
116
+ }
117
+ : {}),
109
118
  };
110
119
  };
package/src/api/index.ts CHANGED
@@ -26,7 +26,7 @@ export interface Page {
26
26
  hits: Hit[];
27
27
  /** Exact when known, else the API's approximation. */
28
28
  total: number;
29
- /** Identifies the result set; /facets takes it. */
29
+ /** Identifies the result set (the API's `request_key`); /facets and /variants take it. */
30
30
  guid: string;
31
31
  /** Cursor of the next page; null when there is none (see status). */
32
32
  cursor: string | null;
@@ -68,11 +68,21 @@ export interface RecoRequest {
68
68
  fields?: string[];
69
69
  }
70
70
 
71
+ /** A product's variant group (/variants): the other members, and the group's size with the product. */
72
+ export interface VariantGroup {
73
+ /** The other members of the group, by `@id`; at most 100. */
74
+ siblings: Hit[];
75
+ /** The group's size, the product included (0: not in the catalog). */
76
+ total: number;
77
+ }
78
+
71
79
  export interface Api {
72
80
  search(r: ListingRequest, signal?: AbortSignal): Promise<Page>;
73
81
  /** Facets of a result set (its guid): values, no counts. */
74
82
  facets(guid: string, facets: { field: string; size?: number }[], signal?: AbortSignal): Promise<Record<string, FacetData>>;
75
83
  recommend(r: RecoRequest, signal?: AbortSignal): Promise<Hit[]>;
84
+ /** The variant groups of products of a result set (its key: `guid`), by `@id`: at most 200 ids. Built only for a listing's `variants`. */
85
+ variants?(ids: string[], key: string, fields: string[] | undefined, signal?: AbortSignal): Promise<Record<string, VariantGroup>>;
76
86
  /** Mint the browser token now, so the first request does not wait for it. */
77
87
  warm(): void;
78
88
  }
@@ -18,6 +18,7 @@ declare const __LBX_CART__: boolean | undefined;
18
18
  declare const __LBX_DRAWER__: boolean | undefined;
19
19
  declare const __LBX_MENU__: boolean | undefined;
20
20
  declare const __LBX_DROPDOWN__: boolean | undefined;
21
+ declare const __LBX_LIGHT_FULL_SCREEN__: boolean | undefined;
21
22
  declare const __LBX_AC_HOOKS__: boolean | undefined;
22
23
  declare const __LBX_ON_RENDER__: boolean | undefined;
23
24
  declare const __LBX_READ_QUERY__: boolean | undefined;
@@ -29,6 +30,7 @@ declare const __LBX_INITS__: boolean | undefined;
29
30
  declare const __LBX_EMPTY_VALUES__: boolean | undefined;
30
31
  declare const __LBX_PRERENDER_TYPING__: boolean | undefined;
31
32
  declare const __LBX_CONTENT_VISIBILITY__: boolean | undefined;
33
+ declare const __LBX_VARIANTS__: boolean | undefined;
32
34
  declare const __LBX_NAMED_PRECONNECT_IMAGES__: boolean | undefined;
33
35
  declare const __LBX_NAMED_PREFETCH_FILTERS__: boolean | undefined;
34
36
  declare const __LBX_NAMED_PRELOAD__: boolean | undefined;
@@ -60,6 +62,8 @@ export const DRAWER = typeof __LBX_DRAWER__ === 'boolean' ? __LBX_DRAWER__ : tru
60
62
  export const MENU = typeof __LBX_MENU__ === 'boolean' ? __LBX_MENU__ : true;
61
63
  /** `data-lbx-dropdown` in any file of the integration: `<details>` menus in the listing's markup, closed from outside. */
62
64
  export const DROPDOWN = typeof __LBX_DROPDOWN__ === 'boolean' ? __LBX_DROPDOWN__ : true;
65
+ /** `data-lbx-full-screen-input` in any file of the integration: a light-DOM autocomplete's own phone sheet (widgets/autocomplete, `fullScreen`). */
66
+ export const LIGHT_FULL_SCREEN = typeof __LBX_LIGHT_FULL_SCREEN__ === 'boolean' ? __LBX_LIGHT_FULL_SCREEN__ : true;
63
67
  /** The autocomplete's `onOpen`, `onClose`, `onSelect`, `onReady` named in the integration's code. */
64
68
  export const AC_HOOKS = typeof __LBX_AC_HOOKS__ === 'boolean' ? __LBX_AC_HOOKS__ : true;
65
69
  /** A listing's `onRender` named in the integration's code. */
@@ -81,10 +85,13 @@ export const INITS = typeof __LBX_INITS__ === 'boolean' ? __LBX_INITS__ : true;
81
85
  * or code setting them): a key no file names keeps its default, as only
82
86
  * code naming it can change it. `emptyValues`, `prerenderWhileTyping` (or
83
87
  * `pageWhileTyping`: prerendering the search page typed for), `contentVisibility`.
88
+ * `variants` (a listing's products' variant groups, widgets/listing/variants.ts):
89
+ * the key in a folder's _config.ts only (the word alone is common).
84
90
  */
85
91
  export const EMPTY_VALUES = typeof __LBX_EMPTY_VALUES__ === 'boolean' ? __LBX_EMPTY_VALUES__ : true;
86
92
  export const PRERENDER_TYPING = typeof __LBX_PRERENDER_TYPING__ === 'boolean' ? __LBX_PRERENDER_TYPING__ : true;
87
93
  export const CONTENT_VISIBILITY = typeof __LBX_CONTENT_VISIBILITY__ === 'boolean' ? __LBX_CONTENT_VISIBILITY__ : true;
94
+ export const VARIANTS = typeof __LBX_VARIANTS__ === 'boolean' ? __LBX_VARIANTS__ : true;
88
95
  /**
89
96
  * Server-rendered listings (Luigi's Box's server-side includes): adopting
90
97
  * and hydrating the server's markup. Declared, not found in the folders:
@@ -5,7 +5,7 @@
5
5
  // templates, translations and helpers the bundle would.
6
6
 
7
7
  import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
8
- import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS } from './features';
8
+ import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS, VARIANTS } from './features';
9
9
 
10
10
  export type Lazy<T> = Record<string, () => Promise<T>>;
11
11
 
@@ -106,9 +106,14 @@ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'init
106
106
  if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
107
107
  const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
108
108
  if (INITS) for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
109
+ // `variants` comes into a build from a folder's _config.ts only (build/vite.mjs
110
+ // featureDefines): set by an _init.ts, a build would leave it out unsaid.
111
+ if (import.meta.env.DEV && (config.search?.variants || config.plp?.variants) && !Object.values(files.configs).some((v: any) => v && typeof v === 'object' && 'variants' in v))
112
+ console.error("[lbx] variants: set it in a folder's _config.ts; a build leaves it out otherwise");
109
113
  // Anything a category page does not set is the search page's.
110
114
  const plp = PLP ? config.plp : null;
111
- if (plp) for (const k of LISTING) if (plp[k] == null && config.search?.[k] != null) (plp as any)[k] = config.search[k];
115
+ // `variants` only where the integration names it (core/features.ts): not a key of every entry.
116
+ if (plp) for (const k of VARIANTS ? [...LISTING, 'variants' as const] : LISTING) if (plp[k] == null && config.search?.[k] != null) (plp as any)[k] = config.search[k];
112
117
  if (SEARCH || PLP) for (const l of [config.search, config.plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
113
118
  return config;
114
119
  };
package/src/types.ts CHANGED
@@ -16,6 +16,14 @@ export interface Result {
16
16
  type: string;
17
17
  a: Hit;
18
18
  c: Record<string, any>;
19
+ /**
20
+ * A listing's `variants`: the other products of this one's variant group
21
+ * (collapsed into it by the surface), as results (`siblings`, by `@id`, at
22
+ * most 100), and the group's size with this one (`total`). Undefined until
23
+ * Luigi's Box has answered (the tile is drawn again then), on the server,
24
+ * and outside listings.
25
+ */
26
+ variants?: { siblings: Result[]; total: number };
19
27
  }
20
28
 
21
29
  /**
@@ -237,6 +245,23 @@ export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType
237
245
  dom?: 'shadow' | 'light';
238
246
  hostClass?: string;
239
247
  hostId?: string;
248
+ /**
249
+ * `dom: 'light'`: the autocomplete full screen on a phone, with the
250
+ * integration's own markup. `true` where the house design is full screen
251
+ * (a viewport up to 600 px, or a short touch screen), or a media query of
252
+ * the site's own (`'(max-width: 767px)'`). There, focus or a tap on the
253
+ * site's input opens it with what the input holds (templates get
254
+ * `fullScreen: true`, `state: 'idle'` until a phrase is long enough); the
255
+ * template renders its own input, marked `data-lbx-full-screen-input`,
256
+ * which takes the focus and is the combobox there.
257
+ * The integration's CSS places it, keyed on `[data-lbx-full-screen]` while open
258
+ * (`inset: 0; top: var(--lbx-top, 0px)`): Luma sets no box on it, only `--lbx-top` (the
259
+ * visual viewport's top) and `--lbx-cover` (what Safari's bars and the
260
+ * keyboard cover of its bottom: the list's end space and
261
+ * `scroll-padding-bottom`). Off by default: a light-DOM dropdown
262
+ * on a phone stays a dropdown. The house design is always full screen there.
263
+ */
264
+ fullScreen?: boolean | string;
240
265
  /**
241
266
  * What the dropdown lines up with (its left edge and width, just below it).
242
267
  * By default the visible search field: the input when it draws its own
@@ -297,6 +322,21 @@ export interface ListingConfigType {
297
322
  sorts: string[];
298
323
  /** return_fields for the tiles (default: the surface's). */
299
324
  fields?: string[];
325
+ /**
326
+ * The variant groups of the products shown, for a surface that collapses
327
+ * them (one result per group: a shoe in eight sizes is one tile): each
328
+ * tile's result gets the group's other products as `r.variants` (their
329
+ * `fields`, e.g. the in-stock sizes on hover or focus). Asked from Luigi's
330
+ * Box once per page of results, as soon as it is here (a page restored on
331
+ * Back has them with it): the tiles show without them first and are drawn
332
+ * again when they come, never waited for; without them (an error) they
333
+ * stay as they are. A tile keeps its box with and without them (a reserved
334
+ * row, an overlay, on hover or focus): a tile growing when they come moves
335
+ * the rows below. `fields`: return_fields of the group's products (keep it
336
+ * to what the tiles show). Off by default; set it in `_config.ts` (the
337
+ * build includes it only where a folder's `_config.ts` does).
338
+ */
339
+ variants?: { fields: string[] };
300
340
  /** Always-on filters (e.g. what this storefront may show), AND-ed with the shopper's. */
301
341
  baseFilters?: FilterNode;
302
342
  /**
@@ -766,11 +806,24 @@ export interface AutocompleteScope extends Helpers {
766
806
  * input reaches it (the options stay out of the Tab order).
767
807
  */
768
808
  allHref: string;
769
- // --- The house design (dom: 'shadow'). -------
770
- /** 'idle' (the phone sheet before a phrase is long enough), 'results', 'empty' (every type answered nothing). */
809
+ /** 'idle' (full screen, before a phrase is long enough), 'results', 'empty' (every type answered nothing). */
771
810
  state: 'idle' | 'results' | 'empty';
772
- /** The phone sheet (full screen, viewport up to 600 px) rather than the panel under the input. */
773
- sheet: boolean;
811
+ /** Full screen on a phone (a light-DOM integration's with the config's `fullScreen`) rather than the dropdown under the input. */
812
+ fullScreen: boolean;
813
+ /** Full screen: the tabs, one per type with results (products first); `select()` shows it. The arrow keys go through the open tab's options. */
814
+ tabs: { kind: string; label: string; selected: boolean; select: () => void }[];
815
+ /**
816
+ * Full screen: its own input (`data-lbx-full-screen-input`): its value, placeholder
817
+ * and name (the site's input's); `clear()` empties it (focus stays in it);
818
+ * `back()` closes it as Escape and the browser's Back do, the site's
819
+ * input back to what it held when it opened.
820
+ */
821
+ value: string;
822
+ placeholder: string;
823
+ inputLabel: string;
824
+ clear: () => void;
825
+ back: () => void;
826
+ // --- The house design (dom: 'shadow'). -------
774
827
  /** The panel is at least 1000 px wide: the top product has its own column. */
775
828
  wide: boolean;
776
829
  /** Query suggestions (pills). */
@@ -781,8 +834,6 @@ export interface AutocompleteScope extends Helpers {
781
834
  hero: { r: Result; i: number } | null;
782
835
  /** The products after it (at most 6 on the panel). */
783
836
  products: { r: Result; i: number }[];
784
- /** The phone sheet's tabs: one per type with results (products first); `select()` shows it. */
785
- tabs: { kind: string; label: string; selected: boolean; select: () => void }[];
786
837
  /** "No results": phrases to try (options), links to browse (options), fallback products (options), the help box. */
787
838
  empty: { tries: { r: Result; i: number }[]; browse: { r: Result; i: number }[]; fallback: { r: Result; i: number }[]; help: { title: string; phone: string; tel: string; hours: string } | null };
788
839
  /** Pointer over an option: it becomes the selection (the keyboard's too). */
@@ -796,12 +847,6 @@ export interface AutocompleteScope extends Helpers {
796
847
  field: (r: Result, name: 'availability' | 'category' | 'oldPrice' | 'meta' | 'price' | 'badge') => string;
797
848
  /** The top product's spec rows (`hero.specFields`, at most 4). */
798
849
  specs: (r: Result) => { label: string; value: string }[];
799
- /** The phone sheet: its input's value, placeholder and name (the site's input's), clearing it, and its back button. */
800
- value: string;
801
- placeholder: string;
802
- inputLabel: string;
803
- clear: () => void;
804
- back: () => void;
805
850
  }
806
851
  export interface AutocompleteResultScope extends AutocompleteScope {
807
852
  r: Result;