@occluze/tether 0.6.6 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Lists rendered from a template: a repeater's first child in the designer is the template, and every item gets a
3
+ * copy of it. The other children are placeholder content and are removed.
4
+ */
5
+ import { cfg, reportError, warn } from './config.js';
6
+
7
+ /** @type {WeakMap<Element, HTMLElement | null>} Each repeater's template, captured on first render. */
8
+ const templates = new WeakMap();
9
+
10
+ /**
11
+ * @param {Element} container
12
+ * @returns {boolean} True once the repeater's template has been captured (it may be empty after that).
13
+ */
14
+ export const hasTemplate = (container) => Boolean(templates.get(container));
15
+
16
+ /**
17
+ * A deep copy of a template. `cloneNode` is typed as returning a `Node`; a copy of an element is an element of the
18
+ * same kind.
19
+ *
20
+ * @param {HTMLElement} template
21
+ */
22
+ const copyOf = (template) => /** @type {HTMLElement} */ (template.cloneNode(true));
23
+
24
+ /** @param {Element} container */
25
+ function templateOf(container) {
26
+ const known = templates.get(container);
27
+ if (known !== undefined) return known;
28
+ const first = container.firstElementChild;
29
+ // Repeaters are HTML lists in the designer; their first child is an HTML element.
30
+ const template = first ? copyOf(/** @type {HTMLElement} */ (first)) : null;
31
+ templates.set(container, template);
32
+ container.replaceChildren();
33
+ return template;
34
+ }
35
+
36
+ /**
37
+ * Rebuilds the list on every render. Fine for images, tags, options.
38
+ *
39
+ * @template Item
40
+ * @param {Element} container
41
+ * @param {readonly Item[]} items
42
+ * @param {(node: HTMLElement, item: Item, index: number) => void} render
43
+ */
44
+ export function repeat(container, items, render) {
45
+ const template = templateOf(container);
46
+ if (!template) {
47
+ if (items.length) warn('repeater without a template child', container);
48
+ return;
49
+ }
50
+ container.replaceChildren();
51
+ items.forEach((item, index) => {
52
+ const node = copyOf(template);
53
+ render(node, item, index);
54
+ container.appendChild(node);
55
+ });
56
+ }
57
+
58
+ /**
59
+ * @typedef {object} KeyedHooks
60
+ * @property {((node: HTMLElement) => void) | null} [onEnter] Runs on a node inserted for a new item.
61
+ * @property {((node: HTMLElement, done: () => void) => void) | null} [onLeave] Runs on a node whose item is gone;
62
+ * `done()` removes it.
63
+ */
64
+
65
+ /**
66
+ * @param {HTMLElement} node
67
+ * @param {KeyedHooks['onLeave']} onLeave
68
+ */
69
+ function leave(node, onLeave) {
70
+ const done = () => {
71
+ node.remove();
72
+ };
73
+ if (!onLeave) {
74
+ done();
75
+ return;
76
+ }
77
+ try {
78
+ onLeave(node, done);
79
+ } catch (error) {
80
+ reportError(error, 'repeater onLeave');
81
+ done();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Inserts the nodes of new items next to the item before them, leaving the existing nodes where they are.
87
+ *
88
+ * @param {Element} container
89
+ * @param {HTMLElement[]} wanted Every item's node, in order.
90
+ * @param {Set<HTMLElement>} fresh The nodes not yet in the container.
91
+ * @param {KeyedHooks['onEnter']} onEnter
92
+ */
93
+ function insertFresh(container, wanted, fresh, onEnter) {
94
+ wanted.forEach((node, index) => {
95
+ if (!fresh.has(node)) return;
96
+ const previous = wanted[index - 1];
97
+ if (previous?.parentNode === container) previous.after(node);
98
+ else container.prepend(node);
99
+ if (!onEnter) return;
100
+ try {
101
+ onEnter(node);
102
+ } catch (error) {
103
+ reportError(error, 'repeater onEnter');
104
+ }
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Keyed repeater: keeps existing nodes, inserts new ones, removes old ones. Used for cart lines, so values can
110
+ * animate and lines can slide in and out.
111
+ *
112
+ * @template Item
113
+ * @param {Element} container
114
+ * @param {readonly Item[]} items
115
+ * @param {(item: Item, index: number) => string | number} keyOf
116
+ * @param {(node: HTMLElement, item: Item, index: number, isNew: boolean) => void} render
117
+ * @param {KeyedHooks} [hooks]
118
+ */
119
+ export function repeatKeyed(container, items, keyOf, render, { onEnter, onLeave } = {}) {
120
+ const template = templateOf(container);
121
+ if (!template) {
122
+ warn('repeater without a template child', container);
123
+ return;
124
+ }
125
+
126
+ const keyAttribute = `data-${cfg.prefix}-key`;
127
+ const leavingAttribute = `data-${cfg.prefix}-leaving`;
128
+ const live = () =>
129
+ [.../** @type {HTMLCollectionOf<HTMLElement>} */ (container.children)].filter(
130
+ (node) => node.hasAttribute(keyAttribute) && !node.hasAttribute(leavingAttribute),
131
+ );
132
+
133
+ const existing = new Map(live().map((node) => [node.getAttribute(keyAttribute), node]));
134
+ /** @type {HTMLElement[]} */
135
+ const wanted = [];
136
+ /** @type {Set<HTMLElement>} */
137
+ const fresh = new Set();
138
+
139
+ items.forEach((item, index) => {
140
+ const key = String(keyOf(item, index));
141
+ let node = existing.get(key);
142
+ if (node) existing.delete(key);
143
+ else {
144
+ node = copyOf(template);
145
+ node.setAttribute(keyAttribute, key);
146
+ fresh.add(node);
147
+ }
148
+ render(node, item, index, fresh.has(node));
149
+ wanted.push(node);
150
+ });
151
+
152
+ const staying = wanted.filter((node) => !fresh.has(node));
153
+ const current = live().filter((node) => staying.includes(node));
154
+ const inOrder = current.length === staying.length && current.every((node, index) => node === staying[index]);
155
+
156
+ if (!inOrder) {
157
+ existing.forEach((node) => {
158
+ node.remove();
159
+ });
160
+ wanted.forEach((node) => container.appendChild(node));
161
+ return;
162
+ }
163
+
164
+ insertFresh(container, wanted, fresh, onEnter);
165
+
166
+ existing.forEach((node) => {
167
+ node.setAttribute(leavingAttribute, '');
168
+ node.removeAttribute(keyAttribute);
169
+ node.style.pointerEvents = 'none';
170
+ node.inert = true;
171
+ leave(node, onLeave);
172
+ });
173
+ }
package/src/search.js CHANGED
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Search as you type (Shopify predictive search). Optional part: loaded only on pages with
3
- * [tether-search].
2
+ * Search as you type (Shopify predictive search). Optional part: loaded only on pages with [tether-search].
4
3
  *
5
4
  * <form tether-search action="/search"> <!-- Enter: your full results page -->
6
5
  * <input tether-search-input name="q" type="search" placeholder="Search">
@@ -12,13 +11,31 @@
12
11
  * </div>
13
12
  * </form>
14
13
  *
15
- * Each list's first child is the template. The root gets is-open while there are results (or none)
16
- * to show, and tether-search-state="loading|ready|empty|error". Arrow keys move through the results,
17
- * Escape closes. tether-search-limit (default 6), tether-search-min (characters, default 2).
14
+ * Each list's first child is the template. The root gets is-open while there are results (or none) to show, and
15
+ * tether-search-state="idle|loading|ready|empty|error". Arrow keys move through the results, Escape closes.
16
+ * tether-search-limit (default 6), tether-search-min (characters, default 2).
18
17
  */
19
- import { cfg, reportError } from './config.js';
18
+ import { cfg, log, reportError } from './config.js';
20
19
  import { gql } from './client.js';
21
20
  import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.js';
21
+ import { within } from './mounting.js';
22
+ /** @import { Money } from './shopify-values.js' */
23
+
24
+ /** @typedef {{ url: string, altText: string | null }} SearchImage */
25
+ /**
26
+ * @typedef {object} SearchProduct
27
+ * @property {string} id
28
+ * @property {string} handle
29
+ * @property {string} title
30
+ * @property {boolean} availableForSale
31
+ * @property {SearchImage | null} featuredImage
32
+ * @property {{ minVariantPrice: Money }} priceRange
33
+ */
34
+ /** @typedef {{ handle: string, title: string, image: SearchImage | null }} SearchCollection */
35
+ /** @typedef {{ text: string }} SearchQuery */
36
+ /** @typedef {{ products: SearchProduct[], collections: SearchCollection[], queries: SearchQuery[] }} SearchResults */
37
+ /** @typedef {'idle' | 'loading' | 'ready' | 'empty' | 'error'} SearchState */
38
+ /** @typedef {{ title?: string, price?: string, text?: string, image?: SearchImage | null }} BindValues */
22
39
 
23
40
  const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
24
41
  predictiveSearch(query: $q, limit: $limit, limitScope: EACH, types: [PRODUCT, COLLECTION, QUERY]) {
@@ -28,126 +45,279 @@ const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
28
45
  }
29
46
  }`;
30
47
 
48
+ const DEFAULT_LIMIT = 6;
49
+ const DEFAULT_MIN_CHARACTERS = 2;
50
+ const DEFAULT_ACTION = '/search';
51
+
52
+ /** @type {WeakSet<Element>} */
31
53
  const mounted = new WeakSet();
32
- let uid = 0;
54
+ let searchBoxCount = 0;
33
55
 
34
- export function mountSearch(root = document) {
35
- const roots = [...(root.matches?.(sel('search')) ? [root] : []), ...qsa(root, sel('search'))];
36
- roots.forEach((el) => { if (!mounted.has(el)) { mounted.add(el); setup(el); } });
56
+ /**
57
+ * @param {Element} el
58
+ * @param {string} name
59
+ * @param {number} fallback
60
+ * @returns {number} The attribute as an integer; `fallback` when it is missing, zero or not a number.
61
+ */
62
+ function integerAttribute(el, name, fallback) {
63
+ return parseInt(get(el, name) || '', 10) || fallback;
64
+ }
65
+
66
+ /**
67
+ * @param {Element} node
68
+ * @returns {Element | null} `node` when it is a link, else its first link.
69
+ */
70
+ const linkIn = (node) => (node.tagName === 'A' ? node : node.querySelector('a'));
71
+
72
+ /**
73
+ * @param {HTMLElement} node
74
+ * @param {BindValues} values
75
+ */
76
+ function bindValues(node, values) {
77
+ qsa(node, sel('search-bind')).forEach((el) => {
78
+ const field = get(el, 'search-bind');
79
+ if (field === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
80
+ else if (field === 'title' || field === 'price' || field === 'text') {
81
+ if (values[field] != null) setText(el, values[field]);
82
+ }
83
+ });
37
84
  }
38
85
 
39
- function setup(rootEl) {
40
- const input = rootEl.querySelector(sel('search-input')) || rootEl.querySelector('input');
41
- const results = rootEl.querySelector(sel('search-results'));
42
- if (!input || !results) return;
43
- const limit = parseInt(get(rootEl, 'search-limit') || '6', 10) || 6;
44
- const min = parseInt(get(rootEl, 'search-min') || '2', 10) || 2;
45
- const id = results.id || (results.id = `${cfg.prefix}-search-${++uid}`);
46
- input.setAttribute('role', 'combobox');
47
- input.setAttribute('aria-autocomplete', 'list');
48
- input.setAttribute('aria-controls', id);
49
- input.setAttribute('aria-expanded', 'false');
50
- input.setAttribute('autocomplete', 'off');
51
- if (!results.hasAttribute('role')) results.setAttribute('role', 'listbox');
52
- results.hidden = true;
53
-
54
- let token = 0;
55
- let timer = null;
56
- let last = '';
57
- const cache = new Map(); // term -> results, for typing back and forth
58
-
59
- const setState = (state) => rootEl.setAttribute(attr('search-state'), state);
60
- const open = (yes) => {
61
- rootEl.classList.toggle('is-open', yes);
62
- results.hidden = !yes;
63
- input.setAttribute('aria-expanded', String(yes));
64
- if (!yes) input.removeAttribute('aria-activedescendant');
65
- };
66
- const items = () => qsa(results, '[role="option"]');
67
-
68
- async function run(term) {
69
- const t = ++token;
70
- if (term.length < min) { open(false); setState('idle'); return; }
71
- setState('loading');
86
+ /**
87
+ * @param {Element} node
88
+ * @param {string} href
89
+ */
90
+ function linkOption(node, href) {
91
+ const link = linkIn(node);
92
+ if (!link) return;
93
+ link.setAttribute('href', href);
94
+ link.setAttribute('role', 'option');
95
+ }
96
+
97
+ /** One `[tether-search]` root: its input, its results and the search in flight. */
98
+ class SearchBox {
99
+ /**
100
+ * @param {HTMLElement} root
101
+ * @param {HTMLInputElement} input
102
+ * @param {HTMLElement} results
103
+ */
104
+ constructor(root, input, results) {
105
+ this.root = root;
106
+ this.input = input;
107
+ this.results = results;
108
+ this.limit = integerAttribute(root, 'search-limit', DEFAULT_LIMIT);
109
+ this.minCharacters = integerAttribute(root, 'search-min', DEFAULT_MIN_CHARACTERS);
110
+ if (!results.id) {
111
+ searchBoxCount += 1;
112
+ results.id = `${cfg.prefix}-search-${searchBoxCount}`;
113
+ }
114
+ this.resultsId = results.id;
115
+ /** Counts searches, so only the latest one renders. */
116
+ this.searchNumber = 0;
117
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
118
+ this.timer = undefined;
119
+ this.lastTerm = '';
120
+ /** @type {Map<string, SearchResults | null>} Results per term, for typing back and forth. */
121
+ this.cache = new Map();
122
+ this.setUpAria();
123
+ this.listen();
124
+ }
125
+
126
+ setUpAria() {
127
+ this.input.setAttribute('role', 'combobox');
128
+ this.input.setAttribute('aria-autocomplete', 'list');
129
+ this.input.setAttribute('aria-controls', this.resultsId);
130
+ this.input.setAttribute('aria-expanded', 'false');
131
+ this.input.setAttribute('autocomplete', 'off');
132
+ if (!this.results.hasAttribute('role')) this.results.setAttribute('role', 'listbox');
133
+ this.results.hidden = true;
134
+ }
135
+
136
+ listen() {
137
+ this.input.addEventListener('input', () => {
138
+ const term = this.input.value.trim();
139
+ if (term === this.lastTerm) return;
140
+ this.lastTerm = term;
141
+ clearTimeout(this.timer);
142
+ this.timer = setTimeout(() => void this.search(term), cfg.searchDelay);
143
+ });
144
+ this.input.addEventListener('focus', () => {
145
+ if (this.input.value.trim().length >= this.minCharacters && this.options().length) this.open(true);
146
+ });
147
+ this.input.addEventListener('keydown', (event) => {
148
+ this.onKeydown(event);
149
+ });
150
+ document.addEventListener('click', (event) => {
151
+ // A click event's target is always a node.
152
+ if (!this.root.contains(/** @type {Node | null} */ (event.target))) this.open(false);
153
+ });
154
+ }
155
+
156
+ /** @param {SearchState} state */
157
+ setState(state) {
158
+ this.root.setAttribute(attr('search-state'), state);
159
+ }
160
+
161
+ /** @param {boolean} open */
162
+ open(open) {
163
+ this.root.classList.toggle('is-open', open);
164
+ this.results.hidden = !open;
165
+ this.input.setAttribute('aria-expanded', String(open));
166
+ if (!open) this.input.removeAttribute('aria-activedescendant');
167
+ }
168
+
169
+ options() {
170
+ return qsa(this.results, '[role="option"]');
171
+ }
172
+
173
+ /**
174
+ * @param {string} term
175
+ * @returns {Promise<SearchResults | null>}
176
+ */
177
+ async fetchResults(term) {
178
+ const cached = this.cache.get(term);
179
+ if (cached) return cached;
180
+ /** @type {{ predictiveSearch: SearchResults | null }} */
181
+ const data = await gql(QUERY, { q: term, limit: this.limit });
182
+ this.cache.set(term, data.predictiveSearch);
183
+ return data.predictiveSearch;
184
+ }
185
+
186
+ /** @param {string} term */
187
+ async search(term) {
188
+ this.searchNumber += 1;
189
+ const searchNumber = this.searchNumber;
190
+ if (term.length < this.minCharacters) {
191
+ this.open(false);
192
+ this.setState('idle');
193
+ return;
194
+ }
195
+ this.setState('loading');
72
196
  try {
73
- const data = cache.get(term) || (await gql(QUERY, { q: term, limit })).predictiveSearch;
74
- cache.set(term, data);
75
- if (t !== token) return; // a newer search is on its way
76
- render(data, term);
77
- } catch (err) {
78
- if (t !== token) return;
79
- setState('error');
80
- reportError(err, 'search');
197
+ const results = await this.fetchResults(term);
198
+ if (searchNumber === this.searchNumber) this.render(results, term);
199
+ } catch (error) {
200
+ // A newer search owns the state and reports its own failure.
201
+ if (searchNumber !== this.searchNumber) {
202
+ log('a superseded search failed', error);
203
+ return;
204
+ }
205
+ this.setState('error');
206
+ reportError(error, 'search');
81
207
  }
82
208
  }
83
209
 
84
- function render(data, term) {
85
- const products = data?.products || [];
86
- const collections = data?.collections || [];
87
- const queries = (data?.queries || []).filter((q) => q.text.toLowerCase() !== term.toLowerCase());
88
- const fill = (name, list, each) => qsa(results, sel(name)).forEach((c) => {
89
- repeat(c, list, each);
90
- show(c, list.length > 0);
210
+ /**
211
+ * @template Item
212
+ * @param {string} name The list attribute, e.g. `search-products`.
213
+ * @param {readonly Item[]} items
214
+ * @param {(node: HTMLElement, item: Item) => void} render
215
+ */
216
+ fillList(name, items, render) {
217
+ qsa(this.results, sel(name)).forEach((list) => {
218
+ repeat(list, items, render);
219
+ show(list, items.length > 0);
91
220
  });
92
- fill('search-products', products, (node, p) => {
93
- binds(node, { title: p.title, price: formatMoney(p.priceRange?.minVariantPrice), image: p.featuredImage });
94
- link(node, productUrl(p.handle, p.id));
95
- node.classList.toggle('is-sold-out', !p.availableForSale);
221
+ }
222
+
223
+ /** @param {string} text */
224
+ queryUrl(text) {
225
+ const action = this.root.getAttribute('action') || DEFAULT_ACTION;
226
+ const separator = action.includes('?') ? '&' : '?';
227
+ return `${action}${separator}${this.input.name || 'q'}=${encodeURIComponent(text)}`;
228
+ }
229
+
230
+ /**
231
+ * @param {SearchResults | null} results
232
+ * @param {string} term
233
+ */
234
+ render(results, term) {
235
+ const products = results?.products ?? [];
236
+ const collections = results?.collections ?? [];
237
+ const queries = (results?.queries ?? []).filter((query) => query.text.toLowerCase() !== term.toLowerCase());
238
+ this.fillList('search-products', products, (node, product) => {
239
+ const price = formatMoney(product.priceRange.minVariantPrice);
240
+ bindValues(node, { title: product.title, price, image: product.featuredImage });
241
+ linkOption(node, productUrl(product.handle, product.id));
242
+ node.classList.toggle('is-sold-out', !product.availableForSale);
96
243
  });
97
- fill('search-collections', collections, (node, c) => {
98
- binds(node, { title: c.title, image: c.image });
99
- link(node, `${(cfg.collectionPath || '/collection').replace(/\/$/, '')}/${c.handle}`);
244
+ this.fillList('search-collections', collections, (node, collection) => {
245
+ bindValues(node, { title: collection.title, image: collection.image });
246
+ linkOption(node, `${(cfg.collectionPath || '/collection').replace(/\/$/, '')}/${collection.handle}`);
100
247
  });
101
- fill('search-queries', queries, (node, q) => {
102
- binds(node, { text: q.text });
103
- const a = node.tagName === 'A' ? node : node.querySelector('a');
104
- const action = rootEl.getAttribute('action') || '/search';
105
- if (a) a.setAttribute('href', `${action}${action.includes('?') ? '&' : '?'}${input.name || 'q'}=${encodeURIComponent(q.text)}`);
248
+ this.fillList('search-queries', queries, (node, query) => {
249
+ bindValues(node, { text: query.text });
250
+ linkIn(node)?.setAttribute('href', this.queryUrl(query.text));
106
251
  });
107
252
  const count = products.length + collections.length + queries.length;
108
- qsa(rootEl, sel('show')).forEach((el) => {
109
- const k = get(el, 'show');
110
- if (k === 'search-empty') show(el, count === 0);
111
- if (k === 'search-results') show(el, count > 0);
253
+ this.showCount(count);
254
+ this.setState(count ? 'ready' : 'empty');
255
+ this.open(true);
256
+ emit('search', { term, products, collections, queries }, this.root);
257
+ }
258
+
259
+ /** @param {number} count */
260
+ showCount(count) {
261
+ qsa(this.root, sel('show')).forEach((el) => {
262
+ const condition = get(el, 'show');
263
+ if (condition === 'search-empty') show(el, count === 0);
264
+ if (condition === 'search-results') show(el, count > 0);
265
+ });
266
+ this.options().forEach((option, index) => {
267
+ option.id ||= `${this.resultsId}-${index}`;
268
+ option.setAttribute('aria-selected', 'false');
112
269
  });
113
- items().forEach((el, i) => { el.id ||= `${id}-${i}`; el.setAttribute('aria-selected', 'false'); });
114
- setState(count ? 'ready' : 'empty');
115
- open(true);
116
- emit('search', { term, products, collections, queries }, rootEl);
117
270
  }
118
271
 
119
- function binds(node, values) {
120
- qsa(node, sel('search-bind')).forEach((el) => {
121
- const k = get(el, 'search-bind');
122
- if (k === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
123
- else if (values[k] != null) setText(el, values[k]);
272
+ /**
273
+ * @param {HTMLElement[]} options
274
+ * @param {number} index
275
+ */
276
+ highlight(options, index) {
277
+ options.forEach((option, optionIndex) => {
278
+ option.setAttribute('aria-selected', String(optionIndex === index));
124
279
  });
280
+ const option = options[index];
281
+ if (!option) return;
282
+ this.input.setAttribute('aria-activedescendant', option.id);
283
+ // jsdom has no scrollIntoView.
284
+ /** @type {Partial<Pick<HTMLElement, 'scrollIntoView'>>} */ (option).scrollIntoView?.({ block: 'nearest' });
125
285
  }
126
286
 
127
- function link(node, href) {
128
- const a = node.tagName === 'A' ? node : node.querySelector('a');
129
- if (a) { a.setAttribute('href', href); a.setAttribute('role', 'option'); }
287
+ /** @param {KeyboardEvent} event */
288
+ onKeydown(event) {
289
+ const options = this.options();
290
+ const current = options.findIndex((option) => option.getAttribute('aria-selected') === 'true');
291
+ if (event.key === 'ArrowDown' && options.length) {
292
+ event.preventDefault();
293
+ this.open(true);
294
+ this.highlight(options, (current + 1) % options.length);
295
+ } else if (event.key === 'ArrowUp' && options.length) {
296
+ event.preventDefault();
297
+ this.highlight(options, current <= 0 ? options.length - 1 : current - 1);
298
+ } else if (event.key === 'Enter' && current >= 0) {
299
+ event.preventDefault();
300
+ options[current]?.click();
301
+ } else if (event.key === 'Escape') {
302
+ this.open(false);
303
+ }
130
304
  }
305
+ }
131
306
 
132
- input.addEventListener('input', () => {
133
- const term = input.value.trim();
134
- if (term === last) return;
135
- last = term;
136
- clearTimeout(timer);
137
- timer = setTimeout(() => run(term), cfg.searchDelay ?? 150);
138
- });
139
- input.addEventListener('focus', () => { if (input.value.trim().length >= min && items().length) open(true); });
140
- input.addEventListener('keydown', (e) => {
141
- const list = items();
142
- const cur = list.findIndex((el) => el.getAttribute('aria-selected') === 'true');
143
- const move = (i) => {
144
- list.forEach((el, j) => el.setAttribute('aria-selected', String(j === i)));
145
- if (list[i]) { input.setAttribute('aria-activedescendant', list[i].id); list[i].scrollIntoView?.({ block: 'nearest' }); }
146
- };
147
- if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(true); move((cur + 1) % list.length); }
148
- else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); move(cur <= 0 ? list.length - 1 : cur - 1); }
149
- else if (e.key === 'Enter' && cur >= 0) { e.preventDefault(); list[cur].click(); }
150
- else if (e.key === 'Escape') { open(false); }
151
- });
152
- document.addEventListener('click', (e) => { if (!rootEl.contains(e.target)) open(false); });
307
+ /**
308
+ * Sets up every `[tether-search]` in `root` (and `root` itself) once.
309
+ *
310
+ * @param {ParentNode & Partial<Pick<Element, 'matches'>>} [root]
311
+ */
312
+ export function mountSearch(root = document) {
313
+ for (const el of within(root, sel('search'))) {
314
+ if (mounted.has(el)) continue;
315
+ mounted.add(el);
316
+ // The markup names an input element; a site without one gets the first input.
317
+ const input = /** @type {HTMLInputElement | null} */ (
318
+ el.querySelector(sel('search-input')) ?? el.querySelector('input')
319
+ );
320
+ const results = /** @type {HTMLElement | null} */ (el.querySelector(sel('search-results')));
321
+ if (input && results) new SearchBox(el, input, results);
322
+ }
153
323
  }