@magic-spells/cart-panel 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import '@magic-spells/cart-item';
1
+ import { CartItem } from '@magic-spells/cart-item';
2
+ export { CartItem } from '@magic-spells/cart-item';
2
3
  import '@magic-spells/focus-trap';
3
4
  import EventEmitter from '@magic-spells/event-emitter';
4
5
 
@@ -8,9 +9,9 @@ import EventEmitter from '@magic-spells/event-emitter';
8
9
  */
9
10
  class CartDialog extends HTMLElement {
10
11
  #handleTransitionEnd;
11
- #scrollPosition = 0;
12
12
  #currentCart = null;
13
13
  #eventEmitter;
14
+ #isInitialRender = true;
14
15
 
15
16
  /**
16
17
  * Clean up event listeners when component is removed from DOM
@@ -30,31 +31,21 @@ class CartDialog extends HTMLElement {
30
31
  }
31
32
 
32
33
  /**
33
- * Saves current scroll position and locks body scrolling
34
+ * Locks body scrolling
34
35
  * @private
35
36
  */
36
37
  #lockScroll() {
37
- const _ = this;
38
- // Save current scroll position
39
- _.#scrollPosition = window.pageYOffset;
40
-
41
- // Apply fixed position to body
38
+ // Apply overflow hidden to body
42
39
  document.body.classList.add('overflow-hidden');
43
- document.body.style.top = `-${_.#scrollPosition}px`;
44
40
  }
45
41
 
46
42
  /**
47
- * Restores scroll position when cart dialog is closed
43
+ * Restores body scrolling when cart dialog is closed
48
44
  * @private
49
45
  */
50
46
  #restoreScroll() {
51
- const _ = this;
52
- // Remove fixed positioning
47
+ // Remove overflow hidden from body
53
48
  document.body.classList.remove('overflow-hidden');
54
- document.body.style.removeProperty('top');
55
-
56
- // Restore scroll position
57
- window.scrollTo(0, _.#scrollPosition);
58
49
  }
59
50
 
60
51
  /**
@@ -95,7 +86,18 @@ class CartDialog extends HTMLElement {
95
86
  return;
96
87
  }
97
88
 
98
- _.focusTrap = document.createElement('focus-trap');
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');
93
+
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));
97
+
98
+ // Insert focus trap inside the cart-panel
99
+ _.contentPanel.appendChild(_.focusTrap);
100
+ }
99
101
 
100
102
  // Ensure we have labelledby and describedby references
101
103
  if (!_.getAttribute('aria-labelledby')) {
@@ -108,15 +110,15 @@ class CartDialog extends HTMLElement {
108
110
  }
109
111
  }
110
112
 
111
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
112
- _.focusTrap.appendChild(_.contentPanel);
113
-
114
- _.focusTrap.setupTrap();
115
-
116
- // Add modal overlay
117
- _.prepend(document.createElement('cart-overlay'));
113
+ // Add modal overlay if it doesn't already exist
114
+ if (!_.querySelector('cart-overlay')) {
115
+ _.prepend(document.createElement('cart-overlay'));
116
+ }
118
117
  _.#attachListeners();
119
118
  _.#bindKeyboard();
119
+
120
+ // Load cart data immediately after component initialization
121
+ _.refreshCart();
120
122
  }
121
123
 
122
124
  /**
@@ -149,6 +151,14 @@ class CartDialog extends HTMLElement {
149
151
  */
150
152
  #emit(eventName, data = null) {
151
153
  this.#eventEmitter.emit(eventName, data);
154
+
155
+ // Also emit as native DOM events for better compatibility
156
+ this.dispatchEvent(
157
+ new CustomEvent(eventName, {
158
+ detail: data,
159
+ bubbles: true,
160
+ })
161
+ );
152
162
  }
153
163
 
