@luigisbox/luma 0.2.0 → 0.4.0

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/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.3.0) may change the
13
+ > **Pre-release.** Until 1.0, a minor version (0.5.0) may change the
14
14
  > integration API (config keys, template scope, the build); a patch version
15
- > (0.2.1) only fixes. Depend on `^0.2.0` to take fixes without breaking
15
+ > (0.4.1) only fixes. Depend on `^0.4.0` to take fixes without breaking
16
16
  > changes.
17
17
 
18
18
  ## Core principles
@@ -331,6 +331,11 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
331
331
  `_global.images` (srcset, sizes, the eager first row, placeholders).
332
332
  - **Pagination** is "show more" only: templates get `results`, `more` and
333
333
  `showMore`; there are no numbered pages.
334
+ - **No results under filters**: the scope still holds the selected groups,
335
+ their `chips` and `clear`. A template that leaves the facets out when
336
+ `total` is 0 must keep them (or at least `clear`) while `chips.length` is
337
+ not 0: otherwise, where no drawer or chips show elsewhere, Back is the
338
+ shopper's only way to undo the filter.
334
339
  - **Translations** are JSON dictionaries per locale (`src/locales/<locale>.json`,
335
340
  `{name}` placeholders, plural forms by `Intl.PluralRules` category); only the
336
341
  site's locale is loaded. Override keys in `_translations.ts`, keyed by locale:
@@ -348,7 +353,7 @@ On by default unless noted; each falls back to what the page would do anyway.
348
353
  |---|---|---|
349
354
  | `search.inPlaceQuery` | a new query on the search page renders in place, without a page load | the host's page-view analytics or server-rendered parts of the search page must follow every query |
350
355
  | `search.prefetchWhileTyping` | the search result is fetched once the typed phrase settles, so Enter shows it at once | |
351
- | `restoreOnBack` | Back from a product restores the listing and the followed tile's position | |
356
+ | `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 | |
352
357
  | `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
353
358
  | `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 |
354
359
  | `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
@@ -400,8 +405,8 @@ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
400
405
  | Event | What `.read()` returns |
401
406
  |---|---|
402
407
  | `pageView` | the page's reference, or `null` for a page without one. On `lumaPages()`, one pageview per page, sent at once. On any other trigger, one per page that waits for its reference from that trigger (`.timeout(ms)`, default 1000 ms) and is sent without one after that. One `pageView` line per file |
403
- | `click` | a product click: a reference, or `{ reference }` |
404
- | `addToCart` | `{ reference, count?, price? }`, or a list of them |
408
+ | `click` | a product click: a reference, or `{ reference, attributedToList? }` |
409
+ | `addToCart` | `{ reference, count?, price?, attributedToList? }`, or a list of them |
405
410
  | `purchase` | `{ id?, currency?, items: [{ reference, title, count, total_price, was_discounted? }] }`; sent once per `id`, so a reloaded thank-you page does not count twice |
406
411
  | `search`, `autocomplete`, `recommender(name)` | a list from the site's own service: `{ query?, items, filters? }`. Not sent on a page where Luma runs that service itself (the API records Luma's own results) |
407
412
 
@@ -423,12 +428,26 @@ collect.purchase.on(dl('purchase')).read(fromGa4.purchase);
423
428
 
424
429
  | Reader | Reads |
425
430
  |---|---|
426
- | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id` |
427
- | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount` |
431
+ | `fromLuma.pageView`, `.click`, `.addToCart` | what Luma's own triggers saw (`lumaPages()`, `lumaClicks()`, `lumaCarts()`). Products are referenced by their catalog `@id`. Clicks and add-to-carts are in a list (`attributedToList: true`) |
432
+ | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 ecommerce: `add_to_cart`, `purchase`, `select_item`, `view_item`, `view_item_list`. The data is taken from the push's `ecommerce` (or `gtag()`'s parameters), or from the push itself when its `items` are there. Items are referenced by `ref.product(item_id)`; purchases get line totals, and `was_discounted` from `discount`. `select_item` is a pick from a list (`attributedToList: true`); `add_to_cart` is not |
428
433
 
429
434
  Each reader belongs to one event: `collect.purchase…read(fromGa4.addToCart)`
430
435
  is a type error, and throws when the file runs.
431
436
 
