@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.
@@ -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,13 @@ 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);
279
+ this.#renderCartPanel(updatedCart);
276
280
  element.setState('ready');
277
281
 
278
282
  // Emit cart updated and data changed events
279
- this.#emit('cart-dialog:updated', { cart: updatedCart });
280
- 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);
281
286
  } else {
282
287
  // Error - reset to ready state
283
288
  element.setState('ready');
@@ -291,11 +296,52 @@ class CartDialog extends HTMLElement {
291
296
  });
292
297
  }
293
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
+
294
340
  /**
295
341
  * Update cart items display based on cart data
296
342
  * @private
297
343
  */
298
- #updateCartItems(cart = null) {
344
+ #renderCartPanel(cart = null) {
299
345
  const cartData = cart || this.#currentCart;
300
346
  if (!cartData) return;
301
347
 
@@ -311,14 +357,22 @@ class CartDialog extends HTMLElement {
311
357
  return;
312
358
  }
313
359
 
314
- // Show/hide sections based on item count
315
- if (cartData.item_count > 0) {
316
- 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 = '';
317
367
  emptySection.style.display = 'none';
318
368
  } else {
319
369
  hasItemsSection.style.display = 'none';
320
- emptySection.style.display = 'block';
370
+ emptySection.style.display = '';
321
371
  }
372
+
373
+ // Update cart count and subtotal across the site
374
+ this.#renderCartCount(cartData);
375
+ this.#renderCartSubtotal(cartData);
322
376
  }
323
377
 
324
378
  /**
@@ -370,20 +424,37 @@ class CartDialog extends HTMLElement {
370
424
 
371
425
  /**
372
426
  * Refresh cart data from server and update components
427
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
373
428
  * @returns {Promise<Object>} Cart data object
374
429
  */
