@magic-spells/cart-panel 0.3.0 → 1.0.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,159 +1,213 @@
1
- import './cart-panel.scss';
2
-
3
- import '@magic-spells/focus-trap';
1
+ import './cart-panel.css';
4
2
  import EventEmitter from '@magic-spells/event-emitter';
5
- import { CartItem } from '@magic-spells/cart-item';
3
+ import { CartItem, CartItemContent, CartItemProcessing } from './cart-item.js';
4
+
5
+ // =============================================================================
6
+ // CartPanel Component
7
+ // =============================================================================
6
8
 
7
9
  /**
8
- * Custom element that creates an accessible modal cart dialog with focus management
10
+ * Shopping cart panel web component for Shopify.
11
+ * Manages cart data and AJAX requests, delegates modal behavior to dialog-panel.
9
12
  * @extends HTMLElement
10
13
  */
11
- class CartDialog extends HTMLElement {
12
- #handleTransitionEnd;
14
+ class CartPanel extends HTMLElement {
13
15
  #currentCart = null;
14
16
  #eventEmitter;
15
17
  #isInitialRender = true;
16
18
 
17
- /**
18
- * Clean up event listeners when component is removed from DOM
19
- */
20
- disconnectedCallback() {
21
- const _ = this;
22
- if (_.contentPanel) {
23
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
24
- }
19
+ constructor() {
20
+ super();
21
+ this.#eventEmitter = new EventEmitter();
22
+ }
25
23
 
26
- // Ensure body scroll is restored if component is removed while open
27
- document.body.classList.remove('overflow-hidden');
28
- this.#restoreScroll();
24
+ connectedCallback() {
25
+ this.#attachListeners();
26
+
27
+ // Load cart data immediately unless manual mode is enabled
28
+ if (!this.hasAttribute('manual')) {
29
+ this.refreshCart();
30
+ }
31
+ }
29
32
 
30
- // Detach event listeners
31
- this.#detachListeners();
33
+ disconnectedCallback() {
34
+ // Clean up handled by garbage collection
32
35
  }
33
36
 
37
+ // =========================================================================
38
+ // Public API - Event Emitter
39
+ // =========================================================================
40
+
34
41
  /**
35
- * Locks body scrolling
36
- * @private
42
+ * Add an event listener
43
+ * @param {string} eventName - Name of the event
44
+ * @param {Function} callback - Callback function
45
+ * @returns {CartPanel} Returns this for method chaining
37
46
  */
38
- #lockScroll() {
39
- // Apply overflow hidden to body
40
- document.body.classList.add('overflow-hidden');
47
+ on(eventName, callback) {
48
+ this.#eventEmitter.on(eventName, callback);
49
+ return this;
41
50
  }
42
51
 
43
52
  /**
44
- * Restores body scrolling when cart dialog is closed
45
- * @private
53
+ * Remove an event listener
54
+ * @param {string} eventName - Name of the event
55
+ * @param {Function} callback - Callback function
56
+ * @returns {CartPanel} Returns this for method chaining
46
57
  */
47
- #restoreScroll() {
48
- // Remove overflow hidden from body
49
- document.body.classList.remove('overflow-hidden');
58
+ off(eventName, callback) {
59
+ this.#eventEmitter.off(eventName, callback);
60
+ return this;
50
61
  }
51
62
 
63
+ // =========================================================================
64
+ // Public API - Dialog Control
65
+ // =========================================================================
66
+
52
67
  /**
53
- * Initializes the cart dialog, sets up focus trap and overlay
68
+ * Show the cart by finding and opening the nearest dialog-panel ancestor
69
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the open
70
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
54
71
  */
55
- constructor() {
56
- super();
72
+ show(triggerEl = null, cartObj = null) {
57
73
  const _ = this;
58
- _.id = _.getAttribute('id');
59
- _.setAttribute('role', 'dialog');
60
- _.setAttribute('aria-modal', 'true');
61
- _.setAttribute('aria-hidden', 'true');
74
+ const dialogPanel = _.#findDialogPanel();
62
75
 
63
- _.triggerEl = null;
76
+ if (dialogPanel) {
77
+ dialogPanel.show(triggerEl);
78
+ _.refreshCart(cartObj);
79
+ _.#emit('cart-panel:show', { triggerElement: triggerEl });
80
+ } else {
81
+ console.warn('cart-panel: No dialog-panel ancestor found. Cart panel is visible but not in a modal.');
82
+ }
83
+ }
64
84
 
65
- // Initialize event emitter
66
- _.#eventEmitter = new EventEmitter();
85
+ /**
86
+ * Hide the cart by finding and closing the nearest dialog-panel ancestor
87
+ */
88
+ hide() {
89
+ const dialogPanel = this.#findDialogPanel();
90
+ if (dialogPanel) {
91
+ dialogPanel.hide();
92
+ this.#emit('cart-panel:hide', {});
93
+ }
94
+ }
67
95
 
68
- // Create a handler for transition end events
69
- _.#handleTransitionEnd = (e) => {
70
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
71
- _.contentPanel.classList.add('hidden');
96
+ // =========================================================================
97
+ // Public API - Cart Data
98
+ // =========================================================================
72
99
 
73
- // Emit afterHide event - cart dialog has completed its transition
74
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
75
- }
76
- };
100
+ /**
101
+ * Fetch current cart data from Shopify
102
+ * @returns {Promise<Object>} Cart data object
103
+ */
104
+ getCart() {
105
+ return fetch('/cart.json', {
106
+ credentials: 'same-origin',
107
+ })
108
+ .then((response) => {
109
+ if (!response.ok) {
110
+ throw Error(response.statusText);
111
+ }
112
+ return response.json();
113
+ })
114
+ .catch((error) => {
115
+ console.error('Error fetching cart:', error);
116
+ return { error: true, message: error.message };
117
+ });
77
118
  }
78
119
 
79
- connectedCallback() {
80
- const _ = this;
120
+ /**
121
+ * Update cart item quantity on Shopify
122
+ * @param {string|number} key - Cart item key/ID
123
+ * @param {number} quantity - New quantity (0 to remove)
124
+ * @returns {Promise<Object>} Updated cart data object
125
+ */
126
+ updateCartItem(key, quantity) {
127
+ return fetch('/cart/change.json', {
128
+ method: 'POST',
129
+ credentials: 'same-origin',
130
+ body: JSON.stringify({ id: key, quantity: quantity }),
131
+ headers: { 'Content-Type': 'application/json' },
132
+ })
133
+ .then((response) => {
134
+ if (!response.ok) {
135
+ throw Error(response.statusText);
136
+ }
137
+ return response.json();
138
+ })
139
+ .catch((error) => {
140
+ console.error('Error updating cart item:', error);
141
+ return { error: true, message: error.message };
142
+ });
143
+ }
81
144
 
82
- // Now that we're in the DOM, find the content panel and set up focus trap
83
- _.contentPanel = _.querySelector('cart-panel');
145
+ /**
146
+ * Refresh cart display - fetches from server if no cart object provided
147
+ * @param {Object} [cartObj=null] - Cart data object to render, or null to fetch
148
+ * @returns {Promise<Object>} Cart data object
149
+ */
150
+ async refreshCart(cartObj = null) {
151
+ const _ = this;
84
152
 
85
- if (!_.contentPanel) {
86
- console.error('cart-panel element not found inside cart-dialog');
87
- return;
153
+ // Fetch from server if no cart object provided
154
+ cartObj = cartObj || (await _.getCart());
155
+ if (!cartObj || cartObj.error) {
156
+ console.warn('Cart data has error or is null:', cartObj);
157
+ return cartObj;
88
158
  }
89
159
 
90
- // Check if focus-trap already exists, if not create one
91
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
92
- if (!_.focusTrap) {
93
- _.focusTrap = document.createElement('focus-trap');
160
+ _.#currentCart = cartObj;
161
+ _.#renderCartItems(cartObj);
162
+ _.#renderCartPanel(cartObj);
94
163
 
95
- // Move all existing cart-panel content into the focus trap
96
- const existingContent = Array.from(_.contentPanel.childNodes);
97
- existingContent.forEach((child) => _.focusTrap.appendChild(child));
164
+ const cartWithCalculatedFields = _.#addCalculatedFields(cartObj);
165
+ _.#emit('cart-panel:refreshed', { cart: cartWithCalculatedFields });
166
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
98
167
 
99
- // Insert focus trap inside the cart-panel
100
- _.contentPanel.appendChild(_.focusTrap);
101
- }
102
-
103
- // Ensure we have labelledby and describedby references
104
- if (!_.getAttribute('aria-labelledby')) {
105
- const heading = _.querySelector('h1, h2, h3');
106
- if (heading && !heading.id) {
107
- heading.id = `${_.id}-title`;
108
- }
109
- if (heading?.id) {
110
- _.setAttribute('aria-labelledby', heading.id);
111
- }
112
- }
168
+ return cartObj;
169
+ }
113
170
 
114
- // Add modal overlay if it doesn't already exist
115
- if (!_.querySelector('cart-overlay')) {
116
- _.prepend(document.createElement('cart-overlay'));
117
- }
118
- _.#attachListeners();
119
- _.#bindKeyboard();
171
+ // =========================================================================
172
+ // Public API - Templates
173
+ // =========================================================================
120
174
 
121
- // Load cart data immediately after component initialization
122
- _.refreshCart();
175
+ /**
176
+ * Set the template function for cart items
177
+ * @param {string} templateName - Name of the template
178
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
179
+ */
180
+ setCartItemTemplate(templateName, templateFn) {
181
+ CartItem.setTemplate(templateName, templateFn);
123
182
  }
124
183
 
125
184
  /**
126
- * Event emitter method - Add an event listener with a cleaner API
127
- * @param {string} eventName - Name of the event to listen for
128
- * @param {Function} callback - Callback function to execute when event is fired
129
- * @returns {CartDialog} Returns this for method chaining
185
+ * Set the processing template function for cart items
186
+ * @param {Function} templateFn - Function that returns HTML string for processing state
130
187
  */
131
- on(eventName, callback) {
132
- this.#eventEmitter.on(eventName, callback);
133
- return this;
188
+ setCartItemProcessingTemplate(templateFn) {
189
+ CartItem.setProcessingTemplate(templateFn);
134
190
  }
135
191
 
192
+ // =========================================================================
193
+ // Private Methods - Core
194
+ // =========================================================================
195
+
136
196
  /**
137
- * Event emitter method - Remove an event listener
138
- * @param {string} eventName - Name of the event to stop listening for
139
- * @param {Function} callback - Callback function to remove
140
- * @returns {CartDialog} Returns this for method chaining
197
+ * Find the nearest dialog-panel ancestor
198
+ * @private
141
199
  */
142
- off(eventName, callback) {
143
- this.#eventEmitter.off(eventName, callback);
144
- return this;
200
+ #findDialogPanel() {
201
+ return this.closest('dialog-panel');
145
202
  }
146
203
 
147
204
  /**
148
- * Internal method to emit events via the event emitter
149
- * @param {string} eventName - Name of the event to emit
150
- * @param {*} [data] - Optional data to include with the event
205
+ * Emit an event via EventEmitter and native CustomEvent
151
206
  * @private
152
207
  */
153
208
  #emit(eventName, data = null) {
154
209
  this.#eventEmitter.emit(eventName, data);
155
210
 
156
- // Also emit as native DOM events for better compatibility
157
211
  this.dispatchEvent(
158
212
  new CustomEvent(eventName, {
159
213
  detail: data,
@@ -163,98 +217,57 @@ class CartDialog extends HTMLElement {
163
217
  }
164
218
 
165
219
  /**
166
- * Attach event listeners for cart dialog functionality
220
+ * Attach event listeners
167
221
  * @private
168
222
  */
169
223
  #attachListeners() {
170
- const _ = this;
171
-
172
- // Handle trigger buttons
173
- document.addEventListener('click', (e) => {
174
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
175
- if (!trigger) return;
176
-
177
- if (trigger.getAttribute('data-prevent-default') === 'true') {
178
- e.preventDefault();
179
- }
180
-
181
- _.show(trigger);
182
- });
183
-
184
224
  // Handle close buttons
185
- _.addEventListener('click', (e) => {
225
+ this.addEventListener('click', (e) => {
186
226
  if (!e.target.closest('[data-action-hide-cart]')) return;
187
- _.hide();
227
+ this.hide();
188
228
  });
189
229
 
190
230
  // Handle cart item remove events
191
- _.addEventListener('cart-item:remove', (e) => {
192
- _.#handleCartItemRemove(e);
231
+ this.addEventListener('cart-item:remove', (e) => {
232
+ this.#handleCartItemRemove(e);
193
233
  });
194
234
 
195
235
  // Handle cart item quantity change events
196
- _.addEventListener('cart-item:quantity-change', (e) => {
197
- _.#handleCartItemQuantityChange(e);
236
+ this.addEventListener('cart-item:quantity-change', (e) => {
237
+ this.#handleCartItemQuantityChange(e);
198
238
  });
199
-
200
- // Add transition end listener
201
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
202
- }
203
-
204
- /**
205
- * Detach event listeners
206
- * @private
207
- */
208
- #detachListeners() {
209
- const _ = this;
210
- if (_.contentPanel) {
211
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
212
- }
213
239
  }
214
240
 
215
- /**
216
- * Binds keyboard events for accessibility
217
- * @private
218
- */
219
- #bindKeyboard() {
220
- this.addEventListener('keydown', (e) => {
221
- if (e.key === 'Escape') {
222
- this.hide();
223
- }
224
- });
225
- }
241
+ // =========================================================================
242
+ // Private Methods - Cart Item Event Handlers
243
+ // =========================================================================
226
244
 
227
245
  /**
228
246
  * Handle cart item removal
229
247
  * @private
230
248
  */
231
249
  #handleCartItemRemove(e) {
250
+ const _ = this;
232
251
  const { cartKey, element } = e.detail;
233
252
 
234
- // Set item to processing state
235
253
  element.setState('processing');
236
254
 
237
- // Remove item by setting quantity to 0
238
- this.updateCartItem(cartKey, 0)
255
+ _.updateCartItem(cartKey, 0)
239
256
  .then((updatedCart) => {
240
257
  if (updatedCart && !updatedCart.error) {
241
- // Success - let smart comparison handle the removal animation
242
- this.#currentCart = updatedCart;
243
- this.#renderCartItems(updatedCart);
244
- this.#renderCartPanel(updatedCart);
245
-
246
- // Emit cart updated and data changed events
247
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
248
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
249
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
258
+ _.#currentCart = updatedCart;
259
+ _.#renderCartItems(updatedCart);
260
+ _.#renderCartPanel(updatedCart);
261
+
262
+ const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
263
+ _.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
264
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
250
265
  } else {
251
- // Error - reset to ready state
252
266
  element.setState('ready');
253
267
  console.error('Failed to remove cart item:', cartKey);
254
268
  }
255
269
  })
256
270
  .catch((error) => {
257
- // Error - reset to ready state
258
271
  element.setState('ready');
259
272
  console.error('Error removing cart item:', error);
260
273
  });
@@ -265,49 +278,46 @@ class CartDialog extends HTMLElement {
265
278
  * @private
266
279
  */
267
280
  #handleCartItemQuantityChange(e) {
281
+ const _ = this;
268
282
  const { cartKey, quantity, element } = e.detail;
269
283
 
270
- // Set item to processing state
271
284
  element.setState('processing');
272
285
 
273
- // Update item quantity
274
- this.updateCartItem(cartKey, quantity)
286
+ _.updateCartItem(cartKey, quantity)
275
287
  .then((updatedCart) => {
276
288
  if (updatedCart && !updatedCart.error) {
277
- // Success - update cart data and refresh items
278
- this.#currentCart = updatedCart;
279
- this.#renderCartItems(updatedCart);
280
- this.#renderCartPanel(updatedCart);
281
-
282
- // Emit cart updated and data changed events
283
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
284
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
285
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
289
+ _.#currentCart = updatedCart;
290
+ _.#renderCartItems(updatedCart);
291
+ _.#renderCartPanel(updatedCart);
292
+
293
+ const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
294
+ _.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
295
+ _.#emit('cart-panel:data-changed', cartWithCalculatedFields);
286
296
  } else {
287
- // Error - reset to ready state
288
297
  element.setState('ready');
289
298
  console.error('Failed to update cart item quantity:', cartKey, quantity);
290
299
  }
291
300
  })
292
301
  .catch((error) => {
293
- // Error - reset to ready state
294
302
  element.setState('ready');
295
303
  console.error('Error updating cart item quantity:', error);
296
304
  });
297
305
  }
298
306
 
307
+ // =========================================================================
308
+ // Private Methods - Rendering
309
+ // =========================================================================
310
+
299
311
  /**
300
- * Update cart count elements across the site
312
+ * Update cart count elements across the page
301
313
  * @private
302
314
  */
303
315
  #renderCartCount(cartData) {
304
316
  if (!cartData) return;
305
317
 
306
- // Calculate visible item count (excluding _hide_in_cart items)
307
318
  const visibleItems = this.#getVisibleCartItems(cartData);
308
319
  const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
309
320
 
310
- // Update all cart count elements across the site
311
321
  const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
312
322
  cartCountElements.forEach((element) => {
313
323
  element.textContent = visibleItemCount;
@@ -315,170 +325,110 @@ class CartDialog extends HTMLElement {
315
325
  }
316
326
 
317
327
  /**
318
- * Update cart subtotal elements across the site
328
+ * Update cart subtotal elements across the page
319
329
  * @private
320
330
  */
321
331
  #renderCartSubtotal(cartData) {
322
332
  if (!cartData) return;
323
333
 
324
- // Calculate subtotal from all items except those marked to ignore pricing
325
334
  const pricedItems = cartData.items.filter((item) => {
326
335
  const ignorePrice = item.properties?._ignore_price_in_subtotal;
327
336
  return !ignorePrice;
328
337
  });
329
338
  const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
330
339
 
331
- // Update all cart subtotal elements across the site
332
340
  const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
333
341
  cartSubtotalElements.forEach((element) => {
334
- // Format as currency (assuming cents, convert to dollars)
335
342
  const formatted = (subtotal / 100).toFixed(2);
336
343
  element.textContent = `$${formatted}`;
337
344
  });
338
345
  }
339
346
 
340
347
  /**
341
- * Update cart items display based on cart data
348
+ * Update cart panel sections (has-items/empty)
342
349
  * @private
343
350
  */
344
351
  #renderCartPanel(cart = null) {
345
- const cartData = cart || this.#currentCart;
352
+ const _ = this;
353
+ const cartData = cart || _.#currentCart;
346
354
  if (!cartData) return;
347
355
 
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]');
356
+ const visibleItems = _.#getVisibleCartItems(cartData);
357
+ const hasVisibleItems = visibleItems.length > 0;
352
358
 
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
+ // Set state attribute for CSS styling (e.g., Tailwind variants)
360
+ _.setAttribute('state', hasVisibleItems ? 'has-items' : 'empty');
359
361
 
360
- // Check visible item count for showing/hiding sections
361
- const visibleItems = this.#getVisibleCartItems(cartData);
362
- const hasVisibleItems = visibleItems.length > 0;
362
+ const hasItemsSection = _.querySelector('[data-cart-has-items]');
363
+ const emptySection = _.querySelector('[data-cart-is-empty]');
363
364
 
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 = '';
365
+ if (hasItemsSection && emptySection) {
366
+ hasItemsSection.style.display = hasVisibleItems ? '' : 'none';
367
+ emptySection.style.display = hasVisibleItems ? 'none' : '';
371
368
  }
372
369
 
373
- // Update cart count and subtotal across the site
374
- this.#renderCartCount(cartData);
375
- this.#renderCartSubtotal(cartData);
370
+ _.#renderCartCount(cartData);
371
+ _.#renderCartSubtotal(cartData);
376
372
  }
377
373
 
378
374
  /**
379
- * Fetch current cart data from server
380
- * @returns {Promise<Object>} Cart data object
375
+ * Render cart items with smart add/update/remove
376
+ * @private
381
377
  */
382
- getCart() {
383
- return fetch('/cart.json', {
384
- crossDomain: true,
385
- credentials: 'same-origin',
386
- })
387
- .then((response) => {
388
- if (!response.ok) {
389
- throw Error(response.statusText);
390
- }
391
- return response.json();
392
- })
393
- .catch((error) => {
394
- console.error('Error fetching cart:', error);
395
- return { error: true, message: error.message };
396
- });
397
- }
378
+ #renderCartItems(cartData) {
379
+ const _ = this;
380
+ const itemsContainer = _.querySelector('[data-content-cart-items]');
398
381
 
399
- /**
400
- * Update cart item quantity on server
401
- * @param {string|number} key - Cart item key/ID
402
- * @param {number} quantity - New quantity (0 to remove)
403
- * @returns {Promise<Object>} Updated cart data object
404
- */
405
- updateCartItem(key, quantity) {
406
- return fetch('/cart/change.json', {
407
- crossDomain: true,
408
- method: 'POST',
409
- credentials: 'same-origin',
410
- body: JSON.stringify({ id: key, quantity: quantity }),
411
- headers: { 'Content-Type': 'application/json' },
412
- })
413
- .then((response) => {
414
- if (!response.ok) {
415
- throw Error(response.statusText);
416
- }
417
- return response.json();
418
- })
419
- .catch((error) => {
420
- console.error('Error updating cart item:', error);
421
- return { error: true, message: error.message };
422
- });
423
- }
382
+ if (!itemsContainer || !cartData || !cartData.items) return;
424
383
 
425
- /**
426
- * Refresh cart data from server and update components
427
- * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
428
- * @returns {Promise<Object>} Cart data object
429
- */
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);
384
+ const visibleItems = _.#getVisibleCartItems(cartData);
385
+
386
+ // Initial render - load all items without animation
387
+ if (_.#isInitialRender) {
388
+ itemsContainer.innerHTML = '';
389
+ visibleItems.forEach((itemData) => {
390
+ itemsContainer.appendChild(new CartItem(itemData, cartData));
391
+ });
392
+ _.#isInitialRender = false;
393
+ return;
444
394
  }
445
395
 
446
- // Otherwise fetch from server
447
- return this.getCart().then((cartData) => {
448
- // console.log('Cart data received:', cartData);
449
- if (cartData && !cartData.error) {
450
- this.#currentCart = cartData;
451
- this.#renderCartItems(cartData);
452
- this.#renderCartPanel(cartData);
453
-
454
- // Emit cart refreshed and data changed events
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);
460
- }
461
- return cartData;
462
- });
396
+ // Get current DOM items
397
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
398
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
399
+
400
+ // Get new cart data keys
401
+ const newKeys = visibleItems.map((item) => item.key || item.id);
402
+ const newKeysSet = new Set(newKeys);
403
+
404
+ // Step 1: Remove items no longer in cart
405
+ _.#removeItemsFromDOM(itemsContainer, newKeysSet);
406
+
407
+ // Step 2: Update existing items
408
+ _.#updateItemsInDOM(itemsContainer, cartData);
409
+
410
+ // Step 3: Add new items with animation
411
+ const itemsToAdd = visibleItems.filter(
412
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
413
+ );
414
+ _.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData);
463
415
  }
464
416
 
465
417
  /**
466
- * Remove items from DOM that are no longer in cart data
418
+ * Remove items from DOM that are no longer in cart
467
419
  * @private
468
420
  */
469
421
  #removeItemsFromDOM(itemsContainer, newKeysSet) {
470
422
  const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
471
-
472
423
  const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
473
424
 
474
425
  itemsToRemove.forEach((item) => {
475
- console.log('destroy yourself', item);
476
426
  item.destroyYourself();
477
427
  });
478
428
  }
479
429
 
480
430
  /**
481
- * Update existing cart-item elements with fresh cart data
431
+ * Update existing cart-item elements with fresh data
482
432
  * @private
483
433
  */
484
434
  #updateItemsInDOM(itemsContainer, cartData) {
@@ -488,12 +438,7 @@ class CartDialog extends HTMLElement {
488
438
  existingItems.forEach((cartItemEl) => {
489
439
  const key = cartItemEl.getAttribute('key');
490
440
  const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
491
-
492
- if (updatedItemData) {
493
- // Update cart-item with fresh data and full cart context
494
- // The cart-item will handle HTML comparison and only re-render if needed
495
- cartItemEl.setData(updatedItemData, cartData);
496
- }
441
+ if (updatedItemData) cartItemEl.setData(updatedItemData, cartData);
497
442
  });
498
443
  }
499
444
 
@@ -501,19 +446,15 @@ class CartDialog extends HTMLElement {
501
446
  * Add new items to DOM with animation delay
502
447
  * @private
503
448
  */
504
- #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
505
- // Delay adding new items by 300ms to let cart slide open first
449
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData) {
506
450
  setTimeout(() => {
507
451
  itemsToAdd.forEach((itemData) => {
508
- const cartItem = CartItem.createAnimated(itemData);
452
+ const cartItem = CartItem.createAnimated(itemData, cartData);
509
453
  const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
510
454
 
511
- // Find the correct position to insert the new item
512
455
  if (targetIndex === 0) {
513
- // Insert at the beginning
514
456
  itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
515
457
  } else {
516
- // Find the item that should come before this one
517
458
  let insertAfter = null;
518
459
  for (let i = targetIndex - 1; i >= 0; i--) {
519
460
  const prevKey = newKeys[i];
@@ -534,246 +475,46 @@ class CartDialog extends HTMLElement {
534
475
  }, 100);
535
476
  }
536
477
 
478
+ // =========================================================================
479
+ // Private Methods - Helpers
480
+ // =========================================================================
481
+
537
482
  /**
538
- * Filter cart items to exclude those with _hide_in_cart property
483
+ * Filter cart items to exclude hidden items
539
484
  * @private
540
485
  */
541
486
  #getVisibleCartItems(cartData) {
542
487
  if (!cartData || !cartData.items) return [];
543
488
  return cartData.items.filter((item) => {
544
- // Check for _hide_in_cart in various possible locations
545
489
  const hidden = item.properties?._hide_in_cart;
546
-
547
490
  return !hidden;
548
491
  });
549
492
  }
550
493
 
551
494
  /**
552
- * Add calculated fields to cart object for events
495
+ * Add calculated fields to cart object
553
496
  * @private
554
497
  */
555
498
  #addCalculatedFields(cartData) {
556
499
  if (!cartData) return cartData;
557
500
 
558
- // For display counts: use visible items (excludes _hide_in_cart)
559
501
  const visibleItems = this.#getVisibleCartItems(cartData);
560
502
  const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
561
503
 
562
- // For pricing: use all items except those marked to ignore pricing
563
- const pricedItems = cartData.items.filter((item) => {
564
- const ignorePrice = item.properties?._ignore_price_in_subtotal;
565
- return !ignorePrice;
566
- });
567
- const calculated_subtotal = pricedItems.reduce(
568
- (total, item) => total + (item.line_price || 0),
569
- 0
570
- );
571
-
572
- return {
573
- ...cartData,
574
- calculated_count,
575
- calculated_subtotal,
576
- };
577
- }
578
-
579
- /**
580
- * Render cart items from Shopify cart data with smart comparison
581
- * @private
582
- */
583
- #renderCartItems(cartData) {
584
- const itemsContainer = this.querySelector('[data-content-cart-items]');
585
-
586
- if (!itemsContainer || !cartData || !cartData.items) {
587
- console.warn('Cannot render cart items:', {
588
- itemsContainer: !!itemsContainer,
589
- cartData: !!cartData,
590
- items: cartData?.items?.length,
591
- });
592
- return;
593
- }
594
-
595
- // Filter out items with _hide_in_cart property
596
- const visibleItems = this.#getVisibleCartItems(cartData);
597
-
598
- // Handle initial render - load all items without animation
599
- if (this.#isInitialRender) {
600
- // console.log('Initial cart render:', visibleItems.length, 'visible items');
601
-
602
- // Clear existing items
603
- itemsContainer.innerHTML = '';
604
-
605
- // Create cart-item elements without animation
606
- visibleItems.forEach((itemData) => {
607
- const cartItem = new CartItem(itemData); // No animation
608
- itemsContainer.appendChild(cartItem);
609
- });
610
-
611
- this.#isInitialRender = false;
612
-
613
- return;
614
- }
615
-
616
- // Get current DOM items and their keys
617
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
618
- const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
619
-
620
- // Get new cart data keys in order (only visible items)
621
- const newKeys = visibleItems.map((item) => item.key || item.id);
622
- const newKeysSet = new Set(newKeys);
623
-
624
- // Step 1: Remove items that are no longer in cart data
625
- this.#removeItemsFromDOM(itemsContainer, newKeysSet);
626
-
627
- // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
628
- this.#updateItemsInDOM(itemsContainer, cartData);
629
-
630
- // Step 3: Add new items that weren't in DOM (with animation delay)
631
- const itemsToAdd = visibleItems.filter(
632
- (itemData) => !currentKeys.has(itemData.key || itemData.id)
633
- );
634
- this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
635
- }
636
-
637
- /**
638
- * Set the template function for cart items
639
- * @param {Function} templateFn - Function that takes item data and returns HTML string
640
- */
641
- setCartItemTemplate(templateName, templateFn) {
642
- CartItem.setTemplate(templateName, templateFn);
643
- }
644
-
645
- /**
646
- * Set the processing template function for cart items
647
- * @param {Function} templateFn - Function that returns HTML string for processing state
648
- */
649
- setCartItemProcessingTemplate(templateFn) {
650
- CartItem.setProcessingTemplate(templateFn);
651
- }
652
-
653
- /**
654
- * Shows the cart dialog and traps focus within it
655
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
656
- * @fires CartDialog#show - Fired when the cart dialog has been shown
657
- */
658
- show(triggerEl = null, cartObj) {
659
- const _ = this;
660
- _.triggerEl = triggerEl || false;
661
-
662
- // Lock body scrolling
663
- _.#lockScroll();
664
-
665
- // Remove the hidden class first to ensure content is rendered
666
- _.contentPanel.classList.remove('hidden');
667
-
668
- // Give the browser a moment to process before starting animation
669
- requestAnimationFrame(() => {
670
- // Update ARIA states
671
- _.setAttribute('aria-hidden', 'false');
672
-
673
- if (_.triggerEl) {
674
- _.triggerEl.setAttribute('aria-expanded', 'true');
675
- }
676
-
677
- // Focus management
678
- const firstFocusable = _.querySelector(
679
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
680
- );
681
-
682
- if (firstFocusable) {
683
- requestAnimationFrame(() => {
684
- firstFocusable.focus();
685
- });
686
- }
687
-
688
- // Refresh cart data when showing
689
- _.refreshCart(cartObj);
504
+ const pricedItems = cartData.items.filter((item) => !item.properties?._ignore_price_in_subtotal);
505
+ const calculated_subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
690
506
 
691
- // Emit show event - cart dialog is now visible
692
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
693
- });
694
- }
695
-
696
- /**
697
- * Hides the cart dialog and restores focus
698
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
699
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
700
- */
701
- hide() {
702
- const _ = this;
703
-
704
- // Update ARIA states
705
- if (_.triggerEl) {
706
- // remove focus from modal panel first
707
- _.triggerEl.focus();
708
- // mark trigger as no longer expanded
709
- _.triggerEl.setAttribute('aria-expanded', 'false');
710
- } else {
711
- // If no trigger element, blur any focused element inside the panel
712
- const activeElement = document.activeElement;
713
- if (activeElement && _.contains(activeElement)) {
714
- activeElement.blur();
715
- }
716
- }
717
-
718
- requestAnimationFrame(() => {
719
- // Set aria-hidden to start transition
720
- // The transitionend event handler will add display:none when complete
721
- _.setAttribute('aria-hidden', 'true');
722
-
723
- // Emit hide event - cart dialog is now starting to hide
724
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
725
-
726
- // Restore body scroll
727
- _.#restoreScroll();
728
- });
507
+ return { ...cartData, calculated_count, calculated_subtotal };
729
508
  }
730
509
  }
731
510
 
732
- /**
733
- * Custom element that creates a clickable overlay for the cart dialog
734
- * @extends HTMLElement
735
- */
736
- class CartOverlay extends HTMLElement {
737
- constructor() {
738
- super();
739
- this.setAttribute('tabindex', '-1');
740
- this.setAttribute('aria-hidden', 'true');
741
- this.cartDialog = this.closest('cart-dialog');
742
- this.#attachListeners();
743
- }
744
-
745
- #attachListeners() {
746
- this.addEventListener('click', () => {
747
- this.cartDialog.hide();
748
- });
749
- }
750
- }
511
+ // =============================================================================
512
+ // Register Custom Elements
513
+ // =============================================================================
751
514
 
752
- /**
753
- * Custom element that wraps the content of the cart dialog
754
- * @extends HTMLElement
755
- */
756
- class CartPanel extends HTMLElement {
757
- constructor() {
758
- super();
759
- this.setAttribute('role', 'document');
760
- }
761
- }
762
-
763
- if (!customElements.get('cart-dialog')) {
764
- customElements.define('cart-dialog', CartDialog);
765
- }
766
- if (!customElements.get('cart-overlay')) {
767
- customElements.define('cart-overlay', CartOverlay);
768
- }
769
515
  if (!customElements.get('cart-panel')) {
770
516
  customElements.define('cart-panel', CartPanel);
771
517
  }
772
518
 
773
- export { CartDialog, CartOverlay, CartPanel, CartItem };
774
- export default CartDialog;
775
-
776
- // Make CartItem available globally for Shopify themes
777
- if (typeof window !== 'undefined') {
778
- window.CartItem = CartItem;
779
- }
519
+ export { CartPanel, CartItem, CartItemContent, CartItemProcessing };
520
+ export default CartPanel;