@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 +28 -9
- package/docs/ACCESSIBILITY.md +8 -3
- package/package.json +1 -1
- package/src/analytics/chain.ts +9 -1
- package/src/analytics/collect.ts +14 -7
- package/src/analytics/from-ga4.ts +5 -2
- package/src/analytics/from-luma.ts +6 -4
- package/src/analytics/index.ts +1 -1
- package/src/core/early-rules.ts +16 -3
- package/src/core/entries.ts +10 -6
- package/src/core/stash.ts +24 -4
- package/src/types.ts +11 -2
- package/src/widgets/autocomplete/index.ts +186 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +209 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
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.
|
|
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.
|
|
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
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -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
|
|
110
|
-
|
|
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.
|
|
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",
|
package/src/analytics/chain.ts
CHANGED
|
@@ -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 |
|
|
53
|
+
click: string | Click;
|
|
46
54
|
addToCart: AddToCart | AddToCart[];
|
|
47
55
|
purchase: Purchase;
|
|
48
56
|
search: List;
|
package/src/analytics/collect.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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',
|
|
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 }));
|
package/src/analytics/index.ts
CHANGED
|
@@ -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';
|
package/src/core/early-rules.ts
CHANGED
|
@@ -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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
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).
|
package/src/core/entries.ts
CHANGED
|
@@ -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
|
|
20
|
-
* hears that popstate (the URL is the
|
|
21
|
-
* popstate was
|
|
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
|
-
|
|
24
|
-
export const onOwnBack = (f: () => boolean) =>
|
|
25
|
-
export const ownBack = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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, … */
|