@magic-spells/cart-panel 0.1.1 → 0.2.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.
@@ -1,242 +1,485 @@
1
1
  (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
- /**
8
- * CartItem class that handles the functionality of a cart item component
9
- */
10
- class CartItem extends HTMLElement {
11
- // Static template functions shared across all instances
12
- static #template = null;
13
- static #processingTemplate = null;
14
-
15
- // Private fields
16
- #currentState = 'ready';
17
- #isDestroying = false;
18
- #handlers = {};
19
- #itemData = null;
20
-
21
- /**
22
- * Set the template function for rendering cart items
23
- * @param {Function} templateFn - Function that takes item data and returns HTML string
24
- */
25
- static setTemplate(templateFn) {
26
- if (typeof templateFn !== 'function') {
27
- throw new Error('Template must be a function');
28
- }
29
- CartItem.#template = templateFn;
30
- }
31
-
32
- /**
33
- * Set the processing template function for rendering processing overlay
34
- * @param {Function} templateFn - Function that returns HTML string for processing state
35
- */
36
- static setProcessingTemplate(templateFn) {
37
- if (typeof templateFn !== 'function') {
38
- throw new Error('Processing template must be a function');
39
- }
40
- CartItem.#processingTemplate = templateFn;
41
- }
42
-
43
- /**
44
- * Define which attributes should be observed for changes
45
- */
46
- static get observedAttributes() {
47
- return ['state', 'key'];
48
- }
49
-
50
- /**
51
- * Called when observed attributes change
52
- */
53
- attributeChangedCallback(name, oldValue, newValue) {
54
- if (oldValue === newValue) return;
55
-
56
- if (name === 'state') {
57
- this.#currentState = newValue || 'ready';
58
- }
59
- }
60
-
61
- constructor(itemData = null) {
62
- super();
63
-
64
- // Store item data if provided
65
- this.#itemData = itemData;
66
-
67
- // Set initial state - start with 'appearing' if we have item data to render
68
- this.#currentState = itemData ? 'appearing' : this.getAttribute('state') || 'ready';
69
-
70
- // Bind event handlers
71
- this.#handlers = {
72
- click: this.#handleClick.bind(this),
73
- change: this.#handleChange.bind(this),
74
- transitionEnd: this.#handleTransitionEnd.bind(this),
75
- };
76
- }
77
-
78
- connectedCallback() {
79
- // If we have item data, render it first
80
- if (this.#itemData) {
81
- this.#renderFromData();
82
- }
83
-
84
- // Find child elements
85
- this.content = this.querySelector('cart-item-content');
86
- this.processing = this.querySelector('cart-item-processing');
87
-
88
- // Attach event listeners
89
- this.#attachListeners();
90
-
91
- // If we started with 'appearing' state, handle the entry animation
92
- if (this.#currentState === 'appearing') {
93
- // Set the state attribute
94
- this.setAttribute('state', 'appearing');
95
-
96
- // Get the natural height after rendering
97
- requestAnimationFrame(() => {
98
- const naturalHeight = this.scrollHeight;
99
-
100
- // Set explicit height for animation
101
- this.style.height = `${naturalHeight}px`;
102
-
103
- // Transition to ready state after a brief delay
104
- setTimeout(() => {
105
- this.setState('ready');
106
- // Remove explicit height after animation completes
107
- setTimeout(() => {
108
- this.style.height = '';
109
- }, 400); // Match appearing duration
110
- }, 50);
111
- });
112
- }
113
- }
114
-
115
- disconnectedCallback() {
116
- // Cleanup event listeners
117
- this.#detachListeners();
118
- }
119
-
120
- /**
121
- * Attach event listeners
122
- */
123
- #attachListeners() {
124
- this.addEventListener('click', this.#handlers.click);
125
- this.addEventListener('change', this.#handlers.change);
126
- this.addEventListener('transitionend', this.#handlers.transitionEnd);
127
- }
128
-
129
- /**
130
- * Detach event listeners
131
- */
132
- #detachListeners() {
133
- this.removeEventListener('click', this.#handlers.click);
134
- this.removeEventListener('change', this.#handlers.change);
135
- this.removeEventListener('transitionend', this.#handlers.transitionEnd);
136
- }
137
-
138
- /**
139
- * Get the current state
140
- */
141
- get state() {
142
- return this.#currentState;
143
- }
144
-
145
- /**
146
- * Get the cart key for this item
147
- */
148
- get cartKey() {
149
- return this.getAttribute('key');
150
- }
151
-
152
- /**
153
- * Handle click events (for Remove buttons, etc.)
154
- */
155
- #handleClick(e) {
156
- // Check if clicked element is a remove button
157
- const removeButton = e.target.closest('[data-action-remove-item]');
158
- if (removeButton) {
159
- e.preventDefault();
160
- this.#emitRemoveEvent();
161
- }
162
- }
163
-
164
- /**
165
- * Handle change events (for quantity inputs)
166
- */
167
- #handleChange(e) {
168
- // Check if changed element is a quantity input
169
- const quantityInput = e.target.closest('[data-cart-quantity]');
170
- if (quantityInput) {
171
- this.#emitQuantityChangeEvent(quantityInput.value);
172
- }
173
- }
174
-
175
- /**
176
- * Handle transition end events for destroy animation
177
- */
178
- #handleTransitionEnd(e) {
179
- if (e.propertyName === 'height' && this.#isDestroying) {
180
- // Remove from DOM after height animation completes
181
- this.remove();
182
- }
183
- }
184
-
185
- /**
186
- * Emit remove event
187
- */
188
- #emitRemoveEvent() {
189
- this.dispatchEvent(
190
- new CustomEvent('cart-item:remove', {
191
- bubbles: true,
192
- detail: {
193
- cartKey: this.cartKey,
194
- element: this,
195
- },
196
- })
197
- );
198
- }
199
-
200
- /**
201
- * Emit quantity change event
202
- */
203
- #emitQuantityChangeEvent(quantity) {
204
- this.dispatchEvent(
205
- new CustomEvent('cart-item:quantity-change', {
206
- bubbles: true,
207
- detail: {
208
- cartKey: this.cartKey,
209
- quantity: parseInt(quantity),
210
- element: this,
211
- },
212
- })
213
- );
214
- }
215
-
216
- /**
217
- * Render cart item from data using the static template
218
- */
219
- #renderFromData() {
220
- if (!this.#itemData || !CartItem.#template) {
221
- return;
222
- }
223
-
224
- // Set the key attribute from item data
225
- const key = this.#itemData.key || this.#itemData.id;
226
- if (key) {
227
- this.setAttribute('key', key);
228
- }
229
-
230
- // Generate HTML from template
231
- const templateHTML = CartItem.#template(this.#itemData);
232
-
233
- // Generate processing HTML from template or use default
234
- const processingHTML = CartItem.#processingTemplate
235
- ? CartItem.#processingTemplate()
236
- : '<div class="cart-item-loader"></div>';
237
-
238
- // Create the cart-item structure with template content inside cart-item-content
239
- this.innerHTML = `
7
+ class QuantityModifier extends HTMLElement {
8
+ // Static flag to track if styles have been injected
9
+ static #stylesInjected = false;
10
+
11
+ constructor() {
12
+ super();
13
+ this.handleDecrement = this.handleDecrement.bind(this);
14
+ this.handleIncrement = this.handleIncrement.bind(this);
15
+ this.handleInputChange = this.handleInputChange.bind(this);
16
+
17
+ // Inject styles once when first component is created
18
+ QuantityModifier.#injectStyles();
19
+ }
20
+
21
+ /**
22
+ * Inject global styles for hiding number input spin buttons
23
+ * Only runs once regardless of how many components exist
24
+ */
25
+ static #injectStyles() {
26
+ if (QuantityModifier.#stylesInjected) return;
27
+
28
+ // this will hide the arrow buttons in the number input field
29
+ const style = document.createElement('style');
30
+ style.textContent = `
31
+ /* Hide number input spin buttons for quantity-modifier */
32
+ quantity-modifier input::-webkit-outer-spin-button,
33
+ quantity-modifier input::-webkit-inner-spin-button {
34
+ -webkit-appearance: none;
35
+ margin: 0;
36
+ }
37
+
38
+ quantity-modifier input[type="number"] {
39
+ -moz-appearance: textfield;
40
+ }
41
+ `;
42
+
43
+ document.head.appendChild(style);
44
+ QuantityModifier.#stylesInjected = true;
45
+ }
46
+
47
+ // Define which attributes trigger attributeChangedCallback when modified
48
+ static get observedAttributes() {
49
+ return ['min', 'max', 'value'];
50
+ }
51
+
52
+ // Called when element is added to the DOM
53
+ connectedCallback() {
54
+ this.render();
55
+ this.attachEventListeners();
56
+ }
57
+
58
+ // Called when element is removed from the DOM
59
+ disconnectedCallback() {
60
+ this.removeEventListeners();
61
+ }
62
+
63
+ // Called when observed attributes change
64
+ attributeChangedCallback(name, oldValue, newValue) {
65
+ if (oldValue !== newValue) {
66
+ this.updateInput();
67
+ }
68
+ }
69
+
70
+ // Get minimum value allowed, defaults to 1
71
+ get min() {
72
+ return parseInt(this.getAttribute('min')) || 1;
73
+ }
74
+
75
+ // Get maximum value allowed, defaults to 99
76
+ get max() {
77
+ return parseInt(this.getAttribute('max')) || 99;
78
+ }
79
+
80
+ // Get current value, defaults to 1
81
+ get value() {
82
+ return parseInt(this.getAttribute('value')) || 1;
83
+ }
84
+
85
+ // Set current value by updating the attribute
86
+ set value(val) {
87
+ this.setAttribute('value', val);
88
+ }
89
+
90
+ // Render the quantity modifier HTML structure
91
+ render() {
92
+ const min = this.min;
93
+ const max = this.max;
94
+ const value = this.value;
95
+
96
+ // check to see if these fields already exist
97
+ const existingDecrement = this.querySelector('[data-action-decrement]');
98
+ const existingIncrement = this.querySelector('[data-action-increment]');
99
+ const existingInput = this.querySelector('[data-quantity-modifier-field]');
100
+
101
+ // if they already exist, just set the values
102
+ if (existingDecrement && existingIncrement && existingInput) {
103
+ existingInput.value = value;
104
+ existingInput.min = min;
105
+ existingInput.max = max;
106
+ existingInput.type = 'number';
107
+ } else {
108
+ // if they don't exist, inject the template
109
+ this.innerHTML = `
110
+ <button data-action-decrement type="button">
111
+ <svg class="svg-decrement" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
112
+ <title>decrement</title>
113
+ <path fill="currentColor" d="M368 224H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h352c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"></path>
114
+ </svg>
115
+ </button>
116
+ <input
117
+ type="number"
118
+ inputmode="numeric"
119
+ pattern="[0-9]*"
120
+ data-quantity-modifier-field
121
+ value="${value}" min="${min}" max="${max}">
122
+ <button data-action-increment type="button">
123
+ <svg class="svg-increment" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
124
+ <title>increment</title>
125
+ <path fill="currentColor" d="M368 224H224V80c0-8.84-7.16-16-16-16h-32c-8.84 0-16 7.16-16 16v144H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h144v144c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V288h144c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"></path>
126
+ </svg>
127
+ </button>
128
+ `;
129
+ }
130
+ }
131
+
132
+ // Attach click and input event listeners to buttons and input field
133
+ attachEventListeners() {
134
+ const decrementBtn = this.querySelector('[data-action-decrement]');
135
+ const incrementBtn = this.querySelector('[data-action-increment]');
136
+ const input = this.querySelector('[data-quantity-modifier-field]');
137
+
138
+ if (decrementBtn) decrementBtn.addEventListener('click', this.handleDecrement);
139
+ if (incrementBtn) incrementBtn.addEventListener('click', this.handleIncrement);
140
+ if (input) input.addEventListener('input', this.handleInputChange);
141
+ }
142
+
143
+ // Remove event listeners to prevent memory leaks
144
+ removeEventListeners() {
145
+ const decrementBtn = this.querySelector('[data-action-decrement]');
146
+ const incrementBtn = this.querySelector('[data-action-increment]');
147
+ const input = this.querySelector('[data-quantity-modifier-field]');
148
+
149
+ if (decrementBtn) decrementBtn.removeEventListener('click', this.handleDecrement);
150
+ if (incrementBtn) incrementBtn.removeEventListener('click', this.handleIncrement);
151
+ if (input) input.removeEventListener('input', this.handleInputChange);
152
+ }
153
+
154
+ // Handle decrement button click, respects minimum value
155
+ handleDecrement() {
156
+ const currentValue = this.value;
157
+ const newValue = Math.max(currentValue - 1, this.min);
158
+ this.updateValue(newValue);
159
+ }
160
+
161
+ // Handle increment button click, respects maximum value
162
+ handleIncrement() {
163
+ const currentValue = this.value;
164
+ const newValue = Math.min(currentValue + 1, this.max);
165
+ this.updateValue(newValue);
166
+ }
167
+
168
+ // Handle direct input changes, clamps value between min and max
169
+ handleInputChange(event) {
170
+ const inputValue = parseInt(event.target.value);
171
+ if (!isNaN(inputValue)) {
172
+ const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
173
+ this.updateValue(clampedValue);
174
+ }
175
+ }
176
+
177
+ // Update the component value and dispatch change event if value changed
178
+ updateValue(newValue) {
179
+ if (newValue !== this.value) {
180
+ this.value = newValue;
181
+ this.updateInput();
182
+ this.dispatchChangeEvent(newValue);
183
+ }
184
+ }
185
+
186
+ // Sync the input field with current component state
187
+ updateInput() {
188
+ const input = this.querySelector('[data-quantity-modifier-field]');
189
+ if (input) {
190
+ input.value = this.value;
191
+ input.min = this.min;
192
+ input.max = this.max;
193
+ }
194
+ }
195
+
196
+ // Dispatch custom event when value changes for external listeners
197
+ dispatchChangeEvent(value) {
198
+ this.dispatchEvent(
199
+ new CustomEvent('quantity-modifier:change', {
200
+ detail: { value },
201
+ bubbles: true,
202
+ })
203
+ );
204
+ }
205
+ }
206
+
207
+ customElements.define('quantity-modifier', QuantityModifier);
208
+
209
+ /**
210
+ * CartItem class that handles the functionality of a cart item component
211
+ */
212
+ class CartItem extends HTMLElement {
213
+ // Static template functions shared across all instances
214
+ static #templates = new Map();
215
+ static #processingTemplate = null;
216
+
217
+ // Private fields
218
+ #currentState = 'ready';
219
+ #isDestroying = false;
220
+ #isAppearing = false;
221
+ #handlers = {};
222
+ #itemData = null;
223
+ #cartData = null;
224
+
225
+ /**
226
+ * Set the template function for rendering cart items
227
+ * @param {string} name - Template name ('default' for default template)
228
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
229
+ */
230
+ static setTemplate(name, templateFn) {
231
+ if (typeof name !== 'string') {
232
+ throw new Error('Template name must be a string');
233
+ }
234
+ if (typeof templateFn !== 'function') {
235
+ throw new Error('Template must be a function');
236
+ }
237
+ CartItem.#templates.set(name, templateFn);
238
+ }
239
+
240
+ /**
241
+ * Set the processing template function for rendering processing overlay
242
+ * @param {Function} templateFn - Function that returns HTML string for processing state
243
+ */
244
+ static setProcessingTemplate(templateFn) {
245
+ if (typeof templateFn !== 'function') {
246
+ throw new Error('Processing template must be a function');
247
+ }
248
+ CartItem.#processingTemplate = templateFn;
249
+ }
250
+
251
+ /**
252
+ * Create a cart item with appearing animation
253
+ * @param {Object} itemData - Shopify cart item data
254
+ * @param {Object} cartData - Full Shopify cart object
255
+ * @returns {CartItem} Cart item instance that will animate in
256
+ */
257
+ static createAnimated(itemData, cartData) {
258
+ return new CartItem(itemData, cartData, { animate: true });
259
+ }
260
+
261
+ /**
262
+ * Define which attributes should be observed for changes
263
+ */
264
+ static get observedAttributes() {
265
+ return ['state', 'key'];
266
+ }
267
+
268
+ /**
269
+ * Called when observed attributes change
270
+ */
271
+ attributeChangedCallback(name, oldValue, newValue) {
272
+ if (oldValue === newValue) return;
273
+
274
+ if (name === 'state') {
275
+ this.#currentState = newValue || 'ready';
276
+ }
277
+ }
278
+
279
+ constructor(itemData = null, cartData = null, options = {}) {
280
+ super();
281
+
282
+ // Store item and cart data if provided
283
+ this.#itemData = itemData;
284
+ this.#cartData = cartData;
285
+
286
+ // Set initial state - start with 'appearing' only if explicitly requested
287
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
288
+ this.#currentState =
289
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
290
+
291
+ // Bind event handlers
292
+ this.#handlers = {
293
+ click: this.#handleClick.bind(this),
294
+ change: this.#handleChange.bind(this),
295
+ transitionEnd: this.#handleTransitionEnd.bind(this),
296
+ };
297
+ }
298
+
299
+ connectedCallback() {
300
+ // If we have item data, render it first
301
+ if (this.#itemData) {
302
+ this.#render();
303
+ }
304
+
305
+ // Find child elements
306
+ this.content = this.querySelector('cart-item-content');
307
+ this.processing = this.querySelector('cart-item-processing');
308
+
309
+ // Update line price elements in case of pre-rendered content
310
+ this.#updateLinePriceElements();
311
+
312
+ // Attach event listeners
313
+ this.#attachListeners();
314
+
315
+ // If we started with 'appearing' state, handle the entry animation
316
+ if (this.#currentState === 'appearing') {
317
+ // Set the state attribute
318
+ this.setAttribute('state', 'appearing');
319
+ this.#isAppearing = true;
320
+
321
+ // Get the natural height after rendering
322
+ requestAnimationFrame(() => {
323
+ const naturalHeight = this.scrollHeight;
324
+
325
+ // Set explicit height for animation
326
+ this.style.height = `${naturalHeight}px`;
327
+
328
+ // Transition to ready state after a brief delay
329
+ requestAnimationFrame(() => {
330
+ this.setState('ready');
331
+ });
332
+ });
333
+ }
334
+ }
335
+
336
+ disconnectedCallback() {
337
+ // Cleanup event listeners
338
+ this.#detachListeners();
339
+ }
340
+
341
+ /**
342
+ * Attach event listeners
343
+ */
344
+ #attachListeners() {
345
+ this.addEventListener('click', this.#handlers.click);
346
+ this.addEventListener('change', this.#handlers.change);
347
+ this.addEventListener('quantity-modifier:change', this.#handlers.change);
348
+ this.addEventListener('transitionend', this.#handlers.transitionEnd);
349
+ }
350
+
351
+ /**
352
+ * Detach event listeners
353
+ */
354
+ #detachListeners() {
355
+ this.removeEventListener('click', this.#handlers.click);
356
+ this.removeEventListener('change', this.#handlers.change);
357
+ this.removeEventListener('quantity-modifier:change', this.#handlers.change);
358
+ this.removeEventListener('transitionend', this.#handlers.transitionEnd);
359
+ }
360
+
361
+ /**
362
+ * Get the current state
363
+ */
364
+ get state() {
365
+ return this.#currentState;
366
+ }
367
+
368
+ /**
369
+ * Get the cart key for this item
370
+ */
371
+ get cartKey() {
372
+ return this.getAttribute('key');
373
+ }
374
+
375
+ /**
376
+ * Handle click events (for Remove buttons, etc.)
377
+ */
378
+ #handleClick(e) {
379
+ // Check if clicked element is a remove button
380
+ const removeButton = e.target.closest('[data-action-remove-item]');
381
+ if (removeButton) {
382
+ e.preventDefault();
383
+ this.#emitRemoveEvent();
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Handle change events (for quantity inputs and quantity-modifier)
389
+ */
390
+ #handleChange(e) {
391
+ // Check if event is from quantity-modifier component
392
+ if (e.type === 'quantity-modifier:change') {
393
+ this.#emitQuantityChangeEvent(e.detail.value);
394
+ return;
395
+ }
396
+
397
+ // Check if changed element is a quantity input
398
+ const quantityInput = e.target.closest('[data-cart-quantity]');
399
+ if (quantityInput) {
400
+ this.#emitQuantityChangeEvent(quantityInput.value);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Handle transition end events for destroy animation and appearing animation
406
+ */
407
+ #handleTransitionEnd(e) {
408
+ if (e.propertyName === 'height' && this.#isDestroying) {
409
+ // Remove from DOM after height animation completes
410
+ this.remove();
411
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
412
+ // Remove explicit height after appearing animation completes
413
+ this.style.height = '';
414
+ this.#isAppearing = false;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Emit remove event
420
+ */
421
+ #emitRemoveEvent() {
422
+ this.dispatchEvent(
423
+ new CustomEvent('cart-item:remove', {
424
+ bubbles: true,
425
+ detail: {
426
+ cartKey: this.cartKey,
427
+ element: this,
428
+ },
429
+ })
430
+ );
431
+ }
432
+
433
+ /**
434
+ * Emit quantity change event
435
+ */
436
+ #emitQuantityChangeEvent(quantity) {
437
+ this.dispatchEvent(
438
+ new CustomEvent('cart-item:quantity-change', {
439
+ bubbles: true,
440
+ detail: {
441
+ cartKey: this.cartKey,
442
+ quantity: parseInt(quantity),
443
+ element: this,
444
+ },
445
+ })
446
+ );
447
+ }
448
+
449
+ /**
450
+ * Render cart item from data using the appropriate template
451
+ */
452
+ #render() {
453
+ if (!this.#itemData || CartItem.#templates.size === 0) {
454
+ console.log('no item data or no template', this.#itemData, CartItem.#templates);
455
+ return;
456
+ }
457
+
458
+ // Set the key attribute from item data
459
+ const key = this.#itemData.key || this.#itemData.id;
460
+ if (key) {
461
+ this.setAttribute('key', key);
462
+ }
463
+
464
+ // Determine which template to use
465
+ const templateName = this.#itemData.properties?._cart_template || 'default';
466
+ const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
467
+
468
+ if (!templateFn) {
469
+ console.warn(`Cart item template '${templateName}' not found and no default template set`);
470
+ return;
471
+ }
472
+
473
+ // Generate HTML from template with both item and cart data
474
+ const templateHTML = templateFn(this.#itemData, this.#cartData);
475
+
476
+ // Generate processing HTML from template or use default
477
+ const processingHTML = CartItem.#processingTemplate
478
+ ? CartItem.#processingTemplate()
479
+ : '<div class="cart-item-loader"></div>';
480
+
481
+ // Create the cart-item structure with template content inside cart-item-content
482
+ this.innerHTML = `
240
483
  <cart-item-content>
