@luigisbox/luma 0.3.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 +95 -14
- 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 +10 -2
- package/src/analytics/collect.ts +14 -7
- package/src/analytics/from-ga4.ts +5 -2
- package/src/analytics/from-luma.ts +6 -4
- package/src/analytics/index.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 +18 -4
- 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
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// Listing results for the next page load
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
1
|
+
// Listing results for the next page load that shows them, in sessionStorage
|
|
2
|
+
// (core/stash.ts), so that load renders at once, as from a server-rendered
|
|
3
|
+
// state:
|
|
4
4
|
// - `spec`: the search page of a query the shopper was typing on another page
|
|
5
|
-
// (widgets/search/speculate.ts)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// (widgets/search/speculate.ts), under the query (`lbx:r:spec:<route>:<q>`),
|
|
6
|
+
// not the URL: the form's URL and the page's often differ (a server that
|
|
7
|
+
// redirects `/search?q=x` to `/search/x/`, a `queryUrl` the form does not
|
|
8
|
+
// know). Used by a plain navigation (or a prerender of the page) whose URL
|
|
9
|
+
// resolves to the same query (its filters, flags and sort too), within its
|
|
10
|
+
// `ttl` (2 minutes), once.
|
|
11
|
+
// - `back` (`lbx:r:<pageKey>`): the listing as the shopper left it for a product page (pages
|
|
8
12
|
// 1..N, where they were). Used by a back/forward load within 30 minutes, or
|
|
9
13
|
// by a reload or a navigation from the page itself within a minute (a link
|
|
10
14
|
// whose server redirects back to it); the listing then restores the scroll
|
|
@@ -14,10 +18,10 @@
|
|
|
14
18
|
// Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
|
|
15
19
|
// drops ours first. Best-effort: storage may be unavailable, never required.
|
|
16
20
|
|
|
17
|
-
import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
|
|
21
|
+
import { navigation, specKey, STASH, stashFits, stashKey } from '../../core/stash';
|
|
18
22
|
import { pageKey } from '../../core/page';
|
|
19
23
|
import type { ListingResult } from './query';
|
|
20
|
-
import type
|
|
24
|
+
import { queryKey, type Query } from './state';
|
|
21
25
|
import type { Spot } from './instant';
|
|
22
26
|
|
|
23
27
|
export interface Stash {
|
|
@@ -57,10 +61,14 @@ const entries = (except?: string) => {
|
|
|
57
61
|
return out.sort((a, b) => a.at - b.at);
|
|
58
62
|
};
|
|
59
63
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The stashed result this page load may render from, or null: the page's
|
|
66
|
+
* (`back`), else the search query's (`spec`, when `query` is this page's
|
|
67
|
+
* resolved state, under `specKey(route, query.q)`). `stale`: a Back entry too
|
|
68
|
+
* old to render, for its scroll only.
|
|
69
|
+
*/
|
|
70
|
+
export const readStash = (route: string, href: string, query?: Query | null): Stash | null => {
|
|
71
|
+
const read = (key: string): Stash | null => {
|
|
64
72
|
const raw = sessionStorage.getItem(key);
|
|
65
73
|
if (!raw) return null;
|
|
66
74
|
const s = JSON.parse(raw) as Stash;
|
|
@@ -69,12 +77,18 @@ export const readStash = (route: string, href: string): Stash | null => {
|
|
|
69
77
|
const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
|
|
70
78
|
if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
|
|
71
79
|
if (s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
|
|
80
|
+
if (s.kind === 'spec' && !(query && queryKey(s.query) === queryKey(query))) return null; // the form's other fields changed since
|
|
72
81
|
if (fresh && stashFits(s.kind, s.at)) return s;
|
|
73
82
|
// The entry left loaded again (Back after long, a reload after the round
|
|
74
83
|
// trip's minute): the browser restores nothing where Luma turned its
|
|
75
84
|
// restoration off; where the shopper was is still known.
|
|
76
85
|
const again = nav.type === 'back_forward' || nav.type === 'reload';
|
|
77
86
|
return s.v === 1 && s.kind === 'back' && s.sr && again ? { ...s, stale: true } : null;
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
// The page's own entry (Back) first; one it cannot use (a navigation from
|
|
90
|
+
// another page: the same query typed on a product page) does not hide the query's.
|
|
91
|
+
return read(stashKey(href)) ?? (query?.q ? read(specKey(route, query.q)) : null);
|
|
78
92
|
} catch {
|
|
79
93
|
return null;
|
|
80
94
|
}
|
|
@@ -90,18 +104,21 @@ export const peekStash = (href: string): Stash | null => {
|
|
|
90
104
|
};
|
|
91
105
|
|
|
92
106
|
/** A page the browser restored itself (the back/forward cache): its stash is not needed. */
|
|
93
|
-
export const dropStash = (href: string) =>
|
|
107
|
+
export const dropStash = (href: string) => removeStash(stashKey(href));
|
|
108
|
+
|
|
109
|
+
/** The entry at `key` (`stashKey` or `specKey`) is not needed any more. */
|
|
110
|
+
export const removeStash = (key: string) => {
|
|
94
111
|
try {
|
|
95
|
-
sessionStorage.removeItem(
|
|
112
|
+
sessionStorage.removeItem(key);
|
|
96
113
|
} catch {}
|
|
97
114
|
};
|
|
98
115
|
|
|
99
|
-
/** false: too big, or storage refused it. */
|
|
116
|
+
/** Under the page's URL (`back`) or the query (`spec`). false: too big, or storage refused it. */
|
|
100
117
|
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
|
|
101
118
|
try {
|
|
102
119
|
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 });
|
|
103
120
|
if (json.length > MAX_CHARS) return false;
|
|
104
|
-
const key = stashKey(href);
|
|
121
|
+
const key = s.kind === 'spec' ? specKey(s.route, s.query.q ?? '') : stashKey(href);
|
|
105
122
|
const others = entries(key);
|
|
106
123
|
// One speculation at a time; the newest few overall.
|
|
107
124
|
const drop = [...others.filter((e) => s.kind === 'spec' && e.spec), ...others.filter((e) => !(s.kind === 'spec' && e.spec)).slice(0, -(KEEP - 1) || undefined)];
|
|
@@ -28,6 +28,18 @@ export interface Query {
|
|
|
28
28
|
*/
|
|
29
29
|
export type UiState = Pick<Query, 'filters' | 'flags' | 'sort'>;
|
|
30
30
|
|
|
31
|
+
/** The listing state a query is, as a key: its query or scope, filters and flags whatever their order, sort (not its title or layout). */
|
|
32
|
+
export const queryKey = (q: Query) =>
|
|
33
|
+
JSON.stringify([
|
|
34
|
+
q.q ?? '',
|
|
35
|
+
q.scope ?? null,
|
|
36
|
+
Object.entries(q.filters)
|
|
37
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
38
|
+
.map(([f, vs]) => [f, [...vs].sort()]),
|
|
39
|
+
[...q.flags].sort(),
|
|
40
|
+
q.sort,
|
|
41
|
+
]);
|
|
42
|
+
|
|
31
43
|
/** What differs between search and category pages: URL <-> state. */
|
|
32
44
|
export interface Route {
|
|
33
45
|
id: string;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { WidgetContext } from '../../main';
|
|
12
12
|
import type { Helpers, SearchScope } from '../../types';
|
|
13
|
+
import { SEARCH } from '../../core/features';
|
|
13
14
|
import { fold } from '../../core/fold';
|
|
14
15
|
import { navigable } from '../../core/result';
|
|
15
16
|
import { hasMore, parseRange, type ListingConfig, type ListingResult } from './query';
|
|
@@ -222,7 +223,7 @@ export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | '
|
|
|
222
223
|
showTitle: !!cfg.showTitle,
|
|
223
224
|
total: res.total,
|
|
224
225
|
results: res.hits.map(resultOf(ctx.result)),
|
|
225
|
-
quick: Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])),
|
|
226
|
+
quick: SEARCH ? Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])) : {},
|
|
226
227
|
facets,
|
|
227
228
|
ranges,
|
|
228
229
|
layout,
|
package/src/widgets/plp/route.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { flagId, has, P, paramValues, plpAccepts, TRACKING, withoutTracking } fr
|
|
|
17
17
|
import { lru, session } from '../../core/storage';
|
|
18
18
|
import type { CategoryFilter, FacetLayout, GlobalConfigType, PlpConfigType } from '../../types';
|
|
19
19
|
import { reportError } from '../../core/health';
|
|
20
|
+
import { FACETS_FOR } from '../../core/features';
|
|
20
21
|
import { readOwn, type Query, type Route, type UiState } from '../listing/state';
|
|
21
22
|
import { parseRange } from '../listing/query';
|
|
22
23
|
|
|
@@ -43,7 +44,7 @@ export const plpRoute = (pc: PlpConfigType, api: Api, g: GlobalConfigType): Rout
|
|
|
43
44
|
const mappedParams = new Set(mapped.map(([, fp]) => fp.param));
|
|
44
45
|
// Our value -> their value, per facet with a value map.
|
|
45
46
|
const inverse = Object.fromEntries(Object.entries(facetParams).map(([facet, fp]) => [facet, fp.values ? new Map(Object.entries(fp.values).map(([h, v]) => [v, h])) : null]));
|
|
46
|
-
const ignore =
|
|
47
|
+
const ignore = RegExp(pm.ignore ?? TRACKING);
|
|
47
48
|
const values = (url: URL, param: string) => paramValues(url, param, sep);
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -157,7 +158,7 @@ export const plpRoute = (pc: PlpConfigType, api: Api, g: GlobalConfigType): Rout
|
|
|
157
158
|
|
|
158
159
|
/** The page's own facet list (`facetsFor`, _init.ts): e.g. the category's parameters. */
|
|
159
160
|
const withFacets = (url: URL, q: Query): Query | Promise<Query> => {
|
|
160
|
-
if (!pc.facetsFor) return q;
|
|
161
|
+
if (!FACETS_FOR || !pc.facetsFor) return q;
|
|
161
162
|
const apply = (l: FacetLayout | null | undefined): Query => (l?.facets ? { ...q, facets: l.facets, layout: l.layout } : q);
|
|
162
163
|
const failed = (e: unknown) => (reportError('plp facetsFor', e), q);
|
|
163
164
|
try {
|
|
@@ -20,6 +20,7 @@ import { settleImages } from '../../core/support';
|
|
|
20
20
|
import type { WidgetContext } from '../../main';
|
|
21
21
|
import type { RecommenderConfigType, Result } from '../../types';
|
|
22
22
|
import { watch } from '../../core/tiles';
|
|
23
|
+
import { NAMED_FADE } from '../../core/features';
|
|
23
24
|
|
|
24
25
|
export { default as css } from './style-recommender.css?raw';
|
|
25
26
|
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
@@ -98,7 +99,7 @@ const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string)
|
|
|
98
99
|
if (ctl.signal.aborted) return;
|
|
99
100
|
s.results = hits.map(ctx.result);
|
|
100
101
|
s.v.render('recommender-box-default', { ...ctx.helpers, results: s.results });
|
|
101
|
-
settleImages(el, ctx.global.images?.fade !== false);
|
|
102
|
+
settleImages(el, (NAMED_FADE ? ctx.global.images?.fade !== false : true));
|
|
102
103
|
el.style.minHeight = '';
|
|
103
104
|
el.dataset.state = hits.length ? 'ready' : 'empty';
|
|
104
105
|
s.failures = 0;
|
|
@@ -243,7 +244,7 @@ export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Elemen
|
|
|
243
244
|
pop.setAttribute('aria-label', ctx.t('title'));
|
|
244
245
|
popResults = hits.map(ctx.result);
|
|
245
246
|
popView!.render('recommender-popup', { ...ctx.helpers, results: popResults, close });
|
|
246
|
-
settleImages(pop, ctx.global.images?.fade !== false);
|
|
247
|
+
settleImages(pop, (NAMED_FADE ? ctx.global.images?.fade !== false : true));
|
|
247
248
|
pop.querySelector('[data-lbx-list]')?.scrollTo(0, 0);
|
|
248
249
|
// Just below the host's notice, right-aligned with it.
|
|
249
250
|
const vw = document.documentElement.clientWidth;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Enter goes to the query's page. Where the site's server redirects its
|
|
2
|
+
// search form to the page of the phrase (`/search?q=x` to `/search/x/`), the
|
|
3
|
+
// integration's `search.queryUrl` names that page (types.ts), and a submit of
|
|
4
|
+
// the site's search form (typed + Enter, its button, our autocomplete's "show
|
|
5
|
+
// all" and its hand-off of a query, which submit that form) loads it directly:
|
|
6
|
+
// the redirect's round trip, and the site's handling of the form, are not paid.
|
|
7
|
+
// Only when that page is another URL than the form's own, and only what the
|
|
8
|
+
// browser would do with the form (./form-url.ts: GET, this window, no
|
|
9
|
+
// modifier, no file field; Enter as the browser's implicit submission); not
|
|
10
|
+
// on the search page while queries there are in place (./in-place.ts takes
|
|
11
|
+
// the submit, or leaves it to the browser). Anything else submits as usual.
|
|
12
|
+
// Loaded with search while typing (main.ts, once the shopper types), never in
|
|
13
|
+
// the entry.
|
|
14
|
+
import { NAMED_IN_PLACE_QUERY } from '../../core/features';
|
|
15
|
+
import { mark } from '../../core/health';
|
|
16
|
+
import { pageKey } from '../../core/page';
|
|
17
|
+
import type { SearchConfigType } from '../../types';
|
|
18
|
+
import { formUrl, queryPage } from './form-url';
|
|
19
|
+
import { onSearchSubmit } from './submit';
|
|
20
|
+
import { searchRoute } from './route';
|
|
21
|
+
|
|
22
|
+
export const startDirect = (cfg: SearchConfigType, inputSelector: string) => {
|
|
23
|
+
const route = searchRoute(cfg);
|
|
24
|
+
onSearchSubmit(
|
|
25
|
+
'search direct',
|
|
26
|
+
inputSelector,
|
|
27
|
+
(form, submitter, input) => {
|
|
28
|
+
if (!input || ((NAMED_IN_PLACE_QUERY ? cfg.inPlaceQuery !== false : true) && route.accepts(new URL(location.href)))) return null; // in-place.ts's
|
|
29
|
+
const own = formUrl(form, submitter);
|
|
30
|
+
if (!own) return null;
|
|
31
|
+
const to = queryPage(cfg.queryUrl, input.value.trim(), own);
|
|
32
|
+
return to.origin === location.origin && route.accepts(to) && pageKey(to.href) !== pageKey(own.href) ? to : null;
|
|
33
|
+
},
|
|
34
|
+
(to, _form, input) => {
|
|
35
|
+
mark(`search direct: ${input!.value.trim()}`);
|
|
36
|
+
location.assign(to.href);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
// The URL a GET form navigates to (formUrl)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The URL a GET form navigates to (formUrl), and the search page a submit of
|
|
2
|
+
// the site's search form leads to (searchTarget: that URL, or the page the
|
|
3
|
+
// integration's `queryUrl` names for the phrase typed, where the site's server
|
|
4
|
+
// redirects the form): what in-place search (./in-place.ts, the search page),
|
|
5
|
+
// search while typing (./speculate.ts, any page) and the direct load of the
|
|
6
|
+
// query's page (./direct.ts) all read. In lbx-nav, which every listing page
|
|
7
|
+
// loads: only the URL helpers (taking a submit is ./submit.ts, loaded where
|
|
8
|
+
// one is taken).
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
import { QUERY_URL } from '../../core/features';
|
|
11
|
+
import { reportError } from '../../core/health';
|
|
12
|
+
import { formDataOf, type Submitter } from '../../core/default-button';
|
|
13
|
+
import type { SearchConfigType } from '../../types';
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
16
|
* The URL a GET form navigates to, as the browser builds it: the submitter's
|
|
@@ -11,22 +19,6 @@ export type Submitter = HTMLButtonElement | HTMLInputElement;
|
|
|
11
19
|
* window, a file field, a non-http action). Attributes are read with
|
|
12
20
|
* getAttribute: a field named `action` or `method` shadows the form's properties.
|
|
13
21
|
*/
|
|
14
|
-
let withSubmitter: boolean | undefined;
|
|
15
|
-
const submitterSupported = () => {
|
|
16
|
-
if (withSubmitter === undefined) {
|
|
17
|
-
try {
|
|
18
|
-
const f = document.createElement('form');
|
|
19
|
-
const b = f.appendChild(document.createElement('button'));
|
|
20
|
-
b.name = 'x';
|
|
21
|
-
b.value = '1';
|
|
22
|
-
withSubmitter = new FormData(f, b).get('x') === '1';
|
|
23
|
-
} catch {
|
|
24
|
-
withSubmitter = false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return withSubmitter;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
22
|
export const formUrl = (form: HTMLFormElement, submitter?: HTMLElement | null): URL | null => {
|
|
31
23
|
const sub = submitter && 'form' in submitter ? (submitter as Submitter) : null;
|
|
32
24
|
const attr = (name: string) => (sub?.hasAttribute(`form${name}`) ? sub.getAttribute(`form${name}`) : form.getAttribute(name));
|
|
@@ -36,24 +28,36 @@ export const formUrl = (form: HTMLFormElement, submitter?: HTMLElement | null):
|
|
|
36
28
|
const action = attr('action')?.trim();
|
|
37
29
|
const url = new URL(action ? action : location.href, action ? document.baseURI : undefined);
|
|
38
30
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
39
|
-
// FormData(form, submitter) is newer (Chrome 112, Safari 16.4, Firefox
|
|
40
|
-
// 111): before, the second argument is ignored (it does not throw), so the
|
|
41
|
-
// submitter's own entry is added by hand there.
|
|
42
|
-
const native = submitterSupported();
|
|
43
|
-
let own = !native; // the submitter's entry is ours to add
|
|
44
|
-
let data: FormData;
|
|
45
|
-
try {
|
|
46
|
-
data = new FormData(form, native && sub ? sub : undefined);
|
|
47
|
-
} catch {
|
|
48
|
-
data = new FormData(form); // not a submit button of this form
|
|
49
|
-
own = true;
|
|
50
|
-
}
|
|
51
|
-
if (own && sub?.name) data.append(sub.name, sub.value);
|
|
52
31
|
const params = new URLSearchParams();
|
|
53
|
-
for (const [k, v] of
|
|
32
|
+
for (const [k, v] of formDataOf(form, sub)) {
|
|
54
33
|
if (typeof v !== 'string') return null;
|
|
55
34
|
params.append(k, v);
|
|
56
35
|
}
|
|
57
36
|
url.search = params.toString();
|
|
58
37
|
return url;
|
|
59
38
|
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The page a search for `q` leads to: `url` (the form's own), or with
|
|
42
|
+
* `search.queryUrl` the page the hook names (the site's server would redirect
|
|
43
|
+
* the form there). An empty phrase is the form's own submit (the site's empty
|
|
44
|
+
* search), and so is a hook's `''` (no page of its own for this phrase); a
|
|
45
|
+
* hook that throws or returns an invalid URL is reported, and `url` stands.
|
|
46
|
+
*/
|
|
47
|
+
export const queryPage = (queryUrl: SearchConfigType['queryUrl'], q: string, url: URL): URL => {
|
|
48
|
+
if (!QUERY_URL || !queryUrl || !q) return url;
|
|
49
|
+
try {
|
|
50
|
+
// '' from the hook: the form's URL (never the current page, which `new URL('')` would be).
|
|
51
|
+
const to = queryUrl(q, new URL(url.href));
|
|
52
|
+
return to === '' ? url : new URL(to, location.href);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
reportError('search queryUrl', e);
|
|
55
|
+
return url;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The search page a submit of `form` leads to: `queryPage` of the form's URL and the phrase in `input`. */
|
|
60
|
+
export const searchTarget = (queryUrl: SearchConfigType['queryUrl'], form: HTMLFormElement, submitter: HTMLElement | null | undefined, input: HTMLInputElement | null | undefined): URL | null => {
|
|
61
|
+
const url = formUrl(form, submitter);
|
|
62
|
+
return url && queryPage(queryUrl, input?.value.trim() ?? '', url);
|
|
63
|
+
};
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// and a re-render of our listing, not a page load. Anywhere else, and for
|
|
5
5
|
// anything we cannot do exactly as the browser would, the form submits as usual.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// The form's submit, and Enter in its input ahead of the site's own handlers,
|
|
8
|
+
// are heard by ./form-url.ts onSearchSubmit (a window-capture listener,
|
|
9
|
+
// nothing bound to the host's form; a shadow-root header's too; Enter as the
|
|
10
|
+
// browser's implicit submission). The form's target is resolved as the browser
|
|
11
|
+
// would (method GET, action, target, its fields), or as the site's server
|
|
12
|
+
// would redirect it (`queryUrl`, searchTarget): only when the page we are on
|
|
13
|
+
// and that URL are both search pages of ours, the navigation is ours. What the page load did for free is
|
|
11
14
|
// done here: the listing starts the new query anew (its filters and sort
|
|
12
15
|
// are our own URL params, which the form's URL does not carry), the input
|
|
13
16
|
// loses focus (the autocomplete closes, a phone's keyboard goes), the page
|
|
@@ -17,8 +20,8 @@
|
|
|
17
20
|
// (it hands the page back) is loaded for real.
|
|
18
21
|
//
|
|
19
22
|
// Opt-out per integration: `inPlaceQuery: false` in search/_config.ts.
|
|
20
|
-
//
|
|
21
|
-
// search submits as usual.
|
|
23
|
+
// Its own chunk (lbx-search-in-place), asked for as the search widget starts and
|
|
24
|
+
// never waited for: until it has loaded, a search submits as usual.
|
|
22
25
|
|
|
23
26
|
import { mark, reportError } from '../../core/health';
|
|
24
27
|
import { searchReadQuery } from '../../core/routes';
|
|
@@ -27,7 +30,8 @@ import type { Takeover } from '../../core/takeover';
|
|
|
27
30
|
import { canon, writeUrl } from '../../core/url';
|
|
28
31
|
import type { SearchConfigType } from '../../types';
|
|
29
32
|
import type { Route } from '../listing/state';
|
|
30
|
-
import {
|
|
33
|
+
import { searchTarget } from './form-url';
|
|
34
|
+
import { onSearchSubmit } from './submit';
|
|
31
35
|
|
|
32
36
|
export const startInPlace = (cfg: SearchConfigType, inputSelector: string | undefined, tk: Takeover, route: Route) => {
|
|
33
37
|
const read = searchReadQuery(cfg);
|
|
@@ -37,32 +41,12 @@ export const startInPlace = (cfg: SearchConfigType, inputSelector: string | unde
|
|
|
37
41
|
const ours = new Set<string>();
|
|
38
42
|
/** Our own history write is running (its URL change is not a back/forward). */
|
|
39
43
|
let pushing = false;
|
|
40
|
-
const isInput = (el: Element | null): el is HTMLInputElement => !!inputSelector && el instanceof HTMLInputElement && el.matches(inputSelector);
|
|
41
44
|
/** The focused element, inside shadow roots too. */
|
|
42
45
|
const focused = () => {
|
|
43
46
|
let a = document.activeElement;
|
|
44
47
|
while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
|
|
45
48
|
return a;
|
|
46
49
|
};
|
|
47
|
-
// A search input in a shadow root (a web-component header): its key events
|
|
48
|
-
// reach window retargeted to the shadow host, and its form's `submit` does
|
|
49
|
-
// not leave the shadow root at all (it is not composed). Such an input is
|
|
50
|
-
// read from the event's composed path, its root gets the same submit
|
|
51
|
-
// listener once one of its inputs is used, and it is remembered for the
|
|
52
|
-
// query on back/forward (document queries do not reach it).
|
|
53
|
-
const shadowInputs = new Set<HTMLInputElement>();
|
|
54
|
-
const roots = new WeakSet<ShadowRoot>();
|
|
55
|
-
const realTarget = (e: Event) => (e.composedPath?.()[0] ?? e.target) as Element | null;
|
|
56
|
-
const seen = (e: Event) => {
|
|
57
|
-
const i = realTarget(e);
|
|
58
|
-
const root = isInput(i) ? i.getRootNode() : null;
|
|
59
|
-
if (!(root instanceof ShadowRoot)) return;
|
|
60
|
-
shadowInputs.add(i as HTMLInputElement);
|
|
61
|
-
if (roots.has(root)) return;
|
|
62
|
-
roots.add(root);
|
|
63
|
-
root.addEventListener('submit', onSubmit as EventListener, true);
|
|
64
|
-
};
|
|
65
|
-
addEventListener('focusin', seen, true);
|
|
66
50
|
const ourElement = () => document.querySelector<HTMLElement>(`${route.tag}[data-lbx]`);
|
|
67
51
|
/**
|
|
68
52
|
* The host's open <dialog>s and popovers around `el`, across shadow roots,
|
|
@@ -81,12 +65,6 @@ export const startInPlace = (cfg: SearchConfigType, inputSelector: string | unde
|
|
|
81
65
|
};
|
|
82
66
|
const top = () => scrollTo({ top: 0, left: 0, behavior: 'instant' } as unknown as ScrollToOptions);
|
|
83
67
|
|
|
84
|
-
// A form submitted with a modifier (a new tab or window, where the browser supports it) stays the browser's.
|
|
85
|
-
let mods = { on: false, t: -1 };
|
|
86
|
-
const track = (e: KeyboardEvent | MouseEvent) => void (mods = { on: e.ctrlKey || e.metaKey || e.shiftKey || e.altKey, t: e.timeStamp });
|
|
87
|
-
addEventListener('keydown', track, true);
|
|
88
|
-
addEventListener('click', track, true);
|
|
89
|
-
|
|
90
68
|
/**
|
|
91
69
|
* The visit settles: ours once it renders (then the page scrolls to the top,
|
|
92
70
|
* after host code that restores its own scroll position, a search overlay
|
|
@@ -115,87 +93,44 @@ export const startInPlace = (cfg: SearchConfigType, inputSelector: string | unde
|
|
|
115
93
|
tick();
|
|
116
94
|
};
|
|
117
95
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (inputSelector && ![...form.elements].some(isInput)) return false; // not the site's search form
|
|
126
|
-
if (mods.on && e.timeStamp - mods.t < 100) return false;
|
|
127
|
-
const here = new URL(location.href);
|
|
96
|
+
// Where a submit of the site's search form leads when it is ours to do (our
|
|
97
|
+
// listing shows this search page, the target a search page of ours), and
|
|
98
|
+
// what it does in place.
|
|
99
|
+
const { isInput, shadowInputs } = onSearchSubmit(
|
|
100
|
+
'search in place',
|
|
101
|
+
inputSelector,
|
|
102
|
+
(form, submitter, input) => {
|
|
128
103
|
// Only while our listing shows this search page (not one handed back, or failing this session).
|
|
129
|
-
if (!route.accepts(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// Respond first: the input lets go (the autocomplete closes, a phone's
|
|
142
|
-
// keyboard hides); the history write and the fetch run in a later task.
|
|
143
|
-
const a = focused();
|
|
144
|
-
if (isInput(a) || (a instanceof HTMLElement && form.contains(a))) a.blur();
|
|
145
|
-
try {
|
|
146
|
-
closeAround(form);
|
|
147
|
-
} catch (err) {
|
|
148
|
-
reportError('search in place', err);
|
|
149
|
-
}
|
|
150
|
-
setTimeout(() => {
|
|
104
|
+
if (!route.accepts(new URL(location.href)) || tk.failing || (tk.state !== 'ours' && tk.state !== 'hold') || !ourElement()) return null;
|
|
105
|
+
const to = searchTarget(cfg.queryUrl, form, submitter, input);
|
|
106
|
+
return to && to.origin === location.origin && route.accepts(to) ? to : null;
|
|
107
|
+
},
|
|
108
|
+
(to, form) => {
|
|
109
|
+
const q = read(to);
|
|
110
|
+
ours.add(q);
|
|
111
|
+
mark(`search in place: ${q}`);
|
|
112
|
+
// Respond first: the input lets go (the autocomplete closes, a phone's
|
|
113
|
+
// keyboard hides); the history write and the fetch run in a later task.
|
|
114
|
+
const a = focused();
|
|
115
|
+
if (isInput(a) || (a instanceof HTMLElement && form.contains(a))) a.blur();
|
|
151
116
|
try {
|
|
152
|
-
|
|
153
|
-
writeUrl(to);
|
|
154
|
-
top();
|
|
117
|
+
closeAround(form);
|
|
155
118
|
} catch (err) {
|
|
156
119
|
reportError('search in place', err);
|
|
157
|
-
location.assign(to.href);
|
|
158
|
-
} finally {
|
|
159
|
-
pushing = false;
|
|
160
120
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
};
|
|
174
|
-
addEventListener('submit', onSubmit, true);
|
|
175
|
-
|
|
176
|
-
// Enter in the site's search input, ahead of the site's own handlers: some
|
|
177
|
-
// navigate with form.submit() or location on Enter, which fires no submit
|
|
178
|
-
// event (a storefront's own autocomplete script: form.submit() on every
|
|
179
|
-
// Enter). An Enter on a suggestion selected in our
|
|
180
|
-
// autocomplete is the autocomplete's (it opens that one); a plain Enter,
|
|
181
|
-
// and the autocomplete's own hand-off of a query (a synthetic Enter on the
|
|
182
|
-
// input), are taken here.
|
|
183
|
-
// In its shadow root by default (light DOM with dom: 'light'); only while it is open.
|
|
184
|
-
const acSelection = () => {
|
|
185
|
-
const o = document.querySelector('[data-lbx="ac-overlay"]');
|
|
186
|
-
return !!o?.matches(':popover-open') && !!(o.shadowRoot ?? o).querySelector('[aria-selected="true"]');
|
|
187
|
-
};
|
|
188
|
-
addEventListener(
|
|
189
|
-
'keydown',
|
|
190
|
-
(e) => {
|
|
191
|
-
if (e.key !== 'Enter' || e.isComposing || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
192
|
-
const input = realTarget(e);
|
|
193
|
-
if (!isInput(input) || !input.form) return;
|
|
194
|
-
seen(e);
|
|
195
|
-
if (e.isTrusted && acSelection()) return;
|
|
196
|
-
if (take(input.form, null, e)) e.stopImmediatePropagation();
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
try {
|
|
123
|
+
pushing = true;
|
|
124
|
+
writeUrl(to);
|
|
125
|
+
top();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
reportError('search in place', err);
|
|
128
|
+
location.assign(to.href);
|
|
129
|
+
} finally {
|
|
130
|
+
pushing = false;
|
|
131
|
+
}
|
|
132
|
+
}, 0);
|
|
197
133
|
},
|
|
198
|
-
true,
|
|
199
134
|
);
|
|
200
135
|
|
|
201
136
|
// Another query in this document (ours, or back/forward between them): the
|
|
@@ -4,13 +4,18 @@ import type { Takeover } from '../../core/takeover';
|
|
|
4
4
|
import type { WidgetContext } from '../../main';
|
|
5
5
|
import type { SearchConfigType } from '../../types';
|
|
6
6
|
import { startListing } from '../listing/element';
|
|
7
|
-
import {
|
|
7
|
+
import { reportError } from '../../core/health';
|
|
8
8
|
import { searchRoute } from './route';
|
|
9
|
+
import { NAMED_IN_PLACE_QUERY } from '../../core/features';
|
|
9
10
|
|
|
10
11
|
export { templates, css } from './assets';
|
|
11
12
|
|
|
12
13
|
export const start = (ctx: WidgetContext<SearchConfigType>, tk: Takeover) => {
|
|
13
14
|
const route = searchRoute(ctx.config);
|
|
14
|
-
|
|
15
|
+
// A new query in place (./in-place.ts, its own chunk lbx-search-in-place):
|
|
16
|
+
// asked for now, not waited for. Only a query typed after the page's first
|
|
17
|
+
// render uses it; one submitted before it ran is a page load, as without it.
|
|
18
|
+
if (NAMED_IN_PLACE_QUERY ? ctx.config.inPlaceQuery !== false : true)
|
|
19
|
+
import('./in-place').then((m) => m.startInPlace(ctx.config, ctx.global.inputSelector, tk, route)).catch((e) => reportError('search in place', e));
|
|
15
20
|
return startListing(ctx, tk, route);
|
|
16
21
|
};
|