@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. package/src/widgets/search/submit.ts +136 -0
@@ -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): what in-place search
2
- // (./in-place.ts, the search page) and search while typing (./speculate.ts,
3
- // any page) both read. Its own module: neither chunk loads the other for it.
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
- export type Submitter = HTMLButtonElement | HTMLInputElement;
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 data) {
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
- // A window-capture `submit` listener, nothing bound to the host's form. The
8
- // form's target is resolved as the browser would (method GET, action, target,
9
- // its fields): only when the page we are on and that URL are both search
10
- // pages of ours, the navigation is ours. What the page load did for free is
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
- // Loaded with the search widget (not in the entry): until it has loaded, a
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 { formUrl } from './form-url';
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
- * The search `form` would do, in place if it is ours to do: true when taken
120
- * (the caller's event is cancelled here). `submitter`: the button, for a submit.
121
- */
122
- const take = (form: HTMLFormElement, submitter: HTMLElement | null | undefined, e: Event): boolean => {
123
- let url: URL | null = null;
124
- try {
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(here) || tk.failing || (tk.state !== 'ours' && tk.state !== 'hold') || !ourElement()) return false;
130
- url = formUrl(form, submitter);
131
- if (!url || url.origin !== location.origin || !route.accepts(url)) return false;
132
- } catch (err) {
133
- reportError('search in place', err);
134
- return false;
135
- }
136
- e.preventDefault();
137
- const to = url;
138
- const q = read(to);
139
- ours.add(q);
140
- mark(`search in place: ${q}`);
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
- pushing = true;
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
- }, 0);
162
- return true;
163
- };
164
-
165
- // A submit we take goes no further: a host handler that ignores
166
- // defaultPrevented would navigate as well (htmx's boosted form: its own
167
- // fetch, swap and second history entry for the same query). The Enter path
168
- // below never lets a submit happen at all.
169
- const onSubmit = (e: SubmitEvent) => {
170
- const form = e.target;
171
- if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
172
- if (take(form, e.submitter, e)) e.stopPropagation();
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 { startInPlace } from './in-place';
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
- if (ctx.config.inPlaceQuery !== false) startInPlace(ctx.config, ctx.global.inputSelector, tk, route);
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
  };
@@ -10,10 +10,16 @@
10
10
  // - in sessionStorage (../listing/stash.ts, `spec`): Enter on another page
11
11
  // loads the search page, whose listing renders it at boot, like a
12
12
  // server-rendered state;
13
- // - optionally (`prerenderWhileTyping`) the search page itself is prerendered
14
- // once that result is stashed and the phrase is still the one typed: Enter
15
- // then activates a page that is already rendered (it renders from the
16
- // stash: no request of its own).
13
+ // - the search page itself (`pageWhileTyping`, Speculation Rules): once that
14
+ // result is stashed and the phrase is still the one typed, its document is
15
+ // fetched ahead (default), so Enter skips the site's server render, or the
16
+ // page is prerendered as well (`'prerender'`): Enter then activates a page
17
+ // that is already rendered (it renders from the stash: no request of its own).
18
+ //
19
+ // The page fetched for is the one the form's submit leads to (./form-url.ts
20
+ // searchTarget: the form's URL, or the integration's `queryUrl` of the phrase
21
+ // where the site's server redirects the form); the stash is under the query,
22
+ // so the page it loads at finds it whatever its URL.
17
23
  //
18
24
  // Only what is likely: the settled phrase (not every keystroke), 2+
19
25
  // characters, not the page we are on, none offline, with Save-Data or on 2G.
@@ -32,6 +38,7 @@
32
38
  import { getApi } from '../../api';
33
39
  import { firstRowImages, resultFactory, warmImages } from '../../core/support';
34
40
  import { mark, reportError } from '../../core/health';
41
+ import { cspNonce } from '../../core/styles';
35
42
  import { pageKey } from '../../core/page';
36
43
  import { dropPrefetch, hasPrefetch, putPrefetch } from '../../core/prefetch';
37
44
  import { onUrlChange } from '../../core/sense';
@@ -39,8 +46,10 @@ import type { GlobalConfigType, SearchConfigType } from '../../types';
39
46
  import { listingQuery, type ListingResult } from '../listing/query';
40
47
  import type { Query, Route } from '../listing/state';
41
48
  import { writeStash } from '../listing/stash';
42
- import { formUrl } from './form-url';
49
+ import { defaultSubmitter } from '../../core/default-button';
50
+ import { searchTarget } from './form-url';
43
51
  import { searchRoute } from './route';
52
+ import { NAMED_IN_PLACE_QUERY, PRERENDER_TYPING } from '../../core/features';
44
53
 
45
54
  const MIN_CHARS = 2;
46
55
  /** Speculative results kept at once (the phrase before the latest one: a shopper backspacing). */
@@ -80,12 +89,14 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
80
89
  return form instanceof HTMLFormElement ? ([...form.elements].find((el) => el instanceof HTMLInputElement && el.matches(sel)) as HTMLInputElement | undefined) ?? null : null;
81
90
  };
82
91
 
83
- const prerender = startPrerender(cfg, route);
92
+ const prerender = startPage(cfg, route);
93
+ // The page Enter would load: the browser's implicit submission, with the form's default button (its name=value, formaction).
94
+ const target = (i: HTMLInputElement) => (i.isConnected && i.form ? searchTarget(cfg.queryUrl, i.form, defaultSubmitter(i.form), i) : null);
84
95
 
