@magic-spells/cart-panel 0.3.0 → 1.0.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,1720 +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('input', 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('input', 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
- if (!isNaN(inputValue)) {
458
- const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
459
- this.updateValue(clampedValue);
460
- }
461
- }
462
-
463
- // Update the component value and dispatch change event if value changed
464
- updateValue(newValue) {
465
- if (newValue !== this.value) {
466
- this.value = newValue;
467
- this.updateInput();
468
- this.dispatchChangeEvent(newValue);
469
- }
470
- }
471
-
472
- // Sync the input field with current component state
473
- updateInput() {
474
- const input = this.querySelector('[data-quantity-modifier-field]');
475
- if (input) {
476
- input.value = this.value;
477
- input.min = this.min;
478
- input.max = this.max;
479
- }
480
- }
481
-
482
- // Dispatch custom event when value changes for external listeners
483
- dispatchChangeEvent(value) {
484
- this.dispatchEvent(
485
- new CustomEvent('quantity-modifier:change', {
486
- detail: { value },
487
- bubbles: true,
488
- })
489
- );
490
- }
491
- }
492
-
493
- customElements.define('quantity-modifier', QuantityModifier);
494
-
495
- /**
496
- * CartItem class that handles the functionality of a cart item component
497
- */
498
- class CartItem extends HTMLElement {
499
- // Static template functions shared across all instances
500
- static #templates = new Map();
501
- static #processingTemplate = null;
502
-
503
- // Private fields
504
- #currentState = 'ready';
505
- #isDestroying = false;
506
- #isAppearing = false;
507
- #handlers = {};
508
- #itemData = null;
509
- #cartData = null;
510
- #lastRenderedHTML = '';
511
-
512
- /**
513
- * Set the template function for rendering cart items
514
- * @param {string} name - Template name ('default' for default template)
515
- * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
516
- */
517
- static setTemplate(name, templateFn) {
518
- if (typeof name !== 'string') {
519
- throw new Error('Template name must be a string');
520
- }
521
- if (typeof templateFn !== 'function') {
522
- throw new Error('Template must be a function');
523
- }
524
- CartItem.#templates.set(name, templateFn);
525
- }
526
-
527
- /**
528
- * Set the processing template function for rendering processing overlay
529
- * @param {Function} templateFn - Function that returns HTML string for processing state
530
- */
531
- static setProcessingTemplate(templateFn) {
532
- if (typeof templateFn !== 'function') {
533
- throw new Error('Processing template must be a function');
534
- }
535
- CartItem.#processingTemplate = templateFn;
536
- }
537
-
538
- /**
539
- * Create a cart item with appearing animation
540
- * @param {Object} itemData - Shopify cart item data
541
- * @param {Object} cartData - Full Shopify cart object
542
- * @returns {CartItem} Cart item instance that will animate in
543
- */
544
- static createAnimated(itemData, cartData) {
545
- return new CartItem(itemData, cartData, { animate: true });
546
- }
547
-
548
- /**
549
- * Define which attributes should be observed for changes
550
- */
551
- static get observedAttributes() {
552
- return ['state', 'key'];
553
- }
554
-
555
- /**
556
- * Called when observed attributes change
557
- */
558
- attributeChangedCallback(name, oldValue, newValue) {
559
- if (oldValue === newValue) return;
560
-
561
- if (name === 'state') {
562
- this.#currentState = newValue || 'ready';
563
- }
564
- }
565
-
566
- constructor(itemData = null, cartData = null, options = {}) {
567
- super();
568
-
569
- // Store item and cart data if provided
570
- this.#itemData = itemData;
571
- this.#cartData = cartData;
572
-
573
- // Set initial state - start with 'appearing' only if explicitly requested
574
- const shouldAnimate = options.animate || this.hasAttribute('animate-in');
575
- this.#currentState =
576
- itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
577
-
578
- // Bind event handlers
579
- this.#handlers = {
580
- click: this.#handleClick.bind(this),
581
- change: this.#handleChange.bind(this),
582
- transitionEnd: this.#handleTransitionEnd.bind(this),
583
- };
584
- }
585
-
586
- connectedCallback() {
587
- // If we have item data, render it first
588
- if (this.#itemData) {
589
- this.#render();
590
- }
591
-
592
- // Find child elements
593
- this.content = this.querySelector('cart-item-content');
594
- this.processing = this.querySelector('cart-item-processing');
595
-
596
- // Update line price elements in case of pre-rendered content
597
- this.#updateLinePriceElements();
598
-
599
- // Attach event listeners
600
- this.#attachListeners();
601
-
602
- // If we started with 'appearing' state, handle the entry animation
603
- if (this.#currentState === 'appearing') {
604
- // Set the state attribute
605
- this.setAttribute('state', 'appearing');
606
- this.#isAppearing = true;
607
-
608
- // Get the natural height after rendering
609
- requestAnimationFrame(() => {
610
- const naturalHeight = this.scrollHeight;
611
-
612
- // Set explicit height for animation
613
- this.style.height = `${naturalHeight}px`;
614
-
615
- // Transition to ready state after a brief delay
616
- requestAnimationFrame(() => {
617
- this.setState('ready');
618
- });
619
- });
620
- }
621
- }
622
-
623
- disconnectedCallback() {
624
- // Cleanup event listeners
625
- this.#detachListeners();
626
- }
627
-
628
- /**
629
- * Attach event listeners
630
- */
631
- #attachListeners() {
632
- this.addEventListener('click', this.#handlers.click);
633
- this.addEventListener('change', this.#handlers.change);
634
- this.addEventListener('quantity-modifier:change', this.#handlers.change);
635
- this.addEventListener('transitionend', this.#handlers.transitionEnd);
636
- }
637
-
638
- /**
639
- * Detach event listeners
640
- */
641
- #detachListeners() {
642
- this.removeEventListener('click', this.#handlers.click);
643
- this.removeEventListener('change', this.#handlers.change);
644
- this.removeEventListener('quantity-modifier:change', this.#handlers.change);
645
- this.removeEventListener('transitionend', this.#handlers.transitionEnd);
646
- }
647
-
648
- /**
649
- * Get the current state
650
- */
651
- get state() {
652
- return this.#currentState;
653
- }
654
-
655
- /**
656
- * Get the cart key for this item
657
- */
658
- get cartKey() {
659
- return this.getAttribute('key');
660
- }
661
-
662
- /**
663
- * Handle click events (for Remove buttons, etc.)
664
- */
665
- #handleClick(e) {
666
- // Check if clicked element is a remove button
667
- const removeButton = e.target.closest('[data-action-remove-item]');
668
- if (removeButton) {
669
- e.preventDefault();
670
- this.#emitRemoveEvent();
671
- }
672
- }
673
-
674
- /**
675
- * Handle change events (for quantity inputs and quantity-modifier)
676
- */
677
- #handleChange(e) {
678
- // Check if event is from quantity-modifier component
679
- if (e.type === 'quantity-modifier:change') {
680
- this.#emitQuantityChangeEvent(e.detail.value);
681
- return;
682
- }
683
-
684
- // Check if changed element is a quantity input
685
- const quantityInput = e.target.closest('[data-cart-quantity]');
686
- if (quantityInput) {
687
- this.#emitQuantityChangeEvent(quantityInput.value);
688
- }
689
- }
690
-
691
- /**
692
- * Handle transition end events for destroy animation and appearing animation
693
- */
694
- #handleTransitionEnd(e) {
695
- if (e.propertyName === 'height' && this.#isDestroying) {
696
- // Remove from DOM after height animation completes
697
- this.remove();
698
- } else if (e.propertyName === 'height' && this.#isAppearing) {
699
- // Remove explicit height after appearing animation completes
700
- this.style.height = '';
701
- this.#isAppearing = false;
702
- }
703
- }
704
-
705
- /**
706
- * Emit remove event
707
- */
708
- #emitRemoveEvent() {
709
- this.dispatchEvent(
710
- new CustomEvent('cart-item:remove', {
711
- bubbles: true,
712
- detail: {
713
- cartKey: this.cartKey,
714
- element: this,
715
- },
716
- })
717
- );
718
- }
719
-
720
- /**
721
- * Emit quantity change event
722
- */
723
- #emitQuantityChangeEvent(quantity) {
724
- this.dispatchEvent(
725
- new CustomEvent('cart-item:quantity-change', {
726
- bubbles: true,
727
- detail: {
728
- cartKey: this.cartKey,
729
- quantity: parseInt(quantity),
730
- element: this,
731
- },
732
- })
733
- );
734
- }
735
-
736
- /**
737
- * Render cart item from data using the appropriate template
738
- */
739
- #render() {
740
- if (!this.#itemData || CartItem.#templates.size === 0) {
741
- console.log('no item data or no template', this.#itemData, CartItem.#templates);
742
- return;
743
- }
744
-
745
- // Set the key attribute from item data
746
- const key = this.#itemData.key || this.#itemData.id;
747
- if (key) {
748
- this.setAttribute('key', key);
749
- }
750
-
751
- // Generate HTML from template and store for future comparisons
752
- const templateHTML = this.#generateTemplateHTML();
753
- this.#lastRenderedHTML = templateHTML;
754
-
755
- // Generate processing HTML from template or use default
756
- const processingHTML = CartItem.#processingTemplate
757
- ? CartItem.#processingTemplate()
758
- : '<div class="cart-item-loader"></div>';
759
-
760
- // Create the cart-item structure with template content inside cart-item-content
761
- this.innerHTML = `
762
- <cart-item-content>
763
- ${templateHTML}
764
- </cart-item-content>
765
- <cart-item-processing>
766
- ${processingHTML}
767
- </cart-item-processing>
768
- `;
769
- }
770
-
771
- /**
772
- * Update the cart item with new data
773
- * @param {Object} itemData - Shopify cart item data
774
- * @param {Object} cartData - Full Shopify cart object
775
- */
776
- setData(itemData, cartData = null) {
777
- // Update internal data
778
- this.#itemData = itemData;
779
- if (cartData) {
780
- this.#cartData = cartData;
781
- }
782
-
783
- // Generate new HTML with updated data
784
- const newHTML = this.#generateTemplateHTML();
785
-
786
- // Compare with previously rendered HTML
787
- if (newHTML === this.#lastRenderedHTML) {
788
- // HTML hasn't changed, just reset processing state
789
- this.setState('ready');
790
- return;
791
- }
792
-
793
- // HTML is different, proceed with full update
794
- this.setState('ready');
795
- this.#render();
796
-
797
- // Re-find child elements after re-rendering
798
- this.content = this.querySelector('cart-item-content');
799
- this.processing = this.querySelector('cart-item-processing');
800
-
801
- // Update line price elements
802
- this.#updateLinePriceElements();
803
- }
804
-
805
- /**
806
- * Generate HTML from the current template with current data
807
- * @returns {string} Generated HTML string or empty string if no template
808
- * @private
809
- */
810
- #generateTemplateHTML() {
811
- // If no templates are available, return empty string
812
- if (!this.#itemData || CartItem.#templates.size === 0) {
813
- return '';
814
- }
815
-
816
- // Determine which template to use
817
- const templateName = this.#itemData.properties?._cart_template || 'default';
818
- const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
819
-
820
- if (!templateFn) {
821
- return '';
822
- }
823
-
824
- // Generate and return HTML from template
825
- return templateFn(this.#itemData, this.#cartData);
826
- }
827
-
828
- /**
829
- * Update elements with data-content-line-price attribute
830
- * @private
831
- */
832
- #updateLinePriceElements() {
833
- if (!this.#itemData) return;
834
-
835
- const linePriceElements = this.querySelectorAll('[data-content-line-price]');
836
- const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
837
-
838
- linePriceElements.forEach((element) => {
839
- element.textContent = formattedLinePrice;
840
- });
841
- }
842
-
843
- /**
844
- * Format currency value from cents to dollar string
845
- * @param {number} cents - Price in cents
846
- * @returns {string} Formatted currency string (e.g., "$29.99")
847
- * @private
848
- */
849
- #formatCurrency(cents) {
850
- if (typeof cents !== 'number') return '$0.00';
851
- return `$${(cents / 100).toFixed(2)}`;
852
- }
853
-
854
- /**
855
- * Get the current item data
856
- */
857
- get itemData() {
858
- return this.#itemData;
859
- }
860
-
861
- /**
862
- * Set the state of the cart item
863
- * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
864
- */
865
- setState(state) {
866
- if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
867
- this.setAttribute('state', state);
868
- }
869
- }
870
-
871
- /**
872
- * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
873
- *
874
- * @returns {void}
875
- */
876
- destroyYourself() {
877
- // bail if already in the middle of a destroy cycle
878
- if (this.#isDestroying) return;
879
-
880
- this.#isDestroying = true;
881
-
882
- // snapshot the current rendered height before applying any "destroying" styles
883
- const initialHeight = this.offsetHeight;
884
-
885
- // switch to 'destroying' state so css can fade / slide visuals
886
- this.setState('destroying');
887
-
888
- // lock the measured height on the next animation frame to ensure layout is fully flushed
889
- requestAnimationFrame(() => {
890
- this.style.height = `${initialHeight}px`;
891
- // this.offsetHeight; // force a reflow so the browser registers the fixed height
892
-
893
- // read the css custom property for timing, defaulting to 400ms
894
- const elementStyle = getComputedStyle(this);
895
- const destroyDuration =
896
- elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
897
-
898
- // animate only the height to zero; other properties stay under stylesheet control
899
- this.style.transition = `height ${destroyDuration} ease`;
900
- this.style.height = '0px';
901
-
902
- // setTimeout(() => {
903
- // this.style.height = '0px';
904
- // }, 1);
905
-
906
- setTimeout(() => {
907
- // make sure item is removed
908
- this.remove();
909
- }, 600);
910
- });
911
- }
912
- }
913
-
914
- /**
915
- * Supporting component classes for cart item
916
- */
917
- class CartItemContent extends HTMLElement {
918
- constructor() {
919
- super();
920
- }
921
- }
922
-
923
- class CartItemProcessing extends HTMLElement {
924
- constructor() {
925
- super();
926
- }
927
- }
928
-
929
- // Define custom elements (check if not already defined)
930
- if (!customElements.get('cart-item')) {
931
- customElements.define('cart-item', CartItem);
932
- }
933
- if (!customElements.get('cart-item-content')) {
934
- customElements.define('cart-item-content', CartItemContent);
935
- }
936
- if (!customElements.get('cart-item-processing')) {
937
- customElements.define('cart-item-processing', CartItemProcessing);
938
- }
939
-
940
- /**
941
- * Custom element that creates an accessible modal cart dialog with focus management
942
- * @extends HTMLElement
943
- */
944
- class CartDialog extends HTMLElement {
945
- #handleTransitionEnd;
946
- #currentCart = null;
947
- #eventEmitter;
948
- #isInitialRender = true;
949
-
950
- /**
951
- * Clean up event listeners when component is removed from DOM
952
- */
953
- disconnectedCallback() {
954
- const _ = this;
955
- if (_.contentPanel) {
956
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
957
- }
958
-
959
- // Ensure body scroll is restored if component is removed while open
960
- document.body.classList.remove('overflow-hidden');
961
- this.#restoreScroll();
962
-
963
- // Detach event listeners
964
- this.#detachListeners();
965
- }
966
-
967
- /**
968
- * Locks body scrolling
969
- * @private
970
- */
971
- #lockScroll() {
972
- // Apply overflow hidden to body
973
- document.body.classList.add('overflow-hidden');
974
- }
975
-
976
- /**
977
- * Restores body scrolling when cart dialog is closed
978
- * @private
979
- */
980
- #restoreScroll() {
981
- // Remove overflow hidden from body
982
- document.body.classList.remove('overflow-hidden');
983
- }
984
-
985
- /**
986
- * Initializes the cart dialog, sets up focus trap and overlay
987
- */
988
- constructor() {
989
- super();
990
- const _ = this;
991
- _.id = _.getAttribute('id');
992
- _.setAttribute('role', 'dialog');
993
- _.setAttribute('aria-modal', 'true');
994
- _.setAttribute('aria-hidden', 'true');
995
-
996
- _.triggerEl = null;
997
-
998
- // Initialize event emitter
999
- _.#eventEmitter = new EventEmitter();
1000
-
1001
- // Create a handler for transition end events
1002
- _.#handleTransitionEnd = (e) => {
1003
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
1004
- _.contentPanel.classList.add('hidden');
1005
-
1006
- // Emit afterHide event - cart dialog has completed its transition
1007
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
1008
- }
1009
- };
1010
- }
1011
-
1012
- connectedCallback() {
1013
- const _ = this;
1014
-
1015
- // Now that we're in the DOM, find the content panel and set up focus trap
1016
- _.contentPanel = _.querySelector('cart-panel');
1017
-
1018
- if (!_.contentPanel) {
1019
- console.error('cart-panel element not found inside cart-dialog');
1020
- return;
1021
- }
1022
-
1023
- // Check if focus-trap already exists, if not create one
1024
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
1025
- if (!_.focusTrap) {
1026
- _.focusTrap = document.createElement('focus-trap');
1027
-
1028
- // Move all existing cart-panel content into the focus trap
1029
- const existingContent = Array.from(_.contentPanel.childNodes);
1030
- existingContent.forEach((child) => _.focusTrap.appendChild(child));
1031
-
1032
- // Insert focus trap inside the cart-panel
1033
- _.contentPanel.appendChild(_.focusTrap);
1034
- }
1035
-
1036
- // Ensure we have labelledby and describedby references
1037
- if (!_.getAttribute('aria-labelledby')) {
1038
- const heading = _.querySelector('h1, h2, h3');
1039
- if (heading && !heading.id) {
1040
- heading.id = `${_.id}-title`;
1041
- }
1042
- if (heading?.id) {
1043
- _.setAttribute('aria-labelledby', heading.id);
1044
- }
1045
- }
1046
-
1047
- // Add modal overlay if it doesn't already exist
1048
- if (!_.querySelector('cart-overlay')) {
1049
- _.prepend(document.createElement('cart-overlay'));
1050
- }
1051
- _.#attachListeners();
1052
- _.#bindKeyboard();
1053
-
1054
- // Load cart data immediately after component initialization
1055
- _.refreshCart();
1056
- }
1057
-
1058
- /**
1059
- * Event emitter method - Add an event listener with a cleaner API
1060
- * @param {string} eventName - Name of the event to listen for
1061
- * @param {Function} callback - Callback function to execute when event is fired
1062
- * @returns {CartDialog} Returns this for method chaining
1063
- */
1064
- on(eventName, callback) {
1065
- this.#eventEmitter.on(eventName, callback);
1066
- return this;
1067
- }
1068
-
1069
- /**
1070
- * Event emitter method - Remove an event listener
1071
- * @param {string} eventName - Name of the event to stop listening for
1072
- * @param {Function} callback - Callback function to remove
1073
- * @returns {CartDialog} Returns this for method chaining
1074
- */
1075
- off(eventName, callback) {
1076
- this.#eventEmitter.off(eventName, callback);
1077
- return this;
1078
- }
1079
-
1080
- /**
1081
- * Internal method to emit events via the event emitter
1082
- * @param {string} eventName - Name of the event to emit
1083
- * @param {*} [data] - Optional data to include with the event
1084
- * @private
1085
- */
1086
- #emit(eventName, data = null) {
1087
- this.#eventEmitter.emit(eventName, data);
1088
-
1089
- // Also emit as native DOM events for better compatibility
1090
- this.dispatchEvent(
1091
- new CustomEvent(eventName, {
1092
- detail: data,
1093
- bubbles: true,
1094
- })
1095
- );
1096
- }
1097
-
1098
- /**
1099
- * Attach event listeners for cart dialog functionality
1100
- * @private
1101
- */
1102
- #attachListeners() {
1103
- const _ = this;
1104
-
1105
- // Handle trigger buttons
1106
- document.addEventListener('click', (e) => {
1107
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
1108
- if (!trigger) return;
1109
-
1110
- if (trigger.getAttribute('data-prevent-default') === 'true') {
1111
- e.preventDefault();
1112
- }
1113
-
1114
- _.show(trigger);
1115
- });
1116
-
1117
- // Handle close buttons
1118
- _.addEventListener('click', (e) => {
1119
- if (!e.target.closest('[data-action-hide-cart]')) return;
1120
- _.hide();
1121
- });
1122
-
1123
- // Handle cart item remove events
1124
- _.addEventListener('cart-item:remove', (e) => {
1125
- _.#handleCartItemRemove(e);
1126
- });
1127
-
1128
- // Handle cart item quantity change events
1129
- _.addEventListener('cart-item:quantity-change', (e) => {
1130
- _.#handleCartItemQuantityChange(e);
1131
- });
1132
-
1133
- // Add transition end listener
1134
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
1135
- }
1136
-
1137
- /**
1138
- * Detach event listeners
1139
- * @private
1140
- */
1141
- #detachListeners() {
1142
- const _ = this;
1143
- if (_.contentPanel) {
1144
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
1145
- }
1146
- }
1147
-
1148
- /**
1149
- * Binds keyboard events for accessibility
1150
- * @private
1151
- */
1152
- #bindKeyboard() {
1153
- this.addEventListener('keydown', (e) => {
1154
- if (e.key === 'Escape') {
1155
- this.hide();
1156
- }
1157
- });
1158
- }
1159
-
1160
- /**
1161
- * Handle cart item removal
1162
- * @private
1163
- */
1164
- #handleCartItemRemove(e) {
1165
- const { cartKey, element } = e.detail;
1166
-
1167
- // Set item to processing state
1168
- element.setState('processing');
1169
-
1170
- // Remove item by setting quantity to 0
1171
- this.updateCartItem(cartKey, 0)
1172
- .then((updatedCart) => {
1173
- if (updatedCart && !updatedCart.error) {
1174
- // Success - let smart comparison handle the removal animation
1175
- this.#currentCart = updatedCart;
1176
- this.#renderCartItems(updatedCart);
1177
- this.#renderCartPanel(updatedCart);
1178
-
1179
- // Emit cart updated and data changed events
1180
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1181
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1182
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1183
- } else {
1184
- // Error - reset to ready state
1185
- element.setState('ready');
1186
- console.error('Failed to remove cart item:', cartKey);
1187
- }
1188
- })
1189
- .catch((error) => {
1190
- // Error - reset to ready state
1191
- element.setState('ready');
1192
- console.error('Error removing cart item:', error);
1193
- });
1194
- }
1195
-
1196
- /**
1197
- * Handle cart item quantity change
1198
- * @private
1199
- */
1200
- #handleCartItemQuantityChange(e) {
1201
- const { cartKey, quantity, element } = e.detail;
1202
-
1203
- // Set item to processing state
1204
- element.setState('processing');
1205
-
1206
- // Update item quantity
1207
- this.updateCartItem(cartKey, quantity)
1208
- .then((updatedCart) => {
1209
- if (updatedCart && !updatedCart.error) {
1210
- // Success - update cart data and refresh items
1211
- this.#currentCart = updatedCart;
1212
- this.#renderCartItems(updatedCart);
1213
- this.#renderCartPanel(updatedCart);
1214
-
1215
- // Emit cart updated and data changed events
1216
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1217
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1218
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1219
- } else {
1220
- // Error - reset to ready state
1221
- element.setState('ready');
1222
- console.error('Failed to update cart item quantity:', cartKey, quantity);
1223
- }
1224
- })
1225
- .catch((error) => {
1226
- // Error - reset to ready state
1227
- element.setState('ready');
1228
- console.error('Error updating cart item quantity:', error);
1229
- });
1230
- }
1231
-
1232
- /**
1233
- * Update cart count elements across the site
1234
- * @private
1235
- */
1236
- #renderCartCount(cartData) {
1237
- if (!cartData) return;
1238
-
1239
- // Calculate visible item count (excluding _hide_in_cart items)
1240
- const visibleItems = this.#getVisibleCartItems(cartData);
1241
- const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
1242
-
1243
- // Update all cart count elements across the site
1244
- const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
1245
- cartCountElements.forEach((element) => {
1246
- element.textContent = visibleItemCount;
1247
- });
1248
- }
1249
-
1250
- /**
1251
- * Update cart subtotal elements across the site
1252
- * @private
1253
- */
1254
- #renderCartSubtotal(cartData) {
1255
- if (!cartData) return;
1256
-
1257
- // Calculate subtotal from all items except those marked to ignore pricing
1258
- const pricedItems = cartData.items.filter((item) => {
1259
- const ignorePrice = item.properties?._ignore_price_in_subtotal;
1260
- return !ignorePrice;
1261
- });
1262
- const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
1263
-
1264
- // Update all cart subtotal elements across the site
1265
- const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
1266
- cartSubtotalElements.forEach((element) => {
1267
- // Format as currency (assuming cents, convert to dollars)
1268
- const formatted = (subtotal / 100).toFixed(2);
1269
- element.textContent = `$${formatted}`;
1270
- });
1271
- }
1272
-
1273
- /**
1274
- * Update cart items display based on cart data
1275
- * @private
1276
- */
1277
- #renderCartPanel(cart = null) {
1278
- const cartData = cart || this.#currentCart;
1279
- if (!cartData) return;
1280
-
1281
- // Get cart sections
1282
- const hasItemsSection = this.querySelector('[data-cart-has-items]');
1283
- const emptySection = this.querySelector('[data-cart-is-empty]');
1284
- const itemsContainer = this.querySelector('[data-content-cart-items]');
1285
-
1286
- if (!hasItemsSection || !emptySection || !itemsContainer) {
1287
- console.warn(
1288
- 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
1289
- );
1290
- return;
1291
- }
1292
-
1293
- // Check visible item count for showing/hiding sections
1294
- const visibleItems = this.#getVisibleCartItems(cartData);
1295
- const hasVisibleItems = visibleItems.length > 0;
1296
-
1297
- // Show/hide sections based on visible item count
1298
- if (hasVisibleItems) {
1299
- hasItemsSection.style.display = '';
1300
- emptySection.style.display = 'none';
1301
- } else {
1302
- hasItemsSection.style.display = 'none';
1303
- emptySection.style.display = '';
1304
- }
1305
-
1306
- // Update cart count and subtotal across the site
1307
- this.#renderCartCount(cartData);
1308
- this.#renderCartSubtotal(cartData);
1309
- }
1310
-
1311
- /**
1312
- * Fetch current cart data from server
1313
- * @returns {Promise<Object>} Cart data object
1314
- */
1315
- getCart() {
1316
- return fetch('/cart.json', {
1317
- crossDomain: true,
1318
- credentials: 'same-origin',
1319
- })
1320
- .then((response) => {
1321
- if (!response.ok) {
1322
- throw Error(response.statusText);
1323
- }
1324
- return response.json();
1325
- })
1326
- .catch((error) => {
1327
- console.error('Error fetching cart:', error);
1328
- return { error: true, message: error.message };
1329
- });
1330
- }
1331
-
1332
- /**
1333
- * Update cart item quantity on server
1334
- * @param {string|number} key - Cart item key/ID
1335
- * @param {number} quantity - New quantity (0 to remove)
1336
- * @returns {Promise<Object>} Updated cart data object
1337
- */
1338
- updateCartItem(key, quantity) {
1339
- return fetch('/cart/change.json', {
1340
- crossDomain: true,
1341
- method: 'POST',
1342
- credentials: 'same-origin',
1343
- body: JSON.stringify({ id: key, quantity: quantity }),
1344
- headers: { 'Content-Type': 'application/json' },
1345
- })
1346
- .then((response) => {
1347
- if (!response.ok) {
1348
- throw Error(response.statusText);
1349
- }
1350
- return response.json();
1351
- })
1352
- .catch((error) => {
1353
- console.error('Error updating cart item:', error);
1354
- return { error: true, message: error.message };
1355
- });
1356
- }
1357
-
1358
- /**
1359
- * Refresh cart data from server and update components
1360
- * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1361
- * @returns {Promise<Object>} Cart data object
1362
- */
1363
- refreshCart(cartObj = null) {
1364
- // If cart object is provided, use it directly
1365
- if (cartObj && !cartObj.error) {
1366
- // console.log('Using provided cart data:', cartObj);
1367
- this.#currentCart = cartObj;
1368
- this.#renderCartItems(cartObj);
1369
- this.#renderCartPanel(cartObj);
1370
-
1371
- // Emit cart refreshed and data changed events
1372
- const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
1373
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1374
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1375
-
1376
- return Promise.resolve(cartObj);
1377
- }
1378
-
1379
- // Otherwise fetch from server
1380
- return this.getCart().then((cartData) => {
1381
- // console.log('Cart data received:', cartData);
1382
- if (cartData && !cartData.error) {
1383
- this.#currentCart = cartData;
1384
- this.#renderCartItems(cartData);
1385
- this.#renderCartPanel(cartData);
1386
-
1387
- // Emit cart refreshed and data changed events
1388
- const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1389
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1390
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1391
- } else {
1392
- console.warn('Cart data has error or is null:', cartData);
1393
- }
1394
- return cartData;
1395
- });
1396
- }
1397
-
1398
- /**
1399
- * Remove items from DOM that are no longer in cart data
1400
- * @private
1401
- */
1402
- #removeItemsFromDOM(itemsContainer, newKeysSet) {
1403
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1404
-
1405
- const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1406
-
1407
- itemsToRemove.forEach((item) => {
1408
- console.log('destroy yourself', item);
1409
- item.destroyYourself();
1410
- });
1411
- }
1412
-
1413
- /**
1414
- * Update existing cart-item elements with fresh cart data
1415
- * @private
1416
- */
1417
- #updateItemsInDOM(itemsContainer, cartData) {
1418
- const visibleItems = this.#getVisibleCartItems(cartData);
1419
- const existingItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1420
-
1421
- existingItems.forEach((cartItemEl) => {
1422
- const key = cartItemEl.getAttribute('key');
1423
- const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
1424
-
1425
- if (updatedItemData) {
1426
- // Update cart-item with fresh data and full cart context
1427
- // The cart-item will handle HTML comparison and only re-render if needed
1428
- cartItemEl.setData(updatedItemData, cartData);
1429
- }
1430
- });
1431
- }
1432
-
1433
- /**
1434
- * Add new items to DOM with animation delay
1435
- * @private
1436
- */
1437
- #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1438
- // Delay adding new items by 300ms to let cart slide open first
1439
- setTimeout(() => {
1440
- itemsToAdd.forEach((itemData) => {
1441
- const cartItem = CartItem.createAnimated(itemData);
1442
- const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1443
-
1444
- // Find the correct position to insert the new item
1445
- if (targetIndex === 0) {
1446
- // Insert at the beginning
1447
- itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1448
- } else {
1449
- // Find the item that should come before this one
1450
- let insertAfter = null;
1451
- for (let i = targetIndex - 1; i >= 0; i--) {
1452
- const prevKey = newKeys[i];
1453
- const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1454
- if (prevItem) {
1455
- insertAfter = prevItem;
1456
- break;
1457
- }
1458
- }
1459
-
1460
- if (insertAfter) {
1461
- insertAfter.insertAdjacentElement('afterend', cartItem);
1462
- } else {
1463
- itemsContainer.appendChild(cartItem);
1464
- }
1465
- }
1466
- });
1467
- }, 100);
1468
- }
1469
-
1470
- /**
1471
- * Filter cart items to exclude those with _hide_in_cart property
1472
- * @private
1473
- */
1474
- #getVisibleCartItems(cartData) {
1475
- if (!cartData || !cartData.items) return [];
1476
- return cartData.items.filter((item) => {
1477
- // Check for _hide_in_cart in various possible locations
1478
- const hidden = item.properties?._hide_in_cart;
1479
-
1480
- return !hidden;
1481
- });
1482
- }
1483
-
1484
- /**
1485
- * Add calculated fields to cart object for events
1486
- * @private
1487
- */
1488
- #addCalculatedFields(cartData) {
1489
- if (!cartData) return cartData;
1490
-
1491
- // For display counts: use visible items (excludes _hide_in_cart)
1492
- const visibleItems = this.#getVisibleCartItems(cartData);
1493
- const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
1494
-
1495
- // For pricing: use all items except those marked to ignore pricing
1496
- const pricedItems = cartData.items.filter((item) => {
1497
- const ignorePrice = item.properties?._ignore_price_in_subtotal;
1498
- return !ignorePrice;
1499
- });
1500
- const calculated_subtotal = pricedItems.reduce(
1501
- (total, item) => total + (item.line_price || 0),
1502
- 0
1503
- );
1504
-
1505
- return {
1506
- ...cartData,
1507
- calculated_count,
1508
- calculated_subtotal,
1509
- };
1510
- }
1511
-
1512
- /**
1513
- * Render cart items from Shopify cart data with smart comparison
1514
- * @private
1515
- */
1516
- #renderCartItems(cartData) {
1517
- const itemsContainer = this.querySelector('[data-content-cart-items]');
1518
-
1519
- if (!itemsContainer || !cartData || !cartData.items) {
1520
- console.warn('Cannot render cart items:', {
1521
- itemsContainer: !!itemsContainer,
1522
- cartData: !!cartData,
1523
- items: cartData?.items?.length,
1524
- });
1525
- return;
1526
- }
1527
-
1528
- // Filter out items with _hide_in_cart property
1529
- const visibleItems = this.#getVisibleCartItems(cartData);
1530
-
1531
- // Handle initial render - load all items without animation
1532
- if (this.#isInitialRender) {
1533
- // console.log('Initial cart render:', visibleItems.length, 'visible items');
1534
-
1535
- // Clear existing items
1536
- itemsContainer.innerHTML = '';
1537
-
1538
- // Create cart-item elements without animation
1539
- visibleItems.forEach((itemData) => {
1540
- const cartItem = new CartItem(itemData); // No animation
1541
- itemsContainer.appendChild(cartItem);
1542
- });
1543
-
1544
- this.#isInitialRender = false;
1545
-
1546
- return;
1547
- }
1548
-
1549
- // Get current DOM items and their keys
1550
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1551
- const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1552
-
1553
- // Get new cart data keys in order (only visible items)
1554
- const newKeys = visibleItems.map((item) => item.key || item.id);
1555
- const newKeysSet = new Set(newKeys);
1556
-
1557
- // Step 1: Remove items that are no longer in cart data
1558
- this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1559
-
1560
- // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
1561
- this.#updateItemsInDOM(itemsContainer, cartData);
1562
-
1563
- // Step 3: Add new items that weren't in DOM (with animation delay)
1564
- const itemsToAdd = visibleItems.filter(
1565
- (itemData) => !currentKeys.has(itemData.key || itemData.id)
1566
- );
1567
- this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1568
- }
1569
-
1570
- /**
1571
- * Set the template function for cart items
1572
- * @param {Function} templateFn - Function that takes item data and returns HTML string
1573
- */
1574
- setCartItemTemplate(templateName, templateFn) {
1575
- CartItem.setTemplate(templateName, templateFn);
1576
- }
1577
-
1578
- /**
1579
- * Set the processing template function for cart items
1580
- * @param {Function} templateFn - Function that returns HTML string for processing state
1581
- */
1582
- setCartItemProcessingTemplate(templateFn) {
1583
- CartItem.setProcessingTemplate(templateFn);
1584
- }
1585
-
1586
- /**
1587
- * Shows the cart dialog and traps focus within it
1588
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1589
- * @fires CartDialog#show - Fired when the cart dialog has been shown
1590
- */
1591
- show(triggerEl = null, cartObj) {
1592
- const _ = this;
1593
- _.triggerEl = triggerEl || false;
1594
-
1595
- // Lock body scrolling
1596
- _.#lockScroll();
1597
-
1598
- // Remove the hidden class first to ensure content is rendered
1599
- _.contentPanel.classList.remove('hidden');
1600
-
1601
- // Give the browser a moment to process before starting animation
1602
- requestAnimationFrame(() => {
1603
- // Update ARIA states
1604
- _.setAttribute('aria-hidden', 'false');
1605
-
1606
- if (_.triggerEl) {
1607
- _.triggerEl.setAttribute('aria-expanded', 'true');
1608
- }
1609
-
1610
- // Focus management
1611
- const firstFocusable = _.querySelector(
1612
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1613
- );
1614
-
1615
- if (firstFocusable) {
1616
- requestAnimationFrame(() => {
1617
- firstFocusable.focus();
1618
- });
1619
- }
1620
-
1621
- // Refresh cart data when showing
1622
- _.refreshCart(cartObj);
1623
-
1624
- // Emit show event - cart dialog is now visible
1625
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1626
- });
1627
- }
1628
-
1629
- /**
1630
- * Hides the cart dialog and restores focus
1631
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1632
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1633
- */
1634
- hide() {
1635
- const _ = this;
1636
-
1637
- // Update ARIA states
1638
- if (_.triggerEl) {
1639
- // remove focus from modal panel first
1640
- _.triggerEl.focus();
1641
- // mark trigger as no longer expanded
1642
- _.triggerEl.setAttribute('aria-expanded', 'false');
1643
- } else {
1644
- // If no trigger element, blur any focused element inside the panel
1645
- const activeElement = document.activeElement;
1646
- if (activeElement && _.contains(activeElement)) {
1647
- activeElement.blur();
1648
- }
1649
- }
1650
-
1651
- requestAnimationFrame(() => {
1652
- // Set aria-hidden to start transition
1653
- // The transitionend event handler will add display:none when complete
1654
- _.setAttribute('aria-hidden', 'true');
1655
-
1656
- // Emit hide event - cart dialog is now starting to hide
1657
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1658
-
1659
- // Restore body scroll
1660
- _.#restoreScroll();
1661
- });
1662
- }
1663
- }
1664
-
1665
- /**
1666
- * Custom element that creates a clickable overlay for the cart dialog
1667
- * @extends HTMLElement
1668
- */
1669
- class CartOverlay extends HTMLElement {
1670
- constructor() {
1671
- super();
1672
- this.setAttribute('tabindex', '-1');
1673
- this.setAttribute('aria-hidden', 'true');
1674
- this.cartDialog = this.closest('cart-dialog');
1675
- this.#attachListeners();
1676
- }
1677
-
1678
- #attachListeners() {
1679
- this.addEventListener('click', () => {
1680
- this.cartDialog.hide();
1681
- });
1682
- }
1683
- }
1684
-
1685
- /**
1686
- * Custom element that wraps the content of the cart dialog
1687
- * @extends HTMLElement
1688
- */
1689
- class CartPanel extends HTMLElement {
1690
- constructor() {
1691
- super();
1692
- this.setAttribute('role', 'document');
1693
- }
1694
- }
1695
-
1696
- if (!customElements.get('cart-dialog')) {
1697
- customElements.define('cart-dialog', CartDialog);
1698
- }
1699
- if (!customElements.get('cart-overlay')) {
1700
- customElements.define('cart-overlay', CartOverlay);
1701
- }
1702
- if (!customElements.get('cart-panel')) {
1703
- customElements.define('cart-panel', CartPanel);
1704
- }
1705
-
1706
- // Make CartItem available globally for Shopify themes
1707
- if (typeof window !== 'undefined') {
1708
- window.CartItem = CartItem;
1709
- }
1710
-
1711
- exports.CartDialog = CartDialog;
1712
- exports.CartItem = CartItem;
1713
- exports.CartOverlay = CartOverlay;
1714
- exports.CartPanel = CartPanel;
1715
- exports.default = CartDialog;
1716
-
1717
- 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 });
1718
1061
 
1719
1062
  }));
1720
1063
  //# sourceMappingURL=cart-panel.js.map