437
+ **Clicks and add-to-carts in a list.** Luigi's Box credits a click or an
438
+ add-to-cart to the last search, suggestions, recommendation or listing the
439
+ shopper saw the product in only when the event says it happened in a list:
440
+ `attributedToList: true` from `.read()`, sent as `attributed_to_list: true`.
441
+ A click without it (a bare reference) counts for the product, never for a
442
+ list, so a click on a product page's own link is not credited to a search the
443
+ shopper made earlier. Luma's own tiles always say it (`fromLuma.click`,
444
+ `fromLuma.addToCart`, autocomplete picks included), and so does GA4's
445
+ `select_item` (`fromGa4.click`). A click on the site's own list, read with a
446
+ function of yours, says it when the element is one of that list's results:
447
+ return `{ reference, attributedToList: true }` (below). The list itself must
448
+ be known too: Luma's own results are; the site's own are when the setup
449
+ collects them (`collect.search`, `.autocomplete`, `.recommender(name)`).
450
+
432
451
  **References** follow `<type>/<field>:<value>`
433
452
  ([docs](https://docs.luigisbox.ai/analytics/references/)).
434
453
  `ref.product(value)` uses the field set by `ref.product.by(field)` (default
@@ -468,8 +487,8 @@ collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
468
487
  query: { string: new URLSearchParams(location.search).get('q') ?? '' },
469
488
  items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({ title: tile.textContent ?? '', type: 'product', reference: ref.product(tile.getAttribute('data-sku')!), position: n + 1 })),
470
489
  }));
471
- // clicks on those results
472
- collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
490
+ // clicks on those results: in that list
491
+ collect.click.on(domClick('#results [data-sku] a')).read(({ element }) => ({ reference: ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!), attributedToList: true }));
473
492
  // the site's suggestions, from its own API
474
493
  collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({ items: /* … */ [] }));
475
494
  // the product page's product, from GA4's view_item
@@ -106,8 +106,13 @@ guessed alt text, and forced colours and the OS setting are honoured.
106
106
  they are not results; the help box's phone link is a plain link. "Show all
107
107
  products" is a real link (`href`: the
108
108
  search page the site's form would submit to) and the open dropdown's one