375
- refreshCart() {
376
- 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
377
447
  return this.getCart().then((cartData) => {
378
- console.log('Cart data received:', cartData);
448
+ // console.log('Cart data received:', cartData);
379
449
  if (cartData && !cartData.error) {
380
450
  this.#currentCart = cartData;
381
451
  this.#renderCartItems(cartData);
382
- this.#updateCartItems(cartData);
452
+ this.#renderCartPanel(cartData);
383
453
 
384
454
  // Emit cart refreshed and data changed events
385
- this.#emit('cart-dialog:refreshed', { cart: cartData });
386
- 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);
387
458
  } else {
388
459
  console.warn('Cart data has error or is null:', cartData);
389
460
  }
@@ -397,14 +468,11 @@ class CartDialog extends HTMLElement {
397
468
  */
398
469
  #removeItemsFromDOM(itemsContainer, newKeysSet) {
399
470
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
400
- const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
401
471
 
402
- console.log(
403
- `Removing ${itemsToRemove.length} items:`,
404
- itemsToRemove.map((item) => item.getAttribute('key'))
405
- );
472
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
406
473
 
407
474
  itemsToRemove.forEach((item) => {
475
+ console.log('destroy yourself', item);
408
476
  item.destroyYourself();
409
477
  });
410
478
  }
@@ -414,11 +482,6 @@ class CartDialog extends HTMLElement {
414
482
  * @private
415
483
  */
416
484
  #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
417
- console.log(
418
- `Adding ${itemsToAdd.length} items:`,
419
- itemsToAdd.map((item) => item.key || item.id)
420
- );
421
-
422
485
  // Delay adding new items by 300ms to let cart slide open first
423
486
  setTimeout(() => {
424
487
  itemsToAdd.forEach((itemData) => {
@@ -451,6 +514,41 @@ class CartDialog extends HTMLElement {
451
514
  }, 100);
452
515
  }
453
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
+
454
552
  /**
455
553
  * Render cart items from Shopify cart data with smart comparison
456
554
  * @private
@@ -467,53 +565,53 @@ class CartDialog extends HTMLElement {
467
565
  return;
468
566
  }
469
567
 
568
+ // Filter out items with _hide_in_cart property
569
+ const visibleItems = this.#getVisibleCartItems(cartData);
570
+
470
571
  // Handle initial render - load all items without animation
471
572
  if (this.#isInitialRender) {
472
- console.log('Initial cart render:', cartData.items.length, 'items');
573
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
473
574
 
474
575
  // Clear existing items
475
576
  itemsContainer.innerHTML = '';
476
577
 
477
578
  // Create cart-item elements without animation
478
- cartData.items.forEach((itemData) => {
579
+ visibleItems.forEach((itemData) => {
479
580
  const cartItem = new CartItem(itemData); // No animation
581
+ // const cartItem = document.createElement('cart-item');
582
+ // cartItem.setData(itemData);
480
583
  itemsContainer.appendChild(cartItem);
481
584
  });
482
585
 
483
586
  this.#isInitialRender = false;
484
- console.log('Initial render complete, container children:', itemsContainer.children.length);
587
+
485
588
  return;
486
589
  }
487
590
 
488
- console.log('Smart rendering cart items:', cartData.items.length, 'items');
489
-
490
591
  // Get current DOM items and their keys
491
592
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
492
593
  const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
493
594
 
494
- // Get new cart data keys in order
495
- 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);
496
597
  const newKeysSet = new Set(newKeys);
497
598
 
498
599
  // Step 1: Remove items that are no longer in cart data
499
600
  this.#removeItemsFromDOM(itemsContainer, newKeysSet);
500
601
 
501
602
  // Step 2: Add new items that weren't in DOM (with animation delay)
502
- const itemsToAdd = cartData.items.filter(
603
+ const itemsToAdd = visibleItems.filter(
503
604
  (itemData) => !currentKeys.has(itemData.key || itemData.id)
504
605
  );
505
-
506
606
  this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
507
-
508
- console.log('Smart rendering complete, container children:', itemsContainer.children.length);
509
607
  }
510
608
 
511
609
  /**
512
610
  * Set the template function for cart items
513
611
  * @param {Function} templateFn - Function that takes item data and returns HTML string
514
612
  */
515
- setCartItemTemplate(templateFn) {
516
- CartItem.setTemplate(templateFn);
613
+ setCartItemTemplate(templateName, templateFn) {
614
+ CartItem.setTemplate(templateName, templateFn);
517
615
  }
518
616
 
519
617
  /**
@@ -529,10 +627,13 @@ class CartDialog extends HTMLElement {
529
627
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
530
628
  * @fires CartDialog#show - Fired when the cart dialog has been shown
531
629
  */
532
- show(triggerEl = null) {
630
+ show(triggerEl = null, cartObj) {
533
631
  const _ = this;
534
632
  _.triggerEl = triggerEl || false;
535
633
 
634
+ // Lock body scrolling
635
+ _.#lockScroll();
636
+
536
637
  // Remove the hidden class first to ensure content is rendered
537
638
  _.contentPanel.classList.remove('hidden');
538
639
 
@@ -540,17 +641,16 @@ class CartDialog extends HTMLElement {
540
641
  requestAnimationFrame(() => {
541
642
  // Update ARIA states
542
643
  _.setAttribute('aria-hidden', 'false');
644
+
543
645
  if (_.triggerEl) {
544
646
  _.triggerEl.setAttribute('aria-expanded', 'true');
545
647
  }
546
648
 
547
- // Lock body scrolling and save scroll position
548
- _.#lockScroll();
549
-
550
649
  // Focus management
551
650
  const firstFocusable = _.querySelector(
552
651
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
553
652
  );
653
+
554
654
  if (firstFocusable) {
555
655
  requestAnimationFrame(() => {
556
656
  firstFocusable.focus();
@@ -558,7 +658,7 @@ class CartDialog extends HTMLElement {
558
658
  }
559
659
 
560
660
  // Refresh cart data when showing
561
- _.refreshCart();
661
+ _.refreshCart(cartObj);
562
662
 
563
663
  // Emit show event - cart dialog is now visible
564
664
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -573,23 +673,31 @@ class CartDialog extends HTMLElement {
573
673
  hide() {
574
674
  const _ = this;
575
675
 
576
- // Restore body scroll and scroll position
577
- _.#restoreScroll();
578
-
579
676
  // Update ARIA states
580
677
  if (_.triggerEl) {
581
678
  // remove focus from modal panel first
582
679
  _.triggerEl.focus();
583
680
  // mark trigger as no longer expanded
584
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
+ }
585
688
  }
586
689
 
587
- // Set aria-hidden to start transition
588
- // The transitionend event handler will add display:none when complete
589
- _.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');
590
694
 
591
- // Emit hide event - cart dialog is now starting to hide
592
- _.#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
+ });
593
701
  }
594
702
  }
595
703