@luigisbox/luma 0.4.0 → 0.5.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/CHANGELOG.md +118 -0
- package/README.md +75 -8
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +253 -20
- package/docs/ACCESSIBILITY.md +9 -2
- package/package.json +3 -2
- package/src/analytics/chain.ts +1 -1
- package/src/analytics/pageview.ts +1 -1
- package/src/analytics/start.ts +3 -4
- package/src/analytics/wire.ts +1 -1
- package/src/api/ai.ts +32 -20
- package/src/api/token.ts +7 -7
- package/src/core/a11y.ts +6 -35
- package/src/core/context.ts +2 -0
- package/src/core/contrast-toggle.ts +32 -0
- package/src/core/default-button.ts +66 -0
- package/src/core/early-rules.ts +3 -2
- package/src/core/features.ts +109 -0
- package/src/core/frameworks-check.ts +32 -0
- package/src/core/frameworks.ts +35 -0
- package/src/core/image-load.ts +0 -26
- package/src/core/image-preload.ts +29 -0
- package/src/core/image-tags.ts +7 -1
- package/src/core/images.ts +3 -2
- package/src/core/integration.ts +42 -26
- package/src/core/listings.ts +9 -5
- package/src/core/locales.ts +2 -1
- package/src/core/own.ts +7 -3
- package/src/core/recommenders.ts +2 -2
- package/src/core/reconciler.ts +32 -23
- package/src/core/routes.ts +7 -6
- package/src/core/scheduler.ts +39 -5
- package/src/core/speculation.ts +2 -0
- package/src/core/stash.ts +15 -6
- package/src/core/styles.ts +6 -5
- package/src/core/support.ts +1 -1
- package/src/core/takeover.ts +6 -5
- package/src/core/templates.ts +5 -2
- package/src/core/tiles.ts +33 -10
- package/src/core/url.ts +6 -3
- package/src/core/widget-inits.ts +4 -1
- package/src/lit.ts +2 -1
- package/src/locales/cs.json +0 -1
- package/src/locales/de.json +0 -1
- package/src/locales/en.json +0 -1
- package/src/main.ts +70 -53
- package/src/types.ts +29 -9
- package/src/widgets/autocomplete/index.ts +58 -19
- package/src/widgets/listing/element.ts +81 -58
- package/src/widgets/listing/query.ts +6 -3
- package/src/widgets/listing/stash.ts +33 -16
- package/src/widgets/listing/state.ts +12 -0
- package/src/widgets/listing/view.ts +2 -1
- package/src/widgets/plp/route.ts +3 -2
- package/src/widgets/recommender/index.ts +3 -2
- package/src/widgets/search/direct.ts +39 -0
- package/src/widgets/search/form-url.ts +38 -34
- package/src/widgets/search/in-place.ts +43 -108
- package/src/widgets/search/index.ts +7 -2
- package/src/widgets/search/speculate.ts +48 -27
- package/src/widgets/search/submit.ts +136 -0
package/src/core/scheduler.ts
CHANGED
|
@@ -28,12 +28,36 @@ let framesRunning = false;
|
|
|
28
28
|
const firstPaintDone = () =>
|
|
29
29
|
framesRunning || (framesRunning = performance.getEntriesByType('paint').length > 0 || document.readyState === 'complete');
|
|
30
30
|
|
|
31
|
+
/** Longest wait for a frame: past it the reconcile runs in a task (afterFrame too, once frames are stalled). */
|
|
32
|
+
const FRAME_FALLBACK_MS = 100;
|
|
33
|
+
/** A frame asked for this long ago that has not come: frames are stalled (WebKit on a busy machine: none for ~5 s). */
|
|
34
|
+
const STALL_MS = 250;
|
|
35
|
+
let asked = 0; // our requestAnimationFrame callbacks not yet run
|
|
36
|
+
let askedAt = 0; // when the earliest of them was asked for, or the last frame of ours ran
|
|
37
|
+
/** requestAnimationFrame, counted: whether a frame is overdue is known to every caller. */
|
|
38
|
+
const frame = (cb: () => void) => {
|
|
39
|
+
if (!asked++) askedAt = performance.now();
|
|
40
|
+
requestAnimationFrame(() => {
|
|
41
|
+
asked--;
|
|
42
|
+
askedAt = performance.now(); // a frame came: the asks still waiting start the clock anew
|
|
43
|
+
cb();
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const framesStalled = () => asked > 0 && performance.now() - askedAt > STALL_MS;
|
|
47
|
+
|
|
31
48
|
export const schedule = () => {
|
|
32
49
|
if (pending) return;
|
|
33
50
|
pending = true;
|
|
34
51
|
// rAF does not run in background tabs or before the first paint; use a timeout there.
|
|
35
|
-
if (document.visibilityState
|
|
36
|
-
|
|
52
|
+
if (document.visibilityState !== 'visible' || !firstPaintDone()) return void setTimeout(flush, document.visibilityState === 'visible' ? 16 : 50);
|
|
53
|
+
// A visible page can still run no frames for seconds while its timers run
|
|
54
|
+
// (WebKit on a busy 2-CPU machine: ~5 s after typing). Waiting for one
|
|
55
|
+
// then outlasted the hold's timeout: their listing came back, ours stayed
|
|
56
|
+
// hidden. Whichever comes first, once.
|
|
57
|
+
let done = false;
|
|
58
|
+
const run = () => !done && ((done = true), flush());
|
|
59
|
+
frame(run);
|
|
60
|
+
setTimeout(run, FRAME_FALLBACK_MS);
|
|
37
61
|
};
|
|
38
62
|
|
|
39
63
|
export const scheduleIn = (ms: number) => setTimeout(schedule, ms);
|
|
@@ -43,7 +67,11 @@ export const scheduleIn = (ms: number) => setTimeout(schedule, ms);
|
|
|
43
67
|
* are clean then, so reading computed styles or sizes costs only what we read,
|
|
44
68
|
* not a style pass over the whole (host) document. Where no frames run (a
|
|
45
69
|
* hidden tab, before the first paint) in a task of its own; a tab hidden
|
|
46
|
-
* meanwhile (rAF waits until it is shown) by a timeout
|
|
70
|
+
* meanwhile (rAF waits until it is shown) by a timeout: 500 ms, so a slow
|
|
71
|
+
* frame (a large page at 4x CPU) still comes first and the read costs no
|
|
72
|
+
* forced style pass; FRAME_FALLBACK_MS once frames are stalled (none for
|
|
73
|
+
* STALL_MS while asked for: WebKit ran none for ~5 s on a busy machine, and a
|
|
74
|
+
* listing's first render, one step per frame, showed nothing for 2.5 s then).
|
|
47
75
|
*/
|
|
48
76
|
export const afterFrame = (f: Task) => {
|
|
49
77
|
if (document.visibilityState !== 'visible') {
|
|
@@ -57,8 +85,14 @@ export const afterFrame = (f: Task) => {
|
|
|
57
85
|
const run = () => !done && ((done = true), f());
|
|
58
86
|
// Ahead of the page's own queued tasks where the browser can (Chrome's scheduler).
|
|
59
87
|
const post = (globalThis as any).scheduler?.postTask;
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
frame(() => (post ? post.call((globalThis as any).scheduler, run, { priority: 'user-blocking' }) : setTimeout(run, 0)));
|
|
89
|
+
const t0 = performance.now();
|
|
90
|
+
const tick = () => {
|
|
91
|
+
if (done) return;
|
|
92
|
+
if (framesStalled() || performance.now() - t0 >= 500) run();
|
|
93
|
+
else setTimeout(tick, FRAME_FALLBACK_MS);
|
|
94
|
+
};
|
|
95
|
+
setTimeout(tick, FRAME_FALLBACK_MS);
|
|
62
96
|
};
|
|
63
97
|
|
|
64
98
|
let pendingNow = false;
|
package/src/core/speculation.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// activation (core/takeover.ts).
|
|
19
19
|
|
|
20
20
|
import { mark } from './health';
|
|
21
|
+
import { cspNonce } from './styles';
|
|
21
22
|
import type { GlobalConfigType } from '../types';
|
|
22
23
|
|
|
23
24
|
const LINKS = ':is(lbx-search, lbx-plp) [data-lbx-list] a[href], lbx-reco a[href], [data-lbx="ac-overlay"] a[href]';
|
|
@@ -49,6 +50,7 @@ export const speculate = (g: GlobalConfigType, onUrlChange: (f: () => void) => u
|
|
|
49
50
|
el = document.createElement('script');
|
|
50
51
|
el.type = 'speculationrules';
|
|
51
52
|
el.setAttribute('data-lbx', 'speculation');
|
|
53
|
+
el.nonce = cspNonce(); // a strict script-src applies to inline speculation rules
|
|
52
54
|
el.textContent = JSON.stringify(rules);
|
|
53
55
|
document.head.append(el);
|
|
54
56
|
};
|
package/src/core/stash.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// A listing result kept in sessionStorage for the next page load
|
|
2
|
-
// (widgets/listing/stash.ts reads and writes them)
|
|
1
|
+
// A listing result kept in sessionStorage for the next page load that shows
|
|
2
|
+
// it (widgets/listing/stash.ts reads and writes them): a listing left for
|
|
3
|
+
// Back, under its page's URL; a typed query's search page, under the query
|
|
4
|
+
// (the URL its form submits to is often not the one its page loads at: a
|
|
5
|
+
// server that redirects `/search?q=x` to `/search/x/`). The entry only asks
|
|
3
6
|
// whether this load can use one: a search page then does not send its own
|
|
4
7
|
// request before the listing's chunk has read it.
|
|
5
8
|
import { pageKey } from './page';
|
|
6
9
|
|
|
7
10
|
export const STASH = 'lbx:r:';
|
|
11
|
+
/** A listing left for Back: its page. */
|
|
8
12
|
export const stashKey = (href: string) => STASH + pageKey(href);
|
|
13
|
+
/** A typed query's result (`spec`): the query, on its route, whatever URL its page loads at. */
|
|
14
|
+
export const specKey = (route: string, q: string) => `${STASH}spec:${route}:${q}`;
|
|
9
15
|
|
|
10
16
|
/** How this document was loaded, and its URL (a later SPA visit is not the load a stash is for). */
|
|
11
17
|
export const navigation = () => {
|
|
@@ -47,11 +53,14 @@ export const stashFits = (kind: string, at = 0) => {
|
|
|
47
53
|
return Date.now() - at < ROUND_TRIP_MS && (t === 'reload' || (t === 'navigate' && fromItself()));
|
|
48
54
|
};
|
|
49
55
|
|
|
50
|
-
/** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
|
|
51
|
-
export const hasStash = (href: string) => {
|
|
56
|
+
/** A stash this load will likely use (the listing checks it fully): the page's for Back, or the search query's (`spec`). Reads only the start of the entry. */
|
|
57
|
+
export const hasStash = (href: string, spec?: { route: string; q: string }) => {
|
|
52
58
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
59
|
+
const fits = (key: string) => {
|
|
60
|
+
const head = sessionStorage.getItem(key)?.slice(0, 60);
|
|
61
|
+
return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
|
|
62
|
+
};
|
|
63
|
+
return fits(stashKey(href)) || (!!spec?.q && fits(specKey(spec.route, spec.q)));
|
|
55
64
|
} catch {
|
|
56
65
|
return false;
|
|
57
66
|
}
|
package/src/core/styles.ts
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
// route-scoped. CSS survives host re-renders, DOM mutations would not.
|
|
14
14
|
// Every sheet is put back when a host re-render drops it (onFlush).
|
|
15
15
|
|
|
16
|
+
import { DOCUMENT_REACT } from './frameworks';
|
|
16
17
|
import { documentMayHydrate, hasFiber, watchOwnRemovals } from './own';
|
|
17
18
|
import { onFlush } from './scheduler';
|
|
18
19
|
|
|
19
|
-
// While <head> is still parsing, on every page
|
|
20
|
-
//
|
|
21
|
-
// puts ours in before anything of the body paints.
|
|
22
|
-
const deferred = () => !document.body;
|
|
20
|
+
// While <head> is still parsing, on every page that may be whole-document
|
|
21
|
+
// React (DOCUMENT_REACT): it announces itself only at the end of <body>, and
|
|
22
|
+
// the body-start observer puts ours in before anything of the body paints.
|
|
23
|
+
const deferred = () => (DOCUMENT_REACT ? !document.body : false);
|
|
23
24
|
|
|
24
25
|
const sheets = new Map<string, HTMLStyleElement>();
|
|
25
26
|
|
|
@@ -38,7 +39,7 @@ const declareLayer = () => {
|
|
|
38
39
|
const head = document.head || document.documentElement;
|
|
39
40
|
// React has it once it has claimed <head>, or <html> when its root renders
|
|
40
41
|
// no <head> of its own (Next's app router: Next fills it): hydrated past it.
|
|
41
|
-
const early = (document.readyState === 'loading' || documentMayHydrate()) && !hasFiber(head) && !hasFiber(document.documentElement);
|
|
42
|
+
const early = DOCUMENT_REACT ? (document.readyState === 'loading' || documentMayHydrate()) && !hasFiber(head) && !hasFiber(document.documentElement) : false;
|
|
42
43
|
if (order?.isConnected && (early || head.firstElementChild === order)) return;
|
|
43
44
|
order ??= styleEl();
|
|
44
45
|
order.textContent = '@layer lbx;';
|
package/src/core/support.ts
CHANGED
|
@@ -9,4 +9,4 @@ export { speculate } from './speculation';
|
|
|
9
9
|
// Images (./images.ts, ./image-tags.ts, ./image-load.ts): every widget's templates and pages.
|
|
10
10
|
export { imageHelpers } from './image-tags';
|
|
11
11
|
export { firstRowCount, firstRowImages, imageOf } from './images';
|
|
12
|
-
export {
|
|
12
|
+
export { settleImages, warmImages } from './image-load';
|
package/src/core/takeover.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { earlyHold } from './early-rules';
|
|
20
20
|
import { canon, isOwnEntry } from './entries';
|
|
21
|
+
import { SSR } from './features';
|
|
21
22
|
import { mark } from './health';
|
|
22
23
|
import { failKey, failures as storedFailures, holding, LOADING, MAX_FAILURES, painted, reserveCss } from './hold';
|
|
23
24
|
import { onUrlChange } from './sense';
|
|
@@ -104,7 +105,7 @@ export const takeover = (r: TakeoverRoute): Takeover => {
|
|
|
104
105
|
// A server-rendered fragment carries its own CSS hiding their listing (for
|
|
105
106
|
// pages without JavaScript). Once we write ours, per state, it goes: theirs
|
|
106
107
|
// must come back when we hand the page back.
|
|
107
|
-
for (const el of document.querySelectorAll(`style[data-lbx-ssr-host="${r.id}"]`)) el.remove();
|
|
108
|
+
if (SSR) for (const el of document.querySelectorAll(`style[data-lbx-ssr-host="${r.id}"]`)) el.remove();
|
|
108
109
|
const key = `host-${r.id}`;
|
|
109
110
|
if (!r.accepts(new URL(location.href))) return setCss(key, null);
|
|
110
111
|
const reserve = reserveCss(r.reserveSelector);
|
|
@@ -116,7 +117,7 @@ export const takeover = (r: TakeoverRoute): Takeover => {
|
|
|
116
117
|
else setCss(key, r.hostCss + reserve);
|
|
117
118
|
};
|
|
118
119
|
|
|
119
|
-
let ssr = ssrOf(r);
|
|
120
|
+
let ssr = SSR ? ssrOf(r) : null;
|
|
120
121
|
let visitStartedAtBoot = true;
|
|
121
122
|
const t: Takeover = {
|
|
122
123
|
get state() {
|
|
@@ -126,7 +127,7 @@ export const takeover = (r: TakeoverRoute): Takeover => {
|
|
|
126
127
|
return ssr;
|
|
127
128
|
},
|
|
128
129
|
serverRendered: () => {
|
|
129
|
-
if (ssr === 'rendered' || !visitStartedAtBoot) return;
|
|
130
|
+
if (!SSR || ssr === 'rendered' || !visitStartedAtBoot) return;
|
|
130
131
|
ssr = 'rendered';
|
|
131
132
|
countFailures(0); // the page is ours after all (a late include is not a failure)
|
|
132
133
|
if (state !== 'ours') set('ours');
|
|
@@ -155,7 +156,7 @@ export const takeover = (r: TakeoverRoute): Takeover => {
|
|
|
155
156
|
// A server-rendered visit whose listing is on the page (the server's, or
|
|
156
157
|
// ours after hydration) keeps it: with SSI there is no listing of theirs
|
|
157
158
|
// in the page to hand back to.
|
|
158
|
-
if (ssr === 'rendered' && document.querySelector(`${r.tag}[data-lbx]`)?.childElementCount) return;
|
|
159
|
+
if (SSR && ssr === 'rendered' && document.querySelector(`${r.tag}[data-lbx]`)?.childElementCount) return;
|
|
159
160
|
if (failed) fail();
|
|
160
161
|
else set('released');
|
|
161
162
|
},
|
|
@@ -208,7 +209,7 @@ export const takeover = (r: TakeoverRoute): Takeover => {
|
|
|
208
209
|
const early = earlyHold();
|
|
209
210
|
// Failing: an early hold is not continued (it ends below, with drop()).
|
|
210
211
|
const claimed = wasActive && !t.failing && !!early?.claim?.(r.id);
|
|
211
|
-
if (wasActive && ssr) set(ssr === 'rendered' ? 'ours' : 'released');
|
|
212
|
+
if (SSR && wasActive && ssr) set(ssr === 'rendered' ? 'ours' : 'released');
|
|
212
213
|
else if (wasActive) set(!t.failing && (claimed || r.held || !painted()) ? 'hold' : 'native');
|
|
213
214
|
else css();
|
|
214
215
|
if (claimed) {
|
package/src/core/templates.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// scope plus props; lit does the DOM updates (only changed bindings touch
|
|
6
6
|
// the DOM, so images, focus and open <details> survive re-renders).
|
|
7
7
|
|
|
8
|
+
import { SSR } from './features';
|
|
8
9
|
import { nothing, render } from 'lit-html';
|
|
9
10
|
|
|
10
11
|
export type Template<S = any> = (s: S) => unknown;
|
|
@@ -31,7 +32,7 @@ export const templateOf = (templates: Templates, name: string, data: Record<stri
|
|
|
31
32
|
const s: any = { ...d };
|
|
32
33
|
s.include = (n: string, props?: Record<string, unknown>) => {
|
|
33
34
|
const t = templates[n];
|
|
34
|
-
if (!t) throw
|
|
35
|
+
if (!t) throw Error(`template "${n}" not found`);
|
|
35
36
|
if (!props) return t(s);
|
|
36
37
|
const r = props.r;
|
|
37
38
|
if (!r || typeof r !== 'object') return t(scope({ ...s, ...props }));
|
|
@@ -70,5 +71,7 @@ export const view = (root: HTMLElement | ShadowRoot, templates: Templates) => ({
|
|
|
70
71
|
* server-side rendering): binds to the existing DOM instead of re-creating it.
|
|
71
72
|
* `hydrate` is @lit-labs/ssr-client's, loaded only on server-rendered pages.
|
|
72
73
|
*/
|
|
73
|
-
hydrate:
|
|
74
|
+
hydrate: SSR
|
|
75
|
+
? (name: string, data: Record<string, unknown>, hydrate: (v: unknown, c: HTMLElement | ShadowRoot) => void) => hydrate(templateOf(templates, name, data), root)
|
|
76
|
+
: (undefined as never), // no server-rendered listings in this build (core/features.ts)
|
|
74
77
|
});
|
package/src/core/tiles.ts
CHANGED
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
// the click may leave the page: so each is first queued in sessionStorage, in
|
|
23
23
|
// the click's own task, where analytics claims it, on this page or the next.
|
|
24
24
|
// Not where the integration has no analytics (`_global.analytics` false: the
|
|
25
|
-
// entry sets it where there is no _analytics.ts)
|
|
25
|
+
// entry sets it where there is no _analytics.ts), and not in its build
|
|
26
|
+
// (core/features.ts ANALYTICS): there a tile only puts products in the cart.
|
|
26
27
|
|
|
28
|
+
import { ANALYTICS, CART } from './features';
|
|
27
29
|
import { reportError } from './health';
|
|
28
30
|
import { session } from './storage';
|
|
29
31
|
import type { GlobalConfigType, Result } from '../types';
|
|
@@ -56,7 +58,7 @@ export interface Watched {
|
|
|
56
58
|
/** The queue analytics claims from (src/analytics/triggers.ts). */
|
|
57
59
|
export const TILES = 'lbx:tiles';
|
|
58
60
|
const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>) => {
|
|
59
|
-
if (g.analytics === false) return;
|
|
61
|
+
if (!ANALYTICS || g.analytics === false) return;
|
|
60
62
|
const d = { ...e, id: crypto.randomUUID(), url: location.href, at: Date.now() };
|
|
61
63
|
session.set(TILES, [...(session.get<TileEvent[]>(TILES) ?? []), d].slice(-20));
|
|
62
64
|
try {
|
|
@@ -68,11 +70,33 @@ const announce = (g: GlobalConfigType, e: Omit<TileEvent, 'id' | 'url' | 'at'>)
|
|
|
68
70
|
const reference = (r: Result) => `${r.type || 'product'}/@id:${r.id}`;
|
|
69
71
|
|
|
70
72
|
/** A result clicked (a link to it, or our own navigation: the autocomplete). */
|
|
71
|
-
export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => announce(g, { kind: 'click', reference: reference(r), result: r, index, list });
|
|
73
|
+
export const picked = (r: Result, index: number, list: string, g: GlobalConfigType) => (ANALYTICS ? announce(g, { kind: 'click', reference: reference(r), result: r, index, list }) : undefined);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A product click, announced: only ever announced, so not in a build without
|
|
77
|
+
* analytics (core/features.ts). Out here, not in watch()'s try: Rollup folds
|
|
78
|
+
* no flag inside a try (its tryCatchDeoptimization).
|
|
79
|
+
*/
|
|
80
|
+
const clicked = ANALYTICS
|
|
81
|
+
? (t: Element, tile: Element, at: number, n: number, results: readonly Result[], w: Watched) => {
|
|
82
|
+
const a = t.closest<HTMLAnchorElement>('a[href]');
|
|
83
|
+
if (!a || !tile.contains(a)) return;
|
|
84
|
+
const r = resultOf(a, at, n, results);
|
|
85
|
+
if (r) picked(r.r, r.i, w.list, w.global);
|
|
86
|
+
}
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The cart button a click is on: only where the integration's templates have
|
|
91
|
+
* one (core/features.ts CART). Out of watch()'s try, as `clicked`.
|
|
92
|
+
*/
|
|
93
|
+
const cartOf = CART ? (t: Element) => t.closest<HTMLElement>('[data-lbx-cart]') : null;
|
|
72
94
|
|
|
73
95
|
const watched = new WeakSet<Element>();
|
|
74
96
|
/** Product clicks and add-to-carts inside `root` (once per root). */
|
|
75
97
|
export const watch = (root: Element, w: Watched) => {
|
|
98
|
+
// Nothing to see: no cart button in the integration's templates, no analytics (core/features.ts).
|
|
99
|
+
if (!CART && !ANALYTICS) return;
|
|
76
100
|
if (watched.has(root)) return;
|
|
77
101
|
watched.add(root);
|
|
78
102
|
const on = (e: MouseEvent) => {
|
|
@@ -84,12 +108,9 @@ export const watch = (root: Element, w: Watched) => {
|
|
|
84
108
|
const results = w.results();
|
|
85
109
|
const tiles = [...tile.parentElement!.children];
|
|
86
110
|
const at = tiles.indexOf(tile);
|
|
87
|
-
const cart = t
|
|
111
|
+
const cart = cartOf?.(t!);
|
|
88
112
|
if (cart && tile.contains(cart)) return void (e.type === 'click' && toCart(e, cart, tile, at, tiles.length, results, w));
|
|
89
|
-
|
|
90
|
-
if (!a || !tile.contains(a)) return;
|
|
91
|
-
const r = resultOf(a, at, tiles.length, results);
|
|
92
|
-
if (r) picked(r.r, r.i, w.list, w.global);
|
|
113
|
+
if (clicked) clicked(t!, tile, at, tiles.length, results, w);
|
|
93
114
|
} catch (err) {
|
|
94
115
|
reportError('tile click', err);
|
|
95
116
|
}
|
|
@@ -126,8 +147,10 @@ const toCart = (e: MouseEvent, el: HTMLElement, tile: Element, at: number, n: nu
|
|
|
126
147
|
const hook = w.global.addToCart;
|
|
127
148
|
if (typeof hook !== 'function') return reportError('add to cart', '[data-lbx-cart] needs _global.addToCart (_init.ts or _global/_widget.ts) to put the product in the site\'s cart');
|
|
128
149
|
const count = Math.max(1, Math.round(Number(el.dataset.lbxCart) || 1));
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
if (ANALYTICS) {
|
|
151
|
+
const price = typeof hit.r.c.price === 'number' ? hit.r.c.price : undefined;
|
|
152
|
+
announce(w.global, { kind: 'add_to_cart', reference: reference(hit.r), result: hit.r, index: hit.i, list: w.list, count, ...(price !== undefined ? { price } : {}) });
|
|
153
|
+
}
|
|
131
154
|
try {
|
|
132
155
|
const p = hook(hit.r, { count, el, event: e });
|
|
133
156
|
if (p && typeof (p as Promise<unknown>).catch === 'function') (p as Promise<unknown>).catch((err) => reportError('addToCart', err));
|
package/src/core/url.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// null-state entries altogether.
|
|
19
19
|
|
|
20
20
|
import { canon, markOwnEntry } from './entries';
|
|
21
|
+
import { ASTRO, NEXT, SVELTEKIT, VUE } from './frameworks';
|
|
21
22
|
|
|
22
23
|
interface HostRouter {
|
|
23
24
|
push(to: string): unknown;
|
|
@@ -42,6 +43,7 @@ const samePath = (a: string, b: string) => {
|
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
const vueRouter = (): HostRouter | null => {
|
|
46
|
+
if (!VUE) return null;
|
|
45
47
|
// Vue 3 (the app on its mount element: every one, a widget app may come first), Vue 2 (the root instance), Nuxt 2.
|
|
46
48
|
const candidates = [
|
|
47
49
|
...[...document.querySelectorAll('#__nuxt, #app, [data-v-app]')].map((el: any) => el.__vue_app__?.config?.globalProperties?.$router ?? el.__vue__?.$router),
|
|
@@ -68,6 +70,7 @@ const vueRouter = (): HostRouter | null => {
|
|
|
68
70
|
|
|
69
71
|
/** Next.js pages router. The app router (it sets window.next.router too) follows raw history writes by itself. */
|
|
70
72
|
const nextRouter = (): HostRouter | null => {
|
|
73
|
+
if (!NEXT) return null;
|
|
71
74
|
const w = window as any;
|
|
72
75
|
if (w.next?.appDir || !w.__NEXT_DATA__) return null;
|
|
73
76
|
const r = w.next?.router;
|
|
@@ -81,7 +84,7 @@ const hostRouter = () => vueRouter() ?? nextRouter();
|
|
|
81
84
|
/** A mounted router found (usable, or hash mode), or waited for one in vain: writes are not queued from then on. */
|
|
82
85
|
let settled = false;
|
|
83
86
|
/** A server-rendered Vue app whose client app has not mounted yet (no router to talk to). vueRouter() may settle it (a hash-mode router: null, settled), hence `settled` is read again after it. */
|
|
84
|
-
const vueNotMountedYet = () => !settled && !!((window as any).__NUXT__ || document.querySelector('#__nuxt, [data-server-rendered]')) && !vueRouter() && !settled;
|
|
87
|
+
const vueNotMountedYet = () => (VUE ? !settled && !!((window as any).__NUXT__ || document.querySelector('#__nuxt, [data-server-rendered]')) && !vueRouter() && !settled : false);
|
|
85
88
|
|
|
86
89
|
/** The router's own path for a URL: without its base, which it adds itself. */
|
|
87
90
|
const routerPath = (r: HostRouter, url: URL) => {
|
|
@@ -140,8 +143,8 @@ const SK_NAVIGATION = 'sveltekit:navigation';
|
|
|
140
143
|
const entryState = () => {
|
|
141
144
|
const s = history.state;
|
|
142
145
|
if (!s || typeof s !== 'object') return null;
|
|
143
|
-
if (s[SK_HISTORY] && SK_NAVIGATION in s) return { [SK_HISTORY]: s[SK_HISTORY], [SK_NAVIGATION]: s[SK_NAVIGATION] };
|
|
144
|
-
if (typeof s.index === 'number' && document.querySelector('meta[name="astro-view-transitions-enabled"]')) return { index: s.index, scrollX, scrollY };
|
|
146
|
+
if (SVELTEKIT && s[SK_HISTORY] && SK_NAVIGATION in s) return { [SK_HISTORY]: s[SK_HISTORY], [SK_NAVIGATION]: s[SK_NAVIGATION] };
|
|
147
|
+
if (ASTRO && typeof s.index === 'number' && document.querySelector('meta[name="astro-view-transitions-enabled"]')) return { index: s.index, scrollX, scrollY };
|
|
145
148
|
return null;
|
|
146
149
|
};
|
|
147
150
|
|
package/src/core/widget-inits.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// entry: the hooks' modules are in the widget's own chunks anyway. No DOM:
|
|
4
4
|
// the server renderer runs them for its listings too.
|
|
5
5
|
|
|
6
|
+
import { WIDGET_INITS } from './features';
|
|
6
7
|
import { folderOf, slot, type IntegrationFiles } from './integration';
|
|
7
8
|
import type { LumaApi, LumaConfigType, LumaWidgetInitType } from '../types';
|
|
8
9
|
|
|
@@ -10,9 +11,11 @@ const widgetInitsRun = new WeakMap<object, Map<string, Promise<void>>>();
|
|
|
10
11
|
/**
|
|
11
12
|
* The `_widget.ts` of a widget's folders, in folder order, each run once per
|
|
12
13
|
* config (a folder shared by widgets, `_global`, runs for the first): before
|
|
13
|
-
* the widget's context is made from the config they complete.
|
|
14
|
+
* the widget's context is made from the config they complete. An integration
|
|
15
|
+
* without a _widget.ts (core/features.ts) builds none of it: 121 B gzip.
|
|
14
16
|
*/
|
|
15
17
|
export const loadWidgetInits = (config: LumaConfigType, files: Pick<IntegrationFiles, 'widgetInits'>, folders: readonly string[], version: string) => {
|
|
18
|
+
if (!WIDGET_INITS) return Promise.resolve();
|
|
16
19
|
let ran = widgetInitsRun.get(config);
|
|
17
20
|
if (!ran) widgetInitsRun.set(config, (ran = new Map()));
|
|
18
21
|
const Luma: LumaApi = { version, config };
|
package/src/lit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lit-html for templates: `import { html, repeat } from '@/lit'` in an
|
|
2
2
|
// integration, so every template uses the framework's one copy of lit.
|
|
3
|
-
// Only what a template uses ends up in the bundle
|
|
3
|
+
// Only what a template uses ends up in the bundle (a directive no template
|
|
4
|
+
// uses too: build/vite.mjs pureDirectives). Note for form controls:
|
|
4
5
|
// bind `.checked` / `.selected` plainly (not with live()): lit then leaves a
|
|
5
6
|
// control the user just changed alone until the state it drives agrees.
|
|
6
7
|
export { html, svg, nothing } from 'lit-html';
|
package/src/locales/cs.json
CHANGED
package/src/locales/de.json
CHANGED