@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/dom.js
CHANGED
|
@@ -1,288 +1,287 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DOM
|
|
3
|
-
*
|
|
2
|
+
* The browser modules' DOM toolkit: what Shopify values look like on the page (money, dates, text, images, lists)
|
|
3
|
+
* and how elements show, hide and report layout changes. Attribute names go through `attr()` so the prefix is
|
|
4
|
+
* configurable. The optional parts import all of it from here (`window.Tether.__core['dom.js']`).
|
|
4
5
|
*/
|
|
5
|
-
import { cfg, warn } from './config.js';
|
|
6
|
+
import { cfg, reportError, warn } from './config.js';
|
|
7
|
+
import { attr, emit, get } from './markup.js';
|
|
8
|
+
import { storeHtmlFragment } from './store-html.js';
|
|
9
|
+
import { formatMoney as formatMoneyIn, productUrl as productUrlFor } from './shopify-values.js';
|
|
10
|
+
/** @import { Money } from './shopify-values.js' */
|
|
6
11
|
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
export const get = (el, name) => el.getAttribute(attr(name));
|
|
11
|
-
export const has = (el, name) => el.hasAttribute(attr(name));
|
|
12
|
-
export const dataKey = () => `data-${cfg.prefix}-key`;
|
|
12
|
+
export { attr, sel, get, has, qsa, scoped, emit } from './markup.js';
|
|
13
|
+
export { imageUrl, cdnUrl, sameImage, setImage } from './images.js';
|
|
14
|
+
export { hasTemplate, repeat, repeatKeyed } from './repeaters.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* with tether-product="url:handle"), '/p/{id}' (numeric product id).
|
|
17
|
+
* State Tether keeps on elements, as `data-tether-*` attributes whatever the prefix: sites style and test against
|
|
18
|
+
* these names, so they stay fixed where the `tether-*` markup attributes follow `prefix`.
|
|
18
19
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const numeric = id ? String(id).split('/').pop() : '';
|
|
23
|
-
return path.replace(/\{handle\}/g, encodeURIComponent(handle || '')).replace(/\{id\}/g, encodeURIComponent(numeric));
|
|
24
|
-
}
|
|
20
|
+
const SEEN = 'data-tether-seen';
|
|
21
|
+
const SHOWN = 'data-tether-shown';
|
|
22
|
+
const PREVIOUS_DISPLAY = 'data-tether-prev-display';
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
/** Async renders change the page height in bursts; one notification per burst. */
|
|
25
|
+
const LAYOUT_DEBOUNCE_MS = 120;
|
|
27
26
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The product page of a product (`productPath`).
|
|
29
|
+
*
|
|
30
|
+
* @param {string | null | undefined} handle
|
|
31
|
+
* @param {string | null} [id]
|
|
32
|
+
*/
|
|
33
|
+
export function productUrl(handle, id) {
|
|
34
|
+
return productUrlFor(cfg.productPath || '/product', handle, id);
|
|
35
|
+
}
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
/**
|
|
38
|
+
* `Intl` throws a RangeError for a locale or currency it doesn't know; the caller then shows a plain fallback.
|
|
39
|
+
*
|
|
40
|
+
* @template T
|
|
41
|
+
* @param {() => T} format
|
|
42
|
+
* @param {T} fallback
|
|
43
|
+
* @returns {T}
|
|
44
|
+
*/
|
|
45
|
+
function formatOr(format, fallback) {
|
|
46
|
+
try {
|
|
47
|
+
return format();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof RangeError) return fallback;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `30` → `30%` (en, nl) or `30 %` (de, fr), in the configured locale.
|
|
56
|
+
*
|
|
57
|
+
* @param {number | null | undefined} percentage
|
|
58
|
+
* @param {string | undefined} [locale]
|
|
59
|
+
* @returns {string} `''` for no or a zero percentage.
|
|
60
|
+
*/
|
|
61
|
+
export function formatPercent(percentage, locale = cfg.locale) {
|
|
62
|
+
if (!percentage) return '';
|
|
63
|
+
const format = () =>
|
|
64
|
+
new Intl.NumberFormat(locale || undefined, { style: 'percent', maximumFractionDigits: 0 }).format(percentage / 100);
|
|
65
|
+
return formatOr(format, `${percentage}%`);
|
|
40
66
|
}
|
|
41
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Money in the configured locale, through the site's `formatMoney` when it has one.
|
|
70
|
+
*
|
|
71
|
+
* @param {Money | null | undefined} money
|
|
72
|
+
*/
|
|
42
73
|
export function formatMoney(money) {
|
|
43
74
|
if (!money) return '';
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
if (Number.isNaN(amount)) return '';
|
|
47
|
-
try {
|
|
48
|
-
return new Intl.NumberFormat(cfg.locale || undefined, {
|
|
49
|
-
style: 'currency', currency: money.currencyCode,
|
|
50
|
-
}).format(amount);
|
|
51
|
-
} catch {
|
|
52
|
-
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
53
|
-
}
|
|
75
|
+
if (cfg.formatMoney) return cfg.formatMoney(money, cfg.locale);
|
|
76
|
+
return formatMoneyIn(money, cfg.locale);
|
|
54
77
|
}
|
|
55
78
|
|
|
79
|
+
/**
|
|
80
|
+
* @param {string | null | undefined} iso
|
|
81
|
+
* @returns {string} The date in the configured locale, `''` without one.
|
|
82
|
+
*/
|
|
56
83
|
export function formatDate(iso) {
|
|
57
84
|
if (!iso) return '';
|
|
58
|
-
|
|
85
|
+
return formatOr(() => new Date(iso).toLocaleDateString(cfg.locale || undefined), '');
|
|
59
86
|
}
|
|
60
87
|
|
|
61
|
-
/** Shopify CDN resize via max-width / max-height / crop attributes on the element. */
|
|
62
88
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
89
|
+
* Writes HTML from the Shopify admin (product and collection descriptions) into `el`, with script removed
|
|
90
|
+
* (`store-html.js`). Everything else from Shopify is written as text (`setText`).
|
|
91
|
+
*
|
|
92
|
+
* @param {Element} el
|
|
93
|
+
* @param {string | null | undefined} html
|
|
65
94
|
*/
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const u = new URL(url);
|
|
70
|
-
if (width) u.searchParams.set('width', String(width));
|
|
71
|
-
if (height) u.searchParams.set('height', String(height));
|
|
72
|
-
if (crop) u.searchParams.set('crop', crop);
|
|
73
|
-
return u.toString();
|
|
74
|
-
} catch { return url; }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function cdnUrl(url, el) {
|
|
78
|
-
if (!url || !el) return url;
|
|
79
|
-
// max-width (Webflow's custom attribute), or valid HTML: tether-max-width / data-max-width.
|
|
80
|
-
const g = (k) => el.getAttribute(k) ?? el.getAttribute(`${cfg.prefix}-${k}`) ?? el.getAttribute(`data-${k}`);
|
|
81
|
-
return imageUrl(url, { width: g('max-width'), height: g('max-height'), crop: g('max-width') || g('max-height') ? g('crop') : null });
|
|
95
|
+
export function setStoreHtml(el, html) {
|
|
96
|
+
el.replaceChildren(storeHtmlFragment(html, el.ownerDocument));
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
99
|
+
/**
|
|
100
|
+
* @param {Element} el
|
|
101
|
+
* @returns {el is HTMLInputElement | HTMLTextAreaElement}
|
|
102
|
+
*/
|
|
103
|
+
const isTextField = (el) => el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
|
86
104
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (el.tagName === 'IMG') {
|
|
95
|
-
el.removeAttribute('srcset');
|
|
96
|
-
el.removeAttribute('sizes');
|
|
97
|
-
el.setAttribute('src', src);
|
|
98
|
-
if (alt != null) el.setAttribute('alt', alt);
|
|
99
|
-
if (!el.getAttribute('loading')) el.setAttribute('loading', 'lazy');
|
|
100
|
-
} else {
|
|
101
|
-
el.style.backgroundImage = `url("${src}")`;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
105
|
+
/**
|
|
106
|
+
* @param {Element} el
|
|
107
|
+
* @returns {el is HTMLInputElement}
|
|
108
|
+
*/
|
|
109
|
+
const isInput = (el) => el.tagName === 'INPUT';
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Sets the text of `el`, or its value when it is a form field.
|
|
113
|
+
*
|
|
114
|
+
* @param {Element} el
|
|
115
|
+
* @param {string | number | boolean | null | undefined} value `null` and `undefined` clear it.
|
|
116
|
+
*/
|
|
117
|
+
export function setText(el, value) {
|
|
118
|
+
const text = value == null ? '' : String(value);
|
|
119
|
+
if (isTextField(el)) el.value = text;
|
|
108
120
|
else el.textContent = text;
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
|
|
123
|
+
/** @param {Element} el */
|
|
124
|
+
const readText = (el) => (isTextField(el) ? el.value : el.textContent);
|
|
112
125
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
126
|
+
/**
|
|
127
|
+
* `setText` that hands a change after the first render to `onValueChange`, for scramble and roll effects.
|
|
128
|
+
*
|
|
129
|
+
* @param {HTMLElement} el
|
|
130
|
+
* @param {string | number | boolean | null | undefined} value
|
|
131
|
+
*/
|
|
132
|
+
export function setTextTracked(el, value) {
|
|
115
133
|
const before = readText(el);
|
|
116
|
-
setText(el,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
setText(el, value);
|
|
135
|
+
if (before !== readText(el) && el.hasAttribute(SEEN) && cfg.onValueChange) {
|
|
136
|
+
try {
|
|
137
|
+
cfg.onValueChange(el, before);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
reportError(error, 'onValueChange');
|
|
140
|
+
}
|
|
120
141
|
}
|
|
121
|
-
el.
|
|
142
|
+
el.setAttribute(SEEN, '1');
|
|
122
143
|
}
|
|
123
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Shows an element hidden before the script ran (a class, an inline `display: none`, a `hidden` attribute), as
|
|
147
|
+
* `block` or the value of `tether-display`.
|
|
148
|
+
*
|
|
149
|
+
* @param {HTMLElement} el
|
|
150
|
+
*/
|
|
151
|
+
function reveal(el) {
|
|
152
|
+
if (el.hasAttribute(SHOWN)) return;
|
|
153
|
+
el.setAttribute(SHOWN, '');
|
|
154
|
+
el.removeAttribute('hidden');
|
|
155
|
+
const previous = el.getAttribute(PREVIOUS_DISPLAY);
|
|
156
|
+
if (previous !== null) {
|
|
157
|
+
el.style.display = previous;
|
|
158
|
+
el.removeAttribute(PREVIOUS_DISPLAY);
|
|
159
|
+
} else if (el.style.display === 'none') {
|
|
160
|
+
el.style.display = '';
|
|
161
|
+
}
|
|
162
|
+
if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
163
|
+
el.style.display = el.getAttribute(attr('display')) || 'block';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Shows or hides `el`, restoring its own display value when shown again.
|
|
169
|
+
*
|
|
170
|
+
* @param {HTMLElement} el
|
|
171
|
+
* @param {boolean} visible
|
|
172
|
+
*/
|
|
124
173
|
export function show(el, visible) {
|
|
125
|
-
const k = 'tetherPrevDisplay';
|
|
126
174
|
if (visible) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
el.style.display = '';
|
|
135
|
-
}
|
|
136
|
-
// Hidden before the script ran (a class, an inline display:none, a hidden attribute): show it
|
|
137
|
-
// as block or the value of tether-display.
|
|
138
|
-
if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
139
|
-
el.style.display = el.getAttribute(`${cfg.prefix}-display`) || 'block';
|
|
140
|
-
}
|
|
141
|
-
} else {
|
|
142
|
-
delete el.dataset.tetherShown;
|
|
143
|
-
// Remember a real display value to restore, never 'none'.
|
|
144
|
-
if (el.dataset[k] === undefined && el.style.display !== 'none') el.dataset[k] = el.style.display || '';
|
|
145
|
-
el.style.display = 'none';
|
|
175
|
+
reveal(el);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
el.removeAttribute(SHOWN);
|
|
179
|
+
// The display value to restore is a real one, never 'none'.
|
|
180
|
+
if (!el.hasAttribute(PREVIOUS_DISPLAY) && el.style.display !== 'none') {
|
|
181
|
+
el.setAttribute(PREVIOUS_DISPLAY, el.style.display);
|
|
146
182
|
}
|
|
183
|
+
el.style.display = 'none';
|
|
147
184
|
}
|
|
148
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Marks an option button or input as chosen: `is-selected`, `aria-pressed`, and its own or inner radio/checkbox.
|
|
188
|
+
*
|
|
189
|
+
* @param {Element} el
|
|
190
|
+
* @param {boolean} selected
|
|
191
|
+
*/
|
|
149
192
|
export function setSelected(el, selected) {
|
|
150
193
|
el.classList.toggle('is-selected', selected);
|
|
151
194
|
el.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
152
|
-
if (el
|
|
153
|
-
const input = el.querySelector
|
|
154
|
-
if (input) input.checked = selected;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
// Repeaters: the first child in Webflow is the template, the rest is removed.
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
const templates = new WeakMap();
|
|
162
|
-
|
|
163
|
-
/** True once a repeater's template has been captured (it may be empty after that). */
|
|
164
|
-
export const hasTemplate = (container) => Boolean(templates.get(container));
|
|
165
|
-
|
|
166
|
-
function template(container) {
|
|
167
|
-
if (!templates.has(container)) {
|
|
168
|
-
const tpl = container.firstElementChild?.cloneNode(true) ?? null;
|
|
169
|
-
templates.set(container, tpl);
|
|
170
|
-
container.innerHTML = '';
|
|
171
|
-
}
|
|
172
|
-
return templates.get(container);
|
|
195
|
+
if (isInput(el) && (el.type === 'radio' || el.type === 'checkbox')) el.checked = selected;
|
|
196
|
+
const input = el.querySelector('input[type="radio"],input[type="checkbox"]');
|
|
197
|
+
if (input && isInput(input)) input.checked = selected;
|
|
173
198
|
}
|
|
174
199
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
const tpl = template(container);
|
|
178
|
-
if (!tpl) { if (items.length) warn('repeater without a template child', container); return; }
|
|
179
|
-
container.innerHTML = '';
|
|
180
|
-
items.forEach((item, i) => {
|
|
181
|
-
const node = tpl.cloneNode(true);
|
|
182
|
-
render(node, item, i);
|
|
183
|
-
container.appendChild(node);
|
|
184
|
-
});
|
|
185
|
-
}
|
|
200
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
201
|
+
let layoutTimer;
|
|
186
202
|
|
|
187
203
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
204
|
+
* Async renders change the page height after the site's page-transition hooks ran. After a burst of them this calls
|
|
205
|
+
* `onLayoutChange` (else refreshes ScrollTrigger) and emits `tether:layout-changed`.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} reason
|
|
190
208
|
*/
|
|
191
|
-
export function repeatKeyed(container, items, keyOf, render, { onEnter, onLeave } = {}) {
|
|
192
|
-
const tpl = template(container);
|
|
193
|
-
if (!tpl) { warn('repeater without a template child', container); return; }
|
|
194
|
-
|
|
195
|
-
const KEY = dataKey();
|
|
196
|
-
const LEAVING = `data-${cfg.prefix}-leaving`;
|
|
197
|
-
const live = () => [...container.children].filter((n) => n.hasAttribute(KEY) && !n.hasAttribute(LEAVING));
|
|
198
|
-
|
|
199
|
-
const existing = new Map(live().map((n) => [n.getAttribute(KEY), n]));
|
|
200
|
-
const wanted = [];
|
|
201
|
-
const fresh = new Set();
|
|
202
|
-
|
|
203
|
-
items.forEach((item, i) => {
|
|
204
|
-
const k = String(keyOf(item, i));
|
|
205
|
-
let node = existing.get(k);
|
|
206
|
-
if (node) existing.delete(k);
|
|
207
|
-
else {
|
|
208
|
-
node = tpl.cloneNode(true);
|
|
209
|
-
node.setAttribute(KEY, k);
|
|
210
|
-
fresh.add(node);
|
|
211
|
-
}
|
|
212
|
-
render(node, item, i, fresh.has(node));
|
|
213
|
-
wanted.push(node);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
const staying = wanted.filter((n) => !fresh.has(n));
|
|
217
|
-
const current = live().filter((n) => staying.includes(n));
|
|
218
|
-
const orderOk = current.length === staying.length && current.every((n, i) => n === staying[i]);
|
|
219
|
-
|
|
220
|
-
if (!orderOk) {
|
|
221
|
-
existing.forEach((n) => n.remove());
|
|
222
|
-
wanted.forEach((n) => container.appendChild(n));
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
wanted.forEach((node, i) => {
|
|
227
|
-
if (!fresh.has(node)) return;
|
|
228
|
-
const prev = wanted[i - 1];
|
|
229
|
-
if (prev && prev.parentNode === container) prev.after(node);
|
|
230
|
-
else container.prepend(node);
|
|
231
|
-
if (onEnter) { try { onEnter(node); } catch { /* ignore */ } }
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
existing.forEach((node) => {
|
|
235
|
-
node.setAttribute(LEAVING, '');
|
|
236
|
-
node.removeAttribute(KEY);
|
|
237
|
-
node.style.pointerEvents = 'none';
|
|
238
|
-
node.inert = true;
|
|
239
|
-
const done = () => node.remove();
|
|
240
|
-
if (onLeave) { try { onLeave(node, done); } catch { done(); } } else done();
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
// Layout notifications: async renders change page height after your
|
|
246
|
-
// page-transition hooks ran. Debounced, then cfg.onLayoutChange or a default.
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
|
|
249
|
-
let layoutTimer = null;
|
|
250
|
-
|
|
251
209
|
export function notifyLayoutChanged(reason) {
|
|
252
210
|
clearTimeout(layoutTimer);
|
|
253
211
|
layoutTimer = setTimeout(() => {
|
|
254
|
-
|
|
255
|
-
|
|
212
|
+
// GSAP's ScrollTrigger, when the site has it, measures positions that a height change makes stale.
|
|
213
|
+
const { ScrollTrigger } = /** @type {Window & { ScrollTrigger?: { refresh?: () => void } }} */ (window);
|
|
214
|
+
if (cfg.onLayoutChange) cfg.onLayoutChange(reason);
|
|
215
|
+
else ScrollTrigger?.refresh?.();
|
|
256
216
|
emit('layout-changed', { reason });
|
|
257
|
-
},
|
|
217
|
+
}, LAYOUT_DEBOUNCE_MS);
|
|
258
218
|
}
|
|
259
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Notifies a layout change (`<reason>:images`) once every image in `root` that is still loading has loaded or failed.
|
|
222
|
+
*
|
|
223
|
+
* @param {ParentNode} root
|
|
224
|
+
* @param {string} reason
|
|
225
|
+
*/
|
|
260
226
|
export function watchImages(root, reason) {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
227
|
+
const loading = [...root.querySelectorAll('img')].filter((img) => !img.complete);
|
|
228
|
+
let left = loading.length;
|
|
229
|
+
const done = () => {
|
|
230
|
+
left -= 1;
|
|
231
|
+
if (left === 0) notifyLayoutChanged(`${reason}:images`);
|
|
232
|
+
};
|
|
233
|
+
loading.forEach((img) => {
|
|
266
234
|
img.addEventListener('load', done, { once: true });
|
|
267
235
|
img.addEventListener('error', done, { once: true });
|
|
268
236
|
});
|
|
269
237
|
}
|
|
270
238
|
|
|
271
|
-
|
|
272
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Runs `task` with `aria-busy` on `el`; does nothing while `el` is already busy.
|
|
241
|
+
*
|
|
242
|
+
* @template T
|
|
243
|
+
* @param {Element} el
|
|
244
|
+
* @param {() => T | Promise<T>} task
|
|
245
|
+
* @returns {Promise<T | undefined>} The task's result, `undefined` when it didn't run.
|
|
246
|
+
*/
|
|
247
|
+
export function busy(el, task) {
|
|
248
|
+
if (el.getAttribute('aria-busy') === 'true') return Promise.resolve(undefined);
|
|
273
249
|
el.setAttribute('aria-busy', 'true');
|
|
274
|
-
return Promise.resolve()
|
|
250
|
+
return Promise.resolve()
|
|
251
|
+
.then(task)
|
|
252
|
+
.finally(() => {
|
|
253
|
+
el.removeAttribute('aria-busy');
|
|
254
|
+
});
|
|
275
255
|
}
|
|
276
256
|
|
|
257
|
+
/**
|
|
258
|
+
* @param {Element | null | undefined} el
|
|
259
|
+
* @param {string} name
|
|
260
|
+
* @returns {unknown} The attribute's JSON, `null` when it is missing or not JSON (with a warning).
|
|
261
|
+
*/
|
|
277
262
|
export function parseJSONAttr(el, name) {
|
|
278
|
-
const raw = el
|
|
263
|
+
const raw = el ? get(el, name) : null;
|
|
279
264
|
if (!raw) return null;
|
|
280
|
-
try {
|
|
265
|
+
try {
|
|
266
|
+
return /** @type {unknown} */ (JSON.parse(raw));
|
|
267
|
+
} catch {
|
|
268
|
+
warn(`invalid JSON in ${attr(name)}`, el);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
281
271
|
}
|
|
282
272
|
|
|
283
|
-
/** {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
273
|
+
/** @typedef {string | number | boolean | null | undefined} AttributeValue */
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `{ a: 1 }` or `[{ key, value }]` → `[{ key, value }]` as Shopify wants cart and line attributes.
|
|
277
|
+
*
|
|
278
|
+
* @param {Record<string, AttributeValue> | { key: string | number, value?: AttributeValue }[] | null | undefined} attributes
|
|
279
|
+
* @returns {{ key: string, value: string }[]}
|
|
280
|
+
*/
|
|
281
|
+
export function toAttributes(attributes) {
|
|
282
|
+
if (!attributes) return [];
|
|
283
|
+
if (Array.isArray(attributes)) {
|
|
284
|
+
return attributes.map((attribute) => ({ key: String(attribute.key), value: String(attribute.value ?? '') }));
|
|
285
|
+
}
|
|
286
|
+
return Object.entries(attributes).map(([key, value]) => ({ key, value: String(value ?? '') }));
|
|
288
287
|
}
|