109
- tab stop: Tab from the input reaches it, Shift+Tab goes back, Escape closes
110
- and goes back, Tab moves on to the page's next control after the input.
109
+ tab stop: Tab from the input reaches it (the options' selection cleared:
110
+ one thing lit), Shift+Tab goes back, Escape closes and goes back, Tab moves
111
+ on to the page's next control after the input. It is also the arrow keys'
112
+ last stop after the options (Arrow Up from the input reaches it first),
113
+ lit as when focused and referenced as the active descendant (it is in the
114
+ combobox's popup; in our shadow root, said by the status region instead),
115
+ Enter its search; focus stays in the input.
111
116
  - **Status messages**: "Suggestions: N" or "No results for “…”", in a polite
112
117
  `role="status"` region inside the dropdown, once typing pauses (700 ms), and
113
118
  only when it changes within an opening (not per keystroke).
@@ -123,7 +128,7 @@ guessed alt text, and forced colours and the OS setting are honoured.
123
128
  never a faint colour alone: an accent fill (pills), or the tint with an
124
129
  accent ring (cards) or edge (rows). Tokens an integration sets
125
130
  (`autocomplete.tokens`, its CSS) are its own to keep at 4.5:1 (`yarn luma a11y`).
126
- - **The phone sheet** (house design, viewport up to 600 px): its own input is
131
+ - **The phone sheet** (house design, viewport up to 600 px, or a short touch screen: a phone in landscape): its own input is
127
132
  the combobox (`aria-controls` the listboxes on screen,
128
133
  `aria-activedescendant` the selected option: the same shadow root); the
129
134
  site's input is left as it was. The back button is named "Close search",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luigisbox/luma",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
@@ -26,6 +26,14 @@ export interface AddToCart {
26
26
  reference: string;
27
27
  count?: number;
28
28
  price?: number;
29
+ /** Added in a list (a tile's cart button): the platform credits it to that list. */
30
+ attributedToList?: boolean;
31
+ }
32
+ /** One click: what `collect.click` sends (a bare reference: not in a list). */
33
+ export interface Click {
34
+ reference: string;
35
+ /** Picked in a list (a search, suggestions, a recommender, a listing): the platform credits it to that list. */
36
+ attributedToList?: boolean;
29
37
  }
30
38
  /** A purchase: what `collect.purchase` sends, once per `id`. */
31
39
  export interface Purchase {
@@ -42,7 +50,7 @@ export interface List {
42
50
  /** What `.read()` returns, per event (null or undefined: nothing sent). */
43
51
  export interface Reads {
44
52
  pageView: string | null | undefined;
45
- click: string | { reference: string };
53
+ click: string | Click;
46
54
  addToCart: AddToCart | AddToCart[];
47
55
  purchase: Purchase;
48
56
  search: List;
@@ -3,9 +3,13 @@
3
3
  // - pageView: `pv` per page (./pageview.ts);
4
4
  // - search, autocomplete, recommender(name): a `list` event of the site's own
5
5
  // service (`{ query?, items, filters? }`; ours the API records itself);
6
- // - click: an `interaction` click (a reference, or `{ reference }`);
7
- // - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price? }` or a list);
6
+ // - click: an `interaction` click (a reference, or `{ reference, attributedToList? }`);
7
+ // - addToCart: `interaction` add_to_cart per item (`{ reference, count?, price?, attributedToList? }` or a list);
8
8
  // - purchase: a `transaction` (`{ id?, currency?, items }`), once per id.
9
+ // `attributedToList`: the product was picked in a list (a search, suggestions,
10
+ // a recommender, a listing): sent as `attributed_to_list: true`, the only
11
+ // interactions the platform credits to the last list in the session that
12
+ // held the product. Without it the interaction counts for the product alone.
9
13
 
10
14
  import { emit } from './outbox';
11
15
  import { state } from './setup';
@@ -26,24 +30,27 @@ export const autocomplete = /* @__PURE__ */ surface<'autocomplete'>('autocomplet
26
30
  /** The site's own recommender `name` (silent where ours of that name runs): a `list` event. */
27
31
  export const recommender = (name: string) => surface<'recommender'>(`recommender:${name}`, list('Recommendation'));
28
32
 
29
- /** A product click: the reference (or `{ reference }`). */
33
+ /** `attributed_to_list: true` where the product was picked in a list (only then: absent is false). */
34
+ const inList = (x: { attributedToList?: boolean } | undefined) => (x?.attributedToList === true ? { attributed_to_list: true } : {});
35
+
36
+ /** A product click: the reference (or `{ reference, attributedToList? }`). */
30
37
  export const click = /* @__PURE__ */ surface<'click'>('click', (p) => {
31
- const reference = typeof p === 'string' ? p : p?.reference;
32
- if (isRef(reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference });
38
+ const o = typeof p === 'string' ? { reference: p } : p;
39
+ if (isRef(o?.reference)) emit({ type: 'interaction', interaction_type: 'click', listing_ab_test: false, reference: o.reference, ...inList(o) });
33
40
  });
34
41
 
35
42
  const currency = () => state.g?.currency;
36
43
  /** When the last add-to-cart on our tiles was: the site's own push its cart then makes is the same one. */
37
44
  let ourCart = 0;
38
45
  const OUR_CART_MS = 10_000;
39
- /** Add-to-cart: `{ reference, count?, price? }` or a list of them. */
46
+ /** Add-to-cart: `{ reference, count?, price?, attributedToList? }` or a list of them. */
40
47
  export const addToCart = /* @__PURE__ */ surface<'addToCart'>('addToCart', (p) => {
41
48
  const all: (AddToCart & { [OURS]?: true })[] = Array.isArray(p) ? p : [p];
42
49
  if (all.some((x) => x?.[OURS])) ourCart = Date.now();
43
50
  else if (Date.now() - ourCart < OUR_CART_MS) return void (ourCart = 0);
44
51
  for (const x of all) {
45
52
  if (!isRef(x?.reference)) continue;
46
- emit({ type: 'interaction', interaction_type: 'add_to_cart', listing_ab_test: false, reference: x.reference, ...(x.count ? { count: x.count } : {}), ...(x.price != null && x.price >= 0 ? { price: x.price } : {}), ...(currency() ? { currency: currency() } : {}) });
53
+ emit({ type: 'interaction', interaction_type: 'add_to_cart', listing_ab_test: false, reference: x.reference, ...(x.count ? { count: x.count } : {}), ...(x.price != null && x.price >= 0 ? { price: x.price } : {}), ...(currency() ? { currency: currency() } : {}), ...inList(x) });
47
54
  }
48
55
  });
49
56
 
@@ -56,8 +56,11 @@ export const purchase = /* @__PURE__ */ reader('purchase', (ctx: Ctx) => {
56
56
  });
57
57
  return items.length ? { id: e.transaction_id, currency: e.currency, items } : null;
58
58
  });
59
- /** select_item: the product clicked. */
60
- export const click = /* @__PURE__ */ reader('click', first);
59
+ /** select_item (GA4: an item picked from a list): the product clicked, in its list. */
60
+ export const click = /* @__PURE__ */ reader('click', (ctx: Ctx) => {
61
+ const reference = first(ctx);
62
+ return reference ? { reference, attributedToList: true } : null;
63
+ });
61
64
  /** view_item: the page's product. */
62
65
  export const pageView = /* @__PURE__ */ reader('pageView', first);
63
66
  /** view_item_list of their search: its items, the query from search_term. */
@@ -6,7 +6,9 @@
6
6
  // collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
7
7
  //
8
8
  // Our tiles' references are their results' `@id`s (every result has one),
9
- // whatever ref.product.by() says for the site's own data.
9
+ // whatever ref.product.by() says for the site's own data. Every tile of ours
10
+ // is in a list (a search, suggestions, a recommender, a listing): its clicks
11
+ // and add-to-carts are attributed to it.
10
12
 
11
13
  import type { TileEvent } from '../core/tiles';
12
14
  import { reader } from './chain';
@@ -15,7 +17,7 @@ import type { Page } from './page';
15
17
 
16
18
  /** A page: no reference of its own. */
17
19
  export const pageView = /* @__PURE__ */ reader('pageView', (_: Page) => null);
18
- /** A click on one of our tiles: the product clicked. */
19
- export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => t.reference);
20
+ /** A click on one of our tiles: the product clicked, in its list. */
21
+ export const click = /* @__PURE__ */ reader('click', (t: TileEvent) => ({ reference: t.reference, attributedToList: true }));
20
22
  /** `[data-lbx-cart]` on one of our tiles: the product, its quantity and price (the site's own push its cart then makes is the same one). */
21
- export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), [OURS]: true as const }));
23
+ export const addToCart = /* @__PURE__ */ reader('addToCart', (t: TileEvent) => ({ reference: t.reference, count: t.count ?? 1, ...(t.price != null ? { price: t.price } : {}), attributedToList: true, [OURS]: true as const }));
@@ -19,6 +19,6 @@ export { user } from './user';
19
19
  export { ref } from './ref';
