@occluze/tether 0.6.7 → 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 +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  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 +331 -242
  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 +3176 -2015
  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 +69 -298
  58. package/src/account.js +280 -323
  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 +320 -488
  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 -287
  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-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/dom.js CHANGED
@@ -1,288 +1,287 @@
1
1
  /**
2
- * DOM helpers. Attribute names go through attr() so the prefix is configurable
3
- * (e.g. prefix: 'wfs' for markup that uses wfs-* attributes).
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 const attr = (name) => `${cfg.prefix}-${name}`;
8
- export const sel = (name, value) =>
9
- value === undefined ? `[${attr(name)}]` : `[${attr(name)}="${value}"]`;
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
- * productPath '/product' -> /product/<handle>.
16
- * Templates: '/product?handle={handle}' (one static page for every product,
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
- export function productUrl(handle, id) {
20
- const path = cfg.productPath || '/product';
21
- if (!/\{(handle|id)\}/.test(path)) return `${path.replace(/\/$/, '')}/${handle}`;
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
- export const qsa = (root, s) => (root ? [...root.querySelectorAll(s)] : []);
24
+ /** Async renders change the page height in bursts; one notification per burst. */
25
+ const LAYOUT_DEBOUNCE_MS = 120;
27
26
 
28
- /** Elements in `root` whose nearest `scopeSelector` ancestor is `root` itself. */
29
- export const scoped = (root, s, scopeSelector) =>
30
- qsa(root, s).filter((el) => el.closest(scopeSelector) === root);
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
- export function emit(name, detail, target = document) {
33
- target.dispatchEvent(new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true }));
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
- /** 30 -> "30%" (en, nl) or "30 %" (de, fr), in the configured locale. */
37
- export function formatPercent(n, locale = cfg.locale) {
38
- if (!n) return '';
39
- try { return new Intl.NumberFormat(locale || undefined, { style: 'percent', maximumFractionDigits: 0 }).format(n / 100); } catch { return `${n}%`; }
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 (typeof cfg.formatMoney === 'function') return cfg.formatMoney(money, cfg.locale);
45
- const amount = parseFloat(money.amount);
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
- try { return new Date(iso).toLocaleDateString(cfg.locale || undefined); } catch { return ''; }
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
- * Shopify CDN image at a size: imageUrl(url, { width: 800 }). Use the same width as the element's
64
- * max-width attribute, so the browser script keeps the image instead of loading it again.
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 imageUrl(url, { width, height, crop } = {}) {
67
- if (!url) return url;
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
- /** Same image, whatever size parameters: the server-rendered one is kept (no second download). */
85
- export const sameImage = (a, b) => Boolean(a && b) && a.split('?')[0] === b.split('?')[0];
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
- export function setImage(el, url, alt) {
88
- if (!url) return;
89
- const src = cdnUrl(url, el);
90
- if (el.tagName === 'IMG' && sameImage(el.getAttribute('src'), src)) {
91
- if (alt != null && !el.getAttribute('alt')) el.setAttribute('alt', alt);
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
- export function setText(el, v) {
106
- const text = v == null ? '' : String(v);
107
- if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') el.value = text;
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
- export const readText = (el) => (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ? el.value : el.textContent);
123
+ /** @param {Element} el */
124
+ const readText = (el) => (isTextField(el) ? el.value : el.textContent);
112
125
 
113
- /** setText that reports changes to cfg.onValueChange (for scramble/roll effects). */
114
- export function setTextTracked(el, v) {
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, v);
117
- const after = readText(el);
118
- if (before !== after && el.dataset.tetherSeen && typeof cfg.onValueChange === 'function') {
119
- try { cfg.onValueChange(el, before); } catch { /* ignore */ }
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.dataset.tetherSeen = '1';
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
- if (el.dataset.tetherShown !== undefined) return; // already visible, nothing to check
128
- el.dataset.tetherShown = '';
129
- el.removeAttribute('hidden');
130
- if (el.dataset[k] !== undefined) {
131
- el.style.display = el.dataset[k];
132
- delete el.dataset[k];
133
- } else if (el.style.display === 'none') {
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.tagName === 'INPUT' && (el.type === 'radio' || el.type === 'checkbox')) el.checked = selected;
153
- const input = el.querySelector?.('input[type="radio"],input[type="checkbox"]');
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
- /** Rebuilds the list on every render. Fine for images, tags, options. */
176
- export function repeat(container, items, render) {
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
- * Keyed repeater: keeps existing nodes, inserts new ones, removes old ones.
189
- * Used for cart lines, so values can animate and lines can slide in/out.
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
- if (typeof cfg.onLayoutChange === 'function') cfg.onLayoutChange(reason);
255
- else if (window.ScrollTrigger?.refresh) window.ScrollTrigger.refresh();
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
- }, 120);
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 imgs = qsa(root, 'img').filter((i) => !i.complete);
262
- if (!imgs.length) return;
263
- let left = imgs.length;
264
- const done = () => { if (--left === 0) notifyLayoutChanged(`${reason}:images`); };
265
- imgs.forEach((img) => {
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
- export function busy(el, fn) {
272
- if (el.getAttribute('aria-busy') === 'true') return Promise.resolve();
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().then(fn).finally(() => el.removeAttribute('aria-busy'));
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 && get(el, name);
263
+ const raw = el ? get(el, name) : null;
279
264
  if (!raw) return null;
280
- try { return JSON.parse(raw); } catch { warn(`invalid JSON in ${attr(name)}`, el); return null; }
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
- /** {a: 1} or [{key, value}] -> [{key, value}] as Shopify wants it. */
284
- export function toAttributes(obj) {
285
- if (!obj) return [];
286
- if (Array.isArray(obj)) return obj.map((a) => ({ key: String(a.key), value: String(a.value ?? '') }));
287
- return Object.entries(obj).map(([key, value]) => ({ key, value: String(value ?? '') }));
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
  }