@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,131 +1,492 @@
1
- import '@magic-spells/focus-trap';
2
1
  import EventEmitter from '@magic-spells/event-emitter';
3
- import { CartItem } from '@magic-spells/cart-item';
4
- export { CartItem } from '@magic-spells/cart-item';
2
+
3
+ // =============================================================================
4
+ // CartItem Component
5
+ // =============================================================================
5
6
 
6
7
  /**
7
- * Custom element that creates an accessible modal cart dialog with focus management
8
- * @extends HTMLElement
8
+ * CartItem class that handles the functionality of a cart item component
9
9
  */
10
- class CartDialog extends HTMLElement {
11
- #handleTransitionEnd;
12
- #currentCart = null;
13
- #eventEmitter;
14
- #isInitialRender = true;
10
+ class CartItem extends HTMLElement {
11
+ // Static template functions shared across all instances
12
+ static #templates = new Map();
13
+ static #processingTemplate = null;
14
+
15
+ // Private fields
16
+ #currentState = 'ready';
17
+ #isDestroying = false;
18
+ #isAppearing = false;
19
+ #handlers = {};
20
+ #itemData = null;
21
+ #cartData = null;
22
+ #lastRenderedHTML = '';
23
+
24
+ /**
25
+ * Set the template function for rendering cart items
26
+ * @param {string} name - Template name ('default' for default template)
27
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
28
+ */
29
+ static setTemplate(name, templateFn) {
30
+ if (typeof name !== 'string') {
31
+ throw new Error('Template name must be a string');
32
+ }
33
+ if (typeof templateFn !== 'function') {
34
+ throw new Error('Template must be a function');
35
+ }
36
+ CartItem.#templates.set(name, templateFn);
37
+ }
38
+
39
+ /**
40
+ * Set the processing template function for rendering processing overlay
41
+ * @param {Function} templateFn - Function that returns HTML string for processing state
42
+ */
43
+ static setProcessingTemplate(templateFn) {
44
+ if (typeof templateFn !== 'function') {
45
+ throw new Error('Processing template must be a function');
46
+ }
47
+ CartItem.#processingTemplate = templateFn;
48
+ }
49
+
50
+ /**
51
+ * Create a cart item with appearing animation
52
+ * @param {Object} itemData - Shopify cart item data
53
+ * @param {Object} cartData - Full Shopify cart object
54
+ * @returns {CartItem} Cart item instance that will animate in
55
+ */
56
+ static createAnimated(itemData, cartData) {
57
+ return new CartItem(itemData, cartData, { animate: true });
58
+ }
59
+
60
+ /**
61
+ * Define which attributes should be observed for changes
62
+ */
63
+ static get observedAttributes() {
64
+ return ['state', 'key'];
65
+ }
15
66
 
16
67
  /**
17
- * Clean up event listeners when component is removed from DOM
68
+ * Called when observed attributes change
18
69
  */
70
+ attributeChangedCallback(name, oldValue, newValue) {
71
+ if (oldValue === newValue) return;
72
+
73
+ if (name === 'state') {
74
+ this.#currentState = newValue || 'ready';
75
+ }
76
+ }
77
+
78
+ constructor(itemData = null, cartData = null, options = {}) {
79
+ super();
80
+
81
+ // Store item and cart data if provided
82
+ this.#itemData = itemData;
83
+ this.#cartData = cartData;
84
+
85
+ // Set initial state - start with 'appearing' only if explicitly requested
86
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
87
+ this.#currentState =
88
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
89
+
90
+ // Bind event handlers
91
+ this.#handlers = {
92
+ click: this.#handleClick.bind(this),
93
+ change: this.#handleChange.bind(this),
94
+ transitionEnd: this.#handleTransitionEnd.bind(this),
95
+ };
96
+ }
97
+
98
+ connectedCallback() {
99
+ const _ = this;
100
+
101
+ // If we have item data, render it first
102
+ if (_.#itemData) _.#render();
103
+
104
+ // Find child elements and attach listeners
105
+ _.#queryDOM();
106
+ _.#updateLinePriceElements();
107
+ _.#attachListeners();
108
+
109
+ // If we started with 'appearing' state, handle the entry animation
110
+ if (_.#currentState === 'appearing') {
111
+ _.setAttribute('state', 'appearing');
112
+ _.#isAppearing = true;
113
+
114
+ requestAnimationFrame(() => {
115
+ _.style.height = `${_.scrollHeight}px`;
116
+ requestAnimationFrame(() => _.setState('ready'));
117
+ });
118
+ }
119
+ }
120
+
19
121
  disconnectedCallback() {
122
+ // Cleanup event listeners
123
+ this.#detachListeners();
124
+ }
125
+
126
+ /**
127
+ * Query and cache DOM elements
128
+ */
129
+ #queryDOM() {
130
+ this.content = this.querySelector('cart-item-content');
131
+ this.processing = this.querySelector('cart-item-processing');
132
+ }
133
+
134
+ /**
135
+ * Attach event listeners
136
+ */
137
+ #attachListeners() {
138
+ const _ = this;
139
+ _.addEventListener('click', _.#handlers.click);
140
+ _.addEventListener('change', _.#handlers.change);
141
+ _.addEventListener('quantity-input:change', _.#handlers.change);
142
+ _.addEventListener('transitionend', _.#handlers.transitionEnd);
143
+ }
144
+
145
+ /**
146
+ * Detach event listeners
147
+ */
148
+ #detachListeners() {
20
149
  const _ = this;
21
- if (_.contentPanel) {
22
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
150
+ _.removeEventListener('click', _.#handlers.click);
151
+ _.removeEventListener('change', _.#handlers.change);
152
+ _.removeEventListener('quantity-input:change', _.#handlers.change);
153
+ _.removeEventListener('transitionend', _.#handlers.transitionEnd);
154
+ }
155
+
156
+ /**
157
+ * Get the current state
158
+ */
159
+ get state() {
160
+ return this.#currentState;
161
+ }
162
+
163
+ /**
164
+ * Get the cart key for this item
165
+ */
166
+ get cartKey() {
167
+ return this.getAttribute('key');
168
+ }
169
+
170
+ /**
171
+ * Handle click events (for Remove buttons, etc.)
172
+ */
173
+ #handleClick(e) {
174
+ // Check if clicked element is a remove button
175
+ const removeButton = e.target.closest('[data-action-remove-item]');
176
+ if (removeButton) {
177
+ e.preventDefault();
178
+ this.#emitRemoveEvent();
23
179
  }
180
+ }
24
181
 
25
- // Ensure body scroll is restored if component is removed while open
26
- document.body.classList.remove('overflow-hidden');
27
- this.#restoreScroll();
182
+ /**
183
+ * Handle change events (for quantity inputs and quantity-input component)
184
+ */
185
+ #handleChange(e) {
186
+ // Check if event is from quantity-input component
187
+ if (e.type === 'quantity-input:change') {
188
+ this.#emitQuantityChangeEvent(e.detail.value);
189
+ return;
190
+ }
28
191
 
29
- // Detach event listeners
30
- this.#detachListeners();
192
+ // Check if changed element is a quantity input
193
+ const quantityInput = e.target.closest('[data-cart-quantity]');
194
+ if (quantityInput) {
195
+ this.#emitQuantityChangeEvent(quantityInput.value);
196
+ }
31
197
  }
32
198
 
33
199
  /**
34
- * Locks body scrolling
35
- * @private
200
+ * Handle transition end events for destroy animation and appearing animation
36
201
  */
37
- #lockScroll() {
38
- // Apply overflow hidden to body
39
- document.body.classList.add('overflow-hidden');
202
+ #handleTransitionEnd(e) {
203
+ if (e.propertyName === 'height' && this.#isDestroying) {
204
+ // Remove from DOM after height animation completes
205
+ this.remove();
206
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
207
+ // Remove explicit height after appearing animation completes
208
+ this.style.height = '';
209
+ this.#isAppearing = false;
210
+ }
40
211
  }
41
212
 
42
213
  /**
43
- * Restores body scrolling when cart dialog is closed
44
- * @private
214
+ * Emit remove event
45
215
  */
46
- #restoreScroll() {
47
- // Remove overflow hidden from body
48
- document.body.classList.remove('overflow-hidden');
216
+ #emitRemoveEvent() {
217
+ this.dispatchEvent(
218
+ new CustomEvent('cart-item:remove', {
219
+ bubbles: true,
220
+ detail: {
221
+ cartKey: this.cartKey,
222
+ element: this,
223
+ },
224
+ })
225
+ );
49
226
  }
50
227
 
51
228
  /**
52
- * Initializes the cart dialog, sets up focus trap and overlay
229
+ * Emit quantity change event
53
230
  */
54
- constructor() {
55
- super();
231
+ #emitQuantityChangeEvent(quantity) {
232
+ this.dispatchEvent(
233
+ new CustomEvent('cart-item:quantity-change', {
234
+ bubbles: true,
235
+ detail: {
236
+ cartKey: this.cartKey,
237
+ quantity: parseInt(quantity),
238
+ element: this,
239
+ },
240
+ })
241
+ );
242
+ }
243
+
244
+ /**
245
+ * Render cart item from data using the appropriate template
246
+ */
247
+ #render() {
56
248
  const _ = this;
57
- _.id = _.getAttribute('id');
58
- _.setAttribute('role', 'dialog');
59
- _.setAttribute('aria-modal', 'true');
60
- _.setAttribute('aria-hidden', 'true');
249
+ if (!_.#itemData || CartItem.#templates.size === 0) return;
61
250
 
62
- _.triggerEl = null;
251
+ // Set the key attribute from item data
252
+ const key = _.#itemData.key || _.#itemData.id;
253
+ if (key) _.setAttribute('key', key);
63
254
 
64
- // Initialize event emitter
65
- _.#eventEmitter = new EventEmitter();
255
+ // Generate HTML from template and store for future comparisons
256
+ const templateHTML = _.#generateTemplateHTML();
257
+ _.#lastRenderedHTML = templateHTML;
66
258
 
67
- // Create a handler for transition end events
68
- _.#handleTransitionEnd = (e) => {
69
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
70
- _.contentPanel.classList.add('hidden');
259
+ // Generate processing HTML from template or use default
260
+ const processingHTML = CartItem.#processingTemplate
261
+ ? CartItem.#processingTemplate()
262
+ : '<div class="cart-item-loader"></div>';
71
263
 
72
- // Emit afterHide event - cart dialog has completed its transition
73
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
74
- }
75
- };
264
+ // Create the cart-item structure with template content inside cart-item-content
265
+ _.innerHTML = `
266
+ <cart-item-content>
267
+ ${templateHTML}
268
+ </cart-item-content>
269
+ <cart-item-processing>
270
+ ${processingHTML}
271
+ </cart-item-processing>
272
+ `;
76
273
  }
77
274
 
78
- connectedCallback() {
275
+ /**
276
+ * Update the cart item with new data
277
+ * @param {Object} itemData - Shopify cart item data
278
+ * @param {Object} cartData - Full Shopify cart object
279
+ */
280
+ setData(itemData, cartData = null) {
79
281
  const _ = this;
80
282
 
81
- // Now that we're in the DOM, find the content panel and set up focus trap
82
- _.contentPanel = _.querySelector('cart-panel');
283
+ // Update internal data
284
+ _.#itemData = itemData;
285
+ if (cartData) _.#cartData = cartData;
286
+
287
+ // Generate new HTML with updated data
288
+ const newHTML = _.#generateTemplateHTML();
83
289
 
84
- if (!_.contentPanel) {
85
- console.error('cart-panel element not found inside cart-dialog');
290
+ // Compare with previously rendered HTML
291
+ if (newHTML === _.#lastRenderedHTML) {
292
+ // HTML hasn't changed, just reset processing state
293
+ _.setState('ready');
294
+ _.#updateQuantityInput();
86
295
  return;
87
296
  }
88
297
 
89
- // Check if focus-trap already exists, if not create one
90
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
91
- if (!_.focusTrap) {
92
- _.focusTrap = document.createElement('focus-trap');
298
+ // HTML is different, proceed with full update
299
+ _.setState('ready');
300
+ _.#render();
301
+ _.#queryDOM();
302
+ _.#updateLinePriceElements();
303
+ }
304
+
305
+ /**
306
+ * Generate HTML from the current template with current data
307
+ * @returns {string} Generated HTML string or empty string if no template
308
+ * @private
309
+ */
310
+ #generateTemplateHTML() {
311
+ // If no templates are available, return empty string
312
+ if (!this.#itemData || CartItem.#templates.size === 0) {
313
+ return '';
314
+ }
93
315
 
94
- // Move all existing cart-panel content into the focus trap
95
- const existingContent = Array.from(_.contentPanel.childNodes);
96
- existingContent.forEach((child) => _.focusTrap.appendChild(child));
316
+ // Determine which template to use
317
+ const templateName = this.#itemData.properties?._cart_template || 'default';
318
+ const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
97
319
 
98
- // Insert focus trap inside the cart-panel
99
- _.contentPanel.appendChild(_.focusTrap);
320
+ if (!templateFn) {
321
+ return '';
100
322
  }
101
323
 
102
- // Ensure we have labelledby and describedby references
103
- if (!_.getAttribute('aria-labelledby')) {
104
- const heading = _.querySelector('h1, h2, h3');
105
- if (heading && !heading.id) {
106
- heading.id = `${_.id}-title`;
107
- }
108
- if (heading?.id) {
109
- _.setAttribute('aria-labelledby', heading.id);
110
- }
324
+ // Generate and return HTML from template
325
+ return templateFn(this.#itemData, this.#cartData);
326
+ }
327
+
328
+ /**
329
+ * Update quantity input component to match server data
330
+ * @private
331
+ */
332
+ #updateQuantityInput() {
333
+ if (!this.#itemData) return;
334
+
335
+ const quantityInput = this.querySelector('quantity-input');
336
+ if (quantityInput) {
337
+ quantityInput.value = this.#itemData.quantity;
111
338
  }
339
+ }
340
+
341
+ /**
342
+ * Update elements with data-content-line-price attribute
343
+ * @private
344
+ */
345
+ #updateLinePriceElements() {
346
+ if (!this.#itemData) return;
347
+
348
+ const linePriceElements = this.querySelectorAll('[data-content-line-price]');
349
+ const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
350
+
351
+ linePriceElements.forEach((element) => {
352
+ element.textContent = formattedLinePrice;
353
+ });
354
+ }
112
355
 
113
- // Add modal overlay if it doesn't already exist
114
- if (!_.querySelector('cart-overlay')) {
115
- _.prepend(document.createElement('cart-overlay'));
356
+ /**
357
+ * Format currency value from cents to dollar string
358
+ * @param {number} cents - Price in cents
359
+ * @returns {string} Formatted currency string (e.g., "$29.99")
360
+ * @private
361
+ */
362
+ #formatCurrency(cents) {
363
+ if (typeof cents !== 'number') return '$0.00';
364
+ return `$${(cents / 100).toFixed(2)}`;
365
+ }
366
+
367
+ /**
368
+ * Get the current item data
369
+ */
370
+ get itemData() {
371
+ return this.#itemData;
372
+ }
373
+
374
+ /**
375
+ * Set the state of the cart item
376
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
377
+ */
378
+ setState(state) {
379
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
380
+ this.setAttribute('state', state);
116
381
  }
117
- _.#attachListeners();
118
- _.#bindKeyboard();
382
+ }
383
+
384
+ /**
385
+ * Gracefully animate this cart item closed, then remove it
386
+ */
387
+ destroyYourself() {
388
+ const _ = this;
389
+
390
+ // bail if already in the middle of a destroy cycle
391
+ if (_.#isDestroying) return;
392
+ _.#isDestroying = true;
393
+
394
+ // snapshot the current rendered height before applying any "destroying" styles
395
+ const initialHeight = _.offsetHeight;
396
+ _.setState('destroying');
397
+
398
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
399
+ requestAnimationFrame(() => {
400
+ _.style.height = `${initialHeight}px`;
401
+
402
+ // read the css custom property for timing, defaulting to 400ms
403
+ const destroyDuration =
404
+ getComputedStyle(_).getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
405
+
406
+ // animate only the height to zero; other properties stay under stylesheet control
407
+ _.style.transition = `height ${destroyDuration} ease`;
408
+ _.style.height = '0px';
409
+
410
+ setTimeout(() => _.remove(), 600);
411
+ });
412
+ }
413
+ }
119
414
 
120
- // Load cart data immediately after component initialization
121
- _.refreshCart();
415
+ /**
416
+ * Supporting component classes for cart item
417
+ */
418
+ class CartItemContent extends HTMLElement {
419
+ constructor() {
420
+ super();
122
421
  }
422
+ }
423
+
424
+ class CartItemProcessing extends HTMLElement {
425
+ constructor() {
426
+ super();
427
+ }
428
+ }
429
+
430
+ // =============================================================================
431
+ // Register Custom Elements
432
+ // =============================================================================
433
+
434
+ if (!customElements.get('cart-item')) {
435
+ customElements.define('cart-item', CartItem);
436
+ }
437
+ if (!customElements.get('cart-item-content')) {
438
+ customElements.define('cart-item-content', CartItemContent);
439
+ }
440
+ if (!customElements.get('cart-item-processing')) {
441
+ customElements.define('cart-item-processing', CartItemProcessing);
442
+ }
443
+
444
+ // Make CartItem available globally for Shopify themes
445
+ if (typeof window !== 'undefined') {
446
+ window.CartItem = CartItem;
447
+ }
448
+
449
+ // =============================================================================
450
+ // CartPanel Component
451
+ // =============================================================================
452
+
453
+ /**
454
+ * Shopping cart panel web component for Shopify.
455
+ * Manages cart data and AJAX requests, delegates modal behavior to dialog-panel.
456
+ * @extends HTMLElement
457
+ */
458
+ class CartPanel extends HTMLElement {
459
+ #currentCart = null;
460
+ #eventEmitter;
461
+ #isInitialRender = true;
462
+
463
+ constructor() {
464
+ super();
465
+ this.#eventEmitter = new EventEmitter();
466
+ }
467
+
468
+ connectedCallback() {
469
+ this.#attachListeners();
470
+
471
+ // Load cart data immediately unless manual mode is enabled
472
+ if (!this.hasAttribute('manual')) {
473
+ this.refreshCart();
474
+ }
475
+ }
476
+
477
+ disconnectedCallback() {
478
+ // Clean up handled by garbage collection
479
+ }
480
+
481
+ // =========================================================================
482
+ // Public API - Event Emitter
483
+ // =========================================================================
123
484
 
124
485
  /**
125
- * Event emitter method - Add an event listener with a cleaner API
126
- * @param {string} eventName - Name of the event to listen for
127
- * @param {Function} callback - Callback function to execute when event is fired
128
- * @returns {CartDialog} Returns this for method chaining
486
+ * Add an event listener
487
+ * @param {string} eventName - Name of the event
488
+ * @param {Function} callback - Callback function
489
+ * @returns {CartPanel} Returns this for method chaining
129
490
  */
130
491
  on(eventName, callback) {
131
492
  this.#eventEmitter.on(eventName, callback);
@@ -133,26 +494,164 @@ class CartDialog extends HTMLElement {
133
494
  }
134
495
 
135
496
  /**
136
- * Event emitter method - Remove an event listener
137
- * @param {string} eventName - Name of the event to stop listening for
138
- * @param {Function} callback - Callback function to remove
139
- * @returns {CartDialog} Returns this for method chaining
497
+ * Remove an event listener
498
+ * @param {string} eventName - Name of the event
499
+ * @param {Function} callback - Callback function
500
+ * @returns {CartPanel} Returns this for method chaining
140
501
  */
141
502
  off(eventName, callback) {
142
503
  this.#eventEmitter.off(eventName, callback);
143
504
  return this;
144
505
  }
145
506
 
507
+ // =========================================================================
508
+ // Public API - Dialog Control
509
+ // =========================================================================
510
+
511
+ /**
512
+ * Show the cart by finding and opening the nearest dialog-panel ancestor
513
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the open
514
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
515
+ */
516
+ show(triggerEl = null, cartObj = null) {
517
+ const _ = this;
518
+ const dialogPanel = _.#findDialogPanel();
519
+
520
+ if (dialogPanel) {
521
+ dialogPanel.show(triggerEl);
522
+ _.refreshCart(cartObj);
523
+ _.#emit('cart-panel:show', { triggerElement: triggerEl });
524
+ } else {
525
+ console.warn('cart-panel: No dialog-panel ancestor found. Cart panel is visible but not in a modal.');
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Hide the cart by finding and closing the nearest dialog-panel ancestor
531
+ */
532
+ hide() {
533
+ const dialogPanel = this.#findDialogPanel();
534
+ if (dialogPanel) {
535
+ dialogPanel.hide();
536
+ this.#emit('cart-panel:hide', {});
537
+ }
538
+ }
539
+
540
+ // =========================================================================
541
+ // Public API - Cart Data
542
+ // =========================================================================
543
+
544
+ /**
545
+ * Fetch current cart data from Shopify
546
+ * @returns {Promise<Object>} Cart data object
547
+ */
548
+ getCart() {
549
+ return fetch('/cart.json', {
550
+ credentials: 'same-origin',
551
+ })
552
+ .then((response) => {
553
+ if (!response.ok) {
554
+ throw Error(response.statusText);
555
+ }
556
+ return response.json();
557
+ })
558
+ .catch((error) => {
559
+ console.error('Error fetching cart:', error);
560
+ return { error: true, message: error.message };
561
+ });
562
+ }
563
+
146
564
  /**
147
- * Internal method to emit events via the event emitter
148
- * @param {string} eventName - Name of the event to emit
149
- * @param {*} [data] - Optional data to include with the event
565
+ * Update cart item quantity on Shopify
566
+ * @param {string|number} key - Cart item key/ID
567
+ * @param {number} quantity - New quantity (0 to remove)
568
+ * @returns {Promise<Object>} Updated cart data object
569
+ */
570
+ updateCartItem(key, quantity) {
571
+ return fetch('/cart/change.json', {
572
+ method: 'POST',
573
+ credentials: 'same-origin',
574
+ body: JSON.stringify({ id: key, quantity: quantity }),
575
+ headers: { 'Content-Type': 'application/json' },
576
+ })
577
+ .then((response) => {
578
+ if (!response.ok) {
579
+ throw Error(response.statusText);
580
+ }
581
+ return response.json();
582
+ })
583
+ .catch((error) => {
584
+ console.error('Error updating cart item:', error);
585
+ return { error: true, message: error.message };
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Refresh cart display - fetches from server if no cart object provided
591
+ * @param {Object} [cartObj=null] - Cart data object to render, or null to fetch
592
+ * @returns {Promise<Object>} Cart data object
593
+ */
594
+ async refreshCart(cartObj = null) {
595
+ const _ = this;
596
+
597
+ // Fetch from server if no cart object provided
598
+ cartObj = cartObj || (await _.getCart());
599
+ if (!cartObj || cartObj.error) {
600
+ console.warn('Cart data has error or is null:', cartObj);
601
+ return cartObj;
602
+ }
603
+
604
+ _.#currentCart = cartObj;
605
+ _.#renderCartItems(cartObj);
606
+ _.#renderCartPanel(cartObj);
607
+
608
+ const cartWithCalculatedFields = _.#addCalculatedFields(cartObj);
609
+ _.#emit('cart-panel:refreshed', { cart: cartWithCalculatedFields });
610
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
611
+
612
+ return cartObj;
613
+ }
614
+
615
+ // =========================================================================
616
+ // Public API - Templates
617
+ // =========================================================================
618
+
619
+ /**
620
+ * Set the template function for cart items
621
+ * @param {string} templateName - Name of the template
622
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
623
+ */
624
+ setCartItemTemplate(templateName, templateFn) {
625
+ CartItem.setTemplate(templateName, templateFn);
626
+ }
627
+
628
+ /**
629
+ * Set the processing template function for cart items
630
+ * @param {Function} templateFn - Function that returns HTML string for processing state
631
+ */
632
+ setCartItemProcessingTemplate(templateFn) {
633
+ CartItem.setProcessingTemplate(templateFn);
634
+ }
635
+
636
+ // =========================================================================
637
+ // Private Methods - Core
638
+ // =========================================================================
639
+
640
+ /**
641
+ * Find the nearest dialog-panel ancestor
642
+ * @private
643
+ */
644
+ #findDialogPanel() {
645
+ return this.closest('dialog-panel');
646
+ }
647
+
648
+ /**
649
+ * Emit an event via EventEmitter and native CustomEvent
150
650
  * @private
151
651
  */
152
652
  #emit(eventName, data = null) {
153
653
  this.#eventEmitter.emit(eventName, data);
154
654
 
155
- // Also emit as native DOM events for better compatibility
156
655
  this.dispatchEvent(
157
656
  new CustomEvent(eventName, {
158
657
  detail: data,
@@ -162,98 +661,57 @@ class CartDialog extends HTMLElement {
162
661
  }
163
662
 
164
663
  /**
165
- * Attach event listeners for cart dialog functionality
664
+ * Attach event listeners
166
665
  * @private
167
666
  */
168
667
  #attachListeners() {
169
- const _ = this;
170
-
171
- // Handle trigger buttons
172
- document.addEventListener('click', (e) => {
173
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
174
- if (!trigger) return;
175
-
176
- if (trigger.getAttribute('data-prevent-default') === 'true') {
177
- e.preventDefault();
178
- }
179
-
180
- _.show(trigger);
181
- });
182
-
183
668
  // Handle close buttons
184
- _.addEventListener('click', (e) => {
669
+ this.addEventListener('click', (e) => {
185
670
  if (!e.target.closest('[data-action-hide-cart]')) return;
186
- _.hide();
671
+ this.hide();
187
672
  });
188
673
 
189
674
  // Handle cart item remove events
190
- _.addEventListener('cart-item:remove', (e) => {
191
- _.#handleCartItemRemove(e);
675
+ this.addEventListener('cart-item:remove', (e) => {
676
+ this.#handleCartItemRemove(e);
192
677
  });
193
678
 
194
679
  // Handle cart item quantity change events
195
- _.addEventListener('cart-item:quantity-change', (e) => {
196
- _.#handleCartItemQuantityChange(e);
680
+ this.addEventListener('cart-item:quantity-change', (e) => {
681
+ this.#handleCartItemQuantityChange(e);
197
682
  });
198
-
199
- // Add transition end listener
200
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
201
683
  }
202
684
 
203
- /**
204
- * Detach event listeners
205
- * @private
206
- */
207
- #detachListeners() {
208
- const _ = this;
209
- if (_.contentPanel) {
210
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
211
- }
212
- }
213
-
214
- /**
215
- * Binds keyboard events for accessibility
216
- * @private
217
- */
218
- #bindKeyboard() {
219
- this.addEventListener('keydown', (e) => {
220
- if (e.key === 'Escape') {
221
- this.hide();
222
- }
223
- });
224
- }
685
+ // =========================================================================
686
+ // Private Methods - Cart Item Event Handlers
687
+ // =========================================================================
225
688
 
226
689
  /**
227
690
  * Handle cart item removal
228
691
  * @private
229
692
  */
230
693
  #handleCartItemRemove(e) {
694
+ const _ = this;
231
695
  const { cartKey, element } = e.detail;
232
696
 
233
- // Set item to processing state
234
697
  element.setState('processing');
235
698
 
236
- // Remove item by setting quantity to 0
237
- this.updateCartItem(cartKey, 0)
699
+ _.updateCartItem(cartKey, 0)
238
700
  .then((updatedCart) => {
239
701
  if (updatedCart && !updatedCart.error) {
240
- // Success - let smart comparison handle the removal animation
241
- this.#currentCart = updatedCart;
242
- this.#renderCartItems(updatedCart);
243
- this.#renderCartPanel(updatedCart);
244
-
245
- // Emit cart updated and data changed events
246
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
247
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
248
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
702
+ _.#currentCart = updatedCart;
703
+ _.#renderCartItems(updatedCart);
704
+ _.#renderCartPanel(updatedCart);
705
+
706
+ const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
707
+ _.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
708
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
249
709
  } else {
250
- // Error - reset to ready state
251
710
  element.setState('ready');
252
711
  console.error('Failed to remove cart item:', cartKey);
253
712
  }
254
713
  })
255
714
  .catch((error) => {
256
- // Error - reset to ready state
257
715
  element.setState('ready');
258
716
  console.error('Error removing cart item:', error);
259
717
  });
@@ -264,49 +722,46 @@ class CartDialog extends HTMLElement {
264
722
  * @private
265
723
  */
266
724
  #handleCartItemQuantityChange(e) {
725
+ const _ = this;
267
726
  const { cartKey, quantity, element } = e.detail;
268
727
 
269
- // Set item to processing state
270
728
  element.setState('processing');
271
729
 
272
- // Update item quantity
273
- this.updateCartItem(cartKey, quantity)
730
+ _.updateCartItem(cartKey, quantity)
274
731
  .then((updatedCart) => {
275
732
  if (updatedCart && !updatedCart.error) {
276
- // Success - update cart data and refresh items
277
- this.#currentCart = updatedCart;
278
- this.#renderCartItems(updatedCart);
279
- this.#renderCartPanel(updatedCart);
280
-
281
- // Emit cart updated and data changed events
282
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
283
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
284
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
733
+ _.#currentCart = updatedCart;
734
+ _.#renderCartItems(updatedCart);
735
+ _.#renderCartPanel(updatedCart);
736
+
737
+ const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
738
+ _.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
739
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
285
740
  } else {
286
- // Error - reset to ready state
287
741
  element.setState('ready');
288
742
  console.error('Failed to update cart item quantity:', cartKey, quantity);
289
743
  }
290
744
  })
291
745
  .catch((error) => {
292
- // Error - reset to ready state
293
746
  element.setState('ready');
294
747
  console.error('Error updating cart item quantity:', error);
295
748
  });
296
749
  }
297
750
 
751
+ // =========================================================================
752
+ // Private Methods - Rendering
753
+ // =========================================================================
754
+
298
755
  /**
299
- * Update cart count elements across the site
756
+ * Update cart count elements across the page
300
757
  * @private
301
758
  */
302
759
  #renderCartCount(cartData) {
303
760
  if (!cartData) return;
304
761
 
305
- // Calculate visible item count (excluding _hide_in_cart items)
306
762
  const visibleItems = this.#getVisibleCartItems(cartData);
307
763
  const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
308
764
 
309
- // Update all cart count elements across the site
310
765
  const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
311
766
  cartCountElements.forEach((element) => {
312
767
  element.textContent = visibleItemCount;
@@ -314,170 +769,110 @@ class CartDialog extends HTMLElement {
314
769
  }
315
770
 
316
771
  /**
317
- * Update cart subtotal elements across the site
772
+ * Update cart subtotal elements across the page
318
773
  * @private
319
774
  */
320
775
  #renderCartSubtotal(cartData) {
321
776
  if (!cartData) return;
322
777
 
323
- // Calculate subtotal from all items except those marked to ignore pricing
324
778
  const pricedItems = cartData.items.filter((item) => {
325
779
  const ignorePrice = item.properties?._ignore_price_in_subtotal;
326
780
  return !ignorePrice;
327
781
  });
328
782
  const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
329
783
 
330
- // Update all cart subtotal elements across the site
331
784
  const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
332
785
  cartSubtotalElements.forEach((element) => {
333
- // Format as currency (assuming cents, convert to dollars)
334
786
  const formatted = (subtotal / 100).toFixed(2);
335
787
  element.textContent = `$${formatted}`;
336
788
  });
337
789
  }
338
790
 
339
791
  /**
340
- * Update cart items display based on cart data
792
+ * Update cart panel sections (has-items/empty)
341
793
  * @private
342
794
  */
343
795
  #renderCartPanel(cart = null) {
344
- const cartData = cart || this.#currentCart;
796
+ const _ = this;
797
+ const cartData = cart || _.#currentCart;
345
798
  if (!cartData) return;
346
799
 
347
- // Get cart sections
348
- const hasItemsSection = this.querySelector('[data-cart-has-items]');
349
- const emptySection = this.querySelector('[data-cart-is-empty]');
350
- const itemsContainer = this.querySelector('[data-content-cart-items]');
800
+ const visibleItems = _.#getVisibleCartItems(cartData);
801
+ const hasVisibleItems = visibleItems.length > 0;
351
802
 
352
- if (!hasItemsSection || !emptySection || !itemsContainer) {
353
- console.warn(
354
- 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
355
- );
356
- return;
357
- }
803
+ // Set state attribute for CSS styling (e.g., Tailwind variants)
804
+ _.setAttribute('state', hasVisibleItems ? 'has-items' : 'empty');
358
805
 
359
- // Check visible item count for showing/hiding sections
360
- const visibleItems = this.#getVisibleCartItems(cartData);
361
- const hasVisibleItems = visibleItems.length > 0;
806
+ const hasItemsSection = _.querySelector('[data-cart-has-items]');
807
+ const emptySection = _.querySelector('[data-cart-is-empty]');
362
808
 
363
- // Show/hide sections based on visible item count
364
- if (hasVisibleItems) {
365
- hasItemsSection.style.display = '';
366
- emptySection.style.display = 'none';
367
- } else {
368
- hasItemsSection.style.display = 'none';
369
- emptySection.style.display = '';
809
+ if (hasItemsSection && emptySection) {
810
+ hasItemsSection.style.display = hasVisibleItems ? '' : 'none';
811
+ emptySection.style.display = hasVisibleItems ? 'none' : '';
370
812
  }
371
813
 
372
- // Update cart count and subtotal across the site
373
- this.#renderCartCount(cartData);
374
- this.#renderCartSubtotal(cartData);
814
+ _.#renderCartCount(cartData);
815
+ _.#renderCartSubtotal(cartData);
375
816
  }
376
817
 
377
818
  /**
378
- * Fetch current cart data from server
379
- * @returns {Promise<Object>} Cart data object
819
+ * Render cart items with smart add/update/remove
820
+ * @private
380
821
  */
381
- getCart() {
382
- return fetch('/cart.json', {
383
- crossDomain: true,
384
- credentials: 'same-origin',
385
- })
386
- .then((response) => {
387
- if (!response.ok) {
388
- throw Error(response.statusText);
389
- }
390
- return response.json();
391
- })
392
- .catch((error) => {
393
- console.error('Error fetching cart:', error);
394
- return { error: true, message: error.message };
395
- });
396
- }
822
+ #renderCartItems(cartData) {
823
+ const _ = this;
824
+ const itemsContainer = _.querySelector('[data-content-cart-items]');
397
825
 
398
- /**
399
- * Update cart item quantity on server
400
- * @param {string|number} key - Cart item key/ID
401
- * @param {number} quantity - New quantity (0 to remove)
402
- * @returns {Promise<Object>} Updated cart data object
403
- */
404
- updateCartItem(key, quantity) {
405
- return fetch('/cart/change.json', {
406
- crossDomain: true,
407
- method: 'POST',
408
- credentials: 'same-origin',
409
- body: JSON.stringify({ id: key, quantity: quantity }),
410
- headers: { 'Content-Type': 'application/json' },
411
- })
412
- .then((response) => {
413
- if (!response.ok) {
414
- throw Error(response.statusText);
415
- }
416
- return response.json();
417
- })
418
- .catch((error) => {
419
- console.error('Error updating cart item:', error);
420
- return { error: true, message: error.message };
826
+ if (!itemsContainer || !cartData || !cartData.items) return;
827
+
828
+ const visibleItems = _.#getVisibleCartItems(cartData);
829
+
830
+ // Initial render - load all items without animation
831
+ if (_.#isInitialRender) {
832
+ itemsContainer.innerHTML = '';
833
+ visibleItems.forEach((itemData) => {
834
+ itemsContainer.appendChild(new CartItem(itemData, cartData));
421
835
  });
422
- }
836
+ _.#isInitialRender = false;
837
+ return;
838
+ }
423
839
 
424
- /**
425
- * Refresh cart data from server and update components
426
- * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
427
- * @returns {Promise<Object>} Cart data object
428
- */
429
- refreshCart(cartObj = null) {
430
- // If cart object is provided, use it directly
431
- if (cartObj && !cartObj.error) {
432
- // console.log('Using provided cart data:', cartObj);
433
- this.#currentCart = cartObj;
434
- this.#renderCartItems(cartObj);
435
- this.#renderCartPanel(cartObj);
840
+ // Get current DOM items
841
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
842
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
436
843
 
437
- // Emit cart refreshed and data changed events
438
- const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
439
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
440
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
844
+ // Get new cart data keys
845
+ const newKeys = visibleItems.map((item) => item.key || item.id);
846
+ const newKeysSet = new Set(newKeys);
441
847
 
442
- return Promise.resolve(cartObj);
443
- }
848
+ // Step 1: Remove items no longer in cart
849
+ _.#removeItemsFromDOM(itemsContainer, newKeysSet);
444
850
 
445
- // Otherwise fetch from server
446
- return this.getCart().then((cartData) => {
447
- // console.log('Cart data received:', cartData);
448
- if (cartData && !cartData.error) {
449
- this.#currentCart = cartData;
450
- this.#renderCartItems(cartData);
451
- this.#renderCartPanel(cartData);
452
-
453
- // Emit cart refreshed and data changed events
454
- const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
455
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
456
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
457
- } else {
458
- console.warn('Cart data has error or is null:', cartData);
459
- }
460
- return cartData;
461
- });
851
+ // Step 2: Update existing items
852
+ _.#updateItemsInDOM(itemsContainer, cartData);
853
+
854
+ // Step 3: Add new items with animation
855
+ const itemsToAdd = visibleItems.filter(
856
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
857
+ );
858
+ _.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData);
462
859
  }
463
860
 
464
861
  /**
465
- * Remove items from DOM that are no longer in cart data
862
+ * Remove items from DOM that are no longer in cart
466
863
  * @private
467
864
  */
468
865
  #removeItemsFromDOM(itemsContainer, newKeysSet) {
469
866
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
470
-
471
867
  const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
472
868
 
473
869
  itemsToRemove.forEach((item) => {
474
- console.log('destroy yourself', item);
475
870
  item.destroyYourself();
476
871
  });
477
872
  }
478
873
 
479
874
  /**
480
- * Update existing cart-item elements with fresh cart data
875
+ * Update existing cart-item elements with fresh data
481
876
  * @private
482
877
  */
483
878
  #updateItemsInDOM(itemsContainer, cartData) {
@@ -487,12 +882,7 @@ class CartDialog extends HTMLElement {
487
882
  existingItems.forEach((cartItemEl) => {
488
883
  const key = cartItemEl.getAttribute('key');
489
884
  const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
490
-
491
- if (updatedItemData) {
492
- // Update cart-item with fresh data and full cart context
493
- // The cart-item will handle HTML comparison and only re-render if needed
494
- cartItemEl.setData(updatedItemData, cartData);
495
- }
885
+ if (updatedItemData) cartItemEl.setData(updatedItemData, cartData);
496
886
  });
497
887
  }
498
888
 
@@ -500,19 +890,15 @@ class CartDialog extends HTMLElement {
500
890
  * Add new items to DOM with animation delay
501
891
  * @private
502
892
  */
503
- #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
504
- // Delay adding new items by 300ms to let cart slide open first
893
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData) {
505
894
  setTimeout(() => {
506
895
  itemsToAdd.forEach((itemData) => {
507
- const cartItem = CartItem.createAnimated(itemData);
896
+ const cartItem = CartItem.createAnimated(itemData, cartData);
508
897
  const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
509
898
 
510
- // Find the correct position to insert the new item
511
899
  if (targetIndex === 0) {
512
- // Insert at the beginning
513
900
  itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
514
901
  } else {
515
- // Find the item that should come before this one
516
902
  let insertAfter = null;
517
903
  for (let i = targetIndex - 1; i >= 0; i--) {
518
904
  const prevKey = newKeys[i];
@@ -533,246 +919,46 @@ class CartDialog extends HTMLElement {
533
919
  }, 100);
534
920
  }
535
921
 
922
+ // =========================================================================
923
+ // Private Methods - Helpers
924
+ // =========================================================================
925
+
536
926
  /**
537
- * Filter cart items to exclude those with _hide_in_cart property
927
+ * Filter cart items to exclude hidden items
538
928
  * @private
539
929
  */
540
930
  #getVisibleCartItems(cartData) {
541
931
  if (!cartData || !cartData.items) return [];
542
932
  return cartData.items.filter((item) => {
543
- // Check for _hide_in_cart in various possible locations
544
933
  const hidden = item.properties?._hide_in_cart;
545
-
546
934
  return !hidden;
547
935
  });
548
936
  }
549
937
 
550
938
  /**
551
- * Add calculated fields to cart object for events
939
+ * Add calculated fields to cart object
552
940
  * @private
553
941
  */
554
942
  #addCalculatedFields(cartData) {
555
943
  if (!cartData) return cartData;
556
944
 
557
- // For display counts: use visible items (excludes _hide_in_cart)
558
945
  const visibleItems = this.#getVisibleCartItems(cartData);
559
946
  const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
560
947
 
561
- // For pricing: use all items except those marked to ignore pricing
562
- const pricedItems = cartData.items.filter((item) => {
563
- const ignorePrice = item.properties?._ignore_price_in_subtotal;
564
- return !ignorePrice;
565
- });
566
- const calculated_subtotal = pricedItems.reduce(
567
- (total, item) => total + (item.line_price || 0),
568
- 0
569
- );
948
+ const pricedItems = cartData.items.filter((item) => !item.properties?._ignore_price_in_subtotal);
949
+ const calculated_subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
570
950
 
571
- return {
572
- ...cartData,
573
- calculated_count,
574
- calculated_subtotal,
575
- };
576
- }
577
-
578
- /**
579
- * Render cart items from Shopify cart data with smart comparison
580
- * @private
581
- */
582
- #renderCartItems(cartData) {
583
- const itemsContainer = this.querySelector('[data-content-cart-items]');
584
-
585
- if (!itemsContainer || !cartData || !cartData.items) {
586
- console.warn('Cannot render cart items:', {
587
- itemsContainer: !!itemsContainer,
588
- cartData: !!cartData,
589
- items: cartData?.items?.length,
590
- });
591
- return;
592
- }
593
-
594
- // Filter out items with _hide_in_cart property
595
- const visibleItems = this.#getVisibleCartItems(cartData);
596
-
597
- // Handle initial render - load all items without animation
598
- if (this.#isInitialRender) {
599
- // console.log('Initial cart render:', visibleItems.length, 'visible items');
600
-
601
- // Clear existing items
602
- itemsContainer.innerHTML = '';
603
-
604
- // Create cart-item elements without animation
605
- visibleItems.forEach((itemData) => {
606
- const cartItem = new CartItem(itemData); // No animation
607
- itemsContainer.appendChild(cartItem);
608
- });
609
-
610
- this.#isInitialRender = false;
611
-
612
- return;
613
- }
614
-
615
- // Get current DOM items and their keys
616
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
617
- const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
618
-
619
- // Get new cart data keys in order (only visible items)
620
- const newKeys = visibleItems.map((item) => item.key || item.id);
621
- const newKeysSet = new Set(newKeys);
622
-
623
- // Step 1: Remove items that are no longer in cart data
624
- this.#removeItemsFromDOM(itemsContainer, newKeysSet);
625
-
626
- // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
627
- this.#updateItemsInDOM(itemsContainer, cartData);
628
-
629
- // Step 3: Add new items that weren't in DOM (with animation delay)
630
- const itemsToAdd = visibleItems.filter(
631
- (itemData) => !currentKeys.has(itemData.key || itemData.id)
632
- );
633
- this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
634
- }
635
-
636
- /**
637
- * Set the template function for cart items
638
- * @param {Function} templateFn - Function that takes item data and returns HTML string
639
- */
640
- setCartItemTemplate(templateName, templateFn) {
641
- CartItem.setTemplate(templateName, templateFn);
642
- }
643
-
644
- /**
645
- * Set the processing template function for cart items
646
- * @param {Function} templateFn - Function that returns HTML string for processing state
647
- */
648
- setCartItemProcessingTemplate(templateFn) {
649
- CartItem.setProcessingTemplate(templateFn);
650
- }
651
-
652
- /**
653
- * Shows the cart dialog and traps focus within it
654
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
655
- * @fires CartDialog#show - Fired when the cart dialog has been shown
656
- */
657
- show(triggerEl = null, cartObj) {
658
- const _ = this;
659
- _.triggerEl = triggerEl || false;
660
-
661
- // Lock body scrolling
662
- _.#lockScroll();
663
-
664
- // Remove the hidden class first to ensure content is rendered
665
- _.contentPanel.classList.remove('hidden');
666
-
667
- // Give the browser a moment to process before starting animation
668
- requestAnimationFrame(() => {
669
- // Update ARIA states
670
- _.setAttribute('aria-hidden', 'false');
671
-
672
- if (_.triggerEl) {
673
- _.triggerEl.setAttribute('aria-expanded', 'true');
674
- }
675
-
676
- // Focus management
677
- const firstFocusable = _.querySelector(
678
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
679
- );
680
-
681
- if (firstFocusable) {
682
- requestAnimationFrame(() => {
683
- firstFocusable.focus();
684
- });
685
- }
686
-
687
- // Refresh cart data when showing
688
- _.refreshCart(cartObj);
689
-
690
- // Emit show event - cart dialog is now visible
691
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
692
- });
693
- }
694
-
695
- /**
696
- * Hides the cart dialog and restores focus
697
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
698
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
699
- */
700
- hide() {
701
- const _ = this;
702
-
703
- // Update ARIA states
704
- if (_.triggerEl) {
705
- // remove focus from modal panel first
706
- _.triggerEl.focus();
707
- // mark trigger as no longer expanded
708
- _.triggerEl.setAttribute('aria-expanded', 'false');
709
- } else {
710
- // If no trigger element, blur any focused element inside the panel
711
- const activeElement = document.activeElement;
712
- if (activeElement && _.contains(activeElement)) {
713
- activeElement.blur();
714
- }
715
- }
716
-
717
- requestAnimationFrame(() => {
718
- // Set aria-hidden to start transition
719
- // The transitionend event handler will add display:none when complete
720
- _.setAttribute('aria-hidden', 'true');
721
-
722
- // Emit hide event - cart dialog is now starting to hide
723
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
724
-
725
- // Restore body scroll
726
- _.#restoreScroll();
727
- });
951
+ return { ...cartData, calculated_count, calculated_subtotal };
728
952
  }
729
953
  }
730
954
 
731
- /**
732
- * Custom element that creates a clickable overlay for the cart dialog
733
- * @extends HTMLElement
734
- */
735
- class CartOverlay extends HTMLElement {
736
- constructor() {
737
- super();
738
- this.setAttribute('tabindex', '-1');
739
- this.setAttribute('aria-hidden', 'true');
740
- this.cartDialog = this.closest('cart-dialog');
741
- this.#attachListeners();
742
- }
743
-
744
- #attachListeners() {
745
- this.addEventListener('click', () => {
746
- this.cartDialog.hide();
747
- });
748
- }
749
- }
955
+ // =============================================================================
956
+ // Register Custom Elements
957
+ // =============================================================================
750
958
 
751
- /**
752
- * Custom element that wraps the content of the cart dialog
753
- * @extends HTMLElement
754
- */
755
- class CartPanel extends HTMLElement {
756
- constructor() {
757
- super();
758
- this.setAttribute('role', 'document');
759
- }
760
- }
761
-
762
- if (!customElements.get('cart-dialog')) {
763
- customElements.define('cart-dialog', CartDialog);
764
- }
765
- if (!customElements.get('cart-overlay')) {
766
- customElements.define('cart-overlay', CartOverlay);
767
- }
768
959
  if (!customElements.get('cart-panel')) {
769
960
  customElements.define('cart-panel', CartPanel);
770
961
  }
771
962
 
772
- // Make CartItem available globally for Shopify themes
773
- if (typeof window !== 'undefined') {
774
- window.CartItem = CartItem;
775
- }
776
-
777
- export { CartDialog, CartOverlay, CartPanel, CartDialog as default };
963
+ export { CartItem, CartItemContent, CartItemProcessing, CartPanel, CartPanel as default };
778
964
  //# sourceMappingURL=cart-panel.esm.js.map