@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.
package/src/cart-panel.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import './cart-panel.scss';
2
- import '@magic-spells/cart-item';
3
- import { CartItem } from '@magic-spells/cart-item';
2
+
4
3
  import '@magic-spells/focus-trap';
5
4
  import EventEmitter from '@magic-spells/event-emitter';
5
+ import { CartItem } from '@magic-spells/cart-item';
6
6
 
7
7
  /**
8
8
  * Custom element that creates an accessible modal cart dialog with focus management
@@ -10,7 +10,6 @@ import EventEmitter from '@magic-spells/event-emitter';
10
10
  */
11
11
  class CartDialog extends HTMLElement {
12
12
  #handleTransitionEnd;
13
- #scrollPosition = 0;
14
13
  #currentCart = null;
15
14
  #eventEmitter;
16
15
  #isInitialRender = true;
@@ -33,31 +32,21 @@ class CartDialog extends HTMLElement {
33
32
  }
34
33
 
35
34
  /**
36
- * Saves current scroll position and locks body scrolling
35
+ * Locks body scrolling
37
36
  * @private
38
37
  */
39
38
  #lockScroll() {
40
- const _ = this;
41
- // Save current scroll position
42
- _.#scrollPosition = window.pageYOffset;
43
-
44
- // Apply fixed position to body
39
+ // Apply overflow hidden to body
45
40
  document.body.classList.add('overflow-hidden');
46
- document.body.style.top = `-${_.#scrollPosition}px`;
47
41
  }
48
42
 
49
43
  /**
50
- * Restores scroll position when cart dialog is closed
44
+ * Restores body scrolling when cart dialog is closed
51
45
  * @private
52
46
  */
53
47
  #restoreScroll() {
54
- const _ = this;
55
- // Remove fixed positioning
48
+ // Remove overflow hidden from body
56
49
  document.body.classList.remove('overflow-hidden');
57
- document.body.style.removeProperty('top');
58
-
59
- // Restore scroll position
60
- window.scrollTo(0, _.#scrollPosition);
61
50
  }
62
51
 
63
52
  /**
@@ -98,7 +87,18 @@ class CartDialog extends HTMLElement {
98
87
  return;
99
88
  }
100
89
 
101
- _.focusTrap = document.createElement('focus-trap');
90
+ // Check if focus-trap already exists, if not create one
91
+ _.focusTrap = _.contentPanel.querySelector('focus-trap');
92
+ if (!_.focusTrap) {
93
+ _.focusTrap = document.createElement('focus-trap');
94
+
95
+ // Move all existing cart-panel content into the focus trap
96
+ const existingContent = Array.from(_.contentPanel.childNodes);
97
+ existingContent.forEach((child) => _.focusTrap.appendChild(child));
98
+
99
+ // Insert focus trap inside the cart-panel
100
+ _.contentPanel.appendChild(_.focusTrap);
101
+ }
102
102
 
103
103
  // Ensure we have labelledby and describedby references
104
104
  if (!_.getAttribute('aria-labelledby')) {
@@ -111,20 +111,15 @@ class CartDialog extends HTMLElement {
111
111
  }
112
112
  }
113
113
 
114
- // Insert focus trap before the cart-panel
115
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
116
- // Move cart-panel inside the focus trap
117
- _.focusTrap.appendChild(_.contentPanel);
118
-
119
- // Setup the trap - this will add focus-trap-start/end elements around the content
120
- _.focusTrap.setupTrap();
121
-
122
114
  // Add modal overlay if it doesn't already exist
123
115
  if (!_.querySelector('cart-overlay')) {
124
116
  _.prepend(document.createElement('cart-overlay'));
125
117
  }
126
118
  _.#attachListeners();
127
119
  _.#bindKeyboard();
120
+
121
+ // Load cart data immediately after component initialization
122
+ _.refreshCart();
128
123
  }
129
124
 
130
125
  /**
@@ -157,6 +152,14 @@ class CartDialog extends HTMLElement {
157
152
  */
158
153
  #emit(eventName, data = null) {
159
154
  this.#eventEmitter.emit(eventName, data);
155
+
156
+ // Also emit as native DOM events for better compatibility
157
+ this.dispatchEvent(
158
+ new CustomEvent(eventName, {
159
+ detail: data,
160
+ bubbles: true,
161
+ })
162
+ );
160
163
  }
161
164
 