241
484
  ${templateHTML}
242
485
  </cart-item-content>
@@ -244,133 +487,175 @@
244
487
  ${processingHTML}
245
488
  </cart-item-processing>
246
489
  `;
247
- }
248
-
249
- /**
250
- * Update the cart item with new data
251
- * @param {Object} itemData - Shopify cart item data
252
- */
253
- setData(itemData) {
254
- this.#itemData = itemData;
255
- this.#renderFromData();
256
-
257
- // Re-find child elements after re-rendering
258
- this.content = this.querySelector('cart-item-content');
259
- this.processing = this.querySelector('cart-item-processing');
260
- }
261
-
262
- /**
263
- * Get the current item data
264
- */
265
- get itemData() {
266
- return this.#itemData;
267
- }
268
-
269
- /**
270
- * Set the state of the cart item
271
- * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
272
- */
273
- setState(state) {
274
- if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
275
- this.setAttribute('state', state);
276
- }
277
- }
278
-
279
- /**
280
- * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
281
- *
282
- * @returns {void}
283
- */
284
- destroyYourself() {
285
- // bail if already in the middle of a destroy cycle
286
- if (this.#isDestroying) return;
287
-
288
- this.#isDestroying = true;
289
-
290
- // snapshot the current rendered height before applying any "destroying" styles
291
- const initialHeight = this.offsetHeight;
292
-
293
- // switch to 'destroying' state so css can fade / slide visuals
294
- this.setState('destroying');
295
-
296
- // lock the measured height on the next animation frame to ensure layout is fully flushed
297
- requestAnimationFrame(() => {
298
- this.style.height = `${initialHeight}px`;
299
- this.offsetHeight; // force a reflow so the browser registers the fixed height
300
-
301
- // read the css custom property for timing, defaulting to 400ms
302
- const elementStyle = getComputedStyle(this);
303
- const destroyDuration =
304
- elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
305
-
306
- // animate only the height to zero; other properties stay under stylesheet control
307
- this.style.transition = `height ${destroyDuration} ease`;
308
- this.style.height = '0px';
309
- });
310
- }
311
- }
312
-
313
- /**
314
- * Supporting component classes for cart item
315
- */
316
- class CartItemContent extends HTMLElement {
317
- constructor() {
318
- super();
319
- }
320
- }
321
-
322
- class CartItemProcessing extends HTMLElement {
323
- constructor() {
324
- super();
325
- }
326
- }
327
-
328
- // Define custom elements (check if not already defined)
329
- if (!customElements.get('cart-item')) {
330
- customElements.define('cart-item', CartItem);
331
- }
332
- if (!customElements.get('cart-item-content')) {
333
- customElements.define('cart-item-content', CartItemContent);
334
- }
335
- if (!customElements.get('cart-item-processing')) {
336
- customElements.define('cart-item-processing', CartItemProcessing);
337
- }
338
-
339
- /**
340
- * Retrieves all focusable elements within a given container.
341
- *
342
- * @param {HTMLElement} container - The container element to search for focusable elements.
343
- * @returns {HTMLElement[]} An array of focusable elements found within the container.
344
- */
345
- const getFocusableElements = (container) => {
346
- const focusableSelectors =
347
- 'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
348
- return Array.from(container.querySelectorAll(focusableSelectors));
349
- };
350
-
351
- class FocusTrap extends HTMLElement {
352
- /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
353
- static styleInjected = false;
354
-
355
- constructor() {
356
- super();
357
- this.trapStart = null;
358
- this.trapEnd = null;
359
-
360
- // Inject styles only once, when the first FocusTrap instance is created.
361
- if (!FocusTrap.styleInjected) {
362
- this.injectStyles();
363
- FocusTrap.styleInjected = true;
364
- }
365
- }
366
-
367
- /**
368
- * Injects necessary styles for the focus trap into the document's head.
369
- * This ensures that focus-trap-start and focus-trap-end elements are hidden.
370
- */
371
- injectStyles() {
372
- const style = document.createElement('style');
373
- style.textContent = `
490
+ }
491
+
492
+ /**
493
+ * Update the cart item with new data
494
+ * @param {Object} itemData - Shopify cart item data
495
+ * @param {Object} cartData - Full Shopify cart object
496
+ */
497
+ setData(itemData, cartData = null) {
498
+ this.#itemData = itemData;
499
+ if (cartData) {
500
+ this.#cartData = cartData;
501
+ }
502
+ this.#render();
503
+
504
+ // Re-find child elements after re-rendering
505
+ this.content = this.querySelector('cart-item-content');
506
+ this.processing = this.querySelector('cart-item-processing');
507
+
508
+ // Update line price elements
509
+ this.#updateLinePriceElements();
510
+ }
511
+
512
+ /**
513
+ * Update elements with data-content-line-price attribute
514
+ * @private
515
+ */
516
+ #updateLinePriceElements() {
517
+ if (!this.#itemData) return;
518
+
519
+ const linePriceElements = this.querySelectorAll('[data-content-line-price]');
520
+ const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
521
+
522
+ linePriceElements.forEach((element) => {
523
+ element.textContent = formattedLinePrice;
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Format currency value from cents to dollar string
529
+ * @param {number} cents - Price in cents
530
+ * @returns {string} Formatted currency string (e.g., "$29.99")
531
+ * @private
532
+ */
533
+ #formatCurrency(cents) {
534
+ if (typeof cents !== 'number') return '$0.00';
535
+ return `$${(cents / 100).toFixed(2)}`;
536
+ }
537
+
538
+ /**
539
+ * Get the current item data
540
+ */
541
+ get itemData() {
542
+ return this.#itemData;
543
+ }
544
+
545
+ /**
546
+ * Set the state of the cart item
547
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
548
+ */
549
+ setState(state) {
550
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
551
+ this.setAttribute('state', state);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
557
+ *
558
+ * @returns {void}
559
+ */
560
+ destroyYourself() {
561
+ // bail if already in the middle of a destroy cycle
562
+ if (this.#isDestroying) return;
563
+
564
+ this.#isDestroying = true;
565
+
566
+ // snapshot the current rendered height before applying any "destroying" styles
567
+ const initialHeight = this.offsetHeight;
568
+
569
+ // switch to 'destroying' state so css can fade / slide visuals
570
+ this.setState('destroying');
571
+
572
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
573
+ requestAnimationFrame(() => {
574
+ this.style.height = `${initialHeight}px`;
575
+ // this.offsetHeight; // force a reflow so the browser registers the fixed height
576
+
577
+ // read the css custom property for timing, defaulting to 400ms
578
+ const elementStyle = getComputedStyle(this);
579
+ const destroyDuration =
580
+ elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
581
+
582
+ // animate only the height to zero; other properties stay under stylesheet control
583
+ this.style.transition = `height ${destroyDuration} ease`;
584
+ this.style.height = '0px';
585
+
586
+ // setTimeout(() => {
587
+ // this.style.height = '0px';
588
+ // }, 1);
589
+
590
+ setTimeout(() => {
591
+ // make sure item is removed
592
+ this.remove();
593
+ }, 600);
594
+ });
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Supporting component classes for cart item
600
+ */
601
+ class CartItemContent extends HTMLElement {
602
+ constructor() {
603
+ super();
604
+ }
605
+ }
606
+
607
+ class CartItemProcessing extends HTMLElement {
608
+ constructor() {
609
+ super();
610
+ }
611
+ }
612
+
613
+ // Define custom elements (check if not already defined)
614
+ if (!customElements.get('cart-item')) {
615
+ customElements.define('cart-item', CartItem);
616
+ }
617
+ if (!customElements.get('cart-item-content')) {
618
+ customElements.define('cart-item-content', CartItemContent);
619
+ }
620
+ if (!customElements.get('cart-item-processing')) {
621
+ customElements.define('cart-item-processing', CartItemProcessing);
622
+ }
623
+
624
+ /**
625
+ * Retrieves all focusable elements within a given container.
626
+ *
627
+ * @param {HTMLElement} container - The container element to search for focusable elements.
628
+ * @returns {HTMLElement[]} An array of focusable elements found within the container.
629
+ */
630
+ const getFocusableElements = (container) => {
631
+ const focusableSelectors =
632
+ 'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
633
+ return Array.from(container.querySelectorAll(focusableSelectors));
634
+ };
635
+
636
+ class FocusTrap extends HTMLElement {
637
+ /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
638
+ static styleInjected = false;
639
+
640
+ constructor() {
641
+ super();
642
+ this.trapStart = null;
643
+ this.trapEnd = null;
644
+
645
+ // Inject styles only once, when the first FocusTrap instance is created.
646
+ if (!FocusTrap.styleInjected) {
647
+ this.injectStyles();
648
+ FocusTrap.styleInjected = true;
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Injects necessary styles for the focus trap into the document's head.
654
+ * This ensures that focus-trap-start and focus-trap-end elements are hidden.
655
+ */
656
+ injectStyles() {
657
+ const style = document.createElement('style');
658
+ style.textContent = `
374
659
  focus-trap-start,
375
660
  focus-trap-end {
376
661
  position: absolute;
@@ -384,710 +669,995 @@
384
669
  white-space: nowrap;
385
670
  }
386
671
  `;
387
- document.head.appendChild(style);
388
- }
389
-
390
- /**
391
- * Called when the element is connected to the DOM.
392
- * Sets up the focus trap and adds the keydown event listener.
393
- */
394
- connectedCallback() {
395
- this.setupTrap();
396
- this.addEventListener('keydown', this.handleKeyDown);
397
- }
398
-
399
- /**
400
- * Called when the element is disconnected from the DOM.
401
- * Removes the keydown event listener.
402
- */
403
- disconnectedCallback() {
404
- this.removeEventListener('keydown', this.handleKeyDown);
405
- }
406
-
407
- /**
408
- * Sets up the focus trap by adding trap start and trap end elements.
409
- * Focuses the trap start element to initiate the focus trap.
410
- */
411
- setupTrap() {
412
- // check to see it there are any focusable children
413
- const focusableElements = getFocusableElements(this);
414
- // exit if there aren't any
415
- if (focusableElements.length === 0) return;
416
-
417
- // create trap start and end elements
418
- this.trapStart = document.createElement('focus-trap-start');
419
- this.trapEnd = document.createElement('focus-trap-end');
420
-
421
- // add to DOM
422
- this.prepend(this.trapStart);
423
- this.append(this.trapEnd);
424
- }
425
-
426
- /**
427
- * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
428
- *
429
- * @param {KeyboardEvent} e - The keyboard event object.
430
- */
431
- handleKeyDown = (e) => {
432
- if (e.key === 'Escape') {
433
- e.preventDefault();
434
- this.exitTrap();
435
- }
436
- };
437
-
438
- /**
439
- * Exits the focus trap by hiding the current container and shifting focus
440
- * back to the trigger element that opened the trap.
441
- */
442
- exitTrap() {
443
- const container = this.closest('[aria-hidden="false"]');
444
- if (!container) return;
445
-
446
- container.setAttribute('aria-hidden', 'true');
447
-
448
- const trigger = document.querySelector(
449
- `[aria-expanded="true"][aria-controls="${container.id}"]`
450
- );
451
- if (trigger) {
452
- trigger.setAttribute('aria-expanded', 'false');
453
- trigger.focus();
454
- }
455
- }
456
- }
457
-
458
- class FocusTrapStart extends HTMLElement {
459
- /**
460
- * Called when the element is connected to the DOM.
461
- * Sets the tabindex and adds the focus event listener.
462
- */
463
- connectedCallback() {
464
- this.setAttribute('tabindex', '0');
465
- this.addEventListener('focus', this.handleFocus);
466
- }
467
-
468
- /**
469
- * Called when the element is disconnected from the DOM.
470
- * Removes the focus event listener.
471
- */
472
- disconnectedCallback() {
473
- this.removeEventListener('focus', this.handleFocus);
474
- }
475
-
476
- /**
477
- * Handles the focus event. If focus moves backwards from the first focusable element,
478
- * it is cycled to the last focusable element, and vice versa.
479
- *
480
- * @param {FocusEvent} e - The focus event object.
481
- */
482
- handleFocus = (e) => {
483
- const trap = this.closest('focus-trap');
484
- const focusableElements = getFocusableElements(trap);
485
-
486
- if (focusableElements.length === 0) return;
487
-
488
- const firstElement = focusableElements[0];
489
- const lastElement =
490
- focusableElements[focusableElements.length - 1];
491
-
492
- if (e.relatedTarget === firstElement) {
493
- lastElement.focus();
494
- } else {
495
- firstElement.focus();
496
- }
497
- };
498
- }
499
-
500
- class FocusTrapEnd extends HTMLElement {
501
- /**
502
- * Called when the element is connected to the DOM.
503
- * Sets the tabindex and adds the focus event listener.
504
- */
505
- connectedCallback() {
506
- this.setAttribute('tabindex', '0');
507
- this.addEventListener('focus', this.handleFocus);
508
- }
509
-
510
- /**
511
- * Called when the element is disconnected from the DOM.
512
- * Removes the focus event listener.
513
- */
514
- disconnectedCallback() {
515
- this.removeEventListener('focus', this.handleFocus);
516
- }
517
-
518
- /**
519
- * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
520
- */
521
- handleFocus = () => {
522
- const trap = this.closest('focus-trap');
523
- const trapStart = trap.querySelector('focus-trap-start');
524
- trapStart.focus();
525
- };
526
- }
527
-
528
- customElements.define('focus-trap', FocusTrap);
529
- customElements.define('focus-trap-start', FocusTrapStart);
530
- customElements.define('focus-trap-end', FocusTrapEnd);
531
-
532
- class EventEmitter {
533
- #events;
534
-
535
- constructor() {
536
- this.#events = new Map();
537
- }
538
-
539
- /**
540
- * Binds a listener to an event.
541
- * @param {string} event - The event to bind the listener to.
542
- * @param {Function} listener - The listener function to bind.
543
- * @returns {EventEmitter} The current instance for chaining.
544
- * @throws {TypeError} If the listener is not a function.
545
- */
546
- on(event, listener) {
547
- if (typeof listener !== "function") {
548
- throw new TypeError("Listener must be a function");
549
- }
550
-
551
- const listeners = this.#events.get(event) || [];
552
- if (!listeners.includes(listener)) {
553
- listeners.push(listener);
554
- }
555
- this.#events.set(event, listeners);
556
-
557
- return this;
558
- }
559
-
560
- /**
561
- * Unbinds a listener from an event.
562
- * @param {string} event - The event to unbind the listener from.
563
- * @param {Function} listener - The listener function to unbind.
564
- * @returns {EventEmitter} The current instance for chaining.
565
- */
566
- off(event, listener) {
567
- const listeners = this.#events.get(event);
568
- if (!listeners) return this;
569
-
570
- const index = listeners.indexOf(listener);
571
- if (index !== -1) {
572
- listeners.splice(index, 1);
573
- if (listeners.length === 0) {
574
- this.#events.delete(event);
575
- } else {
576
- this.#events.set(event, listeners);
577
- }
578
- }
579
-
580
- return this;
581
- }
582
-
583
- /**
584
- * Triggers an event and calls all bound listeners.
585
- * @param {string} event - The event to trigger.
586
- * @param {...*} args - Arguments to pass to the listener functions.
587
- * @returns {boolean} True if the event had listeners, false otherwise.
588
- */
589
- emit(event, ...args) {
590
- const listeners = this.#events.get(event);
591
- if (!listeners || listeners.length === 0) return false;
592
-
593
- for (let i = 0, n = listeners.length; i < n; ++i) {
594
- try {
595
- listeners[i].apply(this, args);
596
- } catch (error) {
597
- console.error(`Error in listener for event '${event}':`, error);
598
- }
599
- }
600
-
601
- return true;
602
- }
603
-
604
- /**
605
- * Removes all listeners for a specific event or all events.
606
- * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
607
- * @returns {EventEmitter} The current instance for chaining.
608
- */
609
- removeAllListeners(event) {
610
- if (event) {
611
- this.#events.delete(event);
612
- } else {
613
- this.#events.clear();
614
- }
615
- return this;
616
- }
617
- }
618
-
619
- /**
620
- * Custom element that creates an accessible modal cart dialog with focus management
621
- * @extends HTMLElement
622
- */
623
- class CartDialog extends HTMLElement {
624
- #handleTransitionEnd;
625
- #scrollPosition = 0;
626
- #currentCart = null;
627
- #eventEmitter;
628
-
629
- /**
630
- * Clean up event listeners when component is removed from DOM
631
- */
632
- disconnectedCallback() {
633
- const _ = this;
634
- if (_.contentPanel) {
635
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
636
- }
637
-
638
- // Ensure body scroll is restored if component is removed while open
639
- document.body.classList.remove('overflow-hidden');
640
- this.#restoreScroll();
641
-
642
- // Detach event listeners
643
- this.#detachListeners();
644
- }
645
-
646
- /**
647
- * Saves current scroll position and locks body scrolling
648
- * @private
649
- */
650
- #lockScroll() {
651
- const _ = this;
652
- // Save current scroll position
653
- _.#scrollPosition = window.pageYOffset;
654
-
655
- // Apply fixed position to body
656
- document.body.classList.add('overflow-hidden');
657
- document.body.style.top = `-${_.#scrollPosition}px`;
658
- }
659
-
660
- /**
661
- * Restores scroll position when cart dialog is closed
662
- * @private
663
- */
664
- #restoreScroll() {
665
- const _ = this;
666
- // Remove fixed positioning
667
- document.body.classList.remove('overflow-hidden');
668
- document.body.style.removeProperty('top');
669
-
670
- // Restore scroll position
671
- window.scrollTo(0, _.#scrollPosition);
672
- }
673
-
674
- /**
675
- * Initializes the cart dialog, sets up focus trap and overlay
676
- */
677
- constructor() {
678
- super();
679
- const _ = this;
680
- _.id = _.getAttribute('id');
681
- _.setAttribute('role', 'dialog');
682
- _.setAttribute('aria-modal', 'true');
683
- _.setAttribute('aria-hidden', 'true');
684
-
685
- _.triggerEl = null;
686
-
687
- // Initialize event emitter
688
- _.#eventEmitter = new EventEmitter();
689
-
690
- // Create a handler for transition end events
691
- _.#handleTransitionEnd = (e) => {
692
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
693
- _.contentPanel.classList.add('hidden');
694
-
695
- // Emit afterHide event - cart dialog has completed its transition
696
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
697
- }
698
- };
699
- }
700
-
701
- connectedCallback() {
702
- const _ = this;
703
-
704
- // Now that we're in the DOM, find the content panel and set up focus trap
705
- _.contentPanel = _.querySelector('cart-panel');
706
-
707
- if (!_.contentPanel) {
708
- console.error('cart-panel element not found inside cart-dialog');
709
- return;
710
- }
711
-
712
- _.focusTrap = document.createElement('focus-trap');
713
-
714
- // Ensure we have labelledby and describedby references
715
- if (!_.getAttribute('aria-labelledby')) {
716
- const heading = _.querySelector('h1, h2, h3');
717
- if (heading && !heading.id) {
718
- heading.id = `${_.id}-title`;
719
- }
720
- if (heading?.id) {
721
- _.setAttribute('aria-labelledby', heading.id);
722
- }
723
- }
724
-
725
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
726
- _.focusTrap.appendChild(_.contentPanel);
727
-
728
- _.focusTrap.setupTrap();
729
-
730
- // Add modal overlay
731
- _.prepend(document.createElement('cart-overlay'));
732
- _.#attachListeners();
733
- _.#bindKeyboard();
734
- }
735
-
736
- /**
737
- * Event emitter method - Add an event listener with a cleaner API
738
- * @param {string} eventName - Name of the event to listen for
739
- * @param {Function} callback - Callback function to execute when event is fired
740
- * @returns {CartDialog} Returns this for method chaining
741
- */
742
- on(eventName, callback) {
743
- this.#eventEmitter.on(eventName, callback);
744
- return this;
745
- }
746
-
747
- /**
748
- * Event emitter method - Remove an event listener
749
- * @param {string} eventName - Name of the event to stop listening for
750
- * @param {Function} callback - Callback function to remove
751
- * @returns {CartDialog} Returns this for method chaining
752
- */
753
- off(eventName, callback) {
754
- this.#eventEmitter.off(eventName, callback);
755
- return this;
756
- }
757
-
758
- /**
759
- * Internal method to emit events via the event emitter
760
- * @param {string} eventName - Name of the event to emit
761
- * @param {*} [data] - Optional data to include with the event
762
- * @private
763
- */
764
- #emit(eventName, data = null) {
765
- this.#eventEmitter.emit(eventName, data);
766
- }
767
-
768
- /**
769
- * Attach event listeners for cart dialog functionality
770
- * @private
771
- */
772
- #attachListeners() {
773
- const _ = this;
774
-
775
- // Handle trigger buttons
776
- document.addEventListener('click', (e) => {
777
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
778
- if (!trigger) return;
779
-
780
- if (trigger.getAttribute('data-prevent-default') === 'true') {
781
- e.preventDefault();
782
- }
783
-
784
- _.show(trigger);
785
- });
786
-
787
- // Handle close buttons
788
- _.addEventListener('click', (e) => {
789
- if (!e.target.closest('[data-action="hide-cart"]')) return;
790
- _.hide();
791
- });
792
-
793
- // Handle cart item remove events
794
- _.addEventListener('cart-item:remove', (e) => {
795
- _.#handleCartItemRemove(e);
796
- });
797
-
798
- // Handle cart item quantity change events
799
- _.addEventListener('cart-item:quantity-change', (e) => {
800
- _.#handleCartItemQuantityChange(e);
801
- });
802
-
803
- // Add transition end listener
804
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
805
- }
806
-
807
- /**
808
- * Detach event listeners
809
- * @private
810
- */
811
- #detachListeners() {
812
- const _ = this;
813
- if (_.contentPanel) {
814
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
815
- }
816
- }
817
-
818
- /**
819
- * Binds keyboard events for accessibility
820
- * @private
821
- */
822
- #bindKeyboard() {
823
- this.addEventListener('keydown', (e) => {
824
- if (e.key === 'Escape') {
825
- this.hide();
826
- }
827
- });
828
- }
829
-
830
- /**
831
- * Handle cart item removal
832
- * @private
833
- */
834
- #handleCartItemRemove(e) {
835
- const { cartKey, element } = e.detail;
836
-
837
- // Set item to processing state
838
- element.setState('processing');
839
-
840
- // Remove item by setting quantity to 0
841
- this.updateCartItem(cartKey, 0)
842
- .then((updatedCart) => {
843
- if (updatedCart && !updatedCart.error) {
844
- // Success - remove with animation
845
- element.destroyYourself();
846
- this.#currentCart = updatedCart;
847
- this.#updateCartItems(updatedCart);
848
-
849
- // Emit cart updated and data changed events
850
- this.#emit('cart-dialog:updated', { cart: updatedCart });
851
- this.#emit('cart-dialog:data-changed', updatedCart);
852
- } else {
853
- // Error - reset to ready state
854
- element.setState('ready');
855
- console.error('Failed to remove cart item:', cartKey);
856
- }
857
- })
858
- .catch((error) => {
859
- // Error - reset to ready state
860
- element.setState('ready');
861
- console.error('Error removing cart item:', error);
862
- });
863
- }
864
-
865
- /**
866
- * Handle cart item quantity change
867
- * @private
868
- */
869
- #handleCartItemQuantityChange(e) {
870
- const { cartKey, quantity, element } = e.detail;
871
-
872
- // Set item to processing state
873
- element.setState('processing');
874
-
875
- // Update item quantity
876
- this.updateCartItem(cartKey, quantity)
877
- .then((updatedCart) => {
878
- if (updatedCart && !updatedCart.error) {
879
- // Success - update cart data
880
- this.#currentCart = updatedCart;
881
- this.#updateCartItems(updatedCart);
882
- element.setState('ready');
883
-
884
- // Emit cart updated and data changed events
885
- this.#emit('cart-dialog:updated', { cart: updatedCart });
886
- this.#emit('cart-dialog:data-changed', updatedCart);
887
- } else {
888
- // Error - reset to ready state
889
- element.setState('ready');
890
- console.error('Failed to update cart item quantity:', cartKey, quantity);
891
- }
892
- })
893
- .catch((error) => {
894
- // Error - reset to ready state
895
- element.setState('ready');
896
- console.error('Error updating cart item quantity:', error);
897
- });
898
- }
899
-
900
- /**
901
- * Update cart items
902
- * @private
903
- */
904
- #updateCartItems(cart = null) {
905
- // Placeholder for cart item updates
906
- // Could be used to sync cart items with server data
907
- cart || this.#currentCart;
908
- }
909
-
910
- /**
911
- * Fetch current cart data from server
912
- * @returns {Promise<Object>} Cart data object
913
- */
914
- getCart() {
915
- return fetch('/cart.json', {
916
- crossDomain: true,
917
- credentials: 'same-origin',
918
- })
919
- .then((response) => {
920
- if (!response.ok) {
921
- throw Error(response.statusText);
922
- }
923
- return response.json();
924
- })
925
- .catch((error) => {
926
- console.error('Error fetching cart:', error);
927
- return { error: true, message: error.message };
928
- });
929
- }
930
-
931
- /**
932
- * Update cart item quantity on server
933
- * @param {string|number} key - Cart item key/ID
934
- * @param {number} quantity - New quantity (0 to remove)
935
- * @returns {Promise<Object>} Updated cart data object
936
- */
937
- updateCartItem(key, quantity) {
938
- return fetch('/cart/change.json', {
939
- crossDomain: true,
940
- method: 'POST',
941
- credentials: 'same-origin',
942
- body: JSON.stringify({ id: key, quantity: quantity }),
943
- headers: { 'Content-Type': 'application/json' },
944
- })
945
- .then((response) => {
946
- if (!response.ok) {
947
- throw Error(response.statusText);
948
- }
949
- return response.json();
950
- })
951
- .catch((error) => {
952
- console.error('Error updating cart item:', error);
953
- return { error: true, message: error.message };
954
- });
955
- }
956
-
957
- /**
958
- * Refresh cart data from server and update components
959
- * @returns {Promise<Object>} Cart data object
960
- */
961
- refreshCart() {
962
- return this.getCart().then((cartData) => {
963
- if (cartData && !cartData.error) {
964
- this.#currentCart = cartData;
965
- this.#updateCartItems(cartData);
966
-
967
- // Emit cart refreshed and data changed events
968
- this.#emit('cart-dialog:refreshed', { cart: cartData });
969
- this.#emit('cart-dialog:data-changed', cartData);
970
- }
971
- return cartData;
972
- });
973
- }
974
-
975
- /**
976
- * Shows the cart dialog and traps focus within it
977
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
978
- * @fires CartDialog#show - Fired when the cart dialog has been shown
979
- */
980
- show(triggerEl = null) {
981
- const _ = this;
982
- _.triggerEl = triggerEl || false;
983
-
984
- // Remove the hidden class first to ensure content is rendered
985
- _.contentPanel.classList.remove('hidden');
986
-
987
- // Give the browser a moment to process before starting animation
988
- requestAnimationFrame(() => {
989
- // Update ARIA states
990
- _.setAttribute('aria-hidden', 'false');
991
- if (_.triggerEl) {
992
- _.triggerEl.setAttribute('aria-expanded', 'true');
993
- }
994
-
995
- // Lock body scrolling and save scroll position
996
- _.#lockScroll();
997
-
998
- // Focus management
999
- const firstFocusable = _.querySelector(
1000
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1001
- );
1002
- if (firstFocusable) {
1003
- requestAnimationFrame(() => {
1004
- firstFocusable.focus();
1005
- });
1006
- }
1007
-
1008
- // Refresh cart data when showing
1009
- _.refreshCart();
1010
-
1011
- // Emit show event - cart dialog is now visible
1012
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1013
- });
1014
- }
1015
-
1016
- /**
1017
- * Hides the cart dialog and restores focus
1018
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1019
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1020
- */
1021
- hide() {
1022
- const _ = this;
1023
-
1024
- // Restore body scroll and scroll position
1025
- _.#restoreScroll();
1026
-
1027
- // Update ARIA states
1028
- if (_.triggerEl) {
1029
- // remove focus from modal panel first
1030
- _.triggerEl.focus();
1031
- // mark trigger as no longer expanded
1032
- _.triggerEl.setAttribute('aria-expanded', 'false');
1033
- }
1034
-
1035
- // Set aria-hidden to start transition
1036
- // The transitionend event handler will add display:none when complete
1037
- _.setAttribute('aria-hidden', 'true');
1038
-
1039
- // Emit hide event - cart dialog is now starting to hide
1040
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1041
- }
1042
- }
1043
-
1044
- /**
1045
- * Custom element that creates a clickable overlay for the cart dialog
1046
- * @extends HTMLElement
1047
- */
1048
- class CartOverlay extends HTMLElement {
1049
- constructor() {
1050
- super();
1051
- this.setAttribute('tabindex', '-1');
1052
- this.setAttribute('aria-hidden', 'true');
1053
- this.cartDialog = this.closest('cart-dialog');
1054
- this.#attachListeners();
1055
- }
1056
-
1057
- #attachListeners() {
1058
- this.addEventListener('click', () => {
1059
- this.cartDialog.hide();
1060
- });
1061
- }
1062
- }
1063
-
1064
- /**
1065
- * Custom element that wraps the content of the cart dialog
1066
- * @extends HTMLElement
1067
- */
1068
- class CartPanel extends HTMLElement {
1069
- constructor() {
1070
- super();
1071
- this.setAttribute('role', 'document');
1072
- }
1073
- }
1074
-
1075
- if (!customElements.get('cart-dialog')) {
1076
- customElements.define('cart-dialog', CartDialog);
1077
- }
1078
- if (!customElements.get('cart-overlay')) {
1079
- customElements.define('cart-overlay', CartOverlay);
1080
- }
1081
- if (!customElements.get('cart-panel')) {
1082
- customElements.define('cart-panel', CartPanel);
1083
- }
1084
-
1085
- exports.CartDialog = CartDialog;
1086
- exports.CartOverlay = CartOverlay;
1087
- exports.CartPanel = CartPanel;
1088
- exports.default = CartDialog;
1089
-
1090
- Object.defineProperty(exports, '__esModule', { value: true });
672
+ document.head.appendChild(style);
673
+ }
674
+
675
+ /**
676
+ * Called when the element is connected to the DOM.
677
+ * Sets up the focus trap and adds the keydown event listener.
678
+ */
679
+ connectedCallback() {
680
+ this.setupTrap();
681
+ this.addEventListener('keydown', this.handleKeyDown);
682
+ }
683
+
684
+ /**
685
+ * Called when the element is disconnected from the DOM.
686
+ * Removes the keydown event listener.
687
+ */
688
+ disconnectedCallback() {
689
+ this.removeEventListener('keydown', this.handleKeyDown);
690
+ }
691
+
692
+ /**
693
+ * Sets up the focus trap by adding trap start and trap end elements.
694
+ * Focuses the trap start element to initiate the focus trap.
695
+ */
696
+ setupTrap() {
697
+ // check to see it there are any focusable children
698
+ const focusableElements = getFocusableElements(this);
699
+ // exit if there aren't any
700
+ if (focusableElements.length === 0) return;
701
+
702
+ // create trap start and end elements
703
+ this.trapStart = document.createElement('focus-trap-start');
704
+ this.trapEnd = document.createElement('focus-trap-end');
705
+
706
+ // add to DOM
707
+ this.prepend(this.trapStart);
708
+ this.append(this.trapEnd);
709
+ }
710
+
711
+ /**
712
+ * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
713
+ *
714
+ * @param {KeyboardEvent} e - The keyboard event object.
715
+ */
716
+ handleKeyDown = (e) => {
717
+ if (e.key === 'Escape') {
718
+ e.preventDefault();
719
+ this.exitTrap();
720
+ }
721
+ };
722
+
723
+ /**
724
+ * Exits the focus trap by hiding the current container and shifting focus
725
+ * back to the trigger element that opened the trap.
726
+ */
727
+ exitTrap() {
728
+ const container = this.closest('[aria-hidden="false"]');
729
+ if (!container) return;
730
+
731
+ container.setAttribute('aria-hidden', 'true');
732
+
733
+ const trigger = document.querySelector(
734
+ `[aria-expanded="true"][aria-controls="${container.id}"]`
735
+ );
736
+ if (trigger) {
737
+ trigger.setAttribute('aria-expanded', 'false');
738
+ trigger.focus();
739
+ }
740
+ }
741
+ }
742
+
743
+ class FocusTrapStart extends HTMLElement {
744
+ /**
745
+ * Called when the element is connected to the DOM.
746
+ * Sets the tabindex and adds the focus event listener.
747
+ */
748
+ connectedCallback() {
749
+ this.setAttribute('tabindex', '0');
750
+ this.addEventListener('focus', this.handleFocus);
751
+ }
752
+
753
+ /**
754
+ * Called when the element is disconnected from the DOM.
755
+ * Removes the focus event listener.
756
+ */
757
+ disconnectedCallback() {
758
+ this.removeEventListener('focus', this.handleFocus);
759
+ }
760
+
761
+ /**
762
+ * Handles the focus event. If focus moves backwards from the first focusable element,
763
+ * it is cycled to the last focusable element, and vice versa.
764
+ *
765
+ * @param {FocusEvent} e - The focus event object.
766
+ */
767
+ handleFocus = (e) => {
768
+ const trap = this.closest('focus-trap');
769
+ const focusableElements = getFocusableElements(trap);
770
+
771
+ if (focusableElements.length === 0) return;
772
+
773
+ const firstElement = focusableElements[0];
774
+ const lastElement =
775
+ focusableElements[focusableElements.length - 1];
776
+
777
+ if (e.relatedTarget === firstElement) {
778
+ lastElement.focus();
779
+ } else {
780
+ firstElement.focus();
781
+ }
782
+ };
783
+ }
784
+
785
+ class FocusTrapEnd extends HTMLElement {
786
+ /**
787
+ * Called when the element is connected to the DOM.
788
+ * Sets the tabindex and adds the focus event listener.
789
+ */
790
+ connectedCallback() {
791
+ this.setAttribute('tabindex', '0');
792
+ this.addEventListener('focus', this.handleFocus);
793
+ }
794
+
795
+ /**
796
+ * Called when the element is disconnected from the DOM.
797
+ * Removes the focus event listener.
798
+ */
799
+ disconnectedCallback() {
800
+ this.removeEventListener('focus', this.handleFocus);
801
+ }
802
+
803
+ /**
804
+ * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
805
+ */
806
+ handleFocus = () => {
807
+ const trap = this.closest('focus-trap');
808
+ const trapStart = trap.querySelector('focus-trap-start');
809
+ trapStart.focus();
810
+ };
811
+ }
812
+
813
+ if (!customElements.get('focus-trap')) {
814
+ customElements.define('focus-trap', FocusTrap);
815
+ }
816
+ if (!customElements.get('focus-trap-start')) {
817
+ customElements.define('focus-trap-start', FocusTrapStart);
818
+ }
819
+ if (!customElements.get('focus-trap-end')) {
820
+ customElements.define('focus-trap-end', FocusTrapEnd);
821
+ }
822
+
823
+ class EventEmitter {
824
+ #events;
825
+
826
+ constructor() {
827
+ this.#events = new Map();
828
+ }
829
+
830
+ /**
831
+ * Binds a listener to an event.
832
+ * @param {string} event - The event to bind the listener to.
833
+ * @param {Function} listener - The listener function to bind.
834
+ * @returns {EventEmitter} The current instance for chaining.
835
+ * @throws {TypeError} If the listener is not a function.
836
+ */
837
+ on(event, listener) {
838
+ if (typeof listener !== "function") {
839
+ throw new TypeError("Listener must be a function");
840
+ }
841
+
842
+ const listeners = this.#events.get(event) || [];
843
+ if (!listeners.includes(listener)) {
844
+ listeners.push(listener);
845
+ }
846
+ this.#events.set(event, listeners);
847
+
848
+ return this;
849
+ }
850
+
851
+ /**
852
+ * Unbinds a listener from an event.
853
+ * @param {string} event - The event to unbind the listener from.
854
+ * @param {Function} listener - The listener function to unbind.
855
+ * @returns {EventEmitter} The current instance for chaining.
856
+ */
857
+ off(event, listener) {
858
+ const listeners = this.#events.get(event);
859
+ if (!listeners) return this;
860
+
861
+ const index = listeners.indexOf(listener);
862
+ if (index !== -1) {
863
+ listeners.splice(index, 1);
864
+ if (listeners.length === 0) {
865
+ this.#events.delete(event);
866
+ } else {
867
+ this.#events.set(event, listeners);
868
+ }
869
+ }
870
+
871
+ return this;
872
+ }
873
+
874
+ /**
875
+ * Triggers an event and calls all bound listeners.
876
+ * @param {string} event - The event to trigger.
877
+ * @param {...*} args - Arguments to pass to the listener functions.
878
+ * @returns {boolean} True if the event had listeners, false otherwise.
879
+ */
880
+ emit(event, ...args) {
881
+ const listeners = this.#events.get(event);
882
+ if (!listeners || listeners.length === 0) return false;
883
+
884
+ for (let i = 0, n = listeners.length; i < n; ++i) {
885
+ try {
886
+ listeners[i].apply(this, args);
887
+ } catch (error) {
888
+ console.error(`Error in listener for event '${event}':`, error);
889
+ }
890
+ }
891
+
892
+ return true;
893
+ }
894
+
895
+ /**
896
+ * Removes all listeners for a specific event or all events.
897
+ * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
898
+ * @returns {EventEmitter} The current instance for chaining.
899
+ */
900
+ removeAllListeners(event) {
901
+ if (event) {
902
+ this.#events.delete(event);
903
+ } else {
904
+ this.#events.clear();
905
+ }
906
+ return this;
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Custom element that creates an accessible modal cart dialog with focus management
912
+ * @extends HTMLElement
913
+ */
914
+ class CartDialog extends HTMLElement {
915
+ #handleTransitionEnd;
916
+ #currentCart = null;
917
+ #eventEmitter;
918
+ #isInitialRender = true;
919
+
920
+ /**
921
+ * Clean up event listeners when component is removed from DOM
922
+ */
923
+ disconnectedCallback() {
924
+ const _ = this;
925
+ if (_.contentPanel) {
926
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
927
+ }
928
+
929
+ // Ensure body scroll is restored if component is removed while open
930
+ document.body.classList.remove('overflow-hidden');
931
+ this.#restoreScroll();
932
+
933
+ // Detach event listeners
934
+ this.#detachListeners();
935
+ }
936
+
937
+ /**
938
+ * Locks body scrolling
939
+ * @private
940
+ */
941
+ #lockScroll() {
942
+ // Apply overflow hidden to body
943
+ document.body.classList.add('overflow-hidden');
944
+ }
945
+
946
+ /**
947
+ * Restores body scrolling when cart dialog is closed
948
+ * @private
949
+ */
950
+ #restoreScroll() {
951
+ // Remove overflow hidden from body
952
+ document.body.classList.remove('overflow-hidden');
953
+ }
954
+
955
+ /**
956
+ * Initializes the cart dialog, sets up focus trap and overlay
957
+ */
958
+ constructor() {
959
+ super();
960
+ const _ = this;
961
+ _.id = _.getAttribute('id');
962
+ _.setAttribute('role', 'dialog');
963
+ _.setAttribute('aria-modal', 'true');
964
+ _.setAttribute('aria-hidden', 'true');
965
+
966
+ _.triggerEl = null;
967
+
968
+ // Initialize event emitter
969
+ _.#eventEmitter = new EventEmitter();
970
+
971
+ // Create a handler for transition end events
972
+ _.#handleTransitionEnd = (e) => {
973
+ if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
974
+ _.contentPanel.classList.add('hidden');
975
+
976
+ // Emit afterHide event - cart dialog has completed its transition
977
+ _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
978
+ }
979
+ };
980
+ }
981
+
982
+ connectedCallback() {
983
+ const _ = this;
984
+
985
+ // Now that we're in the DOM, find the content panel and set up focus trap
986
+ _.contentPanel = _.querySelector('cart-panel');
987
+
988
+ if (!_.contentPanel) {
989
+ console.error('cart-panel element not found inside cart-dialog');
990
+ return;
991
+ }
992
+
993
+ // Check if focus-trap already exists, if not create one
994
+ _.focusTrap = _.contentPanel.querySelector('focus-trap');
995
+ if (!_.focusTrap) {
996
+ _.focusTrap = document.createElement('focus-trap');
997
+
998
+ // Move all existing cart-panel content into the focus trap
999
+ const existingContent = Array.from(_.contentPanel.childNodes);
1000
+ existingContent.forEach((child) => _.focusTrap.appendChild(child));
1001
+
1002
+ // Insert focus trap inside the cart-panel
1003
+ _.contentPanel.appendChild(_.focusTrap);
1004
+ }
1005
+
1006
+ // Ensure we have labelledby and describedby references
1007
+ if (!_.getAttribute('aria-labelledby')) {
1008
+ const heading = _.querySelector('h1, h2, h3');
1009
+ if (heading && !heading.id) {
1010
+ heading.id = `${_.id}-title`;
1011
+ }
1012
+ if (heading?.id) {
1013
+ _.setAttribute('aria-labelledby', heading.id);
1014
+ }
1015
+ }
1016
+
1017
+ // Add modal overlay if it doesn't already exist
1018
+ if (!_.querySelector('cart-overlay')) {
1019
+ _.prepend(document.createElement('cart-overlay'));
1020
+ }
1021
+ _.#attachListeners();
1022
+ _.#bindKeyboard();
1023
+
1024
+ // Load cart data immediately after component initialization
1025
+ _.refreshCart();
1026
+ }
1027
+
1028
+ /**
1029
+ * Event emitter method - Add an event listener with a cleaner API
1030
+ * @param {string} eventName - Name of the event to listen for
1031
+ * @param {Function} callback - Callback function to execute when event is fired
1032
+ * @returns {CartDialog} Returns this for method chaining
1033
+ */
1034
+ on(eventName, callback) {
1035
+ this.#eventEmitter.on(eventName, callback);
1036
+ return this;
1037
+ }
1038
+
1039
+ /**
1040
+ * Event emitter method - Remove an event listener
1041
+ * @param {string} eventName - Name of the event to stop listening for
1042
+ * @param {Function} callback - Callback function to remove
1043
+ * @returns {CartDialog} Returns this for method chaining
1044
+ */
1045
+ off(eventName, callback) {
1046
+ this.#eventEmitter.off(eventName, callback);
1047
+ return this;
1048
+ }
1049
+
1050
+ /**
1051
+ * Internal method to emit events via the event emitter
1052
+ * @param {string} eventName - Name of the event to emit
1053
+ * @param {*} [data] - Optional data to include with the event
1054
+ * @private
1055
+ */
1056
+ #emit(eventName, data = null) {
1057
+ this.#eventEmitter.emit(eventName, data);
1058
+
1059
+ // Also emit as native DOM events for better compatibility
1060
+ this.dispatchEvent(
1061
+ new CustomEvent(eventName, {
1062
+ detail: data,
1063
+ bubbles: true,
1064
+ })
1065
+ );
1066
+ }
1067
+
1068
+ /**
1069
+ * Attach event listeners for cart dialog functionality
1070
+ * @private
1071
+ */
1072
+ #attachListeners() {
1073
+ const _ = this;
1074
+
1075
+ // Handle trigger buttons
1076
+ document.addEventListener('click', (e) => {
1077
+ const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
1078
+ if (!trigger) return;
1079
+
1080
+ if (trigger.getAttribute('data-prevent-default') === 'true') {
1081
+ e.preventDefault();
1082
+ }
1083
+
1084
+ _.show(trigger);
1085
+ });
1086
+
1087
+ // Handle close buttons
1088
+ _.addEventListener('click', (e) => {
1089
+ if (!e.target.closest('[data-action-hide-cart]')) return;
1090
+ _.hide();
1091
+ });
1092
+
1093
+ // Handle cart item remove events
1094
+ _.addEventListener('cart-item:remove', (e) => {
1095
+ _.#handleCartItemRemove(e);
1096
+ });
1097
+
1098
+ // Handle cart item quantity change events
1099
+ _.addEventListener('cart-item:quantity-change', (e) => {
1100
+ _.#handleCartItemQuantityChange(e);
1101
+ });
1102
+
1103
+ // Add transition end listener
1104
+ _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
1105
+ }
1106
+
1107
+ /**
1108
+ * Detach event listeners
1109
+ * @private
1110
+ */
1111
+ #detachListeners() {
1112
+ const _ = this;
1113
+ if (_.contentPanel) {
1114
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Binds keyboard events for accessibility
1120
+ * @private
1121
+ */
1122
+ #bindKeyboard() {
1123
+ this.addEventListener('keydown', (e) => {
1124
+ if (e.key === 'Escape') {
1125
+ this.hide();
1126
+ }
1127
+ });
1128
+ }
1129
+
1130
+ /**
1131
+ * Handle cart item removal
1132
+ * @private
1133
+ */
1134
+ #handleCartItemRemove(e) {
1135
+ const { cartKey, element } = e.detail;
1136
+
1137
+ // Set item to processing state
1138
+ element.setState('processing');
1139
+
1140
+ // Remove item by setting quantity to 0
1141
+ this.updateCartItem(cartKey, 0)
1142
+ .then((updatedCart) => {
1143
+ if (updatedCart && !updatedCart.error) {
1144
+ // Success - let smart comparison handle the removal animation
1145
+ this.#currentCart = updatedCart;
1146
+ this.#renderCartItems(updatedCart);
1147
+ this.#renderCartPanel(updatedCart);
1148
+
1149
+ // Emit cart updated and data changed events
1150
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1151
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1152
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1153
+ } else {
1154
+ // Error - reset to ready state
1155
+ element.setState('ready');
1156
+ console.error('Failed to remove cart item:', cartKey);
1157
+ }
1158
+ })
1159
+ .catch((error) => {
1160
+ // Error - reset to ready state
1161
+ element.setState('ready');
1162
+ console.error('Error removing cart item:', error);
1163
+ });
1164
+ }
1165
+
1166
+ /**
1167
+ * Handle cart item quantity change
1168
+ * @private
1169
+ */
1170
+ #handleCartItemQuantityChange(e) {
1171
+ const { cartKey, quantity, element } = e.detail;
1172
+
1173
+ // Set item to processing state
1174
+ element.setState('processing');
1175
+
1176
+ // Update item quantity
1177
+ this.updateCartItem(cartKey, quantity)
1178
+ .then((updatedCart) => {
1179
+ if (updatedCart && !updatedCart.error) {
1180
+ // Success - update cart data and refresh items
1181
+ this.#currentCart = updatedCart;
1182
+ this.#renderCartItems(updatedCart);
1183
+ this.#renderCartPanel(updatedCart);
1184
+ element.setState('ready');
1185
+
1186
+ // Emit cart updated and data changed events
1187
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1188
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1189
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1190
+ } else {
1191
+ // Error - reset to ready state
1192
+ element.setState('ready');
1193
+ console.error('Failed to update cart item quantity:', cartKey, quantity);
1194
+ }
1195
+ })
1196
+ .catch((error) => {
1197
+ // Error - reset to ready state
1198
+ element.setState('ready');
1199
+ console.error('Error updating cart item quantity:', error);
1200
+ });
1201
+ }
1202
+
1203
+ /**
1204
+ * Update cart count elements across the site
1205
+ * @private
1206
+ */
1207
+ #renderCartCount(cartData) {
1208
+ if (!cartData) return;
1209
+
1210
+ // Calculate visible item count (excluding _hide_in_cart items)
1211
+ const visibleItems = this.#getVisibleCartItems(cartData);
1212
+ const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
1213
+
1214
+ // Update all cart count elements across the site
1215
+ const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
1216
+ cartCountElements.forEach((element) => {
1217
+ element.textContent = visibleItemCount;
1218
+ });
1219
+ }
1220
+
1221
+ /**
1222
+ * Update cart subtotal elements across the site
1223
+ * @private
1224
+ */
1225
+ #renderCartSubtotal(cartData) {
1226
+ if (!cartData) return;
1227
+
1228
+ // Calculate subtotal from all items except those marked to ignore pricing
1229
+ const pricedItems = cartData.items.filter(item => {
1230
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
1231
+ return !ignorePrice;
1232
+ });
1233
+ const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
1234
+
1235
+ // Update all cart subtotal elements across the site
1236
+ const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
1237
+ cartSubtotalElements.forEach((element) => {
1238
+ // Format as currency (assuming cents, convert to dollars)
1239
+ const formatted = (subtotal / 100).toFixed(2);
1240
+ element.textContent = `$${formatted}`;
1241
+ });
1242
+ }
1243
+
1244
+ /**
1245
+ * Update cart items display based on cart data
1246
+ * @private
1247
+ */
1248
+ #renderCartPanel(cart = null) {
1249
+ const cartData = cart || this.#currentCart;
1250
+ if (!cartData) return;
1251
+
1252
+ // Get cart sections
1253
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
1254
+ const emptySection = this.querySelector('[data-cart-is-empty]');
1255
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1256
+
1257
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
1258
+ console.warn(
1259
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
1260
+ );
1261
+ return;
1262
+ }
1263
+
1264
+ // Check visible item count for showing/hiding sections
1265
+ const visibleItems = this.#getVisibleCartItems(cartData);
1266
+ const hasVisibleItems = visibleItems.length > 0;
1267
+
1268
+ // Show/hide sections based on visible item count
1269
+ if (hasVisibleItems) {
1270
+ hasItemsSection.style.display = '';
1271
+ emptySection.style.display = 'none';
1272
+ } else {
1273
+ hasItemsSection.style.display = 'none';
1274
+ emptySection.style.display = '';
1275
+ }
1276
+
1277
+ // Update cart count and subtotal across the site
1278
+ this.#renderCartCount(cartData);
1279
+ this.#renderCartSubtotal(cartData);
1280
+ }
1281
+
1282
+ /**
1283
+ * Fetch current cart data from server
1284
+ * @returns {Promise<Object>} Cart data object
1285
+ */
1286
+ getCart() {
1287
+ return fetch('/cart.json', {
1288
+ crossDomain: true,
1289
+ credentials: 'same-origin',
1290
+ })
1291
+ .then((response) => {
1292
+ if (!response.ok) {
1293
+ throw Error(response.statusText);
1294
+ }
1295
+ return response.json();
1296
+ })
1297
+ .catch((error) => {
1298
+ console.error('Error fetching cart:', error);
1299
+ return { error: true, message: error.message };
1300
+ });
1301
+ }
1302
+
1303
+ /**
1304
+ * Update cart item quantity on server
1305
+ * @param {string|number} key - Cart item key/ID
1306
+ * @param {number} quantity - New quantity (0 to remove)
1307
+ * @returns {Promise<Object>} Updated cart data object
1308
+ */
1309
+ updateCartItem(key, quantity) {
1310
+ return fetch('/cart/change.json', {
1311
+ crossDomain: true,
1312
+ method: 'POST',
1313
+ credentials: 'same-origin',
1314
+ body: JSON.stringify({ id: key, quantity: quantity }),
1315
+ headers: { 'Content-Type': 'application/json' },
1316
+ })
1317
+ .then((response) => {
1318
+ if (!response.ok) {
1319
+ throw Error(response.statusText);
1320
+ }
1321
+ return response.json();
1322
+ })
1323
+ .catch((error) => {
1324
+ console.error('Error updating cart item:', error);
1325
+ return { error: true, message: error.message };
1326
+ });
1327
+ }
1328
+
1329
+ /**
1330
+ * Refresh cart data from server and update components
1331
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1332
+ * @returns {Promise<Object>} Cart data object
1333
+ */
1334
+ refreshCart(cartObj = null) {
1335
+ // If cart object is provided, use it directly
1336
+ if (cartObj && !cartObj.error) {
1337
+ // console.log('Using provided cart data:', cartObj);
1338
+ this.#currentCart = cartObj;
1339
+ this.#renderCartItems(cartObj);
1340
+ this.#renderCartPanel(cartObj);
1341
+
1342
+ // Emit cart refreshed and data changed events
1343
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
1344
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1345
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1346
+
1347
+ return Promise.resolve(cartObj);
1348
+ }
1349
+
1350
+ // Otherwise fetch from server
1351
+ return this.getCart().then((cartData) => {
1352
+ // console.log('Cart data received:', cartData);
1353
+ if (cartData && !cartData.error) {
1354
+ this.#currentCart = cartData;
1355
+ this.#renderCartItems(cartData);
1356
+ this.#renderCartPanel(cartData);
1357
+
1358
+ // Emit cart refreshed and data changed events
1359
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1360
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1361
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1362
+ } else {
1363
+ console.warn('Cart data has error or is null:', cartData);
1364
+ }
1365
+ return cartData;
1366
+ });
1367
+ }
1368
+
1369
+ /**
1370
+ * Remove items from DOM that are no longer in cart data
1371
+ * @private
1372
+ */
1373
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
1374
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1375
+
1376
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1377
+
1378
+ itemsToRemove.forEach((item) => {
1379
+ console.log('destroy yourself', item);
1380
+ item.destroyYourself();
1381
+ });
1382
+ }
1383
+
1384
+ /**
1385
+ * Add new items to DOM with animation delay
1386
+ * @private
1387
+ */
1388
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1389
+ // Delay adding new items by 300ms to let cart slide open first
1390
+ setTimeout(() => {
1391
+ itemsToAdd.forEach((itemData) => {
1392
+ const cartItem = CartItem.createAnimated(itemData);
1393
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1394
+
1395
+ // Find the correct position to insert the new item
1396
+ if (targetIndex === 0) {
1397
+ // Insert at the beginning
1398
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1399
+ } else {
1400
+ // Find the item that should come before this one
1401
+ let insertAfter = null;
1402
+ for (let i = targetIndex - 1; i >= 0; i--) {
1403
+ const prevKey = newKeys[i];
1404
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1405
+ if (prevItem) {
1406
+ insertAfter = prevItem;
1407
+ break;
1408
+ }
1409
+ }
1410
+
1411
+ if (insertAfter) {
1412
+ insertAfter.insertAdjacentElement('afterend', cartItem);
1413
+ } else {
1414
+ itemsContainer.appendChild(cartItem);
1415
+ }
1416
+ }
1417
+ });
1418
+ }, 100);
1419
+ }
1420
+
1421
+ /**
1422
+ * Filter cart items to exclude those with _hide_in_cart property
1423
+ * @private
1424
+ */
1425
+ #getVisibleCartItems(cartData) {
1426
+ if (!cartData || !cartData.items) return [];
1427
+ return cartData.items.filter((item) => {
1428
+ // Check for _hide_in_cart in various possible locations
1429
+ const hidden = item.properties?._hide_in_cart;
1430
+
1431
+ return !hidden;
1432
+ });
1433
+ }
1434
+
1435
+ /**
1436
+ * Add calculated fields to cart object for events
1437
+ * @private
1438
+ */
1439
+ #addCalculatedFields(cartData) {
1440
+ if (!cartData) return cartData;
1441
+
1442
+ const visibleItems = this.#getVisibleCartItems(cartData);
1443
+ const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
1444
+ const calculated_subtotal = visibleItems.reduce(
1445
+ (total, item) => total + (item.line_price || 0),
1446
+ 0
1447
+ );
1448
+
1449
+ return {
1450
+ ...cartData,
1451
+ calculated_count,
1452
+ calculated_subtotal,
1453
+ };
1454
+ }
1455
+
1456
+ /**
1457
+ * Render cart items from Shopify cart data with smart comparison
1458
+ * @private
1459
+ */
1460
+ #renderCartItems(cartData) {
1461
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1462
+
1463
+ if (!itemsContainer || !cartData || !cartData.items) {
1464
+ console.warn('Cannot render cart items:', {
1465
+ itemsContainer: !!itemsContainer,
1466
+ cartData: !!cartData,
1467
+ items: cartData?.items?.length,
1468
+ });
1469
+ return;
1470
+ }
1471
+
1472
+ // Filter out items with _hide_in_cart property
1473
+ const visibleItems = this.#getVisibleCartItems(cartData);
1474
+
1475
+ // Handle initial render - load all items without animation
1476
+ if (this.#isInitialRender) {
1477
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
1478
+
1479
+ // Clear existing items
1480
+ itemsContainer.innerHTML = '';
1481
+
1482
+ // Create cart-item elements without animation
1483
+ visibleItems.forEach((itemData) => {
1484
+ const cartItem = new CartItem(itemData); // No animation
1485
+ // const cartItem = document.createElement('cart-item');
1486
+ // cartItem.setData(itemData);
1487
+ itemsContainer.appendChild(cartItem);
1488
+ });
1489
+
1490
+ this.#isInitialRender = false;
1491
+
1492
+ return;
1493
+ }
1494
+
1495
+ // Get current DOM items and their keys
1496
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1497
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1498
+
1499
+ // Get new cart data keys in order (only visible items)
1500
+ const newKeys = visibleItems.map((item) => item.key || item.id);
1501
+ const newKeysSet = new Set(newKeys);
1502
+
1503
+ // Step 1: Remove items that are no longer in cart data
1504
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1505
+
1506
+ // Step 2: Add new items that weren't in DOM (with animation delay)
1507
+ const itemsToAdd = visibleItems.filter(
1508
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
1509
+ );
1510
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1511
+ }
1512
+
1513
+ /**
1514
+ * Set the template function for cart items
1515
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
1516
+ */
1517
+ setCartItemTemplate(templateName, templateFn) {
1518
+ CartItem.setTemplate(templateName, templateFn);
1519
+ }
1520
+
1521
+ /**
1522
+ * Set the processing template function for cart items
1523
+ * @param {Function} templateFn - Function that returns HTML string for processing state
1524
+ */
1525
+ setCartItemProcessingTemplate(templateFn) {
1526
+ CartItem.setProcessingTemplate(templateFn);
1527
+ }
1528
+
1529
+ /**
1530
+ * Shows the cart dialog and traps focus within it
1531
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1532
+ * @fires CartDialog#show - Fired when the cart dialog has been shown
1533
+ */
1534
+ show(triggerEl = null, cartObj) {
1535
+ const _ = this;
1536
+ _.triggerEl = triggerEl || false;
1537
+
1538
+ // Lock body scrolling
1539
+ _.#lockScroll();
1540
+
1541
+ // Remove the hidden class first to ensure content is rendered
1542
+ _.contentPanel.classList.remove('hidden');
1543
+
1544
+ // Give the browser a moment to process before starting animation
1545
+ requestAnimationFrame(() => {
1546
+ // Update ARIA states
1547
+ _.setAttribute('aria-hidden', 'false');
1548
+
1549
+ if (_.triggerEl) {
1550
+ _.triggerEl.setAttribute('aria-expanded', 'true');
1551
+ }
1552
+
1553
+ // Focus management
1554
+ const firstFocusable = _.querySelector(
1555
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1556
+ );
1557
+
1558
+ if (firstFocusable) {
1559
+ requestAnimationFrame(() => {
1560
+ firstFocusable.focus();
1561
+ });
1562
+ }
1563
+
1564
+ // Refresh cart data when showing
1565
+ _.refreshCart(cartObj);
1566
+
1567
+ // Emit show event - cart dialog is now visible
1568
+ _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1569
+ });
1570
+ }
1571
+
1572
+ /**
1573
+ * Hides the cart dialog and restores focus
1574
+ * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1575
+ * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1576
+ */
1577
+ hide() {
1578
+ const _ = this;
1579
+
1580
+ // Update ARIA states
1581
+ if (_.triggerEl) {
1582
+ // remove focus from modal panel first
1583
+ _.triggerEl.focus();
1584
+ // mark trigger as no longer expanded
1585
+ _.triggerEl.setAttribute('aria-expanded', 'false');
1586
+ } else {
1587
+ // If no trigger element, blur any focused element inside the panel
1588
+ const activeElement = document.activeElement;
1589
+ if (activeElement && _.contains(activeElement)) {
1590
+ activeElement.blur();
1591
+ }
1592
+ }
1593
+
1594
+ requestAnimationFrame(() => {
1595
+ // Set aria-hidden to start transition
1596
+ // The transitionend event handler will add display:none when complete
1597
+ _.setAttribute('aria-hidden', 'true');
1598
+
1599
+ // Emit hide event - cart dialog is now starting to hide
1600
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1601
+
1602
+ // Restore body scroll
1603
+ _.#restoreScroll();
1604
+ });
1605
+ }
1606
+ }
1607
+
1608
+ /**
1609
+ * Custom element that creates a clickable overlay for the cart dialog
1610
+ * @extends HTMLElement
1611
+ */
1612
+ class CartOverlay extends HTMLElement {
1613
+ constructor() {
1614
+ super();
1615
+ this.setAttribute('tabindex', '-1');
1616
+ this.setAttribute('aria-hidden', 'true');
1617
+ this.cartDialog = this.closest('cart-dialog');
1618
+ this.#attachListeners();
1619
+ }
1620
+
1621
+ #attachListeners() {
1622
+ this.addEventListener('click', () => {
1623
+ this.cartDialog.hide();
1624
+ });
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * Custom element that wraps the content of the cart dialog
1630
+ * @extends HTMLElement
1631
+ */
1632
+ class CartPanel extends HTMLElement {
1633
+ constructor() {
1634
+ super();
1635
+ this.setAttribute('role', 'document');
1636
+ }
1637
+ }
1638
+
1639
+ if (!customElements.get('cart-dialog')) {
1640
+ customElements.define('cart-dialog', CartDialog);
1641
+ }
1642
+ if (!customElements.get('cart-overlay')) {
1643
+ customElements.define('cart-overlay', CartOverlay);
1644
+ }
1645
+ if (!customElements.get('cart-panel')) {
1646
+ customElements.define('cart-panel', CartPanel);
1647
+ }
1648
+
1649
+ // Make CartItem available globally for Shopify themes
1650
+ if (typeof window !== 'undefined') {
1651
+ window.CartItem = CartItem;
1652
+ }
1653
+
1654
+ exports.CartDialog = CartDialog;
1655
+ exports.CartItem = CartItem;
1656
+ exports.CartOverlay = CartOverlay;
1657
+ exports.CartPanel = CartPanel;
1658
+ exports.default = CartDialog;
1659
+
1660
+ Object.defineProperty(exports, '__esModule', { value: true });
1091
1661
 
1092
1662
  }));
1093
1663
  //# sourceMappingURL=cart-panel.js.map