20
20
  export { dl, domClick, lumaCarts, lumaClicks, lumaPages, manual, mutationOf, urlChange, xhrMatching } from './triggers';
21
21
  export type { DlContext, DomClickContext, ManualContext, MutationContext, UrlChangeContext, XhrContext } from './triggers';
22
- export type { AddToCart, List, Purchase, Read, Trigger } from './chain';
22
+ export type { AddToCart, Click, List, Purchase, Read, Trigger } from './chain';
23
23
  export type { Page } from './page';
24
24
  export type { TileEvent } from '../core/tiles';
@@ -35,9 +35,22 @@ export interface EarlyRoute {
35
35
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
36
  // reserveSelector: set by loadConfig (default: the first anchor).
37
37
 
38
- /** Only what the snippet can express: a path regex and a query param (no custom readQuery/active). */
39
- export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null =>
40
- c.urlMatch && c.queryParam && !c.readQuery && !c.active ? { id: 'search', path: c.urlMatch, need: [c.queryParam], css: holdCss(hostCss, c.reserveSelector!) } : null;
38
+ /** Capture groups in a regex source (its alternation with the empty string always matches). */
39
+ const groups = (re: string) => new RegExp(`${re}|`).exec('')!.length - 1;
40
+
41
+ /**
42
+ * Only what the snippet can express (no custom readQuery/active): a path
43
+ * regex and a query param, or a path regex whose capture group 1 is the query
44
+ * (/search/<q>/). The snippet cannot check that capture (the bundle decodes
45
+ * and trims it), so it also holds a blank one (/search/+/); the bundle
46
+ * rejects that URL and drops the unclaimed hold at boot, as for plpRule's `active`.
47
+ */
48
+ export const searchRule = (c: SearchConfigType, hostCss: string): EarlyRoute | null => {
49
+ if (!c.urlMatch || c.readQuery || c.active) return null;
50
+ const css = holdCss(hostCss, c.reserveSelector!);
51
+ if (c.queryParam) return { id: 'search', path: c.urlMatch, need: [c.queryParam], css };
52
+ return groups(c.urlMatch) ? { id: 'search', path: c.urlMatch, css } : null;
53
+ };
41
54
 
42
55
  /**
43
56
  * Param names the category route can express (values are checked by the bundle).
@@ -16,10 +16,14 @@ export const isOwnEntry = (href: string) => own.has(canon(href));
16
16
 
17
17
  /**
18
18
  * A history entry of ours that only stands for something open (the
19
- * autocomplete's phone sheet): Back closes it, and no router of the host's
20
- * hears that popstate (the URL is the same). `back` returns true when the
21
- * popstate was that entry's (set by the widget that pushed it).
19
+ * autocomplete's phone sheet, the listing's staged filter drawer): Back
20
+ * closes it, and no router of the host's hears that popstate (the URL is the
21
+ * same). Each handler returns true when the popstate was its entry's (set by
22
+ * the widget that pushed it). Returns the removal.
22
23
  */
23
- let back: (() => boolean) | null = null;
24
- export const onOwnBack = (f: () => boolean) => void (back = f);
25
- export const ownBack = () => !!back?.();
24
+ const backs = new Set<() => boolean>();
25
+ export const onOwnBack = (f: () => boolean) => (backs.add(f), () => void backs.delete(f));
26
+ export const ownBack = () => {
27
+ for (const f of backs) if (f()) return true;
28
+ return false;
29
+ };
package/src/core/stash.ts CHANGED
@@ -17,21 +17,41 @@ export const navigation = () => {
17
17
  }
18
18
  };
