@occluze/tether 0.7.0 → 0.7.1

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 (132) hide show
  1. package/README.md +21 -21
  2. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
  3. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
  4. package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
  5. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
  6. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
  7. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
  8. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  9. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  10. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  11. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
  12. package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
  13. package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
  14. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
  15. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  16. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  17. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
  18. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  19. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
  20. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  21. package/dist/esm/index.js +1 -1
  22. package/dist/esm/server.js +2 -2
  23. package/dist/tether.account-pages.js +13 -3
  24. package/dist/tether.account-pages.min.js +1 -1
  25. package/dist/tether.account.js +13 -3
  26. package/dist/tether.account.min.js +1 -1
  27. package/dist/tether.analytics.js +18 -3
  28. package/dist/tether.analytics.min.js +1 -1
  29. package/dist/tether.diagnose.js +18 -3
  30. package/dist/tether.diagnose.min.js +2 -2
  31. package/dist/tether.extras.js +19 -6
  32. package/dist/tether.extras.min.js +1 -1
  33. package/dist/tether.js +162 -112
  34. package/dist/tether.markets.js +18 -3
  35. package/dist/tether.markets.min.js +1 -1
  36. package/dist/tether.min.js +3 -3
  37. package/dist/tether.min.js.map +4 -4
  38. package/dist/tether.search.js +18 -3
  39. package/dist/tether.search.min.js +1 -1
  40. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  41. package/dist/types/src/cart/mutations.d.ts +6 -0
  42. package/dist/types/src/cart/store.d.ts +5 -2
  43. package/dist/types/src/cart.d.ts +2 -2
  44. package/dist/types/src/client.d.ts +53 -19
  45. package/dist/types/src/config.d.ts +6 -1
  46. package/dist/types/src/index.d.ts +2 -2
  47. package/dist/types/src/product-shape.d.ts +3 -1
  48. package/package.json +1 -2
  49. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  50. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  51. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  52. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  53. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  54. package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
  55. package/src/account/addresses-view.ts +0 -58
  56. package/src/account/answers.ts +0 -79
  57. package/src/account/markup.ts +0 -36
  58. package/src/account/operations.ts +0 -145
  59. package/src/account/order-lines.ts +0 -75
  60. package/src/account/order-list.ts +0 -107
  61. package/src/account/order-page.ts +0 -137
  62. package/src/account/page-answers.ts +0 -317
  63. package/src/account/page-events.ts +0 -253
  64. package/src/account/server-client.ts +0 -183
  65. package/src/account/subscriptions-view.ts +0 -115
  66. package/src/account-api.ts +0 -98
  67. package/src/account-marks.ts +0 -35
  68. package/src/account-pages.ts +0 -81
  69. package/src/account.ts +0 -284
  70. package/src/amounts.ts +0 -64
  71. package/src/analytics-shopify.ts +0 -246
  72. package/src/analytics.ts +0 -315
  73. package/src/api-version.ts +0 -79
  74. package/src/astro.ts +0 -272
  75. package/src/cart/cart-shape.ts +0 -25
  76. package/src/cart/checkout.ts +0 -101
  77. package/src/cart/drawer.ts +0 -125
  78. package/src/cart/mutations.ts +0 -237
  79. package/src/cart/render.ts +0 -266
  80. package/src/cart/store.ts +0 -279
  81. package/src/cart.ts +0 -345
  82. package/src/client.ts +0 -391
  83. package/src/collection.ts +0 -175
  84. package/src/config-sections.ts +0 -284
  85. package/src/config.ts +0 -480
  86. package/src/controls/cart-controls.ts +0 -123
  87. package/src/controls/discount.ts +0 -75
  88. package/src/controls/extras.ts +0 -54
  89. package/src/controls/navigation.ts +0 -77
  90. package/src/diagnose-markup.ts +0 -298
  91. package/src/diagnose.ts +0 -294
  92. package/src/dom.ts +0 -268
  93. package/src/extras.ts +0 -136
  94. package/src/fragments.ts +0 -261
  95. package/src/globals.d.ts +0 -15
  96. package/src/images.ts +0 -91
  97. package/src/index.ts +0 -415
  98. package/src/initial.ts +0 -101
  99. package/src/lazy-iife.ts +0 -197
  100. package/src/lazy.ts +0 -42
  101. package/src/loader.ts +0 -363
  102. package/src/market-codes.ts +0 -6
  103. package/src/markets-ui.ts +0 -227
  104. package/src/markets.ts +0 -246
  105. package/src/markup.ts +0 -127
  106. package/src/mounting.ts +0 -264
  107. package/src/page-market.ts +0 -110
  108. package/src/part-integrity.ts +0 -7
  109. package/src/parts/account-pages.ts +0 -7
  110. package/src/parts/account.ts +0 -7
  111. package/src/parts/analytics.ts +0 -7
  112. package/src/parts/diagnose.ts +0 -7
  113. package/src/parts/extras.ts +0 -7
  114. package/src/parts/markets.ts +0 -7
  115. package/src/parts/search.ts +0 -7
  116. package/src/presets/barba.ts +0 -194
  117. package/src/product-events.ts +0 -115
  118. package/src/product-render.ts +0 -218
  119. package/src/product-shape.ts +0 -49
  120. package/src/product-state.ts +0 -318
  121. package/src/product.ts +0 -308
  122. package/src/repeaters.ts +0 -161
  123. package/src/search.ts +0 -305
  124. package/src/selling-plans.ts +0 -145
  125. package/src/server.ts +0 -211
  126. package/src/shopify-values.ts +0 -91
  127. package/src/status.ts +0 -106
  128. package/src/storage.ts +0 -34
  129. package/src/store-html.ts +0 -359
  130. package/src/storefront/catalog.ts +0 -196
  131. package/src/storefront/json-ld.ts +0 -144
  132. package/src/variant-render.ts +0 -139
