@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.
Files changed (41) hide show
  1. package/README.md +208 -76
  2. package/build/vite.mjs +9 -0
  3. package/chrome/block.mjs +8 -0
  4. package/chrome/run.mjs +10 -5
  5. package/docs/ACCESSIBILITY.md +9 -5
  6. package/docs/INTEGRATION-PERFORMANCE.md +5 -6
  7. package/package.json +1 -1
  8. package/parity/hold.mjs +1 -1
  9. package/src/analytics/chain.ts +137 -0
  10. package/src/analytics/collect.ts +64 -0
  11. package/src/analytics/datalayer.ts +67 -0
  12. package/src/analytics/from-ga4.ts +68 -0
  13. package/src/analytics/from-luma.ts +21 -0
  14. package/src/analytics/index.ts +24 -0
  15. package/src/analytics/outbox.ts +118 -0
  16. package/src/analytics/page.ts +46 -0
  17. package/src/analytics/pageview.ts +82 -0
  18. package/src/analytics/ref.ts +19 -0
  19. package/src/analytics/setup.ts +41 -0
  20. package/src/analytics/start.ts +53 -0
  21. package/src/analytics/triggers.ts +239 -0
  22. package/src/analytics/user.ts +12 -0
  23. package/src/analytics/wire.ts +67 -0
  24. package/src/api/token.ts +3 -2
  25. package/src/core/entries.ts +10 -6
  26. package/src/core/integration.ts +6 -0
  27. package/src/core/kept.ts +25 -0
  28. package/src/core/stash.ts +24 -4
  29. package/src/core/tiles.ts +137 -0
  30. package/src/main.ts +54 -4
  31. package/src/types.ts +29 -2
  32. package/src/widgets/autocomplete/index.ts +206 -31
  33. package/src/widgets/autocomplete/style-autocomplete.css +11 -5
  34. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
  35. package/src/widgets/listing/element.ts +212 -21
  36. package/src/widgets/listing/instant.ts +83 -23
  37. package/src/widgets/listing/stash.ts +31 -8
  38. package/src/widgets/listing/view.ts +12 -2
  39. package/src/widgets/recommender/index.ts +15 -4
  40. package/src/widgets/search/range.ts +40 -18
  41. 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
- /** A group's accordion state: the shopper's toggle, else open with a selection, else the default (all, none, the first N of the sidebar). */
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
- return { id: groupId(route.id, name), open: view.open?.[name] ?? (selected || byDefault), toggle: () => view.toggleGroup?.(name) };
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.v.render('recommender-box-default', { ...ctx.helpers, results: hits.map(ctx.result) });
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
- popView!.render('recommender-popup', { ...ctx.helpers, results: hits.map(ctx.result), close });
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
- if (track) {
191
- e.preventDefault();
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
- const move = (x: number) => {
197
- const { from: f, to: t } = values(p, r);
198
- const n = valueAt(x);
199
- show(p, r, thumb === p.lo ? Math.min(n, t) : f, thumb === p.lo ? t : Math.max(n, f));
200
- };
201
- move(e.clientX);
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
- const onMove = (m: PointerEvent) => m.pointerId === e.pointerId && move(m.clientX);
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 up = (u: PointerEvent) => {
225
+ const end = (u: PointerEvent) => {
211
226
  if (u.pointerId !== e.pointerId) return;
212
- window.removeEventListener('pointerup', up, true);
213
- window.removeEventListener('pointercancel', up, true);
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', up, true);
219
- window.addEventListener('pointercancel', up, true);
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%); }