@magic-spells/cart-panel 0.1.2 → 0.3.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.
@@ -4,29 +4,524 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
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;
53
+ }
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;
322
+ }
323
+
324
+ quantity-modifier input[type="number"] {
325
+ -moz-appearance: textfield;
326
+ }
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
+
7
495
  /**
8
496
  * CartItem class that handles the functionality of a cart item component
9
497
  */
10
498
  class CartItem extends HTMLElement {
11
499
  // Static template functions shared across all instances
12
- static #template = null;
500
+ static #templates = new Map();
13
501
  static #processingTemplate = null;
14
502
 
15
503
  // Private fields
16
504
  #currentState = 'ready';
17
505
  #isDestroying = false;
506
+ #isAppearing = false;
18
507
  #handlers = {};
19
508
  #itemData = null;
509
+ #cartData = null;
510
+ #lastRenderedHTML = '';
20
511
 
21
512
  /**
22
513
  * Set the template function for rendering cart items
23
- * @param {Function} templateFn - Function that takes item data and returns HTML string
514
+ * @param {string} name - Template name ('default' for default template)
515
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
24
516
  */
25
- static setTemplate(templateFn) {
517
+ static setTemplate(name, templateFn) {
518
+ if (typeof name !== 'string') {
519
+ throw new Error('Template name must be a string');
520
+ }
26
521
  if (typeof templateFn !== 'function') {
27
522
  throw new Error('Template must be a function');
28
523
  }
29
- CartItem.#template = templateFn;
524
+ CartItem.#templates.set(name, templateFn);
30
525
  }
31
526
 
32
527
  /**
@@ -43,10 +538,11 @@
43
538
  /**
44
539
  * Create a cart item with appearing animation
45
540
  * @param {Object} itemData - Shopify cart item data
541
+ * @param {Object} cartData - Full Shopify cart object
46
542
  * @returns {CartItem} Cart item instance that will animate in
47
543
  */
48
- static createAnimated(itemData) {
49
- return new CartItem(itemData, { animate: true });
544
+ static createAnimated(itemData, cartData) {
545
+ return new CartItem(itemData, cartData, { animate: true });
50
546
  }
51
547
 
52
548
  /**
@@ -67,11 +563,12 @@
67
563
  }
68
564
  }
69
565
 
70
- constructor(itemData = null, options = {}) {
566
+ constructor(itemData = null, cartData = null, options = {}) {
71
567
  super();
72
568
 
73
- // Store item data if provided
569
+ // Store item and cart data if provided
74
570
  this.#itemData = itemData;
571
+ this.#cartData = cartData;
75
572
 
76
573
  // Set initial state - start with 'appearing' only if explicitly requested
77
574
  const shouldAnimate = options.animate || this.hasAttribute('animate-in');
@@ -89,13 +586,16 @@
89
586
  connectedCallback() {
90
587
  // If we have item data, render it first
91
588
  if (this.#itemData) {
92
- this.#renderFromData();
589
+ this.#render();
93
590
  }
94
591
 
95
592
  // Find child elements
96
593
  this.content = this.querySelector('cart-item-content');
97
594
  this.processing = this.querySelector('cart-item-processing');
98
595
 
596
+ // Update line price elements in case of pre-rendered content
597
+ this.#updateLinePriceElements();
598
+
99
599
  // Attach event listeners
100
600
  this.#attachListeners();
101
601
 
@@ -103,6 +603,7 @@
103
603
  if (this.#currentState === 'appearing') {
104
604
  // Set the state attribute
105
605
  this.setAttribute('state', 'appearing');
606
+ this.#isAppearing = true;
106
607
 
107
608
  // Get the natural height after rendering
108
609
  requestAnimationFrame(() => {
@@ -130,6 +631,7 @@
130
631
  #attachListeners() {
131
632
  this.addEventListener('click', this.#handlers.click);
132
633
  this.addEventListener('change', this.#handlers.change);
634
+ this.addEventListener('quantity-modifier:change', this.#handlers.change);
133
635
  this.addEventListener('transitionend', this.#handlers.transitionEnd);
134
636
  }
135
637
 
@@ -139,6 +641,7 @@
139
641
  #detachListeners() {
140
642
  this.removeEventListener('click', this.#handlers.click);
141
643
  this.removeEventListener('change', this.#handlers.change);
644
+ this.removeEventListener('quantity-modifier:change', this.#handlers.change);
142
645
  this.removeEventListener('transitionend', this.#handlers.transitionEnd);
143
646
  }
144
647
 
@@ -169,9 +672,15 @@
169
672
  }
170
673
 
171
674
  /**
172
- * Handle change events (for quantity inputs)
675
+ * Handle change events (for quantity inputs and quantity-modifier)
173
676
  */
174
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
+
175
684
  // Check if changed element is a quantity input
176
685
  const quantityInput = e.target.closest('[data-cart-quantity]');
177
686
  if (quantityInput) {
@@ -186,9 +695,10 @@
186
695
  if (e.propertyName === 'height' && this.#isDestroying) {
187
696
  // Remove from DOM after height animation completes
188
697
  this.remove();
189
- } else if (e.propertyName === 'height' && this.#currentState === 'ready') {
698
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
190
699
  // Remove explicit height after appearing animation completes
191
700
  this.style.height = '';
701
+ this.#isAppearing = false;
192
702
  }
193
703
  }
194
704
 
@@ -224,10 +734,11 @@
224
734
  }
225
735
 
226
736
  /**
227
- * Render cart item from data using the static template
737
+ * Render cart item from data using the appropriate template
228
738
  */
229
- #renderFromData() {
230
- if (!this.#itemData || !CartItem.#template) {
739
+ #render() {
740
+ if (!this.#itemData || CartItem.#templates.size === 0) {
741
+ console.log('no item data or no template', this.#itemData, CartItem.#templates);
231
742
  return;
232
743
  }
233
744
 
@@ -237,8 +748,9 @@
237
748
  this.setAttribute('key', key);
238
749
  }
239
750
 
240
- // Generate HTML from template
241
- const templateHTML = CartItem.#template(this.#itemData);
751
+ // Generate HTML from template and store for future comparisons
752
+ const templateHTML = this.#generateTemplateHTML();
753
+ this.#lastRenderedHTML = templateHTML;
242
754
 
243
755
  // Generate processing HTML from template or use default
244
756
  const processingHTML = CartItem.#processingTemplate
@@ -259,377 +771,170 @@
259
771
  /**
260
772
  * Update the cart item with new data
261
773
  * @param {Object} itemData - Shopify cart item data
774
+ * @param {Object} cartData - Full Shopify cart object
262
775
  */
263
- setData(itemData) {
776
+ setData(itemData, cartData = null) {
777
+ // Update internal data
264
778
  this.#itemData = itemData;
265
- this.#renderFromData();
779
+ if (cartData) {
780
+ this.#cartData = cartData;
781
+ }
266
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
+
267
797
  // Re-find child elements after re-rendering
268
798
  this.content = this.querySelector('cart-item-content');
269
799
  this.processing = this.querySelector('cart-item-processing');
800
+
801
+ // Update line price elements
802
+ this.#updateLinePriceElements();
270
803
  }
271
804
 
272
805
  /**
273
- * Get the current item data
274
- */
275
- get itemData() {
276
- return this.#itemData;
277
- }
278
-
279
- /**
280
- * Set the state of the cart item
281
- * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
282
- */
283
- setState(state) {
284
- if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
285
- this.setAttribute('state', state);
286
- }
287
- }
288
-
289
- /**
290
- * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
291
- *
292
- * @returns {void}
293
- */
294
- destroyYourself() {
295
- // bail if already in the middle of a destroy cycle
296
- if (this.#isDestroying) return;
297
-
298
- this.#isDestroying = true;
299
-
300
- // snapshot the current rendered height before applying any "destroying" styles
301
- const initialHeight = this.offsetHeight;
302
-
303
- // switch to 'destroying' state so css can fade / slide visuals
304
- this.setState('destroying');
305
-
306
- // lock the measured height on the next animation frame to ensure layout is fully flushed
307
- requestAnimationFrame(() => {
308
- this.style.height = `${initialHeight}px`;
309
- this.offsetHeight; // force a reflow so the browser registers the fixed height
310
-
311
- // read the css custom property for timing, defaulting to 400ms
312
- const elementStyle = getComputedStyle(this);
313
- const destroyDuration =
314
- elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
315
-
316
- // animate only the height to zero; other properties stay under stylesheet control
317
- this.style.transition = `height ${destroyDuration} ease`;
318
- this.style.height = '0px';
319
- });
320
- }
321
- }
322
-
323
- /**
324
- * Supporting component classes for cart item
325
- */
326
- class CartItemContent extends HTMLElement {
327
- constructor() {
328
- super();
329
- }
330
- }
331
-
332
- class CartItemProcessing extends HTMLElement {
333
- constructor() {
334
- super();
335
- }
336
- }
337
-
338
- // Define custom elements (check if not already defined)
339
- if (!customElements.get('cart-item')) {
340
- customElements.define('cart-item', CartItem);
341
- }
342
- if (!customElements.get('cart-item-content')) {
343
- customElements.define('cart-item-content', CartItemContent);
344
- }
345
- if (!customElements.get('cart-item-processing')) {
346
- customElements.define('cart-item-processing', CartItemProcessing);
347
- }
348
-
349
- /**
350
- * Retrieves all focusable elements within a given container.
351
- *
352
- * @param {HTMLElement} container - The container element to search for focusable elements.
353
- * @returns {HTMLElement[]} An array of focusable elements found within the container.
354
- */
355
- const getFocusableElements = (container) => {
356
- const focusableSelectors =
357
- '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';
358
- return Array.from(container.querySelectorAll(focusableSelectors));
359
- };
360
-
361
- class FocusTrap extends HTMLElement {
362
- /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
363
- static styleInjected = false;
364
-
365
- constructor() {
366
- super();
367
- this.trapStart = null;
368
- this.trapEnd = null;
369
-
370
- // Inject styles only once, when the first FocusTrap instance is created.
371
- if (!FocusTrap.styleInjected) {
372
- this.injectStyles();
373
- FocusTrap.styleInjected = true;
374
- }
375
- }
376
-
377
- /**
378
- * Injects necessary styles for the focus trap into the document's head.
379
- * This ensures that focus-trap-start and focus-trap-end elements are hidden.
380
- */
381
- injectStyles() {
382
- const style = document.createElement('style');
383
- style.textContent = `
384
- focus-trap-start,
385
- focus-trap-end {
386
- position: absolute;
387
- width: 1px;
388
- height: 1px;
389
- margin: -1px;
390
- padding: 0;
391
- border: 0;
392
- clip: rect(0, 0, 0, 0);
393
- overflow: hidden;
394
- white-space: nowrap;
395
- }
396
- `;
397
- document.head.appendChild(style);
398
- }
399
-
400
- /**
401
- * Called when the element is connected to the DOM.
402
- * Sets up the focus trap and adds the keydown event listener.
403
- */
404
- connectedCallback() {
405
- this.setupTrap();
406
- this.addEventListener('keydown', this.handleKeyDown);
407
- }
408
-
409
- /**
410
- * Called when the element is disconnected from the DOM.
411
- * Removes the keydown event listener.
412
- */
413
- disconnectedCallback() {
414
- this.removeEventListener('keydown', this.handleKeyDown);
415
- }
416
-
417
- /**
418
- * Sets up the focus trap by adding trap start and trap end elements.
419
- * Focuses the trap start element to initiate the focus trap.
420
- */
421
- setupTrap() {
422
- // check to see it there are any focusable children
423
- const focusableElements = getFocusableElements(this);
424
- // exit if there aren't any
425
- if (focusableElements.length === 0) return;
426
-
427
- // create trap start and end elements
428
- this.trapStart = document.createElement('focus-trap-start');
429
- this.trapEnd = document.createElement('focus-trap-end');
430
-
431
- // add to DOM
432
- this.prepend(this.trapStart);
433
- this.append(this.trapEnd);
434
- }
435
-
436
- /**
437
- * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
438
- *
439
- * @param {KeyboardEvent} e - The keyboard event object.
440
- */
441
- handleKeyDown = (e) => {
442
- if (e.key === 'Escape') {
443
- e.preventDefault();
444
- this.exitTrap();
445
- }
446
- };
447
-
448
- /**
449
- * Exits the focus trap by hiding the current container and shifting focus
450
- * back to the trigger element that opened the trap.
806
+ * Generate HTML from the current template with current data
807
+ * @returns {string} Generated HTML string or empty string if no template
808
+ * @private
451
809
  */
452
- exitTrap() {
453
- const container = this.closest('[aria-hidden="false"]');
454
- if (!container) return;
455
-
456
- container.setAttribute('aria-hidden', 'true');
457
-
458
- const trigger = document.querySelector(
459
- `[aria-expanded="true"][aria-controls="${container.id}"]`
460
- );
461
- if (trigger) {
462
- trigger.setAttribute('aria-expanded', 'false');
463
- trigger.focus();
810
+ #generateTemplateHTML() {
811
+ // If no templates are available, return empty string
812
+ if (!this.#itemData || CartItem.#templates.size === 0) {
813
+ return '';
464
814
  }
465
- }
466
- }
467
-
468
- class FocusTrapStart extends HTMLElement {
469
- /**
470
- * Called when the element is connected to the DOM.
471
- * Sets the tabindex and adds the focus event listener.
472
- */
473
- connectedCallback() {
474
- this.setAttribute('tabindex', '0');
475
- this.addEventListener('focus', this.handleFocus);
476
- }
477
-
478
- /**
479
- * Called when the element is disconnected from the DOM.
480
- * Removes the focus event listener.
481
- */
482
- disconnectedCallback() {
483
- this.removeEventListener('focus', this.handleFocus);
484
- }
485
-
486
- /**
487
- * Handles the focus event. If focus moves backwards from the first focusable element,
488
- * it is cycled to the last focusable element, and vice versa.
489
- *
490
- * @param {FocusEvent} e - The focus event object.
491
- */
492
- handleFocus = (e) => {
493
- const trap = this.closest('focus-trap');
494
- const focusableElements = getFocusableElements(trap);
495
-
496
- if (focusableElements.length === 0) return;
497
815
 
498
- const firstElement = focusableElements[0];
499
- const lastElement =
500
- focusableElements[focusableElements.length - 1];
501
-
502
- if (e.relatedTarget === firstElement) {
503
- lastElement.focus();
504
- } else {
505
- firstElement.focus();
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 '';
506
822
  }
507
- };
508
- }
509
823
 
510
- class FocusTrapEnd extends HTMLElement {
824
+ // Generate and return HTML from template
825
+ return templateFn(this.#itemData, this.#cartData);
826
+ }
827
+
511
828
  /**
512
- * Called when the element is connected to the DOM.
513
- * Sets the tabindex and adds the focus event listener.
829
+ * Update elements with data-content-line-price attribute
830
+ * @private
514
831
  */
515
- connectedCallback() {
516
- this.setAttribute('tabindex', '0');
517
- this.addEventListener('focus', this.handleFocus);
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
+ });
518
841
  }
519
842
 
520
843
  /**
521
- * Called when the element is disconnected from the DOM.
522
- * Removes the focus event listener.
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
523
848
  */
524
- disconnectedCallback() {
525
- this.removeEventListener('focus', this.handleFocus);
849
+ #formatCurrency(cents) {
850
+ if (typeof cents !== 'number') return '$0.00';
851
+ return `$${(cents / 100).toFixed(2)}`;
526
852
  }
527
853
 
528
854
  /**
529
- * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
855
+ * Get the current item data
530
856
  */
531
- handleFocus = () => {
532
- const trap = this.closest('focus-trap');
533
- const trapStart = trap.querySelector('focus-trap-start');
534
- trapStart.focus();
535
- };
536
- }
857
+ get itemData() {
858
+ return this.#itemData;
859
+ }
537
860
 
538
- if (!customElements.get('focus-trap')) {
539
- customElements.define('focus-trap', FocusTrap);
540
- }
541
- if (!customElements.get('focus-trap-start')) {
542
- customElements.define('focus-trap-start', FocusTrapStart);
543
- }
544
- if (!customElements.get('focus-trap-end')) {
545
- customElements.define('focus-trap-end', FocusTrapEnd);
546
- }
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
+ }
547
870
 
548
- class EventEmitter {
549
- #events;
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;
550
879
 
551
- constructor() {
552
- this.#events = new Map();
553
- }
880
+ this.#isDestroying = true;
554
881
 
555
- /**
556
- * Binds a listener to an event.
557
- * @param {string} event - The event to bind the listener to.
558
- * @param {Function} listener - The listener function to bind.
559
- * @returns {EventEmitter} The current instance for chaining.
560
- * @throws {TypeError} If the listener is not a function.
561
- */
562
- on(event, listener) {
563
- if (typeof listener !== "function") {
564
- throw new TypeError("Listener must be a function");
565
- }
882
+ // snapshot the current rendered height before applying any "destroying" styles
883
+ const initialHeight = this.offsetHeight;
566
884
 
567
- const listeners = this.#events.get(event) || [];
568
- if (!listeners.includes(listener)) {
569
- listeners.push(listener);
570
- }
571
- this.#events.set(event, listeners);
885
+ // switch to 'destroying' state so css can fade / slide visuals
886
+ this.setState('destroying');
572
887
 
573
- return this;
574
- }
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
575
892
 
576
- /**
577
- * Unbinds a listener from an event.
578
- * @param {string} event - The event to unbind the listener from.
579
- * @param {Function} listener - The listener function to unbind.
580
- * @returns {EventEmitter} The current instance for chaining.
581
- */
582
- off(event, listener) {
583
- const listeners = this.#events.get(event);
584
- if (!listeners) return this;
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';
585
897
 
586
- const index = listeners.indexOf(listener);
587
- if (index !== -1) {
588
- listeners.splice(index, 1);
589
- if (listeners.length === 0) {
590
- this.#events.delete(event);
591
- } else {
592
- this.#events.set(event, listeners);
593
- }
594
- }
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';
595
901
 
596
- return this;
597
- }
902
+ // setTimeout(() => {
903
+ // this.style.height = '0px';
904
+ // }, 1);
598
905
 
599
- /**
600
- * Triggers an event and calls all bound listeners.
601
- * @param {string} event - The event to trigger.
602
- * @param {...*} args - Arguments to pass to the listener functions.
603
- * @returns {boolean} True if the event had listeners, false otherwise.
604
- */
605
- emit(event, ...args) {
606
- const listeners = this.#events.get(event);
607
- if (!listeners || listeners.length === 0) return false;
906
+ setTimeout(() => {
907
+ // make sure item is removed
908
+ this.remove();
909
+ }, 600);
910
+ });
911
+ }
912
+ }
608
913
 
