@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/main.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
|
|
|
25
25
|
import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
|
|
26
26
|
import { askChallenge, storedToken } from './api/token-key';
|
|
27
27
|
import { loadDictionary } from './core/locales';
|
|
28
|
+
import { ANALYTICS, AUTOCOMPLETE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_WHILE_TYPING, NAMED_SPECULATION, PLP, QUERY_URL, RECOMMENDER, SEARCH, SSR } from './core/features';
|
|
28
29
|
import { keepDl } from './core/kept';
|
|
29
30
|
import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
|
|
30
31
|
import { onFlush, schedule } from './core/scheduler';
|
|
@@ -32,7 +33,7 @@ import { onUrlChange, startSensing } from './core/sense';
|
|
|
32
33
|
import { layered, setCss, whenApplied } from './core/styles';
|
|
33
34
|
import type { ListingRoute } from './core/listings';
|
|
34
35
|
import type { Reco } from './core/recommenders';
|
|
35
|
-
import type { GlobalConfigType, LumaConfigType, Result } from './types';
|
|
36
|
+
import type { GlobalConfigType, LumaConfigType, Result, SearchConfigType } from './types';
|
|
36
37
|
|
|
37
38
|
declare const __VERSION__: string;
|
|
38
39
|
// Replaced at build time; the dev server (the loader's development mode) does not replace it.
|
|
@@ -54,6 +55,8 @@ export interface WidgetContext<C = any> {
|
|
|
54
55
|
result: (hit: any) => Result;
|
|
55
56
|
/** Helpers every template scope gets. */
|
|
56
57
|
helpers: Record<string, unknown>;
|
|
58
|
+
/** The search page of a phrase where the site's server redirects its form there (`search.queryUrl`): the autocomplete's "show all" link. */
|
|
59
|
+
queryUrl?: SearchConfigType['queryUrl'];
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
export const startIntegration = async (files: IntegrationFiles) => {
|
|
@@ -78,7 +81,7 @@ export const startIntegration = async (files: IntegrationFiles) => {
|
|
|
78
81
|
// its solver is plain JS since (api/token.ts), the rule stands. Said
|
|
79
82
|
// once, with the fix; nothing starts and the site keeps its own UI.
|
|
80
83
|
if (!isSecureContext)
|
|
81
|
-
return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only
|
|
84
|
+
return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only; its widgets did not start (the site keeps its own).`), off());
|
|
82
85
|
return boot(config, files);
|
|
83
86
|
}
|
|
84
87
|
} catch (e) {
|
|
@@ -105,11 +108,12 @@ type Loaded = { mod: any; ctx: WidgetContext };
|
|
|
105
108
|
const run = (where: string, p: Promise<Loaded>, f: (m: Loaded) => unknown, onFail?: () => void) =>
|
|
106
109
|
p.then(f, () => onFail?.()).catch((e) => reportError(where, e));
|
|
107
110
|
|
|
111
|
+
// A widget the integration has no folder for (core/features.ts): no chunk.
|
|
108
112
|
const modules = {
|
|
109
|
-
autocomplete: () => import('./widgets/autocomplete'),
|
|
110
|
-
search: () => import('./widgets/search'),
|
|
111
|
-
plp: () => import('./widgets/plp'),
|
|
112
|
-
recommender: () => import('./widgets/recommender'),
|
|
113
|
+
autocomplete: AUTOCOMPLETE ? () => import('./widgets/autocomplete') : null,
|
|
114
|
+
search: SEARCH ? () => import('./widgets/search') : null,
|
|
115
|
+
plp: PLP ? () => import('./widgets/plp') : null,
|
|
116
|
+
recommender: RECOMMENDER ? () => import('./widgets/recommender') : null,
|
|
113
117
|
};
|
|
114
118
|
type Kind = keyof typeof modules;
|
|
115
119
|
|
|
@@ -121,7 +125,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
121
125
|
// The product image host, learnt from an earlier listing (widgets/listing/instant.ts).
|
|
122
126
|
let img: string | null = null;
|
|
123
127
|
try {
|
|
124
|
-
img = g.preconnectImages !== false ? localStorage.getItem('lbx:img') : null;
|
|
128
|
+
img = (NAMED_PRECONNECT_IMAGES ? g.preconnectImages !== false : true) ? localStorage.getItem('lbx:img') : null;
|
|
125
129
|
} catch {}
|
|
126
130
|
const api = `https://api.${region}.luigisbox.ai`;
|
|
127
131
|
const preconnect = (host: string, cors = true) => {
|
|
@@ -160,6 +164,9 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
160
164
|
else addEventListener('load', idle, { once: true });
|
|
161
165
|
};
|
|
162
166
|
afterLoad(warm);
|
|
167
|
+
// A framework on the page whose support the build left out (clientConfig
|
|
168
|
+
// `framework`): said in development (core/frameworks-check.ts, not in a build).
|
|
169
|
+
if (import.meta.env.DEV) afterLoad(() => void import('./core/frameworks-check').then((m) => m.checkFrameworks()).catch((e) => reportError('frameworks check', e)));
|
|
163
170
|
startSensing();
|
|
164
171
|
|
|
165
172
|
// Analytics starts once the page has loaded, or with the first widget (its
|
|
@@ -177,7 +184,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
177
184
|
startAnalytics();
|
|
178
185
|
const support = import('./core/support');
|
|
179
186
|
const p = Promise.all([
|
|
180
|
-
modules[kind]() as Promise<any>,
|
|
187
|
+
modules[kind]!() as Promise<any>,
|
|
181
188
|
support,
|
|
182
189
|
widgetFiles(kind, [...folders], files.templates),
|
|
183
190
|
widgetFiles(kind, [...folders], files.styles),
|
|
@@ -188,7 +195,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
188
195
|
]).then(([mod, s, tpls, css, dict]) => {
|
|
189
196
|
if (mod.css) setCss(`default:${kind}`, layered(mod.css));
|
|
190
197
|
// Product pages on intent (default on, core/speculation.ts): our links come with the first widget.
|
|
191
|
-
if (g.speculation !== false) s.speculate(g, onUrlChange);
|
|
198
|
+
if ((NAMED_SPECULATION ? g.speculation !== false : true)) s.speculate(g, onUrlChange);
|
|
192
199
|
const ctx = s.widgetContext(s, config, { id, folders: [...folders], app, scopes: [...scopes] }, mod.templates, tpls, css, dict);
|
|
193
200
|
mark(`loaded ${id}`);
|
|
194
201
|
return { mod, ctx };
|
|
@@ -207,7 +214,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
207
214
|
// --- autocomplete: the first interaction with the search input loads it
|
|
208
215
|
const ac = config.autocomplete;
|
|
209
216
|
// The dropdown is a popover (top layer): without it (Safari < 17, Firefox < 125) the site keeps its own.
|
|
210
|
-
if (ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
|
|
217
|
+
if (AUTOCOMPLETE && ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
|
|
211
218
|
const css = hostCss('autocomplete');
|
|
212
219
|
const TYPES = ['pointerdown', 'focusin', 'input'];
|
|
213
220
|
const loadAc = () => load('autocomplete', 'autocomplete', ['_global', 'autocomplete'], ac, ['autocomplete']);
|
|
@@ -297,9 +304,11 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
297
304
|
clearTimeout(lateTimer);
|
|
298
305
|
lateTimer = window.setTimeout(() => (recently = false), LATE_MS);
|
|
299
306
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
307
|
+
if (SEARCH || PLP || RECOMMENDER) {
|
|
308
|
+
late();
|
|
309
|
+
if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
|
|
310
|
+
onUrlChange(late);
|
|
311
|
+
}
|
|
303
312
|
const recent = () => recently;
|
|
304
313
|
|
|
305
314
|
// --- listings: search results and category pages. Their boot (takeover,
|
|
@@ -310,8 +319,8 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
310
319
|
// timer), or writes the hold CSS itself before first paint and in the same
|
|
311
320
|
// task as an SPA's URL change into the route; the takeover continues that hold.
|
|
312
321
|
const routes: ListingRoute[] = [];
|
|
313
|
-
const search = config.search && !config.search.disabled ? config.search : null;
|
|
314
|
-
const plp = config.plp && !config.plp.disabled ? config.plp : null;
|
|
322
|
+
const search = SEARCH ? (config.search && !config.search.disabled ? config.search : null) : null;
|
|
323
|
+
const plp = PLP ? (config.plp && !config.plp.disabled ? config.plp : null) : null;
|
|
315
324
|
if (search) setUp('search', () => routes.push({ kind: 'search', cfg: search, accepts: searchAccepts(search), hostCss: hostCss('search') }));
|
|
316
325
|
if (plp) setUp('plp', () => routes.push({ kind: 'plp', cfg: plp, accepts: plpAccepts(plp), hostCss: hostCss('plp') }));
|
|
317
326
|
const early = earlyHold();
|
|
@@ -359,7 +368,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
359
368
|
any = true;
|
|
360
369
|
// Theirs shows at once where we failed too often; a server-rendered page holds nothing.
|
|
361
370
|
// Moving within the route (their filters) keeps what is shown.
|
|
362
|
-
if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`)) continue;
|
|
371
|
+
if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || (SSR ? !!document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`) : false)) continue;
|
|
363
372
|
const claimed = !spa && !!early?.claim?.(r.kind);
|
|
364
373
|
if (!claimed && !spa && painted()) continue; // booted after first paint: theirs stays until ours renders
|
|
365
374
|
// Ours too where the early hold is claimed: with the height ours had on this
|
|
@@ -376,37 +385,44 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
376
385
|
// Loading adds modulepreload links to <head>: not while it is being parsed.
|
|
377
386
|
for (const r of routes) if (r.accepts(u)) whenApplied(() => void loadListing(r.kind).catch(() => {}));
|
|
378
387
|
};
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
388
|
+
if (SEARCH || PLP) {
|
|
389
|
+
enter(new URL(location.href), false);
|
|
390
|
+
onUrlChange((u) => enter(u, true));
|
|
391
|
+
// A page of a listing route's URL shape whose hook accepts it only later
|
|
392
|
+
// (their data layer, after our boot): its token from now, not from then
|
|
393
|
+
// (a production storefront's category pages: ~0.3 s sooner).
|
|
394
|
+
if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
|
|
395
|
+
// A route whose hook reads the page (an `active()` on their data layer or a
|
|
396
|
+
// node) may accept it only once the page has that: looked at again on
|
|
397
|
+
// reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
|
|
398
|
+
if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
|
|
399
|
+
}
|
|
400
|
+
// Results ready on Enter, and Enter straight to the query's page (search.queryUrl):
|
|
401
|
+
// the first input into the search field loads the typing chunk (both are in it;
|
|
402
|
+
// the second only where the integration names the hook: QUERY_URL).
|
|
403
|
+
if (search && routes.some((r) => r.kind === 'search') && ((NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) || (QUERY_URL ? !!search.queryUrl : false)) && g.inputSelector) {
|
|
391
404
|
const first = (e: Event) => {
|
|
392
405
|
const i = e.composedPath?.()[0] ?? e.target;
|
|
393
406
|
if (!(i instanceof HTMLInputElement) || !i.matches(g.inputSelector!)) return;
|
|
394
407
|
removeEventListener('input', first, true);
|
|
395
|
-
import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
|
|
408
|
+
if (NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
|
|
409
|
+
if (QUERY_URL && search.queryUrl) import('./widgets/search/direct').then((m) => m.startDirect(search, g.inputSelector!)).catch((err) => reportError('search direct', err));
|
|
396
410
|
};
|
|
397
411
|
addEventListener('input', first, true);
|
|
398
412
|
}
|
|
399
413
|
// A hold no route of ours continues (stale rules, a URL the snippet
|
|
400
414
|
// over-matched) ends now, not at its timeout; a held route's ends with its takeover.
|
|
401
|
-
if (!held.size) whenApplied(() => early?.drop?.());
|
|
415
|
+
if (!(SEARCH || PLP) || !held.size) whenApplied(() => early?.drop?.());
|
|
402
416
|
// Rules for the inline early-hold snippet's next hard load (src/core/early-rules.ts).
|
|
403
417
|
// Dev bundles boot late (unbundled modules): a longer snippet timeout there.
|
|
404
418
|
// A route failing this session gets no rule: the snippet must not hold it on every page view.
|
|
405
419
|
const ok = (kind: string) => routes.some((r) => r.kind === kind) && failures(kind) < MAX_FAILURES;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
if (SEARCH || PLP)
|
|
421
|
+
writeEarlyRules(
|
|
422
|
+
[search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
|
|
423
|
+
VERSION === 'dev' ? 6000 : 1500,
|
|
424
|
+
);
|
|
425
|
+
else clearEarlyRules(); // none to hold (a listing folder removed: its stale rules go)
|
|
410
426
|
|
|
411
427
|
// --- recommenders: their triggers and placement (core/recommenders.ts, in
|
|
412
428
|
// lbx-place with the reconciler) load on a page an inline one's urlMatch /
|
|
@@ -416,22 +432,23 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
416
432
|
const pages: ((u: URL) => boolean)[] = [];
|
|
417
433
|
/** Our recommenders by name, and the pages each runs on: where ours runs, the site's of that name is not tracked. */
|
|
418
434
|
const recoPages = new Map<string, (u: URL) => boolean>();
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
+
if (RECOMMENDER)
|
|
436
|
+
for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
|
|
437
|
+
if (rc.disabled) continue;
|
|
438
|
+
const id = `reco-${name}`;
|
|
439
|
+
const folders = ['_global', `recommender ${name}`];
|
|
440
|
+
// style-host.css of the folder: e.g. hides the host's own box our widget replaces
|
|
441
|
+
// (scoped by our element's state, so it is inert where the widget is not).
|
|
442
|
+
const css = hostCss(`recommender ${name}`);
|
|
443
|
+
if (css) setCss(`host-${id}`, css);
|
|
444
|
+
// The popup is a popover (top layer): without the API there is no popup.
|
|
445
|
+
if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
|
|
446
|
+
recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
|
|
447
|
+
const re = !rc.confirm && rc.urlMatch ? setUp(id, () => RegExp(rc.urlMatch!)) : null;
|
|
448
|
+
pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
|
|
449
|
+
recoPages.set(name, pages[pages.length - 1]);
|
|
450
|
+
}
|
|
451
|
+
if (RECOMMENDER && recos.length) {
|
|
435
452
|
let asked = false;
|
|
436
453
|
let failedOn = '';
|
|
437
454
|
const check = () => {
|
|
@@ -456,12 +473,12 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
|
|
|
456
473
|
// _analytics.ts (nothing is sent that it does not say), every page once it
|
|
457
474
|
// has loaded or with the first widget (its chains on our tiles listen then).
|
|
458
475
|
// Without one, `analytics: false`: our tiles queue nothing (core/tiles.ts).
|
|
459
|
-
if (!Object.keys(files.analytics ?? {}).length) g.analytics = false;
|
|
460
|
-
if (g.analytics !== false) {
|
|
476
|
+
if (!ANALYTICS || !Object.keys(files.analytics ?? {}).length) g.analytics = false;
|
|
477
|
+
if (ANALYTICS && g.analytics !== false) {
|
|
461
478
|
const here = () => new URL(location.href);
|
|
462
479
|
const readQ = search ? searchReadQuery(search) : null;
|
|
463
480
|
const owns = (s: string) => {
|
|
464
|
-
if (s === 'autocomplete') return !!(ac && !ac.disabled && g.inputSelector);
|
|
481
|
+
if (s === 'autocomplete') return AUTOCOMPLETE ? !!(ac && !ac.disabled && g.inputSelector) : false;
|
|
465
482
|
if (s === 'search') return routes.some((r) => r.kind === 'search' && r.accepts(here()));
|
|
466
483
|
const reco = s.startsWith('recommender:') ? recoPages.get(s.slice(12)) : undefined;
|
|
467
484
|
return !!reco && !!setUp(`${s} active`, () => reco(here()));
|
package/src/types.ts
CHANGED
|
@@ -109,7 +109,8 @@ export interface GlobalConfigType {
|
|
|
109
109
|
* Puts a product in the site's cart (`_init.ts`, or `_global/_widget.ts`:
|
|
110
110
|
* only widgets call it): what a click on `[data-lbx-cart]` in one of our
|
|
111
111
|
* tiles runs (announced to analytics first: `lumaCarts()`). `count`: the
|
|
112
|
-
* attribute's value (default 1).
|
|
112
|
+
* attribute's value (default 1). The build has it only where a file of the
|
|
113
|
+
* integration contains `data-lbx-cart` (a template: core/features.ts CART).
|
|
113
114
|
*/
|
|
114
115
|
addToCart?: (r: Result, o: { count: number; el: HTMLElement; event: MouseEvent }) => unknown;
|
|
115
116
|
}
|
|
@@ -375,6 +376,18 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
|
|
|
375
376
|
/** _init.ts overrides for URLs a regex cannot describe. */
|
|
376
377
|
active?: (url: URL) => boolean;
|
|
377
378
|
readQuery?: (url: URL) => string;
|
|
379
|
+
/**
|
|
380
|
+
* _init.ts: the search page of a phrase, where the site's server sends the
|
|
381
|
+
* search form (a form that submits to `/search?q=…` and is redirected to
|
|
382
|
+
* `/search/<phrase>/`). `form` is the URL the form would submit to (its other
|
|
383
|
+
* fields). The URL returned must be one `urlMatch` / `readQuery` accept;
|
|
384
|
+
* `''` means no page of its own for this phrase (the form submits as it is). With
|
|
385
|
+
* it, Enter in the site's search input (and the autocomplete's "show all" and
|
|
386
|
+
* its hand-off of a query) loads that page directly, without the redirect;
|
|
387
|
+
* the result fetched while typing, the page prerendered and a query in place
|
|
388
|
+
* are that page's. An empty phrase submits the form as the site has it.
|
|
389
|
+
*/
|
|
390
|
+
queryUrl?: (q: string, form: URL) => string | URL;
|
|
378
391
|
/** Extra result types shown above the results (category and brand tiles), one request each. */
|
|
379
392
|
quick?: { type: string; size: number; fields?: string[] }[];
|
|
380
393
|
/**
|
|
@@ -400,17 +413,24 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
|
|
|
400
413
|
/** How long a result fetched while typing is used (ms; older: asked anew). Default 120000. */
|
|
401
414
|
prefetchMaxAgeMs?: number;
|
|
402
415
|
/**
|
|
403
|
-
*
|
|
404
|
-
* phrase
|
|
405
|
-
* typed,
|
|
406
|
-
*
|
|
407
|
-
* the site's server
|
|
416
|
+
* The search page itself, ahead of Enter (Speculation Rules): once the
|
|
417
|
+
* phrase's result is fetched while typing and the phrase is still the one
|
|
418
|
+
* typed, one list rule for that page. `'prefetch'` (default): its document
|
|
419
|
+
* is fetched (Chrome, Edge; Firefox and Safari as they ship it), so Enter
|
|
420
|
+
* from another page skips the site's server render and still renders from
|
|
421
|
+
* the stash: a document fetch on the site's server per settled phrase, no
|
|
422
|
+
* scripts run. `'prerender'`: the page is rendered ahead as well (Chrome,
|
|
423
|
+
* Edge; a prefetch rule too, for engines without prerendering): Enter
|
|
424
|
+
* activates a page already rendered, but the site's scripts run in it, and
|
|
408
425
|
* analytics that do not wait for `prerenderingchange` count a page view the
|
|
409
|
-
* shopper may never see.
|
|
410
|
-
*
|
|
426
|
+
* shopper may never see. `false`: neither. One page at a time (a newer
|
|
427
|
+
* phrase replaces it); not on the search page when `inPlaceQuery` is on
|
|
428
|
+
* (no page load to save); nothing offline, with Save-Data or on 2G.
|
|
411
429
|
*/
|
|
430
|
+
pageWhileTyping?: 'prefetch' | 'prerender' | false;
|
|
431
|
+
/** `true` is `pageWhileTyping: 'prerender'`. */
|
|
412
432
|
prerenderWhileTyping?: boolean;
|
|
413
|
-
/** Speculation Rules eagerness of
|
|
433
|
+
/** Speculation Rules eagerness of the prerender rule: 'immediate' (default) or 'eager'. */
|
|
414
434
|
prerenderEagerness?: 'immediate' | 'eager';
|
|
415
435
|
}
|
|
416
436
|
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
18
18
|
import { luminanceOf, readable } from '../../core/color';
|
|
19
|
+
import { defaultSubmitter, formDataOf } from '../../core/default-button';
|
|
19
20
|
import { onOwnBack } from '../../core/entries';
|
|
21
|
+
import { AC_HOOKS, ANALYTICS, QUERY_URL } from '../../core/features';
|
|
20
22
|
import { mark, reportError } from '../../core/health';
|
|
21
23
|
import { modalOf } from '../../core/own';
|
|
22
24
|
import { byName, view } from '../../core/templates';
|
|
@@ -341,13 +343,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
341
343
|
const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
|
|
342
344
|
box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
|
|
343
345
|
box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
|
|
344
|
-
// A middle click opens a suggestion in a new tab (no choose()): a click too
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
346
|
+
// A middle click opens a suggestion in a new tab (no choose()): a click too
|
|
347
|
+
// (core/tiles.ts), announced for analytics only (core/features.ts ANALYTICS).
|
|
348
|
+
if (ANALYTICS)
|
|
349
|
+
box.addEventListener('auxclick', (e) => {
|
|
350
|
+
if ((e as MouseEvent).button !== 1) return;
|
|
351
|
+
const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
|
|
352
|
+
const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
|
|
353
|
+
if (i >= 0) track(i);
|
|
354
|
+
});
|
|
351
355
|
// "Show all products" has focus (Tab from the input): Shift+Tab goes back
|
|
352
356
|
// to the input, Escape closes and goes back, Tab moves on as from the
|
|
353
357
|
// input (focus put back on it, the browser's own Tab then leaves it: the
|
|
@@ -591,12 +595,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
591
595
|
host!.showPopover();
|
|
592
596
|
open = true;
|
|
593
597
|
aria?.set('aria-expanded', 'true');
|
|
594
|
-
hook('onOpen', ac.onOpen);
|
|
598
|
+
if (AC_HOOKS) hook('onOpen', ac.onOpen);
|
|
595
599
|
cancelAnimationFrame(raf);
|
|
596
600
|
// A hook may move the field (a storefront's opens its search strip, the
|
|
597
601
|
// input lower): placed from where it is now, not from `m`, read before
|
|
598
602
|
// it (otherwise the dropdown opens at the old place, then jumps).
|
|
599
|
-
position(false, ac.onOpen ? undefined : m);
|
|
603
|
+
position(false, AC_HOOKS && ac.onOpen ? undefined : m);
|
|
600
604
|
}
|
|
601
605
|
};
|
|
602
606
|
|
|
@@ -605,11 +609,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
605
609
|
* the focus's frame has rendered (layout clean then), ahead of the first
|
|
606
610
|
* opening: read at the opening, after the page and our render had changed,
|
|
607
611
|
* they forced a style and layout into the task that shows the results.
|
|
612
|
+
* Not once focus has left the input: putting the dropdown in the page
|
|
613
|
+
* borrows its ARIA, which focus leaving had put back (Shift+Tab back and
|
|
614
|
+
* Tab on within a frame left role=combobox on their input: WebKit on a slow
|
|
615
|
+
* machine, 2026-10-15).
|
|
608
616
|
*/
|
|
609
617
|
let prepared: HTMLInputElement | null = null;
|
|
610
618
|
const prepare = (i: HTMLInputElement) =>
|
|
611
619
|
afterFrame(() => {
|
|
612
|
-
if (open || input !== i || !i.isConnected || sheet) return;
|
|
620
|
+
if (open || input !== i || !i.isConnected || sheet || focused() !== i) return;
|
|
613
621
|
lineUp(i);
|
|
614
622
|
if (!light) sampleTokens(i, field!);
|
|
615
623
|
prepared = i;
|
|
@@ -741,7 +749,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
741
749
|
host!.style.setProperty('display', 'block', 'important');
|
|
742
750
|
host!.showPopover();
|
|
743
751
|
open = true;
|
|
744
|
-
hook('onOpen', ac.onOpen);
|
|
752
|
+
if (AC_HOOKS) hook('onOpen', ac.onOpen);
|
|
745
753
|
setCss('ac:sheet', ':root { overflow: hidden !important; }');
|
|
746
754
|
try {
|
|
747
755
|
history.pushState(null, '', location.href);
|
|
@@ -847,7 +855,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
847
855
|
aria?.set('aria-activedescendant', null);
|
|
848
856
|
live.clear();
|
|
849
857
|
said = '';
|
|
850
|
-
hook('onClose', ac.onClose, reason);
|
|
858
|
+
if (AC_HOOKS) hook('onClose', ac.onClose, reason);
|
|
851
859
|
};
|
|
852
860
|
|
|
853
861
|
/** The sheet input's value the last render drew (its clear button). */
|
|
@@ -956,16 +964,37 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
956
964
|
* target only: its click still hands the query to the host (submitViaHost).
|
|
957
965
|
* `value`: the phrase instead of the input's (the phone sheet's own input).
|
|
958
966
|
*/
|
|
967
|
+
// Where the site's server redirects the form to the phrase's page (search.queryUrl): that page,
|
|
968
|
+
// same origin and http(s) as the form's (as every other use of the hook checks), else the form's.
|
|
969
|
+
// Decided outside searchHref's try (a flag in a try block is never folded: build/vite.mjs).
|
|
970
|
+
const pageOf = QUERY_URL
|
|
971
|
+
? (q: string, u: URL) => {
|
|
972
|
+
if (!q || !ctx.queryUrl) return u.href;
|
|
973
|
+
try {
|
|
974
|
+
const to = ctx.queryUrl(q, new URL(u.href));
|
|
975
|
+
if (to === '') return u.href;
|
|
976
|
+
const r = new URL(to, location.href);
|
|
977
|
+
return (r.protocol === 'https:' || r.protocol === 'http:') && r.origin === location.origin ? r.href : u.href;
|
|
978
|
+
} catch (e) {
|
|
979
|
+
reportError('search queryUrl', e);
|
|
980
|
+
return u.href;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
: null;
|
|
959
984
|
const searchHref = (i: HTMLInputElement | null, value?: string) => {
|
|
960
985
|
const f = i?.form;
|
|
961
986
|
if (!f || (f.getAttribute('method') || 'get').trim().toLowerCase() !== 'get') return '';
|
|
962
987
|
try {
|
|
963
988
|
const u = new URL(f.getAttribute('action')?.trim() || location.href, document.baseURI);
|
|
964
989
|
const p = new URLSearchParams();
|
|
965
|
-
|
|
990
|
+
// With the form's default button, as Enter submits (its name=value, or an
|
|
991
|
+
// image button's x and y): the URL the result and the page were fetched
|
|
992
|
+
// ahead for (widgets/search/form-url.ts reads the same, core/default-button.ts).
|
|
993
|
+
for (const [k, v] of formDataOf(f, defaultSubmitter(f))) if (typeof v === 'string') p.append(k, v);
|
|
966
994
|
if (value !== undefined && i!.name) p.set(i!.name, value);
|
|
967
995
|
u.search = p.toString();
|
|
968
|
-
|
|
996
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
|
|
997
|
+
return pageOf ? pageOf((value ?? i!.value).trim(), u) : u.href;
|
|
969
998
|
} catch {
|
|
970
999
|
return '';
|
|
971
1000
|
}
|
|
@@ -1214,7 +1243,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1214
1243
|
/** Hand a query to the host so its own router/form performs navigation. */
|
|
1215
1244
|
const submitViaHost = (q: string) => {
|
|
1216
1245
|
const i = input!;
|
|
1217
|
-
hook('onSelect', ac.onSelect, null);
|
|
1246
|
+
if (AC_HOOKS) hook('onSelect', ac.onSelect, null);
|
|
1218
1247
|
// Paint our close first; the host's router render then runs in its own task.
|
|
1219
1248
|
if (sheet) return leave('select', () => handToHost(i, q));
|
|
1220
1249
|
close('select');
|
|
@@ -1254,7 +1283,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1254
1283
|
const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true } as KeyboardEventInit;
|
|
1255
1284
|
const notCancelled = i.dispatchEvent(new KeyboardEvent('keydown', opts));
|
|
1256
1285
|
i.dispatchEvent(new KeyboardEvent('keyup', opts));
|
|
1257
|
-
|
|
1286
|
+
// As Enter would: with the form's default button (its name=value, formaction), so
|
|
1287
|
+
// the page loads at the URL the result and the document were fetched ahead for
|
|
1288
|
+
// (instant-search.spec 'a search button with a name', on a phone: here, not an Enter).
|
|
1289
|
+
if (notCancelled && i.form) {
|
|
1290
|
+
const f = i.form;
|
|
1291
|
+
try {
|
|
1292
|
+
f.requestSubmit(defaultSubmitter(f) ?? undefined);
|
|
1293
|
+
} catch {
|
|
1294
|
+
f.requestSubmit(); // a button the engine refuses as a submitter (none of this form)
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1258
1297
|
};
|
|
1259
1298
|
|
|
1260
1299
|
/**
|
|
@@ -1274,8 +1313,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1274
1313
|
if (r.type === 'query') submitViaHost(plain(r.c.title));
|
|
1275
1314
|
else if (safeUrl(r.c.url)) {
|
|
1276
1315
|
const url = safeUrl(r.c.url);
|
|
1277
|
-
track(idx);
|
|
1278
|
-
hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
|
|
1316
|
+
if (ANALYTICS) track(idx);
|
|
1317
|
+
if (AC_HOOKS) hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
|
|
1279
1318
|
leave('select', () => location.assign(url));
|
|
1280
1319
|
}
|
|
1281
1320
|
}
|
|
@@ -1534,7 +1573,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1534
1573
|
close('navigate');
|
|
1535
1574
|
});
|
|
1536
1575
|
|
|
1537
|
-
hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
|
|
1576
|
+
if (AC_HOOKS) hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
|
|
1538
1577
|
|
|
1539
1578
|
// The interaction that loaded us has already happened: pick it up.
|
|
1540
1579
|
const now = focused();
|