@occluze/tether 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  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 +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Preset for sites with page transitions (Barba, Swup), smooth scroll (Lenis) and GSAP. Nothing is read from globals
3
+ * except GSAP and ScrollTrigger by default: pass in what your site has.
4
+ *
5
+ * import Tether from "@occluze/tether";
6
+ * import { barbaPreset } from "@occluze/tether/presets/barba";
7
+ *
8
+ * Tether.init({
9
+ * ...window.TETHER_CONFIG,
10
+ * ...barbaPreset({
11
+ * lenis: () => myLenis, // resized after async renders
12
+ * lockScroll: (drawer) => myLock.lock(drawer), // when the cart opens
13
+ * unlockScroll: () => myLock.unlock(), // when the cart closes
14
+ * onValueChange: (el, previous) => {}, // e.g. a text scramble
15
+ * }),
16
+ * });
17
+ *
18
+ * // once per page container, e.g. in Barba's beforeEnter AND once():
19
+ * Tether.mount(container);
20
+ * // when leaving a page:
21
+ * Tether.unmount();
22
+ */
23
+ import type { TetherConfig } from '../config.ts';
24
+
25
+ // GSAP isn't a dependency, so the two calls this preset makes are described here.
26
+ type TweenVars = Record<string, string | number | (() => void)>;
27
+ interface Gsap {
28
+ fromTo: (target: HTMLElement, from: TweenVars, to: TweenVars) => unknown;
29
+ to: (target: HTMLElement, to: TweenVars) => unknown;
30
+ }
31
+
32
+ /** `gsap`, `lenis` and `scrollTrigger` are getters, read each time they are used, so an instance made later is found. */
33
+ interface BarbaPresetOptions {
34
+ /** Default `window.gsap`. */
35
+ gsap?: () => unknown;
36
+ /** Resized after async renders. */
37
+ lenis?: (() => unknown) | null;
38
+ /** Default `window.ScrollTrigger`, refreshed after async renders. */
39
+ scrollTrigger?: () => unknown;
40
+ /** When the cart opens. */
41
+ lockScroll?: ((drawer: HTMLElement) => void) | null;
42
+ /** When the cart closes. */
43
+ unlockScroll?: (() => void) | null;
44
+ /** After a cart line animated in or out. */
45
+ onResize?: (() => void) | null;
46
+ onValueChange?: TetherConfig['onValueChange'];
47
+ /** Animate cart lines in and out (default true). */
48
+ animateLines?: boolean;
49
+ /** Seconds. */
50
+ lineDuration?: number;
51
+ }
52
+
53
+ /** The hooks the preset always sets, and `onValueChange` as passed (`null` when it isn't). */
54
+ type BarbaPresetConfig = { autoMount: false } & {
55
+ [Hook in 'onLayoutChange' | 'onCartOpen' | 'onCartClose' | 'onLineEnter' | 'onLineLeave']: NonNullable<
56
+ TetherConfig[Hook]
57
+ >;
58
+ } & Pick<TetherConfig, 'onValueChange'>;
59
+
60
+ export type { BarbaPresetOptions, BarbaPresetConfig };
61
+
62
+ const LINE_DURATION_SECONDS = 0.3;
63
+ /** @returns A new object each time: GSAP writes into the vars it is given. */
64
+ const collapsed = (): TweenVars => ({
65
+ height: 0,
66
+ paddingTop: 0,
67
+ paddingBottom: 0,
68
+ borderBottomWidth: 0,
69
+ opacity: 0,
70
+ overflow: 'hidden',
71
+ });
72
+ const ANIMATED_PROPS = 'height,paddingTop,paddingBottom,borderBottomWidth,opacity,overflow';
73
+
74
+ const reducedMotion = () =>
75
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
76
+
77
+ const GETTER_OPTIONS = ['gsap', 'lenis', 'scrollTrigger'] as const;
78
+
79
+ /**
80
+ * @throws {TypeError} When a getter option is a value, which JavaScript callers can pass: a value that is a function
81
+ * (ScrollTrigger is a class) can't be told from a getter.
82
+ */
83
+ function checkGetters(options: BarbaPresetOptions) {
84
+ for (const name of GETTER_OPTIONS) {
85
+ const option: unknown = options[name];
86
+ if (option !== undefined && option !== null && typeof option !== 'function') {
87
+ throw new TypeError(`barbaPreset: ${name} must be a function that returns it, such as () => ${name}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Calls `target[method]()` when it has one: Lenis and ScrollTrigger are optional and versions differ.
94
+ *
95
+ * @param target An object, or a class with a static method (`ScrollTrigger.refresh()`).
96
+ */
97
+ function callIfPresent(target: unknown, method: string) {
98
+ if ((typeof target !== 'object' && typeof target !== 'function') || target === null) return;
99
+ const fn: unknown = Reflect.get(target, method);
100
+ // A method of `target`, called on it.
101
+ if (typeof fn === 'function') (fn as (this: unknown) => unknown).call(target);
102
+ }
103
+
104
+ const isGsap = (value: unknown): value is Gsap =>
105
+ typeof value === 'object' &&
106
+ value !== null &&
107
+ typeof Reflect.get(value, 'fromTo') === 'function' &&
108
+ typeof Reflect.get(value, 'to') === 'function';
109
+
110
+ /**
111
+ * Hooks for sites with page transitions, smooth scroll and GSAP: spread into `Tether.init()`.
112
+ */
113
+ export function barbaPreset(options: BarbaPresetOptions = {}): BarbaPresetConfig {
114
+ checkGetters(options);
115
+ const {
116
+ gsap = (): unknown => Reflect.get(window, 'gsap'),
117
+ lenis = null,
118
+ scrollTrigger = (): unknown => Reflect.get(window, 'ScrollTrigger'),
119
+ lockScroll = null,
120
+ unlockScroll = null,
121
+ onResize = null,
122
+ onValueChange = null,
123
+ animateLines = true,
124
+ lineDuration = LINE_DURATION_SECONDS,
125
+ } = options;
126
+
127
+ /** Whether this preset holds the scroll lock, so a modal's own lock stays intact. */
128
+ let locked = false;
129
+
130
+ /** @returns GSAP, when this line should animate. */
131
+ const animator = (node: HTMLElement): Gsap | null => {
132
+ if (!animateLines || reducedMotion() || node.offsetHeight <= 0) return null;
133
+ const animation = gsap();
134
+ return isGsap(animation) ? animation : null;
135
+ };
136
+
137
+ return {
138
+ autoMount: false,
139
+
140
+ onLayoutChange() {
141
+ callIfPresent(lenis?.(), 'resize');
142
+ callIfPresent(scrollTrigger(), 'refresh');
143
+ },
144
+
145
+ onCartOpen(drawers) {
146
+ const drawer = drawers[0];
147
+ if (locked || !lockScroll || !drawer) return;
148
+ lockScroll(drawer);
149
+ locked = true;
150
+ },
151
+
152
+ onCartClose() {
153
+ if (locked && unlockScroll) unlockScroll();
154
+ locked = false;
155
+ },
156
+
157
+ onLineEnter(node) {
158
+ const animation = animator(node);
159
+ if (!animation) return;
160
+ const style = getComputedStyle(node);
161
+ animation.fromTo(node, collapsed(), {
162
+ height: node.offsetHeight,
163
+ paddingTop: style.paddingTop,
164
+ paddingBottom: style.paddingBottom,
165
+ borderBottomWidth: style.borderBottomWidth,
166
+ opacity: 1,
167
+ duration: lineDuration,
168
+ clearProps: ANIMATED_PROPS,
169
+ onComplete: () => onResize?.(),
170
+ });
171
+ },
172
+
173
+ onLineLeave(node, done) {
174
+ const animation = animator(node);
175
+ if (!animation) {
176
+ done();
177
+ return;
178
+ }
179
+ animation.to(node, {
180
+ ...collapsed(),
181
+ duration: lineDuration,
182
+ onComplete: () => {
183
+ done();
184
+ onResize?.();
185
+ },
186
+ });
187
+ },
188
+
189
+ onValueChange,
190
+ };
191
+ }
192
+
193
+ /** @alias `barbaPreset`, for `import barbaPreset from '@occluze/tether/presets/barba'`. */
194
+ export default barbaPreset;
@@ -2,47 +2,33 @@
2
2
  * A product scope's clicks and changes: option and plan buttons, purchase toggles, quantity steppers, the selects and
3
3
  * add to cart. One delegated listener of each kind on the root, so cards a list re-renders need no new listeners.
4
4
  */
5
- import { reportError } from './config.js';
6
- import { sel, get, emit, setText, busy } from './dom.js';
7
- import { addButtonLabel } from './variant-render.js';
8
- /** @import { ProductScope } from './product.js' */
5
+ import { reportError } from './config.ts';
6
+ import { sel, get, emit, setText, busy } from './dom.ts';
7
+ import { addButtonLabel } from './variant-render.ts';
8
+ import type { ProductScope } from './product.ts';
9
+ import type { Purchase } from './product-state.ts';
9
10
 
10
11
  const productSelector = () => sel('product');
11
12
 
12
- /**
13
- * @param {Element} el
14
- * @returns {el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement}
15
- */
16
- const isField = (el) => el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
13
+ const isField = (el: Element): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
14
+ el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
17
15
 
18
- /**
19
- * @param {EventTarget | null} target
20
- * @returns {target is Element}
21
- */
22
- const isElement = (target) => target instanceof window.Element;
16
+ const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
23
17
 
24
18
  /**
25
- * @param {ProductScope} scope
26
- * @param {Element} target
27
- * @param {string} selector
28
- * @returns {HTMLElement | null} The nearest `selector` around `target` that belongs to the scope, not to a nested one.
29
- * Typed as an HTML element, as `qsa` types its matches.
19
+ * @returns The nearest `selector` around `target` that belongs to the scope, not to a nested one. Typed as an HTML
20
+ * element, as `qsa` types its matches.
30
21
  */
31
- function closestOwn(scope, target, selector) {
32
- const el = /** @type {HTMLElement | null} */ (target.closest(selector));
22
+ function closestOwn(scope: ProductScope, target: Element, selector: string): HTMLElement | null {
23
+ const el = target.closest<HTMLElement>(selector);
33
24
  return el?.closest(productSelector()) === scope.root ? el : null;
34
25
  }
35
26
 
36
- /**
37
- * Adds to the cart with `aria-busy` on the button and `tether-adding-text` as its label meanwhile.
38
- *
39
- * @param {ProductScope} scope
40
- * @param {HTMLElement} button
41
- */
42
- function onAddClick(scope, button) {
27
+ /** Adds to the cart with `aria-busy` on the button and `tether-adding-text` as its label meanwhile. */
28
+ function onAddClick(scope: ProductScope, button: HTMLElement): void {
43
29
  const { label, original } = addButtonLabel(button);
44
30
  const addingText = get(button, 'adding-text');
45
- void busy(button, async () => {
31
+ busy(button, async () => {
46
32
  if (addingText) setText(label, addingText);
47
33
  try {
48
34
  await scope.addToCart();
@@ -52,18 +38,17 @@ function onAddClick(scope, button) {
52
38
  } finally {
53
39
  if (addingText) setText(label, original);
54
40
  }
55
- }).then(() => {
56
- // The mode-dependent labels (tether-subscribe-text, tether-soldout-text) wait for aria-busy to clear.
57
- scope.renderVariant();
58
- });
41
+ })
42
+ .then(() => {
43
+ // The mode-dependent labels (tether-subscribe-text, tether-soldout-text) wait for aria-busy to clear.
44
+ scope.renderVariant();
45
+ })
46
+ .catch((error: unknown) => {
47
+ reportError(error, 'add to cart render');
48
+ });
59
49
  }
60
50
 
61
- /**
62
- * @param {ProductScope} scope
63
- * @param {Event} event
64
- * @param {Element} target
65
- */
66
- function onClick(scope, event, target) {
51
+ function onClick(scope: ProductScope, event: Event, target: Element): void {
67
52
  const optionValue = closestOwn(scope, target, sel('option-value'));
68
53
  const optionList = optionValue?.closest(sel('option-list'));
69
54
  if (optionValue && optionList) {
@@ -81,7 +66,7 @@ function onClick(scope, event, target) {
81
66
  if (purchase) {
82
67
  // A radio or checkbox input keeps its own click, so it shows checked.
83
68
  if (purchase.tagName !== 'INPUT') event.preventDefault();
84
- scope.setPurchase(get(purchase, 'purchase'));
69
+ scope.setPurchase(purchaseKind(get(purchase, 'purchase')));
85
70
  return;
86
71
  }
87
72
  const plus = closestOwn(scope, target, sel('qty-plus'));
@@ -98,11 +83,12 @@ function onClick(scope, event, target) {
98
83
  }
99
84
  }
100
85
 
101
- /**
102
- * @param {ProductScope} scope
103
- * @param {Element} target A form field in the scope.
104
- */
105
- function onChange(scope, target) {
86
+ /** @returns The kind a `tether-purchase` value asks for: `subscription`, else buying once. */
87
+ const purchaseKind = (value: string | null): Purchase['kind'] =>
88
+ value === 'subscription' ? 'subscription' : 'one-time';
89
+
90
+ /** @param target A form field in the scope. */
91
+ function onChange(scope: ProductScope, target: Element): void {
106
92
  const value = isField(target) ? target.value : '';
107
93
  if (target.matches(`select${sel('option')}`)) {
108
94
  scope.select(scope.optName(get(target, 'option')), value);
@@ -112,14 +98,13 @@ function onChange(scope, target) {
112
98
  if (value) scope.selectPlan(value);
113
99
  else scope.setPurchase('one-time');
114
100
  } else if (target.matches(`select${sel('purchase')}`)) {
115
- scope.setPurchase(value);
101
+ scope.setPurchase(purchaseKind(value));
116
102
  } else if (target.matches(sel('qty'))) {
117
103
  scope.setQuantity(value);
118
104
  }
119
105
  }
120
106
 
121
- /** @param {ProductScope} scope */
122
- export function bindProductEvents(scope) {
107
+ export function bindProductEvents(scope: ProductScope): void {
123
108
  scope.root.addEventListener('click', (event) => {
124
109
  if (isElement(event.target)) onClick(scope, event, event.target);
125
110
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The DOM writes of a product scope that depend on the product alone, once per product: its binds, the option and
3
- * plan lists and the options of its selects. `variant-render.js` writes what follows the visitor's choice.
3
+ * plan lists and the options of its selects. `variant-render.ts` writes what follows the visitor's choice.
4
4
  */
5
5
  import {
6
6
  attr,
@@ -18,23 +18,20 @@ import {
18
18
  hasTemplate,
19
19
  sameImage,
20
20
  productUrl,
21
- } from './dom.js';
22
- /** @import { Product, Image } from './fragments.js' */
23
- /** @import { PlanInGroup } from './selling-plans.js' */
21
+ } from './dom.ts';
22
+ import type { Product } from './fragments.ts';
23
+ import type { PlanInGroup } from './selling-plans.ts';
24
24
 
25
- /**
26
- * @typedef {object} RenderScope The product scope as its renders see it.
27
- * @property {HTMLElement} root
28
- * @property {Product} product
29
- * @property {PlanInGroup[]} plans
30
- * @property {(selector: string) => HTMLElement[]} $ Matches in this scope, the root included.
31
- * @property {(authored: string | null) => string} optName The product's option name for one written in the markup.
32
- */
33
- /**
34
- * The shape `fragments.js` selects for an option value's swatch, which it types as `unknown`.
35
- *
36
- * @typedef {{ color: string | null, image: { previewImage: Image | null } | null } | null} Swatch
37
- */
25
+ /** The product scope as its renders see it. */
26
+ export interface RenderScope {
27
+ readonly root: HTMLElement;
28
+ readonly product: Product;
29
+ readonly plans: PlanInGroup[];
30
+ /** Matches in this scope, the root included. */
31
+ $: (selector: string) => HTMLElement[];
32
+ /** The product's option name for one written in the markup. */
33
+ optName: (authored: string | null) => string;
34
+ }
38
35
 
39
36
  /** `tether-bind="image:2"`: the second product image. */
40
37
  const NTH_IMAGE_BIND = /^image:(\d+)$/;
@@ -43,26 +40,14 @@ const MULTI_LINE_METAFIELD = /^(rich_text|multi_line_text)_field$/;
43
40
  /** `tether-plan-list` values that list every plan, not one group: Webflow's Designer can't always leave it empty. */
44
41
  const ALL_PLAN_GROUPS = ['', 'true', 'all', '*'];
45
42
 
46
- /**
47
- * @param {Element} el
48
- * @returns {el is HTMLSelectElement}
49
- */
50
- export const isSelect = (el) => el.tagName === 'SELECT';
43
+ export const isSelect = (el: Element): el is HTMLSelectElement => el.tagName === 'SELECT';
51
44
 
52
- /**
53
- * @param {RenderScope} scope
54
- * @param {string} selector
55
- * @returns {HTMLSelectElement[]} The `<select>` elements in the scope that match `selector`.
56
- */
57
- export const selectsIn = (scope, selector) => scope.$(`select${selector}`).filter(isSelect);
45
+ /** @returns The `<select>` elements in the scope that match `selector`. */
46
+ export const selectsIn = (scope: RenderScope, selector: string): HTMLSelectElement[] =>
47
+ scope.$(`select${selector}`).filter(isSelect);
58
48
 
59
- /**
60
- * Leaves a server-rendered gallery with the same images as it is.
61
- *
62
- * @param {HTMLElement} el
63
- * @param {Product} product
64
- */
65
- function renderGallery(el, product) {
49
+ /** Leaves a server-rendered gallery with the same images as it is. */
50
+ function renderGallery(el: HTMLElement, product: Product): void {
66
51
  const images = product.images.nodes;
67
52
  const rendered = [...el.children].map((node) => (node.tagName === 'IMG' ? node : node.querySelector('img')));
68
53
  const same =
@@ -76,12 +61,8 @@ function renderGallery(el, product) {
76
61
  });
77
62
  }
78
63
 
79
- /**
80
- * @param {HTMLElement} el
81
- * @param {Product} product
82
- * @param {string} ref `namespace.key`
83
- */
84
- function renderMetafield(el, product, ref) {
64
+ /** @param ref `namespace.key` */
65
+ function renderMetafield(el: HTMLElement, product: Product, ref: string): void {
85
66
  const dot = ref.indexOf('.');
86
67
  const namespace = ref.slice(0, dot);
87
68
  const key = ref.slice(dot + 1);
@@ -99,8 +80,8 @@ function renderMetafield(el, product, ref) {
99
80
  setText(el, metafield.value);
100
81
  }
101
82
 
102
- /** @type {Map<string, (product: Product) => string>} The product binds written as text. */
103
- const PRODUCT_TEXTS = new Map(
83
+ /** The product binds written as text. */
84
+ const PRODUCT_TEXTS = new Map<string, (product: Product) => string>(
104
85
  Object.entries({
105
86
  title: (product) => product.title,
106
87
  'description-text': (product) => product.description,
@@ -113,11 +94,7 @@ const PRODUCT_TEXTS = new Map(
113
94
  }),
114
95
  );
115
96
 
116
- /**
117
- * @param {HTMLElement} el
118
- * @param {Product} product
119
- */
120
- function renderTags(el, product) {
97
+ function renderTags(el: HTMLElement, product: Product): void {
121
98
  const exclude = (el.getAttribute('exclude') ?? '')
122
99
  .split(',')
123
100
  .map((tag) => tag.trim())
@@ -130,12 +107,8 @@ function renderTags(el, product) {
130
107
 
131
108
  /**
132
109
  * A `tether-bind` of the product itself; binds of the variant are left to `writeVariant`.
133
- *
134
- * @param {HTMLElement} el
135
- * @param {string} key
136
- * @param {Product} product
137
110
  */
138
- function renderProductBind(el, key, product) {
111
+ function renderProductBind(el: HTMLElement, key: string, product: Product): void {
139
112
  const text = PRODUCT_TEXTS.get(key);
140
113
  if (text) {
141
114
  setText(el, text(product));
@@ -149,10 +122,12 @@ function renderProductBind(el, key, product) {
149
122
  setImage(el, product.featuredImage?.url, product.featuredImage?.altText || product.title);
150
123
  break;
151
124
  case 'url':
152
- case 'link':
153
- el.setAttribute('href', productUrl(product.handle, product.id));
125
+ case 'link': {
126
+ const url = productUrl(product.handle, product.id);
127
+ if (url) el.setAttribute('href', url);
154
128
  if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, product.title);
155
129
  break;
130
+ }
156
131
  case 'images':
157
132
  renderGallery(el, product);
158
133
  break;
@@ -164,14 +139,8 @@ function renderProductBind(el, key, product) {
164
139
  }
165
140
  }
166
141
 
167
- /**
168
- * `image:<n>` (falling back to the featured image) and `metafield:<namespace.key>`.
169
- *
170
- * @param {HTMLElement} el
171
- * @param {string} key
172
- * @param {Product} product
173
- */
174
- function renderIndexedBind(el, key, product) {
142
+ /** `image:<n>` (falling back to the featured image) and `metafield:<namespace.key>`. */
143
+ function renderIndexedBind(el: HTMLElement, key: string, product: Product): void {
175
144
  const nth = NTH_IMAGE_BIND.exec(key);
176
145
  if (nth) {
177
146
  const image = product.images.nodes[Number(nth[1]) - 1] ?? product.featuredImage;
@@ -181,8 +150,7 @@ function renderIndexedBind(el, key, product) {
181
150
  }
182
151
  }
183
152
 
184
- /** @param {RenderScope} scope */
185
- function renderOptionLists(scope) {
153
+ function renderOptionLists(scope: RenderScope): void {
186
154
  const { product } = scope;
187
155
  scope.$(sel('option-list')).forEach((container) => {
188
156
  const name = scope.optName(get(container, 'option-list'));
@@ -197,8 +165,7 @@ function renderOptionLists(scope) {
197
165
  if (text) setText(text, value.name);
198
166
  else if (!node.firstElementChild) setText(node, value.name);
199
167
  const swatchEl = qsa(node, sel('bind', 'swatch'))[0];
200
- // fragments.js selects `swatch { color image { previewImage { … } } }`.
201
- const swatch = /** @type {Swatch} */ (value.swatch);
168
+ const { swatch } = value;
202
169
  if (!swatchEl || !swatch) return;
203
170
  if (swatch.color) swatchEl.style.backgroundColor = swatch.color;
204
171
  if (swatch.image?.previewImage) setImage(swatchEl, swatch.image.previewImage.url, value.name);
@@ -206,8 +173,7 @@ function renderOptionLists(scope) {
206
173
  });
207
174
  }
208
175
 
209
- /** @param {RenderScope} scope */
210
- function renderSelects(scope) {
176
+ function renderSelects(scope: RenderScope): void {
211
177
  const { product } = scope;
212
178
  selectsIn(scope, sel('option')).forEach((select) => {
213
179
  const option = product.options.find((candidate) => candidate.name === scope.optName(get(select, 'option')));
@@ -228,8 +194,7 @@ function renderSelects(scope) {
228
194
  });
229
195
  }
230
196
 
231
- /** @param {RenderScope} scope */
232
- function renderPlanLists(scope) {
197
+ function renderPlanLists(scope: RenderScope): void {
233
198
  scope.$(sel('plan-list')).forEach((container) => {
234
199
  // The value is a selling plan group's name, or one of ALL_PLAN_GROUPS.
235
200
  const raw = (get(container, 'plan-list') ?? '').trim();
@@ -242,12 +207,8 @@ function renderPlanLists(scope) {
242
207
  });
243
208
  }
244
209
 
245
- /**
246
- * Writes what depends on the product alone: its binds, option and plan lists and the selects' options.
247
- *
248
- * @param {RenderScope} scope
249
- */
250
- export function writeProduct(scope) {
210
+ /** Writes what depends on the product alone: its binds, option and plan lists and the selects' options. */
211
+ export function writeProduct(scope: RenderScope): void {
251
212
  scope.$(sel('bind')).forEach((el) => {
252
213
  renderProductBind(el, get(el, 'bind') ?? '', scope.product);
253
214
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The structure of Tether's product fragment, checked on a value read from the page or from a Storefront answer
3
+ * before it is used as a `Product`. Rendering walks the product's lists and prices without checking each step, so a
4
+ * truncated or hand-written entry, or an answer missing fields, would otherwise fail deep inside a render instead of
5
+ * being skipped or refused.
6
+ */
7
+ import { matches } from './client.ts';
8
+ import { field, isRecord } from '../server/src/shared/json.ts';
9
+ import type { Shape } from './client.ts';
10
+ import type { Product } from './fragments.ts';
11
+ import type { PageInfo } from './loader.ts';
12
+
13
+ /** A page of a GraphQL connection of products. */
14
+ export interface ProductConnection {
15
+ nodes: Product[];
16
+ pageInfo: PageInfo;
17
+ }
18
+
19
+ /** A Storefront API `MoneyV2`. */
20
+ export const MONEY = { amount: 'string', currencyCode: 'string' } as const satisfies Shape;
21
+
22
+ /**
23
+ * A product's id, handle and title, and every list and price a render walks without a fallback: images, options,
24
+ * variants (each with an id, a price and its options) and the price range. Text fields are written as text, and the
25
+ * selling plan lists are read with a fallback, so they need no check.
26
+ */
27
+ export const PRODUCT = {
28
+ id: 'string',
29
+ handle: 'string',
30
+ title: 'string',
31
+ options: ['object'],
32
+ images: { nodes: ['object'] },
33
+ priceRange: { minVariantPrice: MONEY, maxVariantPrice: MONEY },
34
+ variants: { nodes: [{ id: 'string', price: MONEY, selectedOptions: ['object'] }] },
35
+ } as const satisfies Shape;
36
+
37
+ /** A page of products, with the `pageInfo` paging reads. */
38
+ export const PRODUCT_CONNECTION = { nodes: [PRODUCT], pageInfo: { hasNextPage: 'boolean' } } as const satisfies Shape;
39
+
40
+ /** True for a value with the structure of `PRODUCT` and a non-empty id and handle. */
41
+ export const isProductShape = (value: unknown): value is Product =>
42
+ field(value, 'id') !== '' && field(value, 'handle') !== '' && matches(value, PRODUCT);
43
+
44
+ /**
45
+ * A node a lookup by gid answers: a product, or a node of another type, for which `... on Product` selects no field
46
+ * (no `id`).
47
+ */
48
+ export const PRODUCT_NODE = (node: unknown): boolean =>
49
+ isRecord(node) && (node['id'] === undefined || matches(node, PRODUCT));