609
- for (let i = 0, n = listeners.length; i < n; ++i) {
610
- try {
611
- listeners[i].apply(this, args);
612
- } catch (error) {
613
- console.error(`Error in listener for event '${event}':`, error);
614
- }
615
- }
914
+ /**
915
+ * Supporting component classes for cart item
916
+ */
917
+ class CartItemContent extends HTMLElement {
918
+ constructor() {
919
+ super();
920
+ }
921
+ }
616
922
 
617
- return true;
618
- }
923
+ class CartItemProcessing extends HTMLElement {
924
+ constructor() {
925
+ super();
926
+ }
927
+ }
619
928
 
620
- /**
621
- * Removes all listeners for a specific event or all events.
622
- * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
623
- * @returns {EventEmitter} The current instance for chaining.
624
- */
625
- removeAllListeners(event) {
626
- if (event) {
627
- this.#events.delete(event);
628
- } else {
629
- this.#events.clear();
630
- }
631
- return this;
632
- }
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);
633
938
  }
634
939
 
635
940
  /**
@@ -638,7 +943,6 @@
638
943
  */
639
944
  class CartDialog extends HTMLElement {
640
945
  #handleTransitionEnd;
641
- #scrollPosition = 0;
642
946
  #currentCart = null;
643
947
  #eventEmitter;
644
948
  #isInitialRender = true;
@@ -661,31 +965,21 @@
661
965
  }
