@magic-spells/cart-panel 0.1.1 → 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,5 @@
1
1
  import './cart-panel.scss';
2
- import '@magic-spells/cart-item';
2
+ import { CartItem } from '@magic-spells/cart-item';
3
3
  import '@magic-spells/focus-trap';
4
4
  import EventEmitter from '@magic-spells/event-emitter';
5
5
 
@@ -9,9 +9,9 @@ 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;
14
+ #isInitialRender = true;
15
15
 
16
16
  /**
17
17
  * Clean up event listeners when component is removed from DOM
@@ -31,31 +31,21 @@ class CartDialog extends HTMLElement {
31
31
  }
32
32
 
33
33
  /**
34
- * Saves current scroll position and locks body scrolling
34
+ * Locks body scrolling
35
35
  * @private
36
36
  */
37
37
  #lockScroll() {
38
- const _ = this;
39
- // Save current scroll position
40
- _.#scrollPosition = window.pageYOffset;
41
-
42
- // Apply fixed position to body
38
+ // Apply overflow hidden to body
43
39
  document.body.classList.add('overflow-hidden');
44
- document.body.style.top = `-${_.#scrollPosition}px`;
45
40
  }
46
41
 
47
42
  /**
48
- * Restores scroll position when cart dialog is closed
43
+ * Restores body scrolling when cart dialog is closed
49
44
  * @private
50
45
  */
51
46
  #restoreScroll() {
52
- const _ = this;
53
- // Remove fixed positioning
47
+ // Remove overflow hidden from body
54
48
  document.body.classList.remove('overflow-hidden');
55
- document.body.style.removeProperty('top');
56
-
57
- // Restore scroll position
58
- window.scrollTo(0, _.#scrollPosition);
59
49
  }
60
50
 
61
51
  /**
@@ -96,7 +86,18 @@ class CartDialog extends HTMLElement {
96
86
  return;
97
87
  }
98
88
 
99
- _.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
+ }
100
101
 
101
102
  // Ensure we have labelledby and describedby references
102
103
  if (!_.getAttribute('aria-labelledby')) {
@@ -109,15 +110,15 @@ class CartDialog extends HTMLElement {
109
110
  }
110
111
  }
111
112
 
112
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
113
- _.focusTrap.appendChild(_.contentPanel);
114
-
115
- _.focusTrap.setupTrap();
116
-
117
- // Add modal overlay
118
- _.prepend(document.createElement('cart-overlay'));
113
+ // Add modal overlay if it doesn't already exist
114
+ if (!_.querySelector('cart-overlay')) {
115
+ _.prepend(document.createElement('cart-overlay'));
116
+ }
119
117
  _.#attachListeners();
120
118
  _.#bindKeyboard();
119
+
120
+ // Load cart data immediately after component initialization
121
+ _.refreshCart();
121
122
  }
122
123
 
123
124
  /**
@@ -150,6 +151,14 @@ class CartDialog extends HTMLElement {
150
151
  */
151
152
  #emit(eventName, data = null) {
152
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
+ );
153
162
  }
154
163
 
155
164
  /**
@@ -173,7 +182,7 @@ class CartDialog extends HTMLElement {
173
182
 
174
183
  // Handle close buttons
175
184
  _.addEventListener('click', (e) => {
176
- if (!e.target.closest('[data-action="hide-cart"]')) return;
185
+ if (!e.target.closest('[data-action-hide-cart]')) return;
177
186
  _.hide();
178
187
  });
179
188
 
@@ -228,14 +237,15 @@ class CartDialog extends HTMLElement {
228
237
  this.updateCartItem(cartKey, 0)
229
238
  .then((updatedCart) => {
230
239
  if (updatedCart && !updatedCart.error) {
231
- // Success - remove with animation
232
- element.destroyYourself();
240
+ // Success - let smart comparison handle the removal animation
233
241
  this.#currentCart = updatedCart;
234
- this.#updateCartItems(updatedCart);
242
+ this.#renderCartItems(updatedCart);
243
+ this.#renderCartPanel(updatedCart);
235
244
 
236
245
  // Emit cart updated and data changed events
237
- this.#emit('cart-dialog:updated', { cart: updatedCart });
238
- 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);
239
249
  } else {
240
250
  // Error - reset to ready state
241
251
  element.setState('ready');
@@ -263,14 +273,16 @@ class CartDialog extends HTMLElement {
263
273
  this.updateCartItem(cartKey, quantity)
264
274
  .then((updatedCart) => {
265
275
  if (updatedCart && !updatedCart.error) {
266
- // Success - update cart data
276
+ // Success - update cart data and refresh items
267
277
  this.#currentCart = updatedCart;
268
- this.#updateCartItems(updatedCart);
278
+ this.#renderCartItems(updatedCart);
279
+ this.#renderCartPanel(updatedCart);
269
280
  element.setState('ready');
270
281
 
271
282
  // Emit cart updated and data changed events
272
- this.#emit('cart-dialog:updated', { cart: updatedCart });
273
- 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);
274
286
  } else {
275
287
  // Error - reset to ready state
276
288
  element.setState('ready');
@@ -285,16 +297,82 @@ class CartDialog extends HTMLElement {
285
297
  }
286
298
 
287
299
  /**
288
- * Update cart items
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
+
340
+ /**
341
+ * Update cart items display based on cart data
289
342
  * @private
290
343
  */
