@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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/repeaters.js
ADDED
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
54
|
+
let searchBoxCount = 0;
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|