162
165
  /**
@@ -238,11 +241,12 @@ class CartDialog extends HTMLElement {
238
241
  // Success - let smart comparison handle the removal animation
239
242
  this.#currentCart = updatedCart;
240
243
  this.#renderCartItems(updatedCart);
241
- this.#updateCartItems(updatedCart);
244
+ this.#renderCartPanel(updatedCart);
242
245
 
243
246
  // Emit cart updated and data changed events
244
- this.#emit('cart-dialog:updated', { cart: updatedCart });
245
- this.#emit('cart-dialog:data-changed', updatedCart);
247
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
248
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
249
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
246
250
  } else {
247
251
  // Error - reset to ready state
248
252
  element.setState('ready');
@@ -273,12 +277,12 @@ class CartDialog extends HTMLElement {
273
277
  // Success - update cart data and refresh items
274
278
  this.#currentCart = updatedCart;
275
279
  this.#renderCartItems(updatedCart);
276
- this.#updateCartItems(updatedCart);
277
- element.setState('ready');
280
+ this.#renderCartPanel(updatedCart);
278
281
 
279
282
  // Emit cart updated and data changed events
280
- this.#emit('cart-dialog:updated', { cart: updatedCart });
281
- 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);
282
286
  } else {
283
287
  // Error - reset to ready state
284
288
  element.setState('ready');
@@ -292,11 +296,52 @@ class CartDialog extends HTMLElement {
292
296
  });
293
297
  }
294
298
 
299
+ /**
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
+
295
340
  /**
296
341
  * Update cart items display based on cart data
297
342
  * @private
298
343
  */
299
- #updateCartItems(cart = null) {
344
+ #renderCartPanel(cart = null) {
300
345
  const cartData = cart || this.#currentCart;
301
346
  if (!cartData) return;
302
347
 
@@ -312,14 +357,22 @@ class CartDialog extends HTMLElement {
312
357
  return;
313
358
  }
314
359
 
315
- // Show/hide sections based on item count
316
- if (cartData.item_count > 0) {
317
- hasItemsSection.style.display = 'block';
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 = '';
318
367
  emptySection.style.display = 'none';
319
368
  } else {
320
369
  hasItemsSection.style.display = 'none';
321
- emptySection.style.display = 'block';
370
+ emptySection.style.display = '';
322
371
  }
372
+
373
+ // Update cart count and subtotal across the site
374
+ this.#renderCartCount(cartData);
375
+ this.#renderCartSubtotal(cartData);
323
376
  }
324
377
 
325
378
  /**
@@ -371,20 +424,37 @@ class CartDialog extends HTMLElement {
371
424
 
372
425
  /**
373
426
  * Refresh cart data from server and update components
427
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
374
428
  * @returns {Promise<Object>} Cart data object
375
429
  */
