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