85
96
  const run = (i: HTMLInputElement) => {
86
97
  clearTimeout(timer);
87
- if (thrifty() || !i.form || !i.isConnected) return;
88
- const url = formUrl(i.form, null);
98
+ if (thrifty()) return;
99
+ const url = target(i);
89
100
  if (!url || url.origin !== location.origin || !route.accepts(url)) return;
90
101
  const key = pageKey(url.href);
91
102
  if (key === pageKey(location.href)) return; // the page we are on
@@ -106,7 +117,7 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
106
117
  // For a page load of it: the whole result, unless something optional failed.
107
118
  if (!res.partial && writeStash(url.href, { kind: 'spec', route: route.id, query: q, result: res, ttl })) {
108
119
  // Still the phrase in the form: that page, rendered ahead (it renders from the stash).
109
- const now = i.isConnected && i.form ? formUrl(i.form, null) : null;
120
+ const now = target(i);
110
121
  if (now && pageKey(now.href) === key) prerender?.start(url.href, key);
111
122
  }
112
123
  if (warm > 0) warmFor(res);
@@ -160,21 +171,27 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
160
171
  };
161
172
 
162
173
  /**
163
- * The search page prerendered while the shopper types (`prerenderWhileTyping`,
164
- * Speculation Rules: Chrome, Edge): one list rule for one URL, the phrase whose
165
- * result was just stashed. A newer phrase replaces it (removing a rule cancels
166
- * its prerender): one prerendered search page at most. Not on the search page
167
- * itself when queries there are in place (no page load to save).
168
- *
169
- * The cost: a host document rendered on their server per settled phrase the
170
- * shopper may never submit, and their scripts run in it (Chromium runs a
171
- * prerendered page's scripts; only APIs with visible effects wait for the
174
+ * The search page itself, ahead of Enter (`pageWhileTyping`, Speculation
175
+ * Rules): one list rule for one URL, the phrase whose result was just stashed.
176
+ * `'prefetch'` (default) fetches its document: a navigation to that URL is
177
+ * served from it (Chromium: `deliveryType` `navigational-prefetch`, cache
178
+ * headers and cookies as any navigation's; Firefox and Safari as they ship
179
+ * prefetch), the site's server render off the shopper's wait, nothing of the
180
+ * page run. `'prerender'` renders it as well (Chromium; the prefetch rule is
181
+ * kept for engines without prerendering, and Chromium's prerender takes the
182
+ * prefetched response): Enter activates a page already rendered, but the
183
+ * site's scripts run in it (only APIs with visible effects wait for the
172
184
  * activation: tags that defer to `prerenderingchange`, as Google's do, count
173
- * nothing until then, others count a page view). Ours renders from the stash
174
- * (no request) and defers onRender to the activation. Off by default.
185
+ * nothing until then, others count a page view); ours renders from the stash
186
+ * (no request) and defers onRender to the activation. A newer phrase replaces
187
+ * the rule (removing it cancels a prerender): one page at most. Not on the
188
+ * search page itself when queries there are in place (no page load to save).
189
+ * The cost either way: a document from their server per settled phrase the
190
+ * shopper may never submit.
175
191
  */
176
- const startPrerender = (cfg: SearchConfigType, route: Route) => {
177
- if (!cfg.prerenderWhileTyping || !(HTMLScriptElement as any).supports?.('speculationrules')) return null;
192
+ const startPage = (cfg: SearchConfigType, route: Route) => {
193
+ const mode = cfg.pageWhileTyping ?? (PRERENDER_TYPING && cfg.prerenderWhileTyping ? 'prerender' : 'prefetch');
194
+ if (!mode || !(HTMLScriptElement as any).supports?.('speculationrules')) return null;
178
195
  let el: HTMLScriptElement | null = null;
179
196
  let at = '';
180
197
  const stop = () => {
@@ -183,20 +200,24 @@ const startPrerender = (cfg: SearchConfigType, route: Route) => {
183
200
  at = '';
184
201
  };
185
202
  return {
186
- /** The phrase now settled: a prerender of another page goes. */
203
+ /** The phrase now settled: another page's rule goes. */
187
204
  keep: (key: string) => void (at && at !== key && stop()),
188
205
  start: (href: string, key: string) => {
189
206
  if (at === key) return;
190
207
  // A search page in place has no page load to save.
191
- if (cfg.inPlaceQuery !== false && route.accepts(new URL(location.href))) return;
208
+ if ((NAMED_IN_PLACE_QUERY ? cfg.inPlaceQuery !== false : true) && route.accepts(new URL(location.href))) return;
192
209
  stop();
193
210
  el = document.createElement('script');
194
211
  el.type = 'speculationrules';
195
- el.setAttribute('data-lbx', 'search-prerender');
196
- el.textContent = JSON.stringify({ prerender: [{ source: 'list', urls: [href], eagerness: cfg.prerenderEagerness ?? 'immediate' }] });
212
+ el.setAttribute('data-lbx', 'search-page');
213
+ el.nonce = cspNonce(); // a strict script-src applies to inline speculation rules
214
+ // A list rule's eagerness is `immediate` unless said otherwise.
215
+ const rules: Record<string, unknown[]> = { prefetch: [{ source: 'list', urls: [href] }] };
216
+ if (PRERENDER_TYPING && mode === 'prerender') rules.prerender = [{ source: 'list', urls: [href], eagerness: cfg.prerenderEagerness }]; // unset: not written (JSON.stringify)
217
+ el.textContent = JSON.stringify(rules);
197
218
  document.head.append(el);
198
219
  at = key;
199
- mark(`search prerender: ${href}`);
220
+ mark(`search page ${mode}: ${href}`);
200
221
  },
201
222
  };
202
223
  };