376
- refreshCart() {
377
- console.log('Refreshing cart...');
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
378
447
  return this.getCart().then((cartData) => {
379
- console.log('Cart data received:', cartData);
448
+ // console.log('Cart data received:', cartData);
380
449
  if (cartData && !cartData.error) {
381
450
  this.#currentCart = cartData;
382
451
  this.#renderCartItems(cartData);
383
- this.#updateCartItems(cartData);
452
+ this.#renderCartPanel(cartData);
384
453
 
385
454
  // Emit cart refreshed and data changed events
386
- this.#emit('cart-dialog:refreshed', { cart: cartData });
387
- 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);
388
458
  } else {
389
459
  console.warn('Cart data has error or is null:', cartData);
390
460
  }
@@ -398,28 +468,40 @@ class CartDialog extends HTMLElement {
398
468
  */
399
469
  #removeItemsFromDOM(itemsContainer, newKeysSet) {
400
470
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
401
- const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
402
471
 
403
- console.log(
404
- `Removing ${itemsToRemove.length} items:`,
405
- itemsToRemove.map((item) => item.getAttribute('key'))
406
- );
472
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
407
473
 
408
474
  itemsToRemove.forEach((item) => {
475
+ console.log('destroy yourself', item);
409
476
  item.destroyYourself();
410
477
  });
411
478
  }
412
479
 
480
+ /**
481
+ * Update existing cart-item elements with fresh cart data
482
+ * @private
483
+ */
484
+ #updateItemsInDOM(itemsContainer, cartData) {
485
+ const visibleItems = this.#getVisibleCartItems(cartData);
486
+ const existingItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
487
+
488
+ existingItems.forEach((cartItemEl) => {
489
+ const key = cartItemEl.getAttribute('key');
490
+ const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
491
+
492
+ if (updatedItemData) {
493
+ // Update cart-item with fresh data and full cart context
494
+ // The cart-item will handle HTML comparison and only re-render if needed
495
+ cartItemEl.setData(updatedItemData, cartData);
496
+ }
497
+ });
498
+ }
499
+
413
500
  /**
414
501
  * Add new items to DOM with animation delay
415
502
  * @private
416
503
  */
417
504
  #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
418
- console.log(
419
- `Adding ${itemsToAdd.length} items:`,
420
- itemsToAdd.map((item) => item.key || item.id)
421
- );
422
-
423
505
  // Delay adding new items by 300ms to let cart slide open first
424
506
  setTimeout(() => {
425
507
  itemsToAdd.forEach((itemData) => {
@@ -452,6 +534,48 @@ class CartDialog extends HTMLElement {
452
534
  }, 100);
453
535
  }
454
536
 
537
+ /**
538
+ * Filter cart items to exclude those with _hide_in_cart property
539
+ * @private
540
+ */
541
+ #getVisibleCartItems(cartData) {
542
+ if (!cartData || !cartData.items) return [];
543
+ return cartData.items.filter((item) => {
544
+ // Check for _hide_in_cart in various possible locations
545
+ const hidden = item.properties?._hide_in_cart;
546
+
547
+ return !hidden;
548
+ });
549
+ }
550
+
551
+ /**
552
+ * Add calculated fields to cart object for events
553
+ * @private
554
+ */
555
+ #addCalculatedFields(cartData) {
556
+ if (!cartData) return cartData;
557
+
558
+ // For display counts: use visible items (excludes _hide_in_cart)
559
+ const visibleItems = this.#getVisibleCartItems(cartData);
560
+ const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
561
+
562
+ // For pricing: use all items except those marked to ignore pricing
563
+ const pricedItems = cartData.items.filter((item) => {
564
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
565
+ return !ignorePrice;
566
+ });
567
+ const calculated_subtotal = pricedItems.reduce(
568
+ (total, item) => total + (item.line_price || 0),
569
+ 0
570
+ );
571
+
572
+ return {
573
+ ...cartData,
574
+ calculated_count,
575
+ calculated_subtotal,
576
+ };
577
+ }
578
+
455
579
  /**
456
580
  * Render cart items from Shopify cart data with smart comparison
457
581
  * @private
@@ -468,53 +592,54 @@ class CartDialog extends HTMLElement {
468
592
  return;
469
593
  }
470
594
 
595
+ // Filter out items with _hide_in_cart property
596
+ const visibleItems = this.#getVisibleCartItems(cartData);
597
+
471
598
  // Handle initial render - load all items without animation
472
599
  if (this.#isInitialRender) {
473
- console.log('Initial cart render:', cartData.items.length, 'items');
600
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
474
601
 
475
602
  // Clear existing items
476
603
  itemsContainer.innerHTML = '';
477
604
 
478
605
  // Create cart-item elements without animation
479
- cartData.items.forEach((itemData) => {
606
+ visibleItems.forEach((itemData) => {
480
607
  const cartItem = new CartItem(itemData); // No animation
481
608
  itemsContainer.appendChild(cartItem);
482
609
  });
483
610
 
484
611
  this.#isInitialRender = false;
485
- console.log('Initial render complete, container children:', itemsContainer.children.length);
612
+
486
613
  return;
487
614
  }
488
615
 
489
- console.log('Smart rendering cart items:', cartData.items.length, 'items');
490
-
491
616
  // Get current DOM items and their keys
492
617
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
493
618
  const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
494
619
 
495
- // Get new cart data keys in order
496
- const newKeys = cartData.items.map((item) => item.key || item.id);
620
+ // Get new cart data keys in order (only visible items)
621
+ const newKeys = visibleItems.map((item) => item.key || item.id);
497
622
  const newKeysSet = new Set(newKeys);
498
623
 
499
624
  // Step 1: Remove items that are no longer in cart data
500
625
  this.#removeItemsFromDOM(itemsContainer, newKeysSet);
501
626
 
502
- // Step 2: Add new items that weren't in DOM (with animation delay)
503
- const itemsToAdd = cartData.items.filter(
627
+ // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
628
+ this.#updateItemsInDOM(itemsContainer, cartData);
629
+
630
+ // Step 3: Add new items that weren't in DOM (with animation delay)
631
+ const itemsToAdd = visibleItems.filter(
504
632
  (itemData) => !currentKeys.has(itemData.key || itemData.id)
505
633
  );
506
-
507
634
  this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
508
-
509
- console.log('Smart rendering complete, container children:', itemsContainer.children.length);
510
635
  }
511
636
 
512
637
  /**
513
638
  * Set the template function for cart items
514
639
  * @param {Function} templateFn - Function that takes item data and returns HTML string
515
640
  */
516
- setCartItemTemplate(templateFn) {
517
- CartItem.setTemplate(templateFn);
641
+ setCartItemTemplate(templateName, templateFn) {
642
+ CartItem.setTemplate(templateName, templateFn);
518
643
  }
519
644
 
520
645
  /**
@@ -530,10 +655,13 @@ class CartDialog extends HTMLElement {
530
655
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
531
656
  * @fires CartDialog#show - Fired when the cart dialog has been shown
532
657
  */
533
- show(triggerEl = null) {
658
+ show(triggerEl = null, cartObj) {
534
659
  const _ = this;
535
660
  _.triggerEl = triggerEl || false;
536
661
 
662
+ // Lock body scrolling
663
+ _.#lockScroll();
664
+
537
665
  // Remove the hidden class first to ensure content is rendered
538
666
  _.contentPanel.classList.remove('hidden');
539
667
 
@@ -541,17 +669,16 @@ class CartDialog extends HTMLElement {
541
669
  requestAnimationFrame(() => {
542
670
  // Update ARIA states
543
671
  _.setAttribute('aria-hidden', 'false');
672
+
544
673
  if (_.triggerEl) {
545
674
  _.triggerEl.setAttribute('aria-expanded', 'true');
546
675
  }
547
676
 
548
- // Lock body scrolling and save scroll position
549
- _.#lockScroll();
550
-
551
677
  // Focus management
552
678
  const firstFocusable = _.querySelector(
553
679
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
554
680
  );
681
+
555
682
  if (firstFocusable) {
556
683
  requestAnimationFrame(() => {
557
684
  firstFocusable.focus();
@@ -559,7 +686,7 @@ class CartDialog extends HTMLElement {
559
686
  }
560
687
 
561
688
  // Refresh cart data when showing
562
- _.refreshCart();
689
+ _.refreshCart(cartObj);
563
690
 
564
691
  // Emit show event - cart dialog is now visible
565
692
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -574,23 +701,31 @@ class CartDialog extends HTMLElement {
574
701
  hide() {
575
702
  const _ = this;
576
703
 
577
- // Restore body scroll and scroll position
578
- _.#restoreScroll();
579
-
580
704
  // Update ARIA states
581
705
  if (_.triggerEl) {
582
706
  // remove focus from modal panel first
583
707
  _.triggerEl.focus();
584
708
  // mark trigger as no longer expanded
585
709
  _.triggerEl.setAttribute('aria-expanded', 'false');
710
+ } else {
711
+ // If no trigger element, blur any focused element inside the panel
712
+ const activeElement = document.activeElement;
713
+ if (activeElement && _.contains(activeElement)) {
714
+ activeElement.blur();
715
+ }
586
716
  }
587
717
 
588
- // Set aria-hidden to start transition
589
- // The transitionend event handler will add display:none when complete
590
- _.setAttribute('aria-hidden', 'true');
718
+ requestAnimationFrame(() => {
719
+ // Set aria-hidden to start transition
720
+ // The transitionend event handler will add display:none when complete
721
+ _.setAttribute('aria-hidden', 'true');
591
722
 
592
- // Emit hide event - cart dialog is now starting to hide
593
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
723
+ // Emit hide event - cart dialog is now starting to hide
724
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
725
+
726
+ // Restore body scroll
727
+ _.#restoreScroll();
728
+ });
594
729
  }
595
730
  }
596
731
 
@@ -85,8 +85,7 @@ cart-panel {
85
85
  top: 0;
86
86
  right: 0;
87
87
  width: var(--cart-panel-width);
88
- height: 100vh;
89
- opacity: 0;
88
+ height: 100dvh;
90
89
  transform: translateX(100%);
91
90
  pointer-events: none;
92
91
  z-index: var(--cart-panel-z-index);
@@ -94,9 +93,7 @@ cart-panel {
94
93
  box-shadow: var(--cart-panel-shadow);
95
94
  border-radius: var(--cart-panel-border-radius);
96
95
  overflow: hidden;
97
- transition:
98
- opacity var(--cart-transition-duration) var(--cart-transition-timing),
99
- transform var(--cart-transition-duration) var(--cart-transition-timing);
96
+ transition: transform var(--cart-transition-duration) var(--cart-transition-timing);
100
97
 
101
98
  // When explicitly hidden, remove from layout
102
99
  &.hidden {
@@ -107,11 +104,4 @@ cart-panel {
107
104
  // Body scroll lock when cart is open
108
105
  body.overflow-hidden {
109
106
  overflow: hidden;
110
- position: fixed;
111
- width: 100%;
112
- height: 100%;
113
- left: 0;
114
- right: 0;
115
- margin: 0;
116
- // The top property will be set dynamically by the component
117
107
  }