662
966
 
663
967
  /**
664
- * Saves current scroll position and locks body scrolling
968
+ * Locks body scrolling
665
969
  * @private
666
970
  */
667
971
  #lockScroll() {
668
- const _ = this;
669
- // Save current scroll position
670
- _.#scrollPosition = window.pageYOffset;
671
-
672
- // Apply fixed position to body
972
+ // Apply overflow hidden to body
673
973
  document.body.classList.add('overflow-hidden');
674
- document.body.style.top = `-${_.#scrollPosition}px`;
675
974
  }
676
975
 
677
976
  /**
678
- * Restores scroll position when cart dialog is closed
977
+ * Restores body scrolling when cart dialog is closed
679
978
  * @private
680
979
  */
681
980
  #restoreScroll() {
682
- const _ = this;
683
- // Remove fixed positioning
981
+ // Remove overflow hidden from body
684
982
  document.body.classList.remove('overflow-hidden');
685
- document.body.style.removeProperty('top');
686
-
687
- // Restore scroll position
688
- window.scrollTo(0, _.#scrollPosition);
689
983
  }
690
984
 
691
985
  /**
@@ -726,7 +1020,18 @@
726
1020
  return;
727
1021
  }
728
1022
 
729
- _.focusTrap = document.createElement('focus-trap');
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
+ }
730
1035
 
731
1036
  // Ensure we have labelledby and describedby references
732
1037
  if (!_.getAttribute('aria-labelledby')) {
@@ -739,20 +1044,15 @@
739
1044
  }
740
1045
  }
741
1046
 
742
- // Insert focus trap before the cart-panel
743
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
744
- // Move cart-panel inside the focus trap
745
- _.focusTrap.appendChild(_.contentPanel);
746
-
747
- // Setup the trap - this will add focus-trap-start/end elements around the content
748
- _.focusTrap.setupTrap();
749
-
750
1047
  // Add modal overlay if it doesn't already exist
751
1048
  if (!_.querySelector('cart-overlay')) {
752
1049
  _.prepend(document.createElement('cart-overlay'));
753
1050
  }
754
1051
  _.#attachListeners();
755
1052
  _.#bindKeyboard();
1053
+
1054
+ // Load cart data immediately after component initialization
1055
+ _.refreshCart();
756
1056
  }
757
1057
 
758
1058
  /**
@@ -785,6 +1085,14 @@
785
1085
  */