291
- #updateCartItems(cart = null) {
292
- // Placeholder for cart item updates
293
- // Could be used to sync cart items with server data
344
+ #renderCartPanel(cart = null) {
294
345
  const cartData = cart || this.#currentCart;
295
- if (cartData) {
296
- // Future implementation: update cart item components
346
+ if (!cartData) return;
347
+
348
+ // Get cart sections
349
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
350
+ const emptySection = this.querySelector('[data-cart-is-empty]');
351
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
352
+
353
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
354
+ console.warn(
355
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
356
+ );
357
+ return;
358
+ }
359
+
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 = '';
367
+ emptySection.style.display = 'none';
368
+ } else {
369
+ hasItemsSection.style.display = 'none';
370
+ emptySection.style.display = '';
297
371
  }
372
+
373
+ // Update cart count and subtotal across the site
374
+ this.#renderCartCount(cartData);
375
+ this.#renderCartSubtotal(cartData);
298
376
  }
299
377
 
300
378
  /**
@@ -346,31 +424,216 @@ class CartDialog extends HTMLElement {
346
424
 
347
425
  /**
348
426
  * Refresh cart data from server and update components
427
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
349
428
  * @returns {Promise<Object>} Cart data object
350
429
  */
351
- refreshCart() {
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
352
447
  return this.getCart().then((cartData) => {
448
+ // console.log('Cart data received:', cartData);
353
449
  if (cartData && !cartData.error) {
354
450
  this.#currentCart = cartData;
355
- this.#updateCartItems(cartData);
451
+ this.#renderCartItems(cartData);
452
+ this.#renderCartPanel(cartData);
356
453
 
357
454
  // Emit cart refreshed and data changed events
358
- this.#emit('cart-dialog:refreshed', { cart: cartData });
359
- 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);
458
+ } else {
459
+ console.warn('Cart data has error or is null:', cartData);
360
460
  }
361
461
  return cartData;
362
462
  });
363
463
  }
364
464
 
465
+ /**
466
+ * Remove items from DOM that are no longer in cart data
467
+ * @private
468
+ */
469
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
470
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
471
+
472
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
473
+
474
+ itemsToRemove.forEach((item) => {
475
+ console.log('destroy yourself', item);
476
+ item.destroyYourself();
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Add new items to DOM with animation delay
482
+ * @private
483
+ */
484
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
485
+ // Delay adding new items by 300ms to let cart slide open first
486
+ setTimeout(() => {
487
+ itemsToAdd.forEach((itemData) => {
488
+ const cartItem = CartItem.createAnimated(itemData);
489
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
490
+
491
+ // Find the correct position to insert the new item
492
+ if (targetIndex === 0) {
493
+ // Insert at the beginning
494
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
495
+ } else {
496
+ // Find the item that should come before this one
497
+ let insertAfter = null;
498
+ for (let i = targetIndex - 1; i >= 0; i--) {
499
+ const prevKey = newKeys[i];
500
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
501
+ if (prevItem) {
502
+ insertAfter = prevItem;
503
+ break;
504
+ }
505
+ }
506
+
507
+ if (insertAfter) {
508
+ insertAfter.insertAdjacentElement('afterend', cartItem);
509
+ } else {
510
+ itemsContainer.appendChild(cartItem);
511
+ }
512
+ }
513
+ });
514
+ }, 100);
515
+ }
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
+
552
+ /**
553
+ * Render cart items from Shopify cart data with smart comparison
554
+ * @private
555
+ */
556
+ #renderCartItems(cartData) {
557
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
558
+
559
+ if (!itemsContainer || !cartData || !cartData.items) {
560
+ console.warn('Cannot render cart items:', {
561
+ itemsContainer: !!itemsContainer,
562
+ cartData: !!cartData,
563
+ items: cartData?.items?.length,
564
+ });
565
+ return;
566
+ }
567
+
568
+ // Filter out items with _hide_in_cart property
569
+ const visibleItems = this.#getVisibleCartItems(cartData);
570
+
571
+ // Handle initial render - load all items without animation
572
+ if (this.#isInitialRender) {
573
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
574
+
575
+ // Clear existing items
576
+ itemsContainer.innerHTML = '';
577
+
578
+ // Create cart-item elements without animation
579
+ visibleItems.forEach((itemData) => {
580
+ const cartItem = new CartItem(itemData); // No animation
581
+ // const cartItem = document.createElement('cart-item');
582
+ // cartItem.setData(itemData);
583
+ itemsContainer.appendChild(cartItem);
584
+ });
585
+
586
+ this.#isInitialRender = false;
587
+
588
+ return;
589
+ }
590
+
591
+ // Get current DOM items and their keys
592
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
593
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
594
+
595
+ // Get new cart data keys in order (only visible items)
596
+ const newKeys = visibleItems.map((item) => item.key || item.id);
597
+ const newKeysSet = new Set(newKeys);
598
+
599
+ // Step 1: Remove items that are no longer in cart data
600
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
601
+
602
+ // Step 2: Add new items that weren't in DOM (with animation delay)
603
+ const itemsToAdd = visibleItems.filter(
604
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
605
+ );
606
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
607
+ }
608
+
609
+ /**
610
+ * Set the template function for cart items
611
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
612
+ */
613
+ setCartItemTemplate(templateName, templateFn) {
614
+ CartItem.setTemplate(templateName, templateFn);
615
+ }
616
+
617
+ /**
618
+ * Set the processing template function for cart items
619
+ * @param {Function} templateFn - Function that returns HTML string for processing state
620
+ */
621
+ setCartItemProcessingTemplate(templateFn) {
622
+ CartItem.setProcessingTemplate(templateFn);
623
+ }
624
+
365
625
  /**
366
626
  * Shows the cart dialog and traps focus within it
367
627
  * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
368
628
  * @fires CartDialog#show - Fired when the cart dialog has been shown
369
629
  */
