@luigisbox/luma 0.2.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.
@@ -5,8 +5,12 @@
5
5
  // (widgets/search/speculate.ts). Used by a plain navigation (or a prerender
6
6
  // of the page) within its `ttl` (2 minutes), once.
7
7
  // - `back`: the listing as the shopper left it for a product page (pages
8
- // 1..N, where they were). Used by a back/forward load within 30 minutes; the
9
- // listing then restores the scroll position and revalidates in the background.
8
+ // 1..N, where they were). Used by a back/forward load within 30 minutes, or
9
+ // by a reload or a navigation from the page itself within a minute (a link
10
+ // whose server redirects back to it); the listing then restores the scroll
11
+ // position and revalidates in the background. Otherwise, on a load of the
12
+ // entry left (back/forward, reload): where they were only (`stale`), the
13
+ // browser's own restoration being off for it (`sr`).
10
14
  // Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
11
15
  // drops ours first. Best-effort: storage may be unavailable, never required.
12
16
 
@@ -14,6 +18,7 @@ import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
14
18
  import { pageKey } from '../../core/page';
15
19
  import type { ListingResult } from './query';
16
20
  import type { Query } from './state';
21
+ import type { Spot } from './instant';
17
22
 
18
23
  export interface Stash {
19
24
  v: 1;
@@ -26,8 +31,12 @@ export interface Stash {
26
31
  route: string;
27
32
  query: Query;
28
33
  result: ListingResult;
29
- /** back: the link the shopper followed (its href, its top in the viewport) and scrollY where it is gone. */
30
- scroll?: { y: number; href?: string; top?: number };
34
+ /** back: the link the shopper followed (its href, which of the laid-out links to it, its top in the viewport) and scrollY where it is gone. */
35
+ scroll?: Spot;
36
+ /** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
37
+ sr?: 1;
38
+ /** Read only: too old to render from, where the shopper was is all that is used. */
39
+ stale?: true;
31
40
  }
32
41
 
33
42
  const MAX_CHARS = 400_000;
@@ -48,7 +57,7 @@ const entries = (except?: string) => {
48
57
  return out.sort((a, b) => a.at - b.at);
49
58
  };
50
59
 
51
- /** The stashed result this page load may render from, or null. */
60
+ /** The stashed result this page load may render from, or null. `stale`: a Back entry too old to render, for its scroll only. */
52
61
  export const readStash = (route: string, href: string): Stash | null => {
53
62
  try {
54
63
  const key = stashKey(href);
@@ -59,8 +68,22 @@ export const readStash = (route: string, href: string): Stash | null => {
59
68
  const nav = navigation();
60
69
  const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
61
70
  if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
62
- if (!fresh || s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
63
- return stashFits(s.kind) ? s : null;
71
+ if (s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
72
+ if (fresh && stashFits(s.kind, s.at)) return s;
73
+ // The entry left loaded again (Back after long, a reload after the round
74
+ // trip's minute): the browser restores nothing where Luma turned its
75
+ // restoration off; where the shopper was is still known.
76
+ const again = nav.type === 'back_forward' || nav.type === 'reload';
77
+ return s.v === 1 && s.kind === 'back' && s.sr && again ? { ...s, stale: true } : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ };
82
+
83
+ /** This URL's stash as it is, whatever the load (the back/forward cache's restore: where the shopper was). */
84
+ export const peekStash = (href: string): Stash | null => {
85
+ try {
86
+ return JSON.parse(sessionStorage.getItem(stashKey(href)) ?? 'null') as Stash | null;
64
87
  } catch {
65
88
  return null;
66
89
  }
@@ -76,7 +99,7 @@ export const dropStash = (href: string) => {
76
99
  /** false: too big, or storage refused it. */
77
100
  export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
78
101
  try {
79
- const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll });
102
+ const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll, sr: s.sr });
80
103
  if (json.length > MAX_CHARS) return false;
81
104
  const key = stashKey(href);
82
105
  const others = entries(key);
@@ -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,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%); }