786
1086
  #emit(eventName, data = null) {
787
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
+ );
788
1096
  }
789
1097
 
790
1098
  /**
@@ -866,11 +1174,12 @@
866
1174
  // Success - let smart comparison handle the removal animation
867
1175
  this.#currentCart = updatedCart;
868
1176
  this.#renderCartItems(updatedCart);
869
- this.#updateCartItems(updatedCart);
1177
+ this.#renderCartPanel(updatedCart);
870
1178
 
871
1179
  // Emit cart updated and data changed events
872
- this.#emit('cart-dialog:updated', { cart: updatedCart });
873
- this.#emit('cart-dialog:data-changed', updatedCart);
1180
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1181
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1182
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
874
1183
  } else {
875
1184
  // Error - reset to ready state
876
1185
  element.setState('ready');
@@ -901,12 +1210,12 @@
901
1210
  // Success - update cart data and refresh items
902
1211
  this.#currentCart = updatedCart;
903
1212
  this.#renderCartItems(updatedCart);
904
- this.#updateCartItems(updatedCart);
905
- element.setState('ready');
1213
+ this.#renderCartPanel(updatedCart);
906
1214
 
907
1215
  // Emit cart updated and data changed events
908
- this.#emit('cart-dialog:updated', { cart: updatedCart });
909
- this.#emit('cart-dialog:data-changed', updatedCart);
1216
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1217
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1218
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
910
1219
  } else {
911
1220
  // Error - reset to ready state
912
1221
  element.setState('ready');
@@ -920,11 +1229,52 @@
920
1229
  });
921
1230
  }
922
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
+
923
1273
  /**
924
1274
  * Update cart items display based on cart data
925
1275
  * @private
926
1276
  */
