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