154
164
  /**
@@ -172,7 +182,7 @@ class CartDialog extends HTMLElement {
172
182
 
173
183
  // Handle close buttons
174
184
  _.addEventListener('click', (e) => {
175
- if (!e.target.closest('[data-action="hide-cart"]')) return;
185
+ if (!e.target.closest('[data-action-hide-cart]')) return;
176
186
  _.hide();
177
187
  });
178
188
 
@@ -227,14 +237,15 @@ class CartDialog extends HTMLElement {
227
237
  this.updateCartItem(cartKey, 0)
228
238
  .then((updatedCart) => {
229
239
  if (updatedCart && !updatedCart.error) {
230
- // Success - remove with animation
231
- element.destroyYourself();
240
+ // Success - let smart comparison handle the removal animation
232
241
  this.#currentCart = updatedCart;
233
- this.#updateCartItems(updatedCart);
242
+ this.#renderCartItems(updatedCart);
243
+ this.#renderCartPanel(updatedCart);
234
244
 
235
245
  // Emit cart updated and data changed events
236
- this.#emit('cart-dialog:updated', { cart: updatedCart });
237
- this.#emit('cart-dialog:data-changed', updatedCart);
246
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
247
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
248
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
238
249
  } else {
239
250
  // Error - reset to ready state
240
251
  element.setState('ready');
@@ -262,14 +273,16 @@ class CartDialog extends HTMLElement {
262
273
  this.updateCartItem(cartKey, quantity)
263
274
  .then((updatedCart) => {
264
275
  if (updatedCart && !updatedCart.error) {
265
- // Success - update cart data
276
+ // Success - update cart data and refresh items
266
277
  this.#currentCart = updatedCart;
267
- this.#updateCartItems(updatedCart);
278
+ this.#renderCartItems(updatedCart);
279
+ this.#renderCartPanel(updatedCart);
268
280
  element.setState('ready');
269
281
 
270
282
  // Emit cart updated and data changed events
271
- this.#emit('cart-dialog:updated', { cart: updatedCart });
272
- this.#emit('cart-dialog:data-changed', updatedCart);
283
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
284
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
285
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
273
286
  } else {
274
287
  // Error - reset to ready state
275
288
  element.setState('ready');
@@ -284,13 +297,82 @@ class CartDialog extends HTMLElement {
284
297
  }
285
298
 
286
299
  /**
287
- * Update cart items
300
+ * Update cart count elements across the site
301
+ * @private
302
+ */
303
+ #renderCartCount(cartData) {
304
+ if (!cartData) return;
305
+
306
+ // Calculate visible item count (excluding _hide_in_cart items)
307
+ const visibleItems = this.#getVisibleCartItems(cartData);
308
+ const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
309
+
310
+ // Update all cart count elements across the site
311
+ const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
312
+ cartCountElements.forEach((element) => {
313
+ element.textContent = visibleItemCount;
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Update cart subtotal elements across the site
319
+ * @private
320
+ */
321
+ #renderCartSubtotal(cartData) {
322
+ if (!cartData) return;
323
+
324
+ // Calculate subtotal from all items except those marked to ignore pricing
325
+ const pricedItems = cartData.items.filter(item => {
326
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
327
+ return !ignorePrice;
328
+ });
329
+ const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
330
+
331
+ // Update all cart subtotal elements across the site
332
+ const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
333
+ cartSubtotalElements.forEach((element) => {
334
+ // Format as currency (assuming cents, convert to dollars)
335
+ const formatted = (subtotal / 100).toFixed(2);
336
+ element.textContent = `$${formatted}`;
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Update cart items display based on cart data
288
342
  * @private
289
343
  */
290
- #updateCartItems(cart = null) {
291
- // Placeholder for cart item updates
292
- // Could be used to sync cart items with server data
293
- cart || this.#currentCart;
344
+ #renderCartPanel(cart = null) {
345
+ const cartData = cart || this.#currentCart;
346
+ if (!cartData) return;
347
+
348
+ // Get cart sections
349
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
350
+ const emptySection = this.querySelector('[data-cart-is-empty]');
351
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
352
+
353
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
354
+ console.warn(
355
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
356
+ );
357
+ return;
358
+ }
359
+
360
+ // Check visible item count for showing/hiding sections
361
+ const visibleItems = this.#getVisibleCartItems(cartData);
362
+ const hasVisibleItems = visibleItems.length > 0;
363
+
364
+ // Show/hide sections based on visible item count
365
+ if (hasVisibleItems) {
366
+ hasItemsSection.style.display = '';
367
+ emptySection.style.display = 'none';
368
+ } else {
369
+ hasItemsSection.style.display = 'none';
370
+ emptySection.style.display = '';
371
+ }
372
+
373
+ // Update cart count and subtotal across the site
374
+ this.#renderCartCount(cartData);
375
+ this.#renderCartSubtotal(cartData);
294
376
  }
295
377
 
296
378
  /**
@@ -342,31 +424,216 @@ class CartDialog extends HTMLElement {
342
424
 
343
425
  /**
344
426
  * Refresh cart data from server and update components
427
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
345
428
  * @returns {Promise<Object>} Cart data object
346
429
  */
347
- refreshCart() {
430
+ refreshCart(cartObj = null) {
431
+ // If cart object is provided, use it directly
432
+ if (cartObj && !cartObj.error) {
433
+ // console.log('Using provided cart data:', cartObj);
434
+ this.#currentCart = cartObj;
435
+ this.#renderCartItems(cartObj);
436
+ this.#renderCartPanel(cartObj);
437
+
438
+ // Emit cart refreshed and data changed events
439
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
440
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
441
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
442
+
443
+ return Promise.resolve(cartObj);
444
+ }
445
+
446
+ // Otherwise fetch from server
348
447
  return this.getCart().then((cartData) => {
448
+ // console.log('Cart data received:', cartData);
349
449
  if (cartData && !cartData.error) {
350
450
  this.#currentCart = cartData;
351
- this.#updateCartItems(cartData);
451
+ this.#renderCartItems(cartData);
452
+ this.#renderCartPanel(cartData);
352
453
 
353
454
  // Emit cart refreshed and data changed events
354
- this.#emit('cart-dialog:refreshed', { cart: cartData });
355
- this.#emit('cart-dialog:data-changed', cartData);
455
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
456
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
457
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
458
+ } else {
459
+ console.warn('Cart data has error or is null:', cartData);
356
460
  }
357
461
  return cartData;
358
462
  });
359
463
  }
360
464
 
465
+ /**
466
+ * Remove items from DOM that are no longer in cart data
467
+ * @private
468
+ */
469
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
470
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
471
+
472
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
473
+
474
+ itemsToRemove.forEach((item) => {
475
+ console.log('destroy yourself', item);
476
+ item.destroyYourself();
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Add new items to DOM with animation delay
482
+ * @private
483
+ */
484
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
485
+ // Delay adding new items by 300ms to let cart slide open first
486
+ setTimeout(() => {
487
+ itemsToAdd.forEach((itemData) => {
488
+ const cartItem = CartItem.createAnimated(itemData);
489
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
490
+
491
+ // Find the correct position to insert the new item
492
+ if (targetIndex === 0) {
493
+ // Insert at the beginning
494
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
495
+ } else {
496
+ // Find the item that should come before this one
497
+ let insertAfter = null;
498
+ for (let i = targetIndex - 1; i >= 0; i--) {
499
+ const prevKey = newKeys[i];
500
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
501
+ if (prevItem) {
502
+ insertAfter = prevItem;
503
+ break;
504
+ }
505
+ }
506
+
507
+ if (insertAfter) {
508
+ insertAfter.insertAdjacentElement('afterend', cartItem);
509
+ } else {
510
+ itemsContainer.appendChild(cartItem);
511
+ }
512
+ }
513
+ });
514
+ }, 100);
515
+ }
516
+
517
+ /**
518
+ * Filter cart items to exclude those with _hide_in_cart property
519
+ * @private
520
+ */
521
+ #getVisibleCartItems(cartData) {
522
+ if (!cartData || !cartData.items) return [];
523
+ return cartData.items.filter((item) => {
524
+ // Check for _hide_in_cart in various possible locations
525
+ const hidden = item.properties?._hide_in_cart;
526
+
527
+ return !hidden;
528
+ });
529
+ }
530
+
531
+ /**
532
+ * Add calculated fields to cart object for events
533
+ * @private
534
+ */
535
+ #addCalculatedFields(cartData) {
536
+ if (!cartData) return cartData;
537
+
538
+ const visibleItems = this.#getVisibleCartItems(cartData);
539
+ const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
540
+ const calculated_subtotal = visibleItems.reduce(
541
+ (total, item) => total + (item.line_price || 0),
542
+ 0
543
+ );
544
+
545
+ return {
546
+ ...cartData,
547
+ calculated_count,
548
+ calculated_subtotal,
549
+ };
550
+ }
551
+
552
+ /**
553
+ * Render cart items from Shopify cart data with smart comparison
554
+ * @private
555
+ */
556
+ #renderCartItems(cartData) {
557
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
558
+
559
+ if (!itemsContainer || !cartData || !cartData.items) {
560
+ console.warn('Cannot render cart items:', {
561
+ itemsContainer: !!itemsContainer,
562
+ cartData: !!cartData,
563
+ items: cartData?.items?.length,
564
+ });
565
+ return;
566
+ }
567
+
568
+ // Filter out items with _hide_in_cart property
569
+ const visibleItems = this.#getVisibleCartItems(cartData);
570
+
571
+ // Handle initial render - load all items without animation
572
+ if (this.#isInitialRender) {
573
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
574
+
575
+ // Clear existing items
576
+ itemsContainer.innerHTML = '';
577
+
578
+ // Create cart-item elements without animation
579
+ visibleItems.forEach((itemData) => {
580
+ const cartItem = new CartItem(itemData); // No animation
581
+ // const cartItem = document.createElement('cart-item');
582
+ // cartItem.setData(itemData);
583
+ itemsContainer.appendChild(cartItem);
584
+ });
585
+
586
+ this.#isInitialRender = false;
587
+
588
+ return;
589
+ }
590
+
591
+ // Get current DOM items and their keys
592
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
593
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
594
+
595
+ // Get new cart data keys in order (only visible items)
596
+ const newKeys = visibleItems.map((item) => item.key || item.id);
597
+ const newKeysSet = new Set(newKeys);
598
+
599
+ // Step 1: Remove items that are no longer in cart data
600
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
601
+
602
+ // Step 2: Add new items that weren't in DOM (with animation delay)
603
+ const itemsToAdd = visibleItems.filter(
604
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
605
+ );
606
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
607
+ }
608
+
609
+ /**
610
+ * Set the template function for cart items
611
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
612
+ */
613
+ setCartItemTemplate(templateName, templateFn) {
614
+ CartItem.setTemplate(templateName, templateFn);
615
+ }
616
+
617
+ /**
618
+ * Set the processing template function for cart items
619
+ * @param {Function} templateFn - Function that returns HTML string for processing state
620
+ */
621
+ setCartItemProcessingTemplate(templateFn) {
622
+ CartItem.setProcessingTemplate(templateFn);
623
+ }
624
+
361
625
  /**
362
626
  * Shows the cart dialog and traps focus within it
363
627
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
364
628
  * @fires CartDialog#show - Fired when the cart dialog has been shown
365
629
  */
366
- show(triggerEl = null) {
630
+ show(triggerEl = null, cartObj) {
367
631
  const _ = this;
368
632
  _.triggerEl = triggerEl || false;
369
633
 
634
+ // Lock body scrolling
635
+ _.#lockScroll();
636
+
370
637
  // Remove the hidden class first to ensure content is rendered
371
638
  _.contentPanel.classList.remove('hidden');
372
639
 
@@ -374,17 +641,16 @@ class CartDialog extends HTMLElement {
374
641
  requestAnimationFrame(() => {
375
642
  // Update ARIA states
376
643
  _.setAttribute('aria-hidden', 'false');
644
+
377
645
  if (_.triggerEl) {
378
646
  _.triggerEl.setAttribute('aria-expanded', 'true');
379
647
  }
380
648
 
381
- // Lock body scrolling and save scroll position
382
- _.#lockScroll();
383
-
384
649
  // Focus management
385
650
  const firstFocusable = _.querySelector(
386
651
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
387
652
  );
653
+
388
654
  if (firstFocusable) {
389
655
  requestAnimationFrame(() => {
390
656
  firstFocusable.focus();
@@ -392,7 +658,7 @@ class CartDialog extends HTMLElement {
392
658
  }
393
659
 
394
660
  // Refresh cart data when showing
395
- _.refreshCart();
661
+ _.refreshCart(cartObj);
396
662
 
397
663
  // Emit show event - cart dialog is now visible
398
664
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -407,23 +673,31 @@ class CartDialog extends HTMLElement {
407
673
  hide() {
408
674
  const _ = this;
409
675
 
410
- // Restore body scroll and scroll position
411
- _.#restoreScroll();
412
-
413
676
  // Update ARIA states
414
677
  if (_.triggerEl) {
415
678
  // remove focus from modal panel first
416
679
  _.triggerEl.focus();
417
680
  // mark trigger as no longer expanded
418
681
  _.triggerEl.setAttribute('aria-expanded', 'false');
682
+ } else {
683
+ // If no trigger element, blur any focused element inside the panel
684
+ const activeElement = document.activeElement;
685
+ if (activeElement && _.contains(activeElement)) {
686
+ activeElement.blur();
687
+ }
419
688
  }
420
689
 
421
- // Set aria-hidden to start transition
422
- // The transitionend event handler will add display:none when complete
423
- _.setAttribute('aria-hidden', 'true');
690
+ requestAnimationFrame(() => {
691
+ // Set aria-hidden to start transition
692
+ // The transitionend event handler will add display:none when complete
693
+ _.setAttribute('aria-hidden', 'true');
694
+
695
+ // Emit hide event - cart dialog is now starting to hide
696
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
424
697
 
425
- // Emit hide event - cart dialog is now starting to hide
426
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
698
+ // Restore body scroll
699
+ _.#restoreScroll();
700
+ });
427
701
  }
428
702
  }
429
703
 
@@ -468,5 +742,10 @@ if (!customElements.get('cart-panel')) {
468
742
  customElements.define('cart-panel', CartPanel);
469
743
  }
470
744
 
745
+ // Make CartItem available globally for Shopify themes
746
+ if (typeof window !== 'undefined') {
747
+ window.CartItem = CartItem;
748
+ }
749
+
471
750
  export { CartDialog, CartOverlay, CartPanel, CartDialog as default };
472
751
  //# sourceMappingURL=cart-panel.esm.js.map