927
- #updateCartItems(cart = null) {
1277
+ #renderCartPanel(cart = null) {
928
1278
  const cartData = cart || this.#currentCart;
929
1279
  if (!cartData) return;
930
1280
 
@@ -940,14 +1290,22 @@
940
1290
  return;
941
1291
  }
942
1292
 
943
- // Show/hide sections based on item count
944
- if (cartData.item_count > 0) {
945
- hasItemsSection.style.display = 'block';
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 = '';
946
1300
  emptySection.style.display = 'none';
947
1301
  } else {
948
1302
  hasItemsSection.style.display = 'none';
949
- emptySection.style.display = 'block';
1303
+ emptySection.style.display = '';
950
1304
  }
1305
+
1306
+ // Update cart count and subtotal across the site
1307
+ this.#renderCartCount(cartData);
1308
+ this.#renderCartSubtotal(cartData);
951
1309
  }
952
1310
 
953
1311
  /**
@@ -999,20 +1357,37 @@
999
1357
 
1000
1358
  /**
1001
1359
  * Refresh cart data from server and update components
1360
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1002
1361
  * @returns {Promise<Object>} Cart data object
1003
1362
  */
1004
- refreshCart() {
1005
- console.log('Refreshing cart...');
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
1006
1380
  return this.getCart().then((cartData) => {
1007
- console.log('Cart data received:', cartData);
1381
+ // console.log('Cart data received:', cartData);
1008
1382
  if (cartData && !cartData.error) {
1009
1383
  this.#currentCart = cartData;
1010
1384
  this.#renderCartItems(cartData);
1011
- this.#updateCartItems(cartData);
1385
+ this.#renderCartPanel(cartData);
1012
1386
 
1013
1387
  // Emit cart refreshed and data changed events
1014
- this.#emit('cart-dialog:refreshed', { cart: cartData });
1015
- this.#emit('cart-dialog:data-changed', cartData);
1388
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1389
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1390
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1016
1391
  } else {
1017
1392
  console.warn('Cart data has error or is null:', cartData);
1018
1393
  }
@@ -1026,28 +1401,40 @@
1026
1401
  */
1027
1402
  #removeItemsFromDOM(itemsContainer, newKeysSet) {
1028
1403
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1029
- const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1030
1404
 
1031
- console.log(
1032
- `Removing ${itemsToRemove.length} items:`,
1033
- itemsToRemove.map((item) => item.getAttribute('key'))
1034
- );
1405
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1035
1406
 
1036
1407
  itemsToRemove.forEach((item) => {
1408
+ console.log('destroy yourself', item);
1037
1409
  item.destroyYourself();
1038
1410
  });
1039
1411
  }
1040
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
+
1041
1433
  /**
1042
1434
  * Add new items to DOM with animation delay
1043
1435
  * @private
1044
1436
  */
1045
1437
  #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1046
- console.log(
1047
- `Adding ${itemsToAdd.length} items:`,
1048
- itemsToAdd.map((item) => item.key || item.id)
1049
- );
1050
-
1051
1438
  // Delay adding new items by 300ms to let cart slide open first
1052
1439
  setTimeout(() => {
1053
1440
  itemsToAdd.forEach((itemData) => {
@@ -1080,6 +1467,48 @@
1080
1467
  }, 100);
1081
1468
  }
1082
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
+
1083
1512
  /**
1084
1513
  * Render cart items from Shopify cart data with smart comparison
1085
1514
  * @private
@@ -1096,53 +1525,54 @@
1096
1525
  return;
1097
1526
  }
1098
1527
 
1528
+ // Filter out items with _hide_in_cart property
1529
+ const visibleItems = this.#getVisibleCartItems(cartData);
1530
+
1099
1531
  // Handle initial render - load all items without animation
1100
1532
  if (this.#isInitialRender) {
1101
- console.log('Initial cart render:', cartData.items.length, 'items');
1533
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
1102
1534
 
1103
1535
  // Clear existing items
1104
1536
  itemsContainer.innerHTML = '';
1105
1537
 
1106
1538
  // Create cart-item elements without animation
1107
- cartData.items.forEach((itemData) => {
1539
+ visibleItems.forEach((itemData) => {
1108
1540
  const cartItem = new CartItem(itemData); // No animation
1109
1541
  itemsContainer.appendChild(cartItem);
1110
1542
  });
1111
1543
 
1112
1544
  this.#isInitialRender = false;
1113
- console.log('Initial render complete, container children:', itemsContainer.children.length);
1545
+
1114
1546
  return;
1115
1547
  }
1116
1548
 
1117
- console.log('Smart rendering cart items:', cartData.items.length, 'items');
1118
-
1119
1549
  // Get current DOM items and their keys
1120
1550
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1121
1551
  const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1122
1552
 
1123
- // Get new cart data keys in order
1124
- const newKeys = cartData.items.map((item) => item.key || item.id);
1553
+ // Get new cart data keys in order (only visible items)
1554
+ const newKeys = visibleItems.map((item) => item.key || item.id);
1125
1555
  const newKeysSet = new Set(newKeys);
1126
1556
 
1127
1557
  // Step 1: Remove items that are no longer in cart data
1128
1558
  this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1129
1559
 
1130
- // Step 2: Add new items that weren't in DOM (with animation delay)
1131
- const itemsToAdd = cartData.items.filter(
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(
1132
1565
  (itemData) => !currentKeys.has(itemData.key || itemData.id)
1133
1566
  );
1134
-
1135
1567
  this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1136
-
1137
- console.log('Smart rendering complete, container children:', itemsContainer.children.length);
1138
1568
  }
1139
1569
 
1140
1570
  /**
1141
1571
  * Set the template function for cart items
1142
1572
  * @param {Function} templateFn - Function that takes item data and returns HTML string
1143
1573
  */
1144
- setCartItemTemplate(templateFn) {
1145
- CartItem.setTemplate(templateFn);
1574
+ setCartItemTemplate(templateName, templateFn) {
1575
+ CartItem.setTemplate(templateName, templateFn);
1146
1576
  }
1147
1577
 
1148
1578
  /**
@@ -1158,10 +1588,13 @@
1158
1588
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1159
1589
  * @fires CartDialog#show - Fired when the cart dialog has been shown
1160
1590
  */
1161
- show(triggerEl = null) {
1591
+ show(triggerEl = null, cartObj) {
1162
1592
  const _ = this;
1163
1593
  _.triggerEl = triggerEl || false;
1164
1594
 
1595
+ // Lock body scrolling
1596
+ _.#lockScroll();
1597
+
1165
1598
  // Remove the hidden class first to ensure content is rendered
1166
1599
  _.contentPanel.classList.remove('hidden');
1167
1600
 
@@ -1169,17 +1602,16 @@
1169
1602
  requestAnimationFrame(() => {
1170
1603
  // Update ARIA states
1171
1604
  _.setAttribute('aria-hidden', 'false');
1605
+
1172
1606
  if (_.triggerEl) {
1173
1607
  _.triggerEl.setAttribute('aria-expanded', 'true');
1174
1608
  }
1175
1609
 
1176
- // Lock body scrolling and save scroll position
1177
- _.#lockScroll();
1178
-
1179
1610
  // Focus management
1180
1611
  const firstFocusable = _.querySelector(
1181
1612
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1182
1613
  );
1614
+
1183
1615
  if (firstFocusable) {
1184
1616
  requestAnimationFrame(() => {
1185
1617
  firstFocusable.focus();
@@ -1187,7 +1619,7 @@
1187
1619
  }
1188
1620
 
1189
1621
  // Refresh cart data when showing
1190
- _.refreshCart();
1622
+ _.refreshCart(cartObj);
1191
1623
 
1192
1624
  // Emit show event - cart dialog is now visible
1193
1625
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -1202,23 +1634,31 @@
1202
1634
  hide() {
1203
1635
  const _ = this;
1204
1636
 
1205
- // Restore body scroll and scroll position
1206
- _.#restoreScroll();
1207
-
1208
1637
  // Update ARIA states
1209
1638
  if (_.triggerEl) {
1210
1639
  // remove focus from modal panel first
1211
1640
  _.triggerEl.focus();
1212
1641
  // mark trigger as no longer expanded
1213
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
+ }
1214
1649
  }
1215
1650
 
1216
- // Set aria-hidden to start transition
1217
- // The transitionend event handler will add display:none when complete
1218
- _.setAttribute('aria-hidden', 'true');
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');
1219
1655
 
1220
- // Emit hide event - cart dialog is now starting to hide
1221
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
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
+ });
1222
1662
  }
1223
1663
  }
1224
1664