package/src/repeaters.ts DELETED
@@ -1,161 +0,0 @@
1
- /**
2
- * Lists rendered from a template: a repeater's first child in the designer is the template, and every item gets a
3
- * copy of it. The other children are placeholder content and are removed.
4
- */
5
- import { cfg, reportError, warn } from './config.ts';
6
-
7
- /** Each repeater's template, captured on first render. */
8
- const templates = new WeakMap<Element, HTMLElement | null>();
9
-
10
- /** @returns True once the repeater's template has been captured (it may be empty after that). */
11
- export const hasTemplate = (container: Element): boolean => Boolean(templates.get(container));
12
-
13
- /**
14
- * A deep copy of a template. `cloneNode` is typed as returning a `Node`; a copy of an element is an element of the
15
- * same kind.
16
- */
17
- const copyOf = (template: HTMLElement) => template.cloneNode(true) as HTMLElement;
18
-
19
- function templateOf(container: Element): HTMLElement | null {
20
- const known = templates.get(container);
21
- if (known !== undefined) return known;
22
- const first = container.firstElementChild;
23
- // Repeaters are HTML lists in the designer; their first child is an HTML element.
24
- const template = first ? copyOf(first as HTMLElement) : null;
25
- templates.set(container, template);
26
- container.replaceChildren();
27
- return template;
28
- }
29
-
30
- /** Rebuilds the list on every render. Fine for images, tags, options. */
31
- export function repeat<Item>(
32
- container: Element,
33
- items: readonly Item[],
34
- render: (node: HTMLElement, item: Item, index: number) => void,
35
- ): void {
36
- const template = templateOf(container);
37
- if (!template) {
38
- if (items.length) warn('repeater without a template child', container);
39
- return;
40
- }
41
- container.replaceChildren();
42
- items.forEach((item, index) => {
43
- const node = copyOf(template);
44
- render(node, item, index);
45
- container.appendChild(node);
46
- });
47
- }
48
-
49
- interface KeyedHooks {
50
- /** Runs on a node inserted for a new item. */
51
- onEnter?: ((node: HTMLElement) => void) | null;
52
- /** Runs on a node whose item is gone; `done()` removes it. */
53
- onLeave?: ((node: HTMLElement, done: () => void) => void) | null;
54
- }
55
-
56
- function leave(node: HTMLElement, onLeave: KeyedHooks['onLeave']): void {
57
- const done = () => {
58
- node.remove();
59
- };
60
- if (!onLeave) {
61
- done();
62
- return;
63
- }
64
- try {
65
- onLeave(node, done);
66
- } catch (error) {
67
- reportError(error, 'repeater onLeave');
68
- done();
69
- }
70
- }
71
-
72
- /**
73
- * Inserts the nodes of new items next to the item before them, leaving the existing nodes where they are.
74
- *
75
- * @param wanted Every item's node, in order.
76
- * @param fresh The nodes not yet in the container.
77
- */
78
- function insertFresh(
79
- container: Element,
80
- wanted: HTMLElement[],
81
- fresh: Set<HTMLElement>,
82
- onEnter: KeyedHooks['onEnter'],
83
- ): void {
84
- wanted.forEach((node, index) => {
85
- if (!fresh.has(node)) return;
86
- const previous = wanted[index - 1];
87
- if (previous?.parentNode === container) previous.after(node);
88
- else container.prepend(node);
89
- if (!onEnter) return;
90
- try {
91
- onEnter(node);
92
- } catch (error) {
93
- reportError(error, 'repeater onEnter');
94
- }
95
- });
96
- }
97
-
98
- /**
99
- * Keyed repeater: keeps existing nodes, inserts new ones, removes old ones. Used for cart lines, so values can
100
- * animate and lines can slide in and out.
101
- */
102
- export function repeatKeyed<Item>(
103
- container: Element,
104
- items: readonly Item[],
105
- keyOf: (item: Item, index: number) => string | number,
106
- render: (node: HTMLElement, item: Item, index: number, isNew: boolean) => void,
107
- { onEnter, onLeave }: KeyedHooks = {},
108
- ): void {
109
- const template = templateOf(container);
110
- if (!template) {
111
- warn('repeater without a template child', container);
112
- return;
113
- }
114
-
115
- const keyAttribute = `data-${cfg.prefix}-key`;
116
- const leavingAttribute = `data-${cfg.prefix}-leaving`;
117
- // The list's children are copies of its template, HTML elements like it.
118
- const live = () =>
119
- [...(container.children as HTMLCollectionOf<HTMLElement>)].filter(
120
- (node) => node.hasAttribute(keyAttribute) && !node.hasAttribute(leavingAttribute),
121
- );
122
-
123
- const existing = new Map(live().map((node) => [node.getAttribute(keyAttribute), node]));
124
- const wanted: HTMLElement[] = [];
125
- const fresh = new Set<HTMLElement>();
126
-
127
- items.forEach((item, index) => {
128
- const key = String(keyOf(item, index));
129
- let node = existing.get(key);
130
- if (node) existing.delete(key);
131
- else {
132
- node = copyOf(template);
133
- node.setAttribute(keyAttribute, key);
134
- fresh.add(node);
135
- }
136
- render(node, item, index, fresh.has(node));
137
- wanted.push(node);
138
- });
139
-
140
- const staying = wanted.filter((node) => !fresh.has(node));
141
- const current = live().filter((node) => staying.includes(node));
142
- const inOrder = current.length === staying.length && current.every((node, index) => node === staying[index]);
143
-
144
- if (!inOrder) {
145
- existing.forEach((node) => {
146
- node.remove();
147
- });
148
- wanted.forEach((node) => container.appendChild(node));
149
- return;
150
- }
151
-
152
- insertFresh(container, wanted, fresh, onEnter);
153
-
154
- existing.forEach((node) => {
155
- node.setAttribute(leavingAttribute, '');
156
- node.removeAttribute(keyAttribute);
157
- node.style.pointerEvents = 'none';
158
- node.inert = true;
159
- leave(node, onLeave);
160
- });
161
- }
package/src/search.ts DELETED
@@ -1,305 +0,0 @@
1
- /**
2
- * Search as you type (Shopify predictive search). Optional part: loaded only on pages with [tether-search].
3
- *
4
- * <form tether-search action="/search"> <!-- Enter: your full results page -->
5
- * <input tether-search-input name="q" type="search" placeholder="Search">
6
- * <div tether-search-results>
7
- * <div tether-search-products><a><img tether-search-bind="image" max-width="120"><span tether-search-bind="title"></span> <span tether-search-bind="price"></span></a></div>
8
- * <div tether-search-queries><a><span tether-search-bind="text"></span></a></div>
9
- * <div tether-search-collections><a><span tether-search-bind="title"></span></a></div>
10
- * <p tether-show="search-empty">No results</p>
11
- * </div>
12
- * </form>
13
- *
14
- * Each list's first child is the template. The root gets is-open while there are results (or none) to show, and
15
- * tether-search-state="idle|loading|ready|empty|error". Arrow keys move through the results, Escape closes.
16
- * tether-search-limit (default 6), tether-search-min (characters, default 2).
17
- */
18
- import { cfg, log, reportError } from './config.ts';
19
- import { gql, orNull, shaped } from './client.ts';
20
- import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.ts';
21
- import { within } from './mounting.ts';
22
- import type { Money } from './shopify-values.ts';
23
-
24
- interface SearchImage {
25
- url: string;
26
- altText: string | null;
27
- }
28
- interface SearchProduct {
29
- id: string;
30
- handle: string;
31
- title: string;
32
- availableForSale: boolean;
33
- featuredImage: SearchImage | null;
34
- priceRange: { minVariantPrice: Money };
35
- }
36
- interface SearchCollection {
37
- handle: string;
38
- title: string;
39
- image: SearchImage | null;
40
- }
41
- interface SearchQuery {
42
- text: string;
43
- }
44
- export interface SearchResults {
45
- products: SearchProduct[];
46
- collections: SearchCollection[];
47
- queries: SearchQuery[];
48
- }
49
- /** No results, or lists whose items have what `render` reads without a fallback. */
50
- const isSearchAnswer = shaped<{ predictiveSearch: SearchResults | null }>({
51
- predictiveSearch: orNull({
52
- products: [{ priceRange: 'object' }],
53
- collections: ['object'],
54
- queries: [{ text: 'string' }],
55
- }),
56
- });
57
- type SearchState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
58
- interface BindValues {
59
- title?: string;
60
- price?: string;
61
- text?: string;
62
- image?: SearchImage | null;
63
- }
64
-
65
- const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
66
- predictiveSearch(query: $q, limit: $limit, limitScope: EACH, types: [PRODUCT, COLLECTION, QUERY]) {
67
- products { id handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
68
- collections { handle title image { url altText } }
69
- queries { text }
70
- }
71
- }`;
72
-
73
- const DEFAULT_LIMIT = 6;
74
- const DEFAULT_MIN_CHARACTERS = 2;
75
- const DEFAULT_ACTION = '/search';
76
-
77
- const mounted = new WeakSet<Element>();
78
- let searchBoxCount = 0;
79
-
80
- /** @returns The attribute as an integer; `fallback` when it is missing, zero or not a number. */
81
- function integerAttribute(el: Element, name: string, fallback: number): number {
82
- return parseInt(get(el, name) || '', 10) || fallback;
83
- }
84
-
85
- /** @returns `node` when it is a link, else its first link. */
86
- const linkIn = (node: Element): Element | null => (node.tagName === 'A' ? node : node.querySelector('a'));
87
-
88
- function bindValues(node: HTMLElement, values: BindValues) {
89
- qsa(node, sel('search-bind')).forEach((el) => {
90
- const field = get(el, 'search-bind');
91
- if (field === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
92
- else if (field === 'title' || field === 'price' || field === 'text') {
93
- if (values[field] != null) setText(el, values[field]);
94
- }
95
- });
96
- }
97
-
98
- /** @param href `null` for a product without a page: the option shows, without a link. */
99
- function linkOption(node: Element, href: string | null) {
100
- const link = linkIn(node);
101
- if (!link) return;
102
- if (href) link.setAttribute('href', href);
103
- link.setAttribute('role', 'option');
104
- }
105
-
106
- /** One `[tether-search]` root: its input, its results and the search in flight. */
107
- class SearchBox {
108
- readonly root: HTMLElement;
109
- readonly input: HTMLInputElement;
110
- readonly results: HTMLElement;
111
- readonly limit: number;
112
- readonly minCharacters: number;
113
- readonly resultsId: string;
114
- /** Counts searches, so only the latest one renders. */
115
- searchNumber = 0;
116
- timer: ReturnType<typeof setTimeout> | undefined = undefined;
117
- lastTerm = '';
118
- /** Results per term, for typing back and forth. */
119
- readonly cache = new Map<string, SearchResults | null>();
120
-
121
- constructor(root: HTMLElement, input: HTMLInputElement, results: HTMLElement) {
122
- this.root = root;
123
- this.input = input;
124
- this.results = results;
125
- this.limit = integerAttribute(root, 'search-limit', DEFAULT_LIMIT);
126
- this.minCharacters = integerAttribute(root, 'search-min', DEFAULT_MIN_CHARACTERS);
127
- if (!results.id) {
128
- searchBoxCount += 1;
129
- results.id = `${cfg.prefix}-search-${searchBoxCount}`;
130
- }
131
- this.resultsId = results.id;
132
- this.setUpAria();
133
- this.listen();
134
- }
135
-
136
- setUpAria() {
137
- this.input.setAttribute('role', 'combobox');
138
- this.input.setAttribute('aria-autocomplete', 'list');
139
- this.input.setAttribute('aria-controls', this.resultsId);
140
- this.input.setAttribute('aria-expanded', 'false');
141
- this.input.setAttribute('autocomplete', 'off');
142
- if (!this.results.hasAttribute('role')) this.results.setAttribute('role', 'listbox');
143
- this.results.hidden = true;
144
- }
145
-
146
- listen() {
147
- this.input.addEventListener('input', () => {
148
- const term = this.input.value.trim();
149
- if (term === this.lastTerm) return;
150
- this.lastTerm = term;
151
- clearTimeout(this.timer);
152
- this.timer = setTimeout(() => void this.search(term), cfg.searchDelay);
153
- });
154
- this.input.addEventListener('focus', () => {
155
- if (this.input.value.trim().length >= this.minCharacters && this.options().length) this.open(true);
156
- });
157
- this.input.addEventListener('keydown', (event) => {
158
- this.onKeydown(event);
159
- });
160
- document.addEventListener('click', (event) => {
161
- // A click event's target is always a node.
162
- if (!this.root.contains(event.target as Node | null)) this.open(false);
163
- });
164
- }
165
-
166
- setState(state: SearchState) {
167
- this.root.setAttribute(attr('search-state'), state);
168
- }
169
-
170
- open(open: boolean) {
171
- this.root.classList.toggle('is-open', open);
172
- this.results.hidden = !open;
173
- this.input.setAttribute('aria-expanded', String(open));
174
- if (!open) this.input.removeAttribute('aria-activedescendant');
175
- }
176
-
177
- options() {
178
- return qsa(this.results, '[role="option"]');
179
- }
180
-
181
- async fetchResults(term: string): Promise<SearchResults | null> {
182
- const cached = this.cache.get(term);
183
- if (cached) return cached;
184
- const data = await gql(QUERY, { q: term, limit: this.limit }, isSearchAnswer);
185
- this.cache.set(term, data.predictiveSearch);
186
- return data.predictiveSearch;
187
- }
188
-
189
- async search(term: string) {
190
- this.searchNumber += 1;
191
- const searchNumber = this.searchNumber;
192
- if (term.length < this.minCharacters) {
193
- this.open(false);
194
- this.setState('idle');
195
- return;
196
- }
197
- this.setState('loading');
198
- try {
199
- const results = await this.fetchResults(term);
200
- if (searchNumber === this.searchNumber) this.render(results, term);
201
- } catch (error) {
202
- // A newer search owns the state and reports its own failure.
203
- if (searchNumber !== this.searchNumber) {
204
- log('a superseded search failed', error);
205
- return;
206
- }
207
- this.setState('error');
208
- reportError(error, 'search');
209
- }
210
- }
211
-
212
- /** @param name The list attribute, e.g. `search-products`. */
213
- fillList<Item>(name: string, items: readonly Item[], render: (node: HTMLElement, item: Item) => void) {
214
- qsa(this.results, sel(name)).forEach((list) => {
215
- repeat(list, items, render);
216
- show(list, items.length > 0);
217
- });
218
- }
219
-
220
- queryUrl(text: string) {
221
- const action = this.root.getAttribute('action') || DEFAULT_ACTION;
222
- const separator = action.includes('?') ? '&' : '?';
223
- return `${action}${separator}${this.input.name || 'q'}=${encodeURIComponent(text)}`;
224
- }
225
-
226
- render(results: SearchResults | null, term: string) {
227
- const products = results?.products ?? [];
228
- const collections = results?.collections ?? [];
229
- const queries = (results?.queries ?? []).filter((query) => query.text.toLowerCase() !== term.toLowerCase());
230
- this.fillList('search-products', products, (node, product) => {
231
- const price = formatMoney(product.priceRange.minVariantPrice);
232
- bindValues(node, { title: product.title, price, image: product.featuredImage });
233
- linkOption(node, productUrl(product.handle, product.id));
234
- node.classList.toggle('is-sold-out', !product.availableForSale);
235
- });
236
- this.fillList('search-collections', collections, (node, collection) => {
237
- bindValues(node, { title: collection.title, image: collection.image });
238
- linkOption(node, `${(cfg.collectionPath || '/collection').replace(/\/$/, '')}/${collection.handle}`);
239
- });
240
- this.fillList('search-queries', queries, (node, query) => {
241
- bindValues(node, { text: query.text });
242
- linkIn(node)?.setAttribute('href', this.queryUrl(query.text));
243
- });
244
- const count = products.length + collections.length + queries.length;
245
- this.showCount(count);
246
- this.setState(count ? 'ready' : 'empty');
247
- this.open(true);
248
- emit('search', { term, products, collections, queries }, this.root);
249
- }
250
-
251
- showCount(count: number) {
252
- qsa(this.root, sel('show')).forEach((el) => {
253
- const condition = get(el, 'show');
254
- if (condition === 'search-empty') show(el, count === 0);
255
- if (condition === 'search-results') show(el, count > 0);
256
- });
257
- this.options().forEach((option, index) => {
258
- option.id ||= `${this.resultsId}-${index}`;
259
- option.setAttribute('aria-selected', 'false');
260
- });
261
- }
262
-
263
- highlight(options: HTMLElement[], index: number) {
264
- options.forEach((option, optionIndex) => {
265
- option.setAttribute('aria-selected', String(optionIndex === index));
266
- });
267
- const option = options[index];
268
- if (!option) return;
269
- this.input.setAttribute('aria-activedescendant', option.id);
270
- // jsdom has no scrollIntoView.
271
- (option as Partial<Pick<HTMLElement, 'scrollIntoView'>>).scrollIntoView?.({ block: 'nearest' });
272
- }
273
-
274
- onKeydown(event: KeyboardEvent) {
275
- const options = this.options();
276
- const current = options.findIndex((option) => option.getAttribute('aria-selected') === 'true');
277
- if (event.key === 'ArrowDown' && options.length) {
278
- event.preventDefault();
279
- this.open(true);
280
- this.highlight(options, (current + 1) % options.length);
281
- } else if (event.key === 'ArrowUp' && options.length) {
282
- event.preventDefault();
283
- this.highlight(options, current <= 0 ? options.length - 1 : current - 1);
284
- } else if (event.key === 'Enter' && current >= 0) {
285
- event.preventDefault();
286
- options[current]?.click();
287
- } else if (event.key === 'Escape') {
288
- this.open(false);
289
- }
290
- }
291
- }
292
-
293
- /**
294
- * Sets up every `[tether-search]` in `root` (and `root` itself) once.
295
- */
296
- export function mountSearch(root: ParentNode & Partial<Pick<Element, 'matches'>> = document): void {
297
- for (const el of within(root, sel('search'))) {
298
- if (mounted.has(el)) continue;
299
- mounted.add(el);
300
- // The markup names an input element; a site without one gets the first input.
301
- const input = el.querySelector<HTMLInputElement>(sel('search-input')) ?? el.querySelector('input');
302
- const results = el.querySelector<HTMLElement>(sel('search-results'));
303
- if (input && results) new SearchBox(el, input, results);
304
- }
305
- }
@@ -1,145 +0,0 @@
1
- /**
2
- * Shopify selling plans (subscriptions): which plans a product and a variant have, and what each costs. Pure, no DOM,
3
- * shared by the browser script and the server helpers.
4
- */
5
- import { fromMinorUnits, toMinorUnits } from './amounts.ts';
6
- import type { Money } from './shopify-values.ts';
7
- import type { SellingPlan, SellingPlanGroup, SellingPlanAllocation } from './fragments.ts';
8
-
9
- const PERCENT = 100;
10
-
11
- /** A plan with the group it belongs to. */
12
- export type PlanInGroup = SellingPlan & { group: SellingPlanGroup };
13
- export interface ProductWithPlans {
14
- sellingPlanGroups?: { nodes: SellingPlanGroup[] } | null;
15
- }
16
- /** A variant as far as plans go. A cart line's variant has no allocations. */
17
- export interface VariantWithPlans {
18
- price: Money;
19
- sellingPlanAllocations?: { nodes: SellingPlanAllocation[] } | null;
20
- }
21
- export type PlanAdjustment =
22
- { type: 'percentage'; value: number } | { type: 'amount'; value: Money } | { type: 'price'; value: Money };
23
-
24
- /**
25
- * @param value A gid, a numeric id, or anything else.
26
- * @param type The Shopify type, e.g. `ProductVariant`.
27
- * @returns The gid of a numeric id; a gid or other value as written; `null` when empty.
28
- */
29
- export function toGid(value: unknown, type: string): string | null {
30
- const text = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
31
- if (!text) return null;
32
- if (text.startsWith('gid://')) return text;
33
- if (/^\d+$/.test(text)) return `gid://shopify/${type}/${text}`;
34
- return text;
35
- }
36
-
37
- export const GID = {
38
- product: (value: unknown) => toGid(value, 'Product'),
39
- variant: (value: unknown) => toGid(value, 'ProductVariant'),
40
- sellingPlan: (value: unknown) => toGid(value, 'SellingPlan'),
41
- };
42
-
43
- /** @returns All plans of the product, flattened. */
44
- export function allPlans(product: ProductWithPlans | null | undefined): PlanInGroup[] {
45
- const groups = product?.sellingPlanGroups?.nodes ?? [];
46
- return groups.flatMap((group) => group.sellingPlans.nodes.map((plan) => ({ ...plan, group })));
47
- }
48
-
49
- export function hasPlans(product: ProductWithPlans | null | undefined): boolean {
50
- return allPlans(product).length > 0;
51
- }
52
-
53
- /** @returns The variant's allocation on the plan; `null` when the variant isn't in it. */
54
- export function allocationFor(
55
- variant: VariantWithPlans | null | undefined,
56
- planId: string | null | undefined,
57
- ): SellingPlanAllocation | null {
58
- if (!variant || !planId) return null;
59
- const allocations = variant.sellingPlanAllocations?.nodes ?? [];
60
- return allocations.find((allocation) => allocation.sellingPlan.id === planId) ?? null;
61
- }
62
-
63
- /** @returns The plans this variant can be bought on. */
64
- export function plansForVariant(
65
- product: ProductWithPlans | null | undefined,
66
- variant: VariantWithPlans | null | undefined,
67
- ): PlanInGroup[] {
68
- return allPlans(product).filter((plan) => allocationFor(variant, plan.id));
69
- }
70
-
71
- /** @returns The price per delivery on the plan; the one-time price when the variant isn't on it. */
72
- export function planPrice(
73
- variant: VariantWithPlans | null | undefined,
74
- planId: string | null | undefined,
75
- ): Money | null {
76
- const adjustment = allocationFor(variant, planId)?.priceAdjustments[0];
77
- return adjustment?.perDeliveryPrice ?? adjustment?.price ?? variant?.price ?? null;
78
- }
79
-
80
- /** @returns What the customer pays at checkout on the plan (prepaid plans charge more up front). */
81
- export function planCheckoutCharge(
82
- variant: VariantWithPlans | null | undefined,
83
- planId: string | null | undefined,
84
- ): Money | null {
85
- const allocation = allocationFor(variant, planId);
86
- return allocation?.checkoutChargeAmount ?? allocation?.priceAdjustments[0]?.price ?? variant?.price ?? null;
87
- }
88
-
89
- /** @returns What the plan saves on the one-time price; no amount and 0% when it saves nothing. */
90
- export function planSavings(
91
- variant: VariantWithPlans | null | undefined,
92
- planId: string | null | undefined,
93
- ): { amount: Money | null; percentage: number } {
94
- const onPlanPrice = planPrice(variant, planId);
95
- if (!variant || !onPlanPrice) return { amount: null, percentage: 0 };
96
- const oneTime = parseFloat(variant.price.amount);
97
- const onPlan = parseFloat(onPlanPrice.amount);
98
- if (!Number.isFinite(oneTime) || !Number.isFinite(onPlan) || oneTime <= 0 || onPlan >= oneTime) {
99
- return { amount: null, percentage: 0 };
100
- }
101
- const saved = toMinorUnits(variant.price) - toMinorUnits(onPlanPrice);
102
- return {
103
- amount: fromMinorUnits(saved, variant.price.currencyCode),
104
- percentage: Math.round((1 - onPlan / oneTime) * PERCENT),
105
- };
106
- }
107
-
108
- /**
109
- * @returns The plan's own discount, whatever the variant: a percentage, a fixed amount off or a fixed price.
110
- */
111
- export function planAdjustment(plan: Pick<SellingPlan, 'priceAdjustments'> | null | undefined): PlanAdjustment | null {
112
- const value = plan?.priceAdjustments[0]?.adjustmentValue;
113
- if (!value) return null;
114
- switch (value.__typename) {
115
- case 'SellingPlanPercentagePriceAdjustment':
116
- return { type: 'percentage', value: value.adjustmentPercentage };
117
- case 'SellingPlanFixedAmountPriceAdjustment':
118
- return { type: 'amount', value: value.adjustmentAmount };
119
- case 'SellingPlanFixedPriceAdjustment':
120
- return { type: 'price', value: value.price };
121
- default:
122
- // An adjustment type newer than the API version Tether was written for.
123
- return null;
124
- }
125
- }
126
-
127
- /**
128
- * Finds a plan by id, numeric id, name or option value (case-insensitive), so Webflow editors can write
129
- * `tether-default-plan="Every month"`.
130
- */
131
- export function findPlan(
132
- product: ProductWithPlans | null | undefined,
133
- ref: string | null | undefined,
134
- ): PlanInGroup | null {
135
- if (!ref) return null;
136
- const plans = allPlans(product);
137
- const gid = GID.sellingPlan(ref);
138
- const needle = ref.trim().toLowerCase();
139
- return (
140
- plans.find((plan) => plan.id === gid) ??
141
- plans.find((plan) => plan.name.toLowerCase() === needle) ??
142
- plans.find((plan) => plan.options.some((option) => option.value.toLowerCase() === needle)) ??
143
- null
144
- );
145
- }