@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
|
@@ -27,6 +27,7 @@ import { cspNonce, setCss } from '../../core/styles';
|
|
|
27
27
|
import type { WidgetContext } from '../../main';
|
|
28
28
|
import type { AutocompleteConfigType, AutocompleteScope, Result } from '../../types';
|
|
29
29
|
import css from './style-autocomplete.css?raw';
|
|
30
|
+
import { picked } from '../../core/tiles';
|
|
30
31
|
|
|
31
32
|
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
32
33
|
|
|
@@ -46,10 +47,23 @@ const PANEL_MAX = 1200;
|
|
|
46
47
|
const PANEL_LIST = 560;
|
|
47
48
|
const PANEL_GUTTER = 24;
|
|
48
49
|
const PANEL_WIDE = 1000;
|
|
50
|
+
/** A content column inset at least this far from the screen's edges is the page's (its edges hold, inside the gutter too); nearer, it is the screen's width. */
|
|
51
|
+
const COLUMN_INSET = 8;
|
|
52
|
+
/** `active` on "Show all products" (`data-lbx-all`): the arrow keys' last stop in the dropdown, after the options. */
|
|
53
|
+
const ALL = -2;
|
|
54
|
+
/** The id an option, or "Show all products", is referenced by (aria-activedescendant). */
|
|
55
|
+
const idOf = (i: number) => (i === ALL ? 'lbx-ac-all' : `lbx-ac-${i}`);
|
|
49
56
|
/** Products beside the top product on the panel (2 x 3). */
|
|
50
57
|
const GRID_MAX = 6;
|
|
51
|
-
/**
|
|
52
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Phones: the house design is a full-screen sheet. A narrow viewport, or a
|
|
60
|
+
* touch screen (no hover, as the entry tells one) that is short: a phone in
|
|
61
|
+
* landscape (844 x 390) got the desktop panel, most of it under the
|
|
62
|
+
* on-screen keyboard. A tablet (768 px and taller) keeps the panel.
|
|
63
|
+
*/
|
|
64
|
+
export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
|
|
65
|
+
/** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
|
|
66
|
+
const SHEET_SHORT = 480;
|
|
53
67
|
|
|
54
68
|
/** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
|
|
55
69
|
const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
|
|
@@ -128,14 +142,58 @@ export const dropdownBox = (r: { left: number; right: number; width: number }, v
|
|
|
128
142
|
* for a batch without products), centred under the field, kept 24 px from
|
|
129
143
|
* the screen's edges; its caret under the field's left edge + 30 px (inside
|
|
130
144
|
* the panel's corners). `wide`: the top product has its own column.
|
|
145
|
+
* `col`: the page's content column (`columnOf`): the panel also kept within
|
|
146
|
+
* its left and right edges (no wider than it), as the page's own content is;
|
|
147
|
+
* an edge of it inset from the screen's holds inside the gutter too (a
|
|
148
|
+
* column 20 px from the edges: the panel at 20, not 24), one at the screen's
|
|
149
|
+
* edge (less than COLUMN_INSET) leaves the gutter.
|
|
131
150
|
*/
|
|
132
|
-
export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false) => {
|
|
133
|
-
const
|
|
134
|
-
const
|
|
151
|
+
export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false, col?: { left: number; right: number } | null) => {
|
|
152
|
+
const lo = col && col.left >= COLUMN_INSET ? col.left : PANEL_GUTTER;
|
|
153
|
+
const hi = col && col.right <= vw - COLUMN_INSET ? col.right : vw - PANEL_GUTTER;
|
|
154
|
+
const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, hi - lo));
|
|
155
|
+
const left = Math.max(lo, Math.min(r.left + r.width / 2 - width / 2, hi - width));
|
|
135
156
|
const caret = Math.max(16, Math.min(r.left + 30 - left, width - 30));
|
|
136
157
|
return { left, width, caret, wide: width >= PANEL_WIDE };
|
|
137
158
|
};
|
|
138
159
|
|
|
160
|
+
/** `alignTo`'s parts: the field (a string: that alone) and the content column. */
|
|
161
|
+
const alignOf = (a: AutocompleteConfigType['alignTo']) => (typeof a === 'object' && a ? a : { field: a, column: undefined });
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The page's content column the house panel stays within (`alignTo.column`),
|
|
165
|
+
* read once per opening with the field:
|
|
166
|
+
* - `'viewport'`: none (the screen, less the gutter);
|
|
167
|
+
* - a selector: the field's nearest ancestor matching it, else the first
|
|
168
|
+
* element on the page matching it (a header and a content column side by
|
|
169
|
+
* side), else none;
|
|
170
|
+
* - by default, the outermost ancestor of the field that is inset from both
|
|
171
|
+
* of the screen's edges (COLUMN_INSET) and at least PANEL_WIDE wide (or the
|
|
172
|
+
* screen less the gutters, on a narrower one): a storefront's centred
|
|
173
|
+
* `max-width` container (the shop's logo and cart at its edges), also
|
|
174
|
+
* where it nearly fills a 1024 px screen. A narrower box is a part of the
|
|
175
|
+
* header, not the page's column.
|
|
176
|
+
*/
|
|
177
|
+
export const columnOf = (field: Element, column?: string): Element | null => {
|
|
178
|
+
if (column === 'viewport') return null;
|
|
179
|
+
if (column) {
|
|
180
|
+
try {
|
|
181
|
+
for (let el = up(field); el; el = up(el)) if (el.matches(column)) return el;
|
|
182
|
+
return document.querySelector(column);
|
|
183
|
+
} catch {
|
|
184
|
+
return null; // not a selector
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const vw = document.documentElement.clientWidth;
|
|
188
|
+
let found: Element | null = null;
|
|
189
|
+
const min = Math.min(PANEL_WIDE, vw - 2 * PANEL_GUTTER);
|
|
190
|
+
for (let el = up(field); el && el !== document.body && el !== document.documentElement; el = up(el)) {
|
|
191
|
+
const b = el.getBoundingClientRect();
|
|
192
|
+
if (b.width >= min && b.left >= COLUMN_INSET && b.right <= vw - COLUMN_INSET) found = el;
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
};
|
|
196
|
+
|
|
139
197
|
/** The house tokens a config may set (`autocomplete.tokens`), as their custom properties. */
|
|
140
198
|
const TOKEN_PROPS: Record<string, string> = {
|
|
141
199
|
font: 'font',
|
|
@@ -186,6 +244,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
186
244
|
/** The arrow keys' order over `items` (the house design's reading order: pills, lists, the top product, products). */
|
|
187
245
|
let order: number[] = [];
|
|
188
246
|
let lastQuery = '';
|
|
247
|
+
/** The arrow keys' selection: an option of `items`, -1 none (the input), ALL "Show all products". */
|
|
189
248
|
let active = -1;
|
|
190
249
|
let open = false;
|
|
191
250
|
let raf = 0;
|
|
@@ -194,6 +253,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
194
253
|
let quietFocus = false;
|
|
195
254
|
/** The phrase the dropdown is for (normalised input), '' while closed by the visitor or below minChars. */
|
|
196
255
|
let want = '';
|
|
256
|
+
/** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
|
|
257
|
+
const scrolled = new Set<Element>();
|
|
258
|
+
const toTop = () => {
|
|
259
|
+
for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
|
|
260
|
+
scrolled.clear();
|
|
261
|
+
};
|
|
197
262
|
/** Per type, what the dropdown shows. */
|
|
198
263
|
const shown = new Map<string, Result[]>();
|
|
199
264
|
/** The house panel: wide enough for the top product's column; a batch without products (the list alone, narrower). */
|
|
@@ -205,6 +270,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
205
270
|
let tab = '';
|
|
206
271
|
let entry = false;
|
|
207
272
|
let afterBack: (() => void) | null = null;
|
|
273
|
+
/** What the site's input held when the sheet opened: put back when the sheet is cancelled (its back button, Escape, Back). */
|
|
274
|
+
let before = '';
|
|
208
275
|
// Accessibility (docs/ACCESSIBILITY.md, "Autocomplete"): the host's input is
|
|
209
276
|
// the combobox. Its ARIA is borrowed while it has focus and put back when
|
|
210
277
|
// focus leaves it; the result count is announced politely, once typing pauses.
|
|
@@ -274,6 +341,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
274
341
|
const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
|
|
275
342
|
box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
|
|
276
343
|
box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
|
|
344
|
+
// A middle click opens a suggestion in a new tab (no choose()): a click too (core/tiles.ts).
|
|
345
|
+
box.addEventListener('auxclick', (e) => {
|
|
346
|
+
if ((e as MouseEvent).button !== 1) return;
|
|
347
|
+
const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
|
|
348
|
+
const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
|
|
349
|
+
if (i >= 0) track(i);
|
|
350
|
+
});
|
|
277
351
|
// "Show all products" has focus (Tab from the input): Shift+Tab goes back
|
|
278
352
|
// to the input, Escape closes and goes back, Tab moves on as from the
|
|
279
353
|
// input (focus put back on it, the browser's own Tab then leaves it: the
|
|
@@ -311,6 +385,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
311
385
|
backToInput();
|
|
312
386
|
}
|
|
313
387
|
});
|
|
388
|
+
// Which of its lists the shopper scrolled (read in their own scroll
|
|
389
|
+
// event, layout clean): a new result set starts at their top (toTop).
|
|
390
|
+
// Bug: a list scrolled down for "fiets" drew "fiets slot" scrolled down,
|
|
391
|
+
// the top product out of sight.
|
|
392
|
+
box.addEventListener(
|
|
393
|
+
'scroll',
|
|
394
|
+
(e) => {
|
|
395
|
+
const el = e.composedPath()[0];
|
|
396
|
+
if (!(el instanceof Element)) return;
|
|
397
|
+
if (el.scrollTop > 0) scrolled.add(el);
|
|
398
|
+
else scrolled.delete(el);
|
|
399
|
+
},
|
|
400
|
+
{ capture: true, passive: true },
|
|
401
|
+
);
|
|
314
402
|
// Focus leaving the dropdown for anywhere but the input: closed, as when
|
|
315
403
|
// it leaves the input. Not the sheet: it covers the page, and its input
|
|
316
404
|
// lets go whenever the shopper puts the keyboard away to read.
|
|
@@ -349,6 +437,14 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
349
437
|
|
|
350
438
|
/** What the dropdown lines up with for this opening (`alignTo`): the visible search field, else the input. */
|
|
351
439
|
let field: Element | null = null;
|
|
440
|
+
/** The page's content column the house panel stays within, for this opening (`alignTo.column`; none: the screen). */
|
|
441
|
+
let column: Element | null = null;
|
|
442
|
+
const align = alignOf(ac.alignTo);
|
|
443
|
+
/** The field and the content column, read once per opening (styles and boxes, before our writes). */
|
|
444
|
+
const lineUp = (i: HTMLInputElement) => {
|
|
445
|
+
field = fieldOf(i, align.field);
|
|
446
|
+
column = light ? null : columnOf(field, align.column);
|
|
447
|
+
};
|
|
352
448
|
|
|
353
449
|
/** Where the field is (null: hidden), read before anything of ours is written in the task. */
|
|
354
450
|
const measure = () => {
|
|
@@ -356,7 +452,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
356
452
|
// checkVisibility: not in Safari before 17.4.
|
|
357
453
|
if (!input.isConnected || input.getBoundingClientRect().width === 0 || (input.checkVisibility && !input.checkVisibility({ checkVisibilityCSS: true }))) return null;
|
|
358
454
|
const at = (field?.isConnected ? field : input).getBoundingClientRect();
|
|
359
|
-
|
|
455
|
+
const col = column?.isConnected ? column.getBoundingClientRect() : null;
|
|
456
|
+
return { r: at.width ? at : input.getBoundingClientRect(), vw: document.documentElement.clientWidth, col: col?.width ? col : null };
|
|
360
457
|
};
|
|
361
458
|
/**
|
|
362
459
|
* Where the dropdown is, every frame while open; `frame`: from the frame
|
|
@@ -368,26 +465,34 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
368
465
|
if (!open || !input) return;
|
|
369
466
|
const s = host!.style;
|
|
370
467
|
if (sheet) {
|
|
371
|
-
// Full screen: what shows of the page (the visual
|
|
372
|
-
// on-screen keyboard), so the sticky "Show all"
|
|
468
|
+
// Full screen: its content in what shows of the page (the visual
|
|
469
|
+
// viewport, above the on-screen keyboard), so the sticky "Show all"
|
|
470
|
+
// sits above the keys. The host reaches on down to the layout
|
|
471
|
+
// viewport's bottom, painted (openSheet): iOS 26 Safari ends the visual
|
|
472
|
+
// viewport above its floating address bar, and between the two the
|
|
473
|
+
// page showed through, see-through bars over it.
|
|
373
474
|
const vv = window.visualViewport;
|
|
374
|
-
|
|
475
|
+
const top = Math.round(vv?.offsetTop ?? 0);
|
|
476
|
+
const h = Math.round(vv?.height ?? innerHeight);
|
|
477
|
+
s.top = `${top}px`;
|
|
375
478
|
s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
|
|
376
479
|
s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
|
|
377
|
-
s.height = `${Math.
|
|
480
|
+
s.height = `${Math.max(h, innerHeight - top)}px`;
|
|
481
|
+
s.setProperty('--lbx-vh', `${h}px`);
|
|
482
|
+
host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
|
|
378
483
|
raf = requestAnimationFrame(() => position(true));
|
|
379
484
|
return;
|
|
380
485
|
}
|
|
381
486
|
const got = m === undefined ? measure() : m;
|
|
382
487
|
if (!got) return close('hidden');
|
|
383
|
-
const { r, vw } = got;
|
|
488
|
+
const { r, vw, col } = got;
|
|
384
489
|
s.top = `${Math.round(r.bottom + 4)}px`;
|
|
385
490
|
if (light) {
|
|
386
491
|
const { left, width } = dropdownBox(r, vw);
|
|
387
492
|
s.left = `${Math.round(left)}px`;
|
|
388
493
|
s.width = `${Math.round(width)}px`;
|
|
389
494
|
} else {
|
|
390
|
-
const p = panelBox(r, vw, listOnly);
|
|
495
|
+
const p = panelBox(r, vw, listOnly, col);
|
|
391
496
|
s.left = `${Math.round(p.left)}px`;
|
|
392
497
|
s.width = `${Math.round(p.width)}px`;
|
|
393
498
|
s.setProperty('--lbx-caret', `${Math.round(p.caret)}px`);
|
|
@@ -450,7 +555,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
450
555
|
* (not for a row the pointer is over: `say` false).
|
|
451
556
|
*/
|
|
452
557
|
const markActive = (say = true) => {
|
|
453
|
-
const
|
|
558
|
+
const all = active === ALL ? allLink() : null;
|
|
559
|
+
if (all && !all.id) all.id = idOf(ALL);
|
|
560
|
+
const row = all ?? (active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(active)) : null);
|
|
454
561
|
if (sheet && mirror) {
|
|
455
562
|
if (row) mirror.setAttribute('aria-activedescendant', row.id);
|
|
456
563
|
else mirror.removeAttribute('aria-activedescendant');
|
|
@@ -472,7 +579,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
472
579
|
// The field and the site's colours: read after the focus's frame (prepare), else now.
|
|
473
580
|
// Reads first (the field, where it is, the colours), then our writes.
|
|
474
581
|
const fresh = prepared !== input;
|
|
475
|
-
if (fresh)
|
|
582
|
+
if (fresh) lineUp(input!);
|
|
476
583
|
const m = measure();
|
|
477
584
|
if (fresh && !light) sampleTokens(input!, field!);
|
|
478
585
|
prepared = null;
|
|
@@ -503,8 +610,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
503
610
|
const prepare = (i: HTMLInputElement) =>
|
|
504
611
|
afterFrame(() => {
|
|
505
612
|
if (open || input !== i || !i.isConnected || sheet) return;
|
|
506
|
-
|
|
507
|
-
if (!light) sampleTokens(i, field);
|
|
613
|
+
lineUp(i);
|
|
614
|
+
if (!light) sampleTokens(i, field!);
|
|
508
615
|
prepared = i;
|
|
509
616
|
place(); // after the reads: it dirties the page's style
|
|
510
617
|
rehearse();
|
|
@@ -585,7 +692,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
585
692
|
st.setProperty('visibility', 'hidden');
|
|
586
693
|
st.top = '0px';
|
|
587
694
|
st.left = '0px';
|
|
588
|
-
st.width = `${Math.round(panelBox(m.r, m.vw).width)}px`;
|
|
695
|
+
st.width = `${Math.round(panelBox(m.r, m.vw, false, m.col).width)}px`;
|
|
589
696
|
st.setProperty('display', 'block', 'important');
|
|
590
697
|
}
|
|
591
698
|
}
|
|
@@ -622,8 +729,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
622
729
|
if (!open) {
|
|
623
730
|
sheet = true;
|
|
624
731
|
tab = '';
|
|
732
|
+
before = i.value;
|
|
625
733
|
place();
|
|
626
734
|
sampleTokens(i, i);
|
|
735
|
+
// Below the visible part, its background (position).
|
|
736
|
+
host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
|
|
737
|
+
// What the site's input holds now, not what the sheet's held when it last
|
|
738
|
+
// closed (letters typed into the site's input before the hand-off, a
|
|
739
|
+
// value the site's page set since).
|
|
740
|
+
if (mirror) mirror.value = i.value;
|
|
627
741
|
host!.style.setProperty('display', 'block', 'important');
|
|
628
742
|
host!.showPopover();
|
|
629
743
|
open = true;
|
|
@@ -640,11 +754,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
640
754
|
}
|
|
641
755
|
if (mirror) {
|
|
642
756
|
mirror.focus();
|
|
643
|
-
// The caret or selection the site's input has (
|
|
644
|
-
//
|
|
757
|
+
// The caret or selection the site's input has (all of it after a Tab):
|
|
758
|
+
// typing goes on as it would have there. A caret at its start is a tap's
|
|
759
|
+
// focus not yet placed (Chromium places it after the focus): the end.
|
|
760
|
+
// Bug: a tap on "helm" then "fietstas" typed "fietstashelm".
|
|
645
761
|
const end = mirror.value.length;
|
|
762
|
+
let [from, to] = [Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end)];
|
|
763
|
+
if (!from && !to) from = to = end;
|
|
646
764
|
try {
|
|
647
|
-
mirror.setSelectionRange(
|
|
765
|
+
mirror.setSelectionRange(from, to);
|
|
648
766
|
} catch {}
|
|
649
767
|
}
|
|
650
768
|
query(mirror?.value ?? i.value);
|
|
@@ -715,8 +833,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
715
833
|
entry = false; // left in history (the same URL) unless Back took it
|
|
716
834
|
setCss('ac:sheet', null);
|
|
717
835
|
host!.style.height = '';
|
|
836
|
+
host!.style.background = 'transparent';
|
|
837
|
+
host!.style.removeProperty('--lbx-vh');
|
|
838
|
+
host!.removeAttribute('data-lbx-short');
|
|
718
839
|
mirror?.blur();
|
|
719
840
|
if (focused() === input) input?.blur();
|
|
841
|
+
// Cancelled: the site's input as it was (what the page still shows
|
|
842
|
+
// results for). Bug: the sheet's back arrow left "helmfietstas" in the
|
|
843
|
+
// site's input over a page of results for "helm".
|
|
844
|
+
if (reason === 'back' && input && input.value !== before) setValue(input, before);
|
|
720
845
|
}
|
|
721
846
|
aria?.set('aria-expanded', 'false');
|
|
722
847
|
aria?.set('aria-activedescendant', null);
|
|
@@ -760,7 +885,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
760
885
|
: sheet
|
|
761
886
|
? [...phrases, ...(tab === PRODUCT ? products : byType(tab))].map((x) => x.i)
|
|
762
887
|
: [...phrases, ...lists.flatMap((g) => g.rows), ...(wide && hero ? [hero] : []), ...grid].map((x) => x.i);
|
|
763
|
-
if (active >= 0 && !order.includes(active)) active = -1;
|
|
888
|
+
if ((active >= 0 && !order.includes(active)) || (active === ALL && sheet)) active = -1;
|
|
764
889
|
const phoneNumber = t('help.phone');
|
|
765
890
|
const help = phoneNumber && phoneNumber !== 'help.phone' ? { title: t('help.title'), phone: phoneNumber, tel: phoneNumber.replace(/[^\d+]/g, ''), hours: t('help.hours').replace(/^help\.hours$/, '') } : null;
|
|
766
891
|
const value = (drawnValue = mirror?.value ?? input?.value ?? '');
|
|
@@ -785,7 +910,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
785
910
|
lists,
|
|
786
911
|
hero: sheet || wide ? hero : null,
|
|
787
912
|
products: grid,
|
|
788
|
-
tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), render(), markActive()) })),
|
|
913
|
+
tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), toTop(), render(), markActive()) })),
|
|
789
914
|
tab,
|
|
790
915
|
empty: { tries, browse, fallback, help },
|
|
791
916
|
hover,
|
|
@@ -819,6 +944,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
819
944
|
},
|
|
820
945
|
back: () => leave('back'),
|
|
821
946
|
});
|
|
947
|
+
// "Show all products" as the arrow keys' selection: lit as when it has the focus (its CSS: [data-lbx-active]).
|
|
948
|
+
const all = box.querySelector('[data-lbx-all]');
|
|
949
|
+
if (all && all.hasAttribute('data-lbx-active') !== (active === ALL)) all.toggleAttribute('data-lbx-active', active === ALL);
|
|
950
|
+
if (active === ALL && !all) active = -1;
|
|
822
951
|
};
|
|
823
952
|
|
|
824
953
|
/**
|
|
@@ -1061,6 +1190,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1061
1190
|
active = sel ? items.findIndex((r) => r.type === sel.type && r.id === sel.id) : -1;
|
|
1062
1191
|
lastQuery = q;
|
|
1063
1192
|
listOnly = results.length > 0 && !results.some((r) => r.type === PRODUCT);
|
|
1193
|
+
toTop();
|
|
1064
1194
|
// The sheet takes what arrives while its input has let go (the keyboard put away to read).
|
|
1065
1195
|
if (!sheet && focused() !== input) return;
|
|
1066
1196
|
// The first opening with an onOpen (the site's own code: one storefront opens its
|
|
@@ -1100,14 +1230,18 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1100
1230
|
const typed = () => {
|
|
1101
1231
|
const i = input;
|
|
1102
1232
|
if (!i || !mirror || i.value === mirror.value) return;
|
|
1233
|
+
setValue(i, mirror.value);
|
|
1234
|
+
};
|
|
1235
|
+
/** A value into the site's input as typing would put it (an `input` event), which ours ignores. */
|
|
1236
|
+
function setValue(i: HTMLInputElement, value: string) {
|
|
1103
1237
|
suppressInput = true;
|
|
1104
1238
|
try {
|
|
1105
|
-
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i,
|
|
1239
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, value);
|
|
1106
1240
|
i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
1107
1241
|
} finally {
|
|
1108
1242
|
suppressInput = false;
|
|
1109
1243
|
}
|
|
1110
|
-
}
|
|
1244
|
+
}
|
|
1111
1245
|
const handToHost = (from: HTMLInputElement, q: string) => {
|
|
1112
1246
|
// A host that re-rendered its header meanwhile: its input as it is now.
|
|
1113
1247
|
const i = from.isConnected ? from : (document.querySelector(selector) as HTMLInputElement | null) ?? from;
|
|
@@ -1123,30 +1257,47 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1123
1257
|
if (notCancelled && i.form) i.form.requestSubmit();
|
|
1124
1258
|
};
|
|
1125
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* A suggestion followed (a click on it, core/tiles.ts). Not a
|
|
1262
|
+
* query suggestion (the search it starts is the API's), nor a "No results"
|
|
1263
|
+
* link of the configuration: the fallback strip's products are clicks.
|
|
1264
|
+
*/
|
|
1265
|
+
const track = (i: number) => {
|
|
1266
|
+
const r = items[i];
|
|
1267
|
+
const kind = (r as any)?.__empty;
|
|
1268
|
+
if (r && r.type !== 'query' && (!kind || kind === 'fallback')) picked(r, i, 'lbx_autocomplete', ctx.global);
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1126
1271
|
function choose(idx: number) {
|
|
1127
1272
|
const r = items[idx];
|
|
1128
1273
|
if (!r) return;
|
|
1129
1274
|
if (r.type === 'query') submitViaHost(plain(r.c.title));
|
|
1130
1275
|
else if (safeUrl(r.c.url)) {
|
|
1131
1276
|
const url = safeUrl(r.c.url);
|
|
1277
|
+
track(idx);
|
|
1132
1278
|
hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
|
|
1133
1279
|
leave('select', () => location.assign(url));
|
|
1134
1280
|
}
|
|
1135
1281
|
}
|
|
1136
1282
|
|
|
1137
|
-
/**
|
|
1283
|
+
/**
|
|
1284
|
+
* The arrow keys: the next option in `order`, then "Show all products" (the
|
|
1285
|
+
* dropdown's, not the sheet's), then back to the input (-1); Arrow Up the
|
|
1286
|
+
* other way round.
|
|
1287
|
+
*/
|
|
1138
1288
|
const step = (by: 1 | -1) => {
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1289
|
+
const stops = !sheet && allLink() ? [...order, ALL] : order;
|
|
1290
|
+
const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
|
|
1291
|
+
const at = stops.indexOf(active);
|
|
1141
1292
|
const next = ((at + by + 1 + n) % n) - 1;
|
|
1142
|
-
active = next < 0 ? -1 :
|
|
1293
|
+
active = next < 0 ? -1 : stops[next];
|
|
1143
1294
|
byPointer = false;
|
|
1144
1295
|
render();
|
|
1145
1296
|
// Into view once the frame with it has rendered (layout clean then): in
|
|
1146
1297
|
// the key's task, right after our render, it forced a style and layout of
|
|
1147
1298
|
// the page (most of typing's forced layout; the key's response waited).
|
|
1148
1299
|
const row = markActive();
|
|
1149
|
-
if (row) afterFrame(() => row.isConnected && row.id ===
|
|
1300
|
+
if (row) afterFrame(() => row.isConnected && row.id === idOf(active) && row.scrollIntoView({ block: 'nearest' }));
|
|
1150
1301
|
};
|
|
1151
1302
|
/** The pointer over an option: the same selection the arrow keys move. A
|
|
1152
1303
|
* touch is not a hover (a tap's pointerenter would leave the row lit). */
|
|
@@ -1194,6 +1345,21 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1194
1345
|
if (!mirror.value !== !drawnValue) render();
|
|
1195
1346
|
return;
|
|
1196
1347
|
}
|
|
1348
|
+
// Typed into the site's input while the sheet has the focus: keys the
|
|
1349
|
+
// phone sent during the hand-off (the tap focused the site's input, the
|
|
1350
|
+
// sheet's took over in that task). Carried into the sheet's input. Bug:
|
|
1351
|
+
// letters typed within a few ms of the tap were lost (the next one typed
|
|
1352
|
+
// into the sheet wrote its value over theirs).
|
|
1353
|
+
if (sheet && mirror && input && target === input && !suppressInput) {
|
|
1354
|
+
if (input.value === mirror.value) return;
|
|
1355
|
+
mirror.value = input.value;
|
|
1356
|
+
try {
|
|
1357
|
+
mirror.setSelectionRange(mirror.value.length, mirror.value.length);
|
|
1358
|
+
} catch {}
|
|
1359
|
+
query(mirror.value);
|
|
1360
|
+
if (!mirror.value !== !drawnValue) render();
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1197
1363
|
// Not one being typed into: a value set for the visitor (search/in-place.ts
|
|
1198
1364
|
// on back/forward) asks for nothing; a focus later asks for what it holds.
|
|
1199
1365
|
if (suppressInput || !matches(target) || target !== focused()) return;
|
|
@@ -1305,7 +1471,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1305
1471
|
consume(e); // also keeps the host's hidden suggestion list from moving
|
|
1306
1472
|
step(e.key === 'ArrowDown' ? 1 : -1);
|
|
1307
1473
|
} else if (e.key === 'Enter') {
|
|
1308
|
-
if (active
|
|
1474
|
+
if (active === ALL) {
|
|
1475
|
+
consume(e);
|
|
1476
|
+
submitAll();
|
|
1477
|
+
} else if (active >= 0) {
|
|
1309
1478
|
consume(e); // our selection wins over the host's Enter handler
|
|
1310
1479
|
choose(active);
|
|
1311
1480
|
} else if (sheet) {
|
|
@@ -1324,6 +1493,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1324
1493
|
const all = allLink();
|
|
1325
1494
|
if (!all) return;
|
|
1326
1495
|
consume(e);
|
|
1496
|
+
// The one thing lit: the options' selection goes (its focus ring shows where Tab went).
|
|
1497
|
+
if (active !== -1) {
|
|
1498
|
+
active = -1;
|
|
1499
|
+
render();
|
|
1500
|
+
markActive(false);
|
|
1501
|
+
}
|
|
1327
1502
|
all.focus();
|
|
1328
1503
|
}
|
|
1329
1504
|
});
|
|
@@ -1359,7 +1534,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1359
1534
|
close('navigate');
|
|
1360
1535
|
});
|
|
1361
1536
|
|
|
1362
|
-
hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && (
|
|
1537
|
+
hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
|
|
1363
1538
|
|
|
1364
1539
|
// The interaction that loaded us has already happened: pick it up.
|
|
1365
1540
|
const now = focused();
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
--lbx-radius-row: calc(var(--lbx-radius) * .6);
|
|
37
37
|
--lbx-radius-tile: calc(var(--lbx-radius) * .5);
|
|
38
38
|
}
|
|
39
|
-
/* The render root: the sheet
|
|
40
|
-
|
|
39
|
+
/* The render root: the sheet is the visual viewport's height (--lbx-vh, index.ts; the host reaches on
|
|
40
|
+
below it, painted); the panel is as tall as it is. */
|
|
41
|
+
.lbx-root { height: var(--lbx-vh, 100%); }
|
|
41
42
|
/* What the rules read: the tokens, or the high-contrast palette while it is on. */
|
|
42
43
|
.box, .sheet {
|
|
43
44
|
--c-fg: var(--lbx-c-fg, var(--lbx-fg));
|
|
@@ -105,14 +106,15 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
105
106
|
.clamp, .card .t, .mproduct .t, .compact .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
106
107
|
|
|
107
108
|
/* The top product: the whole card is the option. */
|
|
108
|
-
.hero { flex: 1; flex-direction: column; align-items: stretch; gap:
|
|
109
|
+
.hero { flex: 1; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 8px; padding: 10px; margin: -10px; border-radius: var(--lbx-radius-row); }
|
|
109
110
|
.pic { position: relative; height: 230px; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-tile); background: var(--lbx-soft); overflow: hidden; }
|
|
110
111
|
.v-spec .pic { height: 120px; }
|
|
111
112
|
.badge { position: absolute; top: 10px; left: 10px; height: 24px; padding: 0 10px; display: flex; align-items: center; border-radius: 999px; background: var(--c-fill); color: var(--c-on-fill); font-size: 12px; font-weight: 700; }
|
|
112
113
|
.hero .t { font-size: 17px; font-weight: 700; }
|
|
113
114
|
.hero .stock { font-size: 13px; gap: 8px; }
|
|
114
115
|
.hero .dot { width: 8px; height: 8px; }
|
|
115
|
-
|
|
116
|
+
/* Under the title, not at the column's foot; the old price first, as on the cards. */
|
|
117
|
+
.hero .prices { margin-top: 4px; align-items: baseline; gap: 8px; }
|
|
116
118
|
.hero .now { font-size: 26px; letter-spacing: -.01em; }
|
|
117
119
|
.hero .old { font-size: 13px; }
|
|
118
120
|
.specs { display: flex; flex-direction: column; border-top: 1px solid var(--c-line); }
|
|
@@ -136,7 +138,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
136
138
|
/* "Show all products": the PRODUCTS column's foot, outlined; filled when focused or hovered. Outside the listbox. */
|
|
137
139
|
.all { display: flex; align-items: center; justify-content: center; gap: 8px; height: 46px; border-radius: var(--lbx-radius-row); box-shadow: inset 0 0 0 1.5px var(--c-fill);
|
|
138
140
|
color: var(--c-accent); font-size: 15px; font-weight: 700; background: var(--c-bg); cursor: pointer; }
|
|
139
|
-
.all:hover, .all:focus-visible { background: var(--c-fill); color: var(--c-on-fill); }
|
|
141
|
+
.all:hover, .all:focus-visible, .all[data-lbx-active] { background: var(--c-fill); color: var(--c-on-fill); }
|
|
140
142
|
.box .all { position: absolute; right: 24px; bottom: 20px; left: 24px; }
|
|
141
143
|
.box.has-list .all { left: 304px; }
|
|
142
144
|
.box.has-hero .all { left: 334px; }
|
|
@@ -208,6 +210,10 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
208
210
|
.mhero .t { font-size: 16px; line-height: 1.3; font-weight: 700; }
|
|
209
211
|
.mhero .stock, .mproduct .stock { font-size: 13px; }
|
|
210
212
|
.mhero .now { font-size: 22px; }
|
|
213
|
+
/* The keyboard up (a short sheet): a smaller top product, more of the list in view. */
|
|
214
|
+
:host([data-lbx-short]) .mhero .tile { width: 72px; height: 72px; }
|
|
215
|
+
:host([data-lbx-short]) .m-hero { padding: 12px 16px; gap: 8px; }
|
|
216
|
+
:host([data-lbx-short]) .mhero .now { font-size: 18px; }
|
|
211
217
|
.mproduct { padding: 12px 16px; gap: 14px; border-bottom: 1px solid var(--c-line); }
|
|
212
218
|
.mproduct .tile { width: 72px; height: 72px; }
|
|
213
219
|
.mproduct .t { font-size: 15px; font-weight: 700; }
|
|
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
|
|
|
34
34
|
<button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
|
|
35
35
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"></path></svg>
|
|
36
36
|
</button>
|
|
37
|
-
<input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
37
|
+
<input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
38
38
|
role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
|
|
39
39
|
aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
|
|
40
40
|
${s.value
|