@magic-spells/cart-panel 0.1.2 → 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.
package/src/cart-panel.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import './cart-panel.scss';
2
- import '@magic-spells/cart-item';
3
2
  import { CartItem } from '@magic-spells/cart-item';
4
3
  import '@magic-spells/focus-trap';
5
4
  import EventEmitter from '@magic-spells/event-emitter';
@@ -10,7 +9,6 @@ import EventEmitter from '@magic-spells/event-emitter';
10
9
  */
11
10
  class CartDialog extends HTMLElement {
12
11
  #handleTransitionEnd;
13
- #scrollPosition = 0;
14
12
  #currentCart = null;
15
13
  #eventEmitter;
16
14
  #isInitialRender = true;
@@ -33,31 +31,21 @@ class CartDialog extends HTMLElement {
33
31
  }
34
32
 
35
33
  /**
36
- * Saves current scroll position and locks body scrolling
34
+ * Locks body scrolling
37
35
  * @private
38
36
  */
39
37
  #lockScroll() {
40
- const _ = this;
41
- // Save current scroll position
42
- _.#scrollPosition = window.pageYOffset;
43
-
44
- // Apply fixed position to body
38
+ // Apply overflow hidden to body
45
39
  document.body.classList.add('overflow-hidden');
46
- document.body.style.top = `-${_.#scrollPosition}px`;
47
40
  }
48
41
 
49
42
  /**
50
- * Restores scroll position when cart dialog is closed
43
+ * Restores body scrolling when cart dialog is closed
51
44
  * @private
52
45
  */
53
46
  #restoreScroll() {
54
- const _ = this;
55
- // Remove fixed positioning
47
+ // Remove overflow hidden from body
56
48
  document.body.classList.remove('overflow-hidden');
57
- document.body.style.removeProperty('top');
58
-
59
- // Restore scroll position
60
- window.scrollTo(0, _.#scrollPosition);
61
49
  }
62
50
 
63
51
  /**
@@ -98,7 +86,18 @@ class CartDialog extends HTMLElement {
98
86
  return;
99
87
  }
100
88
 
101
- _.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
+ }
102
101
 
103
102
  // Ensure we have labelledby and describedby references
104
103
  if (!_.getAttribute('aria-labelledby')) {
@@ -111,20 +110,15 @@ class CartDialog extends HTMLElement {
111
110
  }
112
111
  }
113
112
 
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
113
  // Add modal overlay if it doesn't already exist
123
114
  if (!_.querySelector('cart-overlay')) {
124
115
  _.prepend(document.createElement('cart-overlay'));
125
116
  }
126
117
  _.#attachListeners();
127
118
  _.#bindKeyboard();
119
+
120
+ // Load cart data immediately after component initialization
121
+ _.refreshCart();
128
122
  }
129
123
 
130
124
  /**
@@ -157,6 +151,14 @@ class CartDialog extends HTMLElement {
157
151
  */
158
152
  #emit(eventName, data = null) {
159
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
+ );
160
162
  }
161
163
 
162
164
  /**
@@ -238,11 +240,12 @@ class CartDialog extends HTMLElement {
238
240
  // Success - let smart comparison handle the removal animation
239
241
  this.#currentCart = updatedCart;
240
242
  this.#renderCartItems(updatedCart);
241
- this.#updateCartItems(updatedCart);
243
+ this.#renderCartPanel(updatedCart);
242
244
 
243
245
  // Emit cart updated and data changed events
244
- this.#emit('cart-dialog:updated', { cart: updatedCart });
245
- 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);
246
249
  } else {
247
250
  // Error - reset to ready state
248
251
  element.setState('ready');
@@ -273,12 +276,13 @@ class CartDialog extends HTMLElement {
273
276
  // Success - update cart data and refresh items
274
277
  this.#currentCart = updatedCart;
275
278
  this.#renderCartItems(updatedCart);
276
- this.#updateCartItems(updatedCart);
279
+ this.#renderCartPanel(updatedCart);
277
280
  element.setState('ready');
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,14 +468,11 @@ 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
  }
@@ -415,11 +482,6 @@ class CartDialog extends HTMLElement {
415
482
  * @private
416
483
  */
417
484
  #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
418
- console.log(
419
- `Adding ${itemsToAdd.length} items:`,
420
- itemsToAdd.map((item) => item.key || item.id)
421
- );
422
-
423
485
  // Delay adding new items by 300ms to let cart slide open first
