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