@luigisbox/luma 0.1.0 → 0.3.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 +208 -76
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +9 -5
- package/docs/INTEGRATION-PERFORMANCE.md +5 -6
- package/package.json +1 -1
- package/parity/hold.mjs +1 -1
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/token.ts +3 -2
- package/src/core/entries.ts +10 -6
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/stash.ts +24 -4
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +29 -2
- package/src/widgets/autocomplete/index.ts +206 -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 +212 -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/recommender/index.ts +15 -4
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
|
@@ -46,6 +46,9 @@ export type ListingData = Omit<SearchScope, keyof Helpers | 'go' | 'toggleFacet'
|
|
|
46
46
|
export interface ListingView {
|
|
47
47
|
/** Groups opened or closed by the shopper, by facet name. */
|
|
48
48
|
open?: Record<string, boolean>;
|
|
49
|
+
/** Groups shown open for their selection earlier in the visit: they stay open with it gone (`keptOpen` records them). */
|
|
50
|
+
kept?: Record<string, boolean>;
|
|
51
|
+
keptOpen?: (name: string) => void;
|
|
49
52
|
/** Groups open by default (`facetGroups.open` at this width): all, none, or the first N of the sidebar. */
|
|
50
53
|
openDefault?: boolean | number;
|
|
51
54
|
/** A group's search text, by facet name. */
|
|
@@ -120,11 +123,18 @@ export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | '
|
|
|
120
123
|
const ranges: SearchScope['ranges'] = [];
|
|
121
124
|
const names = facetFields(cfg, q);
|
|
122
125
|
const layout = q.layout ? [...q.layout, ...names.filter((n) => !q.layout!.includes(n))] : names;
|
|
123
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* A group's accordion state: the shopper's toggle, else open with a
|
|
128
|
+
* selection (and from then on in the visit: unticking its last value does
|
|
129
|
+
* not collapse it under the finger), else the default (all, none, the
|
|
130
|
+
* first N of the sidebar).
|
|
131
|
+
*/
|
|
124
132
|
const group = (name: string, selected: boolean) => {
|
|
125
133
|
const at = layout.indexOf(name);
|
|
126
134
|
const byDefault = typeof openDefault === 'number' ? at >= 0 && at < openDefault : !!openDefault;
|
|
127
|
-
|
|
135
|
+
const toggled = view.open?.[name];
|
|
136
|
+
if (toggled == null && selected) view.keptOpen?.(name);
|
|
137
|
+
return { id: groupId(route.id, name), open: toggled ?? (selected || !!view.kept?.[name] || byDefault), toggle: () => view.toggleGroup?.(name) };
|
|
128
138
|
};
|
|
129
139
|
for (const name of names) {
|
|
130
140
|
const data = res.facets[name];
|
|
@@ -18,7 +18,8 @@ import { onUrlChange } from '../../core/sense';
|
|
|
18
18
|
import { setCss } from '../../core/styles';
|
|
19
19
|
import { settleImages } from '../../core/support';
|
|
20
20
|
import type { WidgetContext } from '../../main';
|
|
21
|
-
import type { RecommenderConfigType } from '../../types';
|
|
21
|
+
import type { RecommenderConfigType, Result } from '../../types';
|
|
22
|
+
import { watch } from '../../core/tiles';
|
|
22
23
|
|
|
23
24
|
export { default as css } from './style-recommender.css?raw';
|
|
24
25
|
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
@@ -52,6 +53,9 @@ const fetchHits = (ctx: Ctx, items: string[], signal?: AbortSignal) => {
|
|
|
52
53
|
return signal ? untilAborted(p, signal) : p;
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
/** Our list, for its tiles' clicks and add-to-carts (core/tiles.ts). */
|
|
57
|
+
const listOf = (ctx: Ctx) => ({ list: `lbx_reco_${ctx.id.slice(5)}`, global: ctx.global });
|
|
58
|
+
|
|
55
59
|
const applyStyles = (ctx: Ctx) => {
|
|
56
60
|
ctx.styles.forEach((c, i) => setCss(`itg:${ctx.id}:${i}`, c));
|
|
57
61
|
useContrast();
|
|
@@ -67,6 +71,8 @@ interface State {
|
|
|
67
71
|
tried: string | null;
|
|
68
72
|
failures: number;
|
|
69
73
|
retryAt: number;
|
|
74
|
+
/** What the tiles show (analytics: a click's result). */
|
|
75
|
+
results: Result[];
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/** Retry a failed widget after 2, 4, 8, 16 s, then only when its inputs change. */
|
|
@@ -90,7 +96,8 @@ const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string)
|
|
|
90
96
|
fetchHits(ctx, items, ctl.signal)
|
|
91
97
|
.then((hits) => {
|
|
92
98
|
if (ctl.signal.aborted) return;
|
|
93
|
-
s.
|
|
99
|
+
s.results = hits.map(ctx.result);
|
|
100
|
+
s.v.render('recommender-box-default', { ...ctx.helpers, results: s.results });
|
|
94
101
|
settleImages(el, ctx.global.images?.fade !== false);
|
|
95
102
|
el.style.minHeight = '';
|
|
96
103
|
el.dataset.state = hits.length ? 'ready' : 'empty';
|
|
@@ -115,8 +122,9 @@ const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string)
|
|
|
115
122
|
/** Called by the entry once the placeholder is near the viewport. */
|
|
116
123
|
export const attach = (ctx: Ctx, el: HTMLElement) => {
|
|
117
124
|
applyStyles(ctx);
|
|
118
|
-
const s: State = { near: true, key: null, v: view(el, ctx.templates), tried: null, failures: 0, retryAt: 0 };
|
|
125
|
+
const s: State = { near: true, key: null, v: view(el, ctx.templates), tried: null, failures: 0, retryAt: 0, results: [] };
|
|
119
126
|
attached.set(el, { ctx, s });
|
|
127
|
+
watch(el, { ...listOf(ctx), results: () => s.results });
|
|
120
128
|
io.observe(el);
|
|
121
129
|
sync();
|
|
122
130
|
};
|
|
@@ -180,6 +188,7 @@ let pop: HTMLElement | null = null;
|
|
|
180
188
|
let popView: ReturnType<typeof view> | null = null;
|
|
181
189
|
let isOpen = false;
|
|
182
190
|
let popCtl: AbortController | null = null;
|
|
191
|
+
let popResults: Result[] = [];
|
|
183
192
|
|
|
184
193
|
const close = () => {
|
|
185
194
|
popCtl?.abort();
|
|
@@ -217,6 +226,7 @@ export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Elemen
|
|
|
217
226
|
// A modal library may set pointer-events: none on body, which inherits.
|
|
218
227
|
pop.style.pointerEvents = 'auto';
|
|
219
228
|
popView = view(pop, ctx.templates);
|
|
229
|
+
watch(pop, { ...listOf(ctx), results: () => popResults });
|
|
220
230
|
// Dismissal: close button, Escape, a press outside, or leaving the page.
|
|
221
231
|
addEventListener('keydown', (e) => isOpen && e.key === 'Escape' && close(), true);
|
|
222
232
|
addEventListener('pointerdown', (e) => isOpen && !pop!.contains(e.target as Node) && close(), true);
|
|
@@ -231,7 +241,8 @@ export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Elemen
|
|
|
231
241
|
isOpen = false;
|
|
232
242
|
}
|
|
233
243
|
pop.setAttribute('aria-label', ctx.t('title'));
|
|
234
|
-
|
|
244
|
+
popResults = hits.map(ctx.result);
|
|
245
|
+
popView!.render('recommender-popup', { ...ctx.helpers, results: popResults, close });
|
|
235
246
|
settleImages(pop, ctx.global.images?.fade !== false);
|
|
236
247
|
pop.querySelector('[data-lbx-list]')?.scrollTo(0, 0);
|
|
237
248
|
// Just below the host's notice, right-aligned with it.
|
|
@@ -18,6 +18,8 @@ type Range = SearchScope['ranges'][number];
|
|
|
18
18
|
|
|
19
19
|
/** A pause after the last key before a thumb moved from the keyboard commits (one request for a run of presses). */
|
|
20
20
|
export const KEY_COMMIT_MS = 600;
|
|
21
|
+
/** How far a touch on the track goes sideways (and more than down) before it is a drag, not the start of a scroll. */
|
|
22
|
+
const TOUCH_SLOP_PX = 8;
|
|
21
23
|
|
|
22
24
|
interface Parts {
|
|
23
25
|
root: HTMLElement;
|
|
@@ -169,7 +171,10 @@ export const rangeHandlers = (r: Range) => {
|
|
|
169
171
|
/**
|
|
170
172
|
* A press on the slider: on a thumb, the native drag (committed when the
|
|
171
173
|
* pointer lets go); on the track, the nearer thumb jumps there and follows
|
|
172
|
-
* the pointer until it lets go.
|
|
174
|
+
* the pointer until it lets go. A touch (or pen) on the track may be the
|
|
175
|
+
* start of a scroll (touch-action: pan-y): nothing moves until the finger
|
|
176
|
+
* goes sideways (a drag) or lifts where it pressed (a tap). A cancelled
|
|
177
|
+
* press (the browser took the gesture) commits nothing.
|
|
173
178
|
*/
|
|
174
179
|
press: (e: PointerEvent) => {
|
|
175
180
|
const p = at(e);
|
|
@@ -187,36 +192,53 @@ export const rangeHandlers = (r: Range) => {
|
|
|
187
192
|
return snap(r.min + Math.min(1, Math.max(0, f)) * (r.max - r.min), r);
|
|
188
193
|
};
|
|
189
194
|
let thumb: HTMLInputElement | null = null;
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
const move = (x: number) => {
|
|
196
|
+
const { from: f, to: t } = values(p, r);
|
|
197
|
+
const n = valueAt(x);
|
|
198
|
+
show(p, r, thumb === p.lo ? Math.min(n, t) : f, thumb === p.lo ? t : Math.max(n, f));
|
|
199
|
+
};
|
|
200
|
+
/** The thumb nearer the press takes it, and goes to x. */
|
|
201
|
+
const grab = (x: number) => {
|
|
192
202
|
const v = valueAt(e.clientX);
|
|
193
203
|
const { from, to } = values(p, r);
|
|
194
|
-
thumb = Math.abs(v - from) < Math.abs(v - to) || (v < from && from === to) ? p.lo : p.hi
|
|
204
|
+
thumb = Math.abs(v - from) < Math.abs(v - to) || (v < from && from === to) ? p.lo! : p.hi!;
|
|
195
205
|
thumb.focus({ preventScroll: true });
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
move(x);
|
|
207
|
+
};
|
|
208
|
+
let onMove: ((m: PointerEvent) => void) | null = null;
|
|
209
|
+
if (track) {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
if (e.pointerType === 'mouse' || !e.pointerType) grab(e.clientX);
|
|
202
212
|
try {
|
|
203
213
|
p.slider.setPointerCapture(e.pointerId);
|
|
204
214
|
} catch {}
|
|
205
|
-
|
|
215
|
+
onMove = (m: PointerEvent) => {
|
|
216
|
+
if (m.pointerId !== e.pointerId) return;
|
|
217
|
+
if (!thumb) {
|
|
218
|
+
const dx = Math.abs(m.clientX - e.clientX);
|
|
219
|
+
if (dx < TOUCH_SLOP_PX || dx <= Math.abs(m.clientY - e.clientY)) return;
|
|
220
|
+
grab(m.clientX);
|
|
221
|
+
} else move(m.clientX);
|
|
222
|
+
};
|
|
206
223
|
p.slider.addEventListener('pointermove', onMove);
|
|
207
|
-
const done = () => p.slider!.removeEventListener('pointermove', onMove);
|
|
208
|
-
p.slider.addEventListener('lostpointercapture', done, { once: true });
|
|
209
224
|
}
|
|
210
|
-
const
|
|
225
|
+
const end = (u: PointerEvent) => {
|
|
211
226
|
if (u.pointerId !== e.pointerId) return;
|
|
212
|
-
window.removeEventListener('pointerup',
|
|
213
|
-
window.removeEventListener('pointercancel',
|
|
227
|
+
window.removeEventListener('pointerup', end, true);
|
|
228
|
+
window.removeEventListener('pointercancel', end, true);
|
|
229
|
+
if (onMove) p.slider!.removeEventListener('pointermove', onMove);
|
|
214
230
|
st.pressed = false;
|
|
231
|
+
if (u.type === 'pointercancel') {
|
|
232
|
+
// The browser took the gesture (a scroll): the track's thumb back where it was drawn. A thumb's own drag is the input's (its change commits, if any).
|
|
233
|
+
if (track && thumb) show(p, r, r.from, r.to);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (track && !thumb) grab(u.clientX); // a tap
|
|
215
237
|
// The native drag's own `change` may come after this: commit() sends a value once.
|
|
216
238
|
setTimeout(() => commit(p, r), 0);
|
|
217
239
|
};
|
|
218
|
-
window.addEventListener('pointerup',
|
|
219
|
-
window.addEventListener('pointercancel',
|
|
240
|
+
window.addEventListener('pointerup', end, true);
|
|
241
|
+
window.addEventListener('pointercancel', end, true);
|
|
220
242
|
},
|
|
221
243
|
/** Typed into an input: the thumbs follow (within the range); nothing is committed yet. */
|
|
222
244
|
numberInput: (e: Event) => {
|
|
@@ -42,8 +42,8 @@ lbx-search:focus, lbx-plp:focus { outline: none; }
|
|
|
42
42
|
.lbx-s__fsearch { margin: 4px 0 8px; }
|
|
43
43
|
.lbx-s__fsearch-input { box-sizing: border-box; width: 100%; min-height: 24px; font: inherit; }
|
|
44
44
|
.lbx-s__fsearch-n { display: block; min-height: 1.2em; font-size: .875em; }
|
|
45
|
-
/* A range facet's slider: its theming variables in template-search-range-default.ts. */
|
|
46
|
-
.lbx-s__range-slider { --lbx-rs: var(--lbx-range-thumb-size, 24px); --lbx-rt: var(--lbx-range-thumb, #fff); --lbx-rb: var(--lbx-range-thumb-border, currentColor); --lbx-rr: var(--lbx-range-ring, currentColor); position: relative; height: max(24px, var(--lbx-rs)); margin: 4px 0 8px; }
|
|
45
|
+
/* A range facet's slider: its theming variables in template-search-range-default.ts. touch-action: a horizontal touch drag is the slider's (a press on the track follows the finger), a vertical one still scrolls. */
|
|
46
|
+
.lbx-s__range-slider { --lbx-rs: var(--lbx-range-thumb-size, 24px); --lbx-rt: var(--lbx-range-thumb, #fff); --lbx-rb: var(--lbx-range-thumb-border, currentColor); --lbx-rr: var(--lbx-range-ring, currentColor); position: relative; height: max(24px, var(--lbx-rs)); margin: 4px 0 8px; touch-action: pan-y; }
|
|
47
47
|
.lbx-s__range-track { position: absolute; top: 50%; left: calc(var(--lbx-rs) / 2); right: calc(var(--lbx-rs) / 2); height: var(--lbx-range-height, 4px); transform: translateY(-50%); border-radius: 4px; background: var(--lbx-range-track, #c4c4c4); }
|
|
48
48
|
.lbx-s__range-fill { position: absolute; top: 0; bottom: 0; left: var(--lbx-from, 0%); right: calc(100% - var(--lbx-to, 100%)); border-radius: inherit; background: var(--lbx-range-fill, currentColor); }
|
|
49
49
|
[dir="rtl"] .lbx-s__range-fill { left: calc(100% - var(--lbx-to, 100%)); right: var(--lbx-from, 0%); }
|