19
19
 
20
+ /**
21
+ * A listing left for another page, loaded again within this long by a reload
22
+ * or a navigation from itself (a server round trip: a "compare" or "add to
23
+ * cart" link that redirects back to the page it came from): the listing as
24
+ * left, as for Back.
25
+ */
26
+ export const ROUND_TRIP_MS = 60e3;
27
+
28
+ /** The page loaded from itself: its referrer is this page (a redirect back keeps the referrer of the page that started it). */
29
+ const fromItself = () => {
30
+ try {
31
+ return !!document.referrer && pageKey(document.referrer) === pageKey(location.href);
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+
20
37
  /**
21
38
  * Which load a stash kind is for: a typed query's page by a navigation (or a
22
39
  * prerender of it, `prerender` from its start through its activation), a
23
- * listing left for a product by back/forward.
40
+ * listing left for a product by back/forward, or by a reload or a navigation
41
+ * from itself within ROUND_TRIP_MS of leaving (`at`: when it was written).
24
42
  */
25
- export const stashFits = (kind: string) => {
43
+ export const stashFits = (kind: string, at = 0) => {
26
44
  const t = navigation().type;
27
- return kind === 'back' ? t === 'back_forward' : t === 'navigate' || t === 'prerender';
45
+ if (kind !== 'back') return t === 'navigate' || t === 'prerender';
46
+ if (t === 'back_forward') return true;
47
+ return Date.now() - at < ROUND_TRIP_MS && (t === 'reload' || (t === 'navigate' && fromItself()));
28
48
  };
29
49
 
30
50
  /** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
31
51
  export const hasStash = (href: string) => {
32
52
  try {
33
53
  const head = sessionStorage.getItem(stashKey(href))?.slice(0, 60);
34
- return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec');
54
+ return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
35
55
  } catch {
36
56
  return false;
37
57
  }
package/src/types.ts CHANGED
@@ -245,8 +245,17 @@ export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType
245
245
  * `'input'`: always the input. At least 360 px wide; on a phone (below
246
246
  * 480 px) a field spanning most of the screen exactly, else the screen's
247
247
  * width.
248
- */
249
- alignTo?: 'input' | string;
248
+ *
249
+ * An object sets the field and, for the house panel (`dom: 'shadow'`), the
250
+ * page's content column it stays within: `{ field?: 'input' | string;
251
+ * column?: 'viewport' | string }`. `column`: a selector (the field's
252
+ * nearest ancestor matching it, else the first element matching it), or
253
+ * `'viewport'` (the screen less 24 px each side). By default the panel
254
+ * stays within the field's outermost ancestor that is at least 1000 px
255
+ * wide and narrower than the screen (a centred page container), else the
256
+ * screen.
257
+ */
258
+ alignTo?: 'input' | string | { field?: 'input' | string; column?: 'viewport' | string };
250
259
  /** _init.ts hooks, for sites whose own code reacts to their dropdown opening, closing or a pick. */
251
260
  onOpen?: () => void;
252
261
  /** Why it closed: a pick, Enter / Escape in the input, focus leaving, a press outside, a route change, … */