370
- show(triggerEl = null) {
630
+ show(triggerEl = null, cartObj) {
371
631
  const _ = this;
372
632
  _.triggerEl = triggerEl || false;
373
633
 
634
+ // Lock body scrolling
635
+ _.#lockScroll();
636
+
374
637
  // Remove the hidden class first to ensure content is rendered
375
638
  _.contentPanel.classList.remove('hidden');
376
639
 
@@ -378,17 +641,16 @@ class CartDialog extends HTMLElement {
378
641
  requestAnimationFrame(() => {
379
642
  // Update ARIA states
380
643
  _.setAttribute('aria-hidden', 'false');
644
+
381
645
  if (_.triggerEl) {
382
646
  _.triggerEl.setAttribute('aria-expanded', 'true');
383
647
  }
384
648
 
385
- // Lock body scrolling and save scroll position
386
- _.#lockScroll();
387
-
388
649
  // Focus management
389
650
  const firstFocusable = _.querySelector(
390
651
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
391
652
  );
653
+
392
654
  if (firstFocusable) {
393
655
  requestAnimationFrame(() => {
394
656
  firstFocusable.focus();
@@ -396,7 +658,7 @@ class CartDialog extends HTMLElement {
396
658
  }
397
659
 
398
660
  // Refresh cart data when showing
399
- _.refreshCart();
661
+ _.refreshCart(cartObj);
400
662
 
401
663
  // Emit show event - cart dialog is now visible
402
664
  _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
@@ -411,23 +673,31 @@ class CartDialog extends HTMLElement {
411
673
  hide() {
412
674
  const _ = this;
413
675
 
414
- // Restore body scroll and scroll position
415
- _.#restoreScroll();
416
-
417
676
  // Update ARIA states
418
677
  if (_.triggerEl) {
419
678
  // remove focus from modal panel first
420
679
  _.triggerEl.focus();
421
680
  // mark trigger as no longer expanded
422
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
+ }
423
688
  }
424
689
 
425
- // Set aria-hidden to start transition
426
- // The transitionend event handler will add display:none when complete
427
- _.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');
694
+
695
+ // Emit hide event - cart dialog is now starting to hide
696
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
428
697
 
429
- // Emit hide event - cart dialog is now starting to hide
430
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
698
+ // Restore body scroll
699
+ _.#restoreScroll();
700
+ });
431
701
  }
432
702
  }
433
703
 
@@ -472,5 +742,10 @@ if (!customElements.get('cart-panel')) {
472
742
  customElements.define('cart-panel', CartPanel);
473
743
  }
474
744
 
475
- export { CartDialog, CartOverlay, CartPanel };
745
+ export { CartDialog, CartOverlay, CartPanel, CartItem };
476
746
  export default CartDialog;
747
+
748
+ // Make CartItem available globally for Shopify themes
749
+ if (typeof window !== 'undefined') {
750
+ window.CartItem = CartItem;
751
+ }
@@ -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
  }