424
486
  setTimeout(() => {
425
487
  itemsToAdd.forEach((itemData) => {
@@ -452,6 +514,41 @@ class CartDialog extends HTMLElement {
452
514
  }, 100);
453
515
  }
454
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
+
455
552
  /**
456
553
  * Render cart items from Shopify cart data with smart comparison
457
554
  * @private
@@ -468,53 +565,53 @@ class CartDialog extends HTMLElement {
468
565
  return;
469
566
  }
470
567
 
568
+ // Filter out items with _hide_in_cart property
569
+ const visibleItems = this.#getVisibleCartItems(cartData);
570
+
471
571
  // Handle initial render - load all items without animation
472
572
  if (this.#isInitialRender) {
473
- console.log('Initial cart render:', cartData.items.length, 'items');
573
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
474
574
 
475
575
  // Clear existing items
476
576
  itemsContainer.innerHTML = '';
477
577
 
478
578
  // Create cart-item elements without animation
479
- cartData.items.forEach((itemData) => {
579
+ visibleItems.forEach((itemData) => {
480
580
  const cartItem = new CartItem(itemData); // No animation
581
+ // const cartItem = document.createElement('cart-item');
582
+ // cartItem.setData(itemData);
481
583
  itemsContainer.appendChild(cartItem);
482
584
  });
483
585
 
484
586
  this.#isInitialRender = false;
485
- console.log('Initial render complete, container children:', itemsContainer.children.length);
587
+
486
588
  return;
487
589
  }
488
590
 
489
- console.log('Smart rendering cart items:', cartData.items.length, 'items');
490
-
491
591
  // Get current DOM items and their keys
492
592
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
493
593
  const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
494
594
 
495
- // Get new cart data keys in order
496
- const newKeys = cartData.items.map((item) => item.key || item.id);
595
+ // Get new cart data keys in order (only visible items)
596
+ const newKeys = visibleItems.map((item) => item.key || item.id);
497
597
  const newKeysSet = new Set(newKeys);
498
598
 
499
599
  // Step 1: Remove items that are no longer in cart data
500
600
  this.#removeItemsFromDOM(itemsContainer, newKeysSet);
501
601
 
502
602
  // Step 2: Add new items that weren't in DOM (with animation delay)
503
- const itemsToAdd = cartData.items.filter(
603
+ const itemsToAdd = visibleItems.filter(
504
604
  (itemData) => !currentKeys.has(itemData.key || itemData.id)
505
605
  );
506
-
507
606
  this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
508
-
509
- console.log('Smart rendering complete, container children:', itemsContainer.children.length);
510
607
  }
511
608
 
512
609
  /**
513
610
  * Set the template function for cart items
514
611
  * @param {Function} templateFn - Function that takes item data and returns HTML string
515
612
  */
516
- setCartItemTemplate(templateFn) {
517
- CartItem.setTemplate(templateFn);
613
+ setCartItemTemplate(templateName, templateFn) {
614
+ CartItem.setTemplate(templateName, templateFn);
518
615
  }
519
616
 
520
617
  /**
@@ -530,10 +627,13 @@ class CartDialog extends HTMLElement {
530
627
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
531
628
  * @fires CartDialog#show - Fired when the cart dialog has been shown
532
629
  */
533
- show(triggerEl = null) {
630
+ show(triggerEl = null, cartObj) {
534
631
  const _ = this;
535
632
  _.triggerEl = triggerEl || false;
536
633
 
634
+ // Lock body scrolling
635
+ _.#lockScroll();
636
+
537
637
  // Remove the hidden class first to ensure content is rendered
538
638
  _.contentPanel.classList.remove('hidden');
539
639
 
@@ -541,17 +641,16 @@ class CartDialog extends HTMLElement {
541
641
  requestAnimationFrame(() => {
542
642
  // Update ARIA states
543
643
  _.setAttribute('aria-hidden', 'false');
644
+
544
645
  if (_.triggerEl) {
545
646
  _.triggerEl.setAttribute('aria-expanded', 'true');
546
647
  }
547
648
 
548
- // Lock body scrolling and save scroll position
549
- _.#lockScroll();
550
-
551
649
  // Focus management
552
650
  const firstFocusable = _.querySelector(
553
651
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
554
652
  );
653
+
555
654
  if (firstFocusable) {
556
655
  requestAnimationFrame(() => {
557
656
  firstFocusable.focus();
@@ -559,7 +658,7 @@ class CartDialog extends HTMLElement {
559
658
  }
560
659
 
561
660
  // Refresh cart data when showing
562
- _.refreshCart();
661
+ _.refreshCart(cartObj);
563
662
 
564
663
  // Emit show event - cart dialog is now visible
565
664
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -574,23 +673,31 @@ class CartDialog extends HTMLElement {
574
673
  hide() {
575
674
  const _ = this;
576
675
 
577
- // Restore body scroll and scroll position
578
- _.#restoreScroll();
579
-
580
676
  // Update ARIA states
581
677
  if (_.triggerEl) {
582
678
  // remove focus from modal panel first
583
679
  _.triggerEl.focus();
584
680
  // mark trigger as no longer expanded
585
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
+ }
586
688
  }
587
689
 
588
- // Set aria-hidden to start transition
589
- // The transitionend event handler will add display:none when complete
590
- _.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');
591
694
 
592
- // Emit hide event - cart dialog is now starting to hide
593
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
695
+ // Emit hide event - cart dialog is now starting to hide
696
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
697
+
698
+ // Restore body scroll
699
+ _.#restoreScroll();
700
+ });
594
701
  }
595
702
  }
596
703
 
@@ -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
  }