@magic-spells/cart-panel 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,319 @@
1
1
  (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
- class QuantityModifier extends HTMLElement {
8
- // Static flag to track if styles have been injected
9
- static #stylesInjected = false;
10
-
11
- constructor() {
12
- super();
13
- this.handleDecrement = this.handleDecrement.bind(this);
14
- this.handleIncrement = this.handleIncrement.bind(this);
15
- this.handleInputChange = this.handleInputChange.bind(this);
16
-
17
- // Inject styles once when first component is created
18
- QuantityModifier.#injectStyles();
19
- }
20
-
21
- /**
22
- * Inject global styles for hiding number input spin buttons
23
- * Only runs once regardless of how many components exist
24
- */
25
- static #injectStyles() {
26
- if (QuantityModifier.#stylesInjected) return;
27
-
28
- // this will hide the arrow buttons in the number input field
29
- const style = document.createElement('style');
30
- style.textContent = `
7
+ /**
8
+ * Retrieves all focusable elements within a given container.
9
+ *
10
+ * @param {HTMLElement} container - The container element to search for focusable elements.
11
+ * @returns {HTMLElement[]} An array of focusable elements found within the container.
12
+ */
13
+ const getFocusableElements = (container) => {
14
+ const focusableSelectors =
15
+ 'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
16
+ return Array.from(container.querySelectorAll(focusableSelectors));
17
+ };
18
+
19
+ class FocusTrap extends HTMLElement {
20
+ /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
21
+ static styleInjected = false;
22
+
23
+ constructor() {
24
+ super();
25
+ this.trapStart = null;
26
+ this.trapEnd = null;
27
+
28
+ // Inject styles only once, when the first FocusTrap instance is created.
29
+ if (!FocusTrap.styleInjected) {
30
+ this.injectStyles();
31
+ FocusTrap.styleInjected = true;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Injects necessary styles for the focus trap into the document's head.
37
+ * This ensures that focus-trap-start and focus-trap-end elements are hidden.
38
+ */
39
+ injectStyles() {
40
+ const style = document.createElement('style');
41
+ style.textContent = `
42
+ focus-trap-start,
43
+ focus-trap-end {
44
+ position: absolute;
45
+ width: 1px;
46
+ height: 1px;
47
+ margin: -1px;
48
+ padding: 0;
49
+ border: 0;
50
+ clip: rect(0, 0, 0, 0);
51
+ overflow: hidden;
52
+ white-space: nowrap;
53
+ }
54
+ `;
55
+ document.head.appendChild(style);
56
+ }
57
+
58
+ /**
59
+ * Called when the element is connected to the DOM.
60
+ * Sets up the focus trap and adds the keydown event listener.
61
+ */
62
+ connectedCallback() {
63
+ this.setupTrap();
64
+ this.addEventListener('keydown', this.handleKeyDown);
65
+ }
66
+
67
+ /**
68
+ * Called when the element is disconnected from the DOM.
69
+ * Removes the keydown event listener.
70
+ */
71
+ disconnectedCallback() {
72
+ this.removeEventListener('keydown', this.handleKeyDown);
73
+ }
74
+
75
+ /**
76
+ * Sets up the focus trap by adding trap start and trap end elements.
77
+ * Focuses the trap start element to initiate the focus trap.
78
+ */
79
+ setupTrap() {
80
+ // check to see it there are any focusable children
81
+ const focusableElements = getFocusableElements(this);
82
+ // exit if there aren't any
83
+ if (focusableElements.length === 0) return;
84
+
85
+ // create trap start and end elements
86
+ this.trapStart = document.createElement('focus-trap-start');
87
+ this.trapEnd = document.createElement('focus-trap-end');
88
+
89
+ // add to DOM
90
+ this.prepend(this.trapStart);
91
+ this.append(this.trapEnd);
92
+ }
93
+
94
+ /**
95
+ * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
96
+ *
97
+ * @param {KeyboardEvent} e - The keyboard event object.
98
+ */
99
+ handleKeyDown = (e) => {
100
+ if (e.key === 'Escape') {
101
+ e.preventDefault();
102
+ this.exitTrap();
103
+ }
104
+ };
105
+
106
+ /**
107
+ * Exits the focus trap by hiding the current container and shifting focus
108
+ * back to the trigger element that opened the trap.
109
+ */
110
+ exitTrap() {
111
+ const container = this.closest('[aria-hidden="false"]');
112
+ if (!container) return;
113
+
114
+ container.setAttribute('aria-hidden', 'true');
115
+
116
+ const trigger = document.querySelector(
117
+ `[aria-expanded="true"][aria-controls="${container.id}"]`
118
+ );
119
+ if (trigger) {
120
+ trigger.setAttribute('aria-expanded', 'false');
121
+ trigger.focus();
122
+ }
123
+ }
124
+ }
125
+
126
+ class FocusTrapStart extends HTMLElement {
127
+ /**
128
+ * Called when the element is connected to the DOM.
129
+ * Sets the tabindex and adds the focus event listener.
130
+ */
131
+ connectedCallback() {
132
+ this.setAttribute('tabindex', '0');
133
+ this.addEventListener('focus', this.handleFocus);
134
+ }
135
+
136
+ /**
137
+ * Called when the element is disconnected from the DOM.
138
+ * Removes the focus event listener.
139
+ */
140
+ disconnectedCallback() {
141
+ this.removeEventListener('focus', this.handleFocus);
142
+ }
143
+
144
+ /**
145
+ * Handles the focus event. If focus moves backwards from the first focusable element,
146
+ * it is cycled to the last focusable element, and vice versa.
147
+ *
148
+ * @param {FocusEvent} e - The focus event object.
149
+ */
150
+ handleFocus = (e) => {
151
+ const trap = this.closest('focus-trap');
152
+ const focusableElements = getFocusableElements(trap);
153
+
154
+ if (focusableElements.length === 0) return;
155
+
156
+ const firstElement = focusableElements[0];
157
+ const lastElement =
158
+ focusableElements[focusableElements.length - 1];
159
+
160
+ if (e.relatedTarget === firstElement) {
161
+ lastElement.focus();
162
+ } else {
163
+ firstElement.focus();
164
+ }
165
+ };
166
+ }
167
+
168
+ class FocusTrapEnd extends HTMLElement {
169
+ /**
170
+ * Called when the element is connected to the DOM.
171
+ * Sets the tabindex and adds the focus event listener.
172
+ */
173
+ connectedCallback() {
174
+ this.setAttribute('tabindex', '0');
175
+ this.addEventListener('focus', this.handleFocus);
176
+ }
177
+
178
+ /**
179
+ * Called when the element is disconnected from the DOM.
180
+ * Removes the focus event listener.
181
+ */
182
+ disconnectedCallback() {
183
+ this.removeEventListener('focus', this.handleFocus);
184
+ }
185
+
186
+ /**
187
+ * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
188
+ */
189
+ handleFocus = () => {
190
+ const trap = this.closest('focus-trap');
191
+ const trapStart = trap.querySelector('focus-trap-start');
192
+ trapStart.focus();
193
+ };
194
+ }
195
+
196
+ if (!customElements.get('focus-trap')) {
197
+ customElements.define('focus-trap', FocusTrap);
198
+ }
199
+ if (!customElements.get('focus-trap-start')) {
200
+ customElements.define('focus-trap-start', FocusTrapStart);
201
+ }
202
+ if (!customElements.get('focus-trap-end')) {
203
+ customElements.define('focus-trap-end', FocusTrapEnd);
204
+ }
205
+
206
+ class EventEmitter {
207
+ #events;
208
+
209
+ constructor() {
210
+ this.#events = new Map();
211
+ }
212
+
213
+ /**
214
+ * Binds a listener to an event.
215
+ * @param {string} event - The event to bind the listener to.
216
+ * @param {Function} listener - The listener function to bind.
217
+ * @returns {EventEmitter} The current instance for chaining.
218
+ * @throws {TypeError} If the listener is not a function.
219
+ */
220
+ on(event, listener) {
221
+ if (typeof listener !== "function") {
222
+ throw new TypeError("Listener must be a function");
223
+ }
224
+
225
+ const listeners = this.#events.get(event) || [];
226
+ if (!listeners.includes(listener)) {
227
+ listeners.push(listener);
228
+ }
229
+ this.#events.set(event, listeners);
230
+
231
+ return this;
232
+ }
233
+
234
+ /**
235
+ * Unbinds a listener from an event.
236
+ * @param {string} event - The event to unbind the listener from.
237
+ * @param {Function} listener - The listener function to unbind.
238
+ * @returns {EventEmitter} The current instance for chaining.
239
+ */
240
+ off(event, listener) {
241
+ const listeners = this.#events.get(event);
242
+ if (!listeners) return this;
243
+
244
+ const index = listeners.indexOf(listener);
245
+ if (index !== -1) {
246
+ listeners.splice(index, 1);
247
+ if (listeners.length === 0) {
248
+ this.#events.delete(event);
249
+ } else {
250
+ this.#events.set(event, listeners);
251
+ }
252
+ }
253
+
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Triggers an event and calls all bound listeners.
259
+ * @param {string} event - The event to trigger.
260
+ * @param {...*} args - Arguments to pass to the listener functions.
261
+ * @returns {boolean} True if the event had listeners, false otherwise.
262
+ */
263
+ emit(event, ...args) {
264
+ const listeners = this.#events.get(event);
265
+ if (!listeners || listeners.length === 0) return false;
266
+
267
+ for (let i = 0, n = listeners.length; i < n; ++i) {
268
+ try {
269
+ listeners[i].apply(this, args);
270
+ } catch (error) {
271
+ console.error(`Error in listener for event '${event}':`, error);
272
+ }
273
+ }
274
+
275
+ return true;
276
+ }
277
+
278
+ /**
279
+ * Removes all listeners for a specific event or all events.
280
+ * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
281
+ * @returns {EventEmitter} The current instance for chaining.
282
+ */
283
+ removeAllListeners(event) {
284
+ if (event) {
285
+ this.#events.delete(event);
286
+ } else {
287
+ this.#events.clear();
288
+ }
289
+ return this;
290
+ }
291
+ }
292
+
293
+ class QuantityModifier extends HTMLElement {
294
+ // Static flag to track if styles have been injected
295
+ static #stylesInjected = false;
296
+
297
+ constructor() {
298
+ super();
299
+ this.handleDecrement = this.handleDecrement.bind(this);
300
+ this.handleIncrement = this.handleIncrement.bind(this);
301
+ this.handleInputChange = this.handleInputChange.bind(this);
302
+
303
+ // Inject styles once when first component is created
304
+ QuantityModifier.#injectStyles();
305
+ }
306
+
307
+ /**
308
+ * Inject global styles for hiding number input spin buttons
309
+ * Only runs once regardless of how many components exist
310
+ */
311
+ static #injectStyles() {
312
+ if (QuantityModifier.#stylesInjected) return;
313
+
314
+ // this will hide the arrow buttons in the number input field
315
+ const style = document.createElement('style');
316
+ style.textContent = `
31
317
  /* Hide number input spin buttons for quantity-modifier */
32
318
  quantity-modifier input::-webkit-outer-spin-button,
33
319
  quantity-modifier input::-webkit-inner-spin-button {
@@ -40,73 +326,73 @@
40
326
  }
41
327
  `;
42
328
 
43
- document.head.appendChild(style);
44
- QuantityModifier.#stylesInjected = true;
45
- }
46
-
47
- // Define which attributes trigger attributeChangedCallback when modified
48
- static get observedAttributes() {
49
- return ['min', 'max', 'value'];
50
- }
51
-
52
- // Called when element is added to the DOM
53
- connectedCallback() {
54
- this.render();
55
- this.attachEventListeners();
56
- }
57
-
58
- // Called when element is removed from the DOM
59
- disconnectedCallback() {
60
- this.removeEventListeners();
61
- }
62
-
63
- // Called when observed attributes change
64
- attributeChangedCallback(name, oldValue, newValue) {
65
- if (oldValue !== newValue) {
66
- this.updateInput();
67
- }
68
- }
69
-
70
- // Get minimum value allowed, defaults to 1
71
- get min() {
72
- return parseInt(this.getAttribute('min')) || 1;
73
- }
74
-
75
- // Get maximum value allowed, defaults to 99
76
- get max() {
77
- return parseInt(this.getAttribute('max')) || 99;
78
- }
79
-
80
- // Get current value, defaults to 1
81
- get value() {
82
- return parseInt(this.getAttribute('value')) || 1;
83
- }
84
-
85
- // Set current value by updating the attribute
86
- set value(val) {
87
- this.setAttribute('value', val);
88
- }
89
-
90
- // Render the quantity modifier HTML structure
91
- render() {
92
- const min = this.min;
93
- const max = this.max;
94
- const value = this.value;
95
-
96
- // check to see if these fields already exist
97
- const existingDecrement = this.querySelector('[data-action-decrement]');
98
- const existingIncrement = this.querySelector('[data-action-increment]');
99
- const existingInput = this.querySelector('[data-quantity-modifier-field]');
100
-
101
- // if they already exist, just set the values
102
- if (existingDecrement && existingIncrement && existingInput) {
103
- existingInput.value = value;
104
- existingInput.min = min;
105
- existingInput.max = max;
106
- existingInput.type = 'number';
107
- } else {
108
- // if they don't exist, inject the template
109
- this.innerHTML = `
329
+ document.head.appendChild(style);
330
+ QuantityModifier.#stylesInjected = true;
331
+ }
332
+
333
+ // Define which attributes trigger attributeChangedCallback when modified
334
+ static get observedAttributes() {
335
+ return ['min', 'max', 'value'];
336
+ }
337
+
338
+ // Called when element is added to the DOM
339
+ connectedCallback() {
340
+ this.render();
341
+ this.attachEventListeners();
342
+ }
343
+
344
+ // Called when element is removed from the DOM
345
+ disconnectedCallback() {
346
+ this.removeEventListeners();
347
+ }
348
+
349
+ // Called when observed attributes change
350
+ attributeChangedCallback(name, oldValue, newValue) {
351
+ if (oldValue !== newValue) {
352
+ this.updateInput();
353
+ }
354
+ }
355
+
356
+ // Get minimum value allowed, defaults to 1
357
+ get min() {
358
+ return parseInt(this.getAttribute('min')) || 1;
359
+ }
360
+
361
+ // Get maximum value allowed, defaults to 99
362
+ get max() {
363
+ return parseInt(this.getAttribute('max')) || 99;
364
+ }
365
+
366
+ // Get current value, defaults to 1
367
+ get value() {
368
+ return parseInt(this.getAttribute('value')) || 1;
369
+ }
370
+
371
+ // Set current value by updating the attribute
372
+ set value(val) {
373
+ this.setAttribute('value', val);
374
+ }
375
+
376
+ // Render the quantity modifier HTML structure
377
+ render() {
378
+ const min = this.min;
379
+ const max = this.max;
380
+ const value = this.value;
381
+
382
+ // check to see if these fields already exist
383
+ const existingDecrement = this.querySelector('[data-action-decrement]');
384
+ const existingIncrement = this.querySelector('[data-action-increment]');
385
+ const existingInput = this.querySelector('[data-quantity-modifier-field]');
386
+
387
+ // if they already exist, just set the values
388
+ if (existingDecrement && existingIncrement && existingInput) {
389
+ existingInput.value = value;
390
+ existingInput.min = min;
391
+ existingInput.max = max;
392
+ existingInput.type = 'number';
393
+ } else {
394
+ // if they don't exist, inject the template
395
+ this.innerHTML = `
110
396
  <button data-action-decrement type="button">
111
397
  <svg class="svg-decrement" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
112
398
  <title>decrement</title>
@@ -126,360 +412,358 @@
126
412
  </svg>
127
413
  </button>
128
414
  `;
129
- }
130
- }
131
-
132
- // Attach click and input event listeners to buttons and input field
133
- attachEventListeners() {
134
- const decrementBtn = this.querySelector('[data-action-decrement]');
135
- const incrementBtn = this.querySelector('[data-action-increment]');
136
- const input = this.querySelector('[data-quantity-modifier-field]');
137
-
138
- if (decrementBtn) decrementBtn.addEventListener('click', this.handleDecrement);
139
- if (incrementBtn) incrementBtn.addEventListener('click', this.handleIncrement);
140
- if (input) input.addEventListener('input', this.handleInputChange);
141
- }
142
-
143
- // Remove event listeners to prevent memory leaks
144
- removeEventListeners() {
145
- const decrementBtn = this.querySelector('[data-action-decrement]');
146
- const incrementBtn = this.querySelector('[data-action-increment]');
147
- const input = this.querySelector('[data-quantity-modifier-field]');
148
-
149
- if (decrementBtn) decrementBtn.removeEventListener('click', this.handleDecrement);
150
- if (incrementBtn) incrementBtn.removeEventListener('click', this.handleIncrement);
151
- if (input) input.removeEventListener('input', this.handleInputChange);
152
- }
153
-
154
- // Handle decrement button click, respects minimum value
155
- handleDecrement() {
156
- const currentValue = this.value;
157
- const newValue = Math.max(currentValue - 1, this.min);
158
- this.updateValue(newValue);
159
- }
160
-
161
- // Handle increment button click, respects maximum value
162
- handleIncrement() {
163
- const currentValue = this.value;
164
- const newValue = Math.min(currentValue + 1, this.max);
165
- this.updateValue(newValue);
166
- }
167
-
168
- // Handle direct input changes, clamps value between min and max
169
- handleInputChange(event) {
170
- const inputValue = parseInt(event.target.value);
171
- if (!isNaN(inputValue)) {
172
- const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
173
- this.updateValue(clampedValue);
174
- }
175
- }
176
-
177
- // Update the component value and dispatch change event if value changed
178
- updateValue(newValue) {
179
- if (newValue !== this.value) {
180
- this.value = newValue;
181
- this.updateInput();
182
- this.dispatchChangeEvent(newValue);
183
- }
184
- }
185
-
186
- // Sync the input field with current component state
187
- updateInput() {
188
- const input = this.querySelector('[data-quantity-modifier-field]');
189
- if (input) {
190
- input.value = this.value;
191
- input.min = this.min;
192
- input.max = this.max;
193
- }
194
- }
195
-
196
- // Dispatch custom event when value changes for external listeners
197
- dispatchChangeEvent(value) {
198
- this.dispatchEvent(
199
- new CustomEvent('quantity-modifier:change', {
200
- detail: { value },
201
- bubbles: true,
202
- })
203
- );
204
- }
205
- }
206
-
207
- customElements.define('quantity-modifier', QuantityModifier);
208
-
209
- /**
210
- * CartItem class that handles the functionality of a cart item component
211
- */
212
- class CartItem extends HTMLElement {
213
- // Static template functions shared across all instances
214
- static #templates = new Map();
215
- static #processingTemplate = null;
216
-
217
- // Private fields
218
- #currentState = 'ready';
219
- #isDestroying = false;
220
- #isAppearing = false;
221
- #handlers = {};
222
- #itemData = null;
223
- #cartData = null;
224
-
225
- /**
226
- * Set the template function for rendering cart items
227
- * @param {string} name - Template name ('default' for default template)
228
- * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
229
- */
230
- static setTemplate(name, templateFn) {
231
- if (typeof name !== 'string') {
232
- throw new Error('Template name must be a string');
233
- }
234
- if (typeof templateFn !== 'function') {
235
- throw new Error('Template must be a function');
236
- }
237
- CartItem.#templates.set(name, templateFn);
238
- }
239
-
240
- /**
241
- * Set the processing template function for rendering processing overlay
242
- * @param {Function} templateFn - Function that returns HTML string for processing state
243
- */
244
- static setProcessingTemplate(templateFn) {
245
- if (typeof templateFn !== 'function') {
246
- throw new Error('Processing template must be a function');
247
- }
248
- CartItem.#processingTemplate = templateFn;
249
- }
250
-
251
- /**
252
- * Create a cart item with appearing animation
253
- * @param {Object} itemData - Shopify cart item data
254
- * @param {Object} cartData - Full Shopify cart object
255
- * @returns {CartItem} Cart item instance that will animate in
256
- */
257
- static createAnimated(itemData, cartData) {
258
- return new CartItem(itemData, cartData, { animate: true });
259
- }
260
-
261
- /**
262
- * Define which attributes should be observed for changes
263
- */
264
- static get observedAttributes() {
265
- return ['state', 'key'];
266
- }
267
-
268
- /**
269
- * Called when observed attributes change
270
- */
271
- attributeChangedCallback(name, oldValue, newValue) {
272
- if (oldValue === newValue) return;
273
-
274
- if (name === 'state') {
275
- this.#currentState = newValue || 'ready';
276
- }
277
- }
278
-
279
- constructor(itemData = null, cartData = null, options = {}) {
280
- super();
281
-
282
- // Store item and cart data if provided
283
- this.#itemData = itemData;
284
- this.#cartData = cartData;
285
-
286
- // Set initial state - start with 'appearing' only if explicitly requested
287
- const shouldAnimate = options.animate || this.hasAttribute('animate-in');
288
- this.#currentState =
289
- itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
290
-
291
- // Bind event handlers
292
- this.#handlers = {
293
- click: this.#handleClick.bind(this),
294
- change: this.#handleChange.bind(this),
295
- transitionEnd: this.#handleTransitionEnd.bind(this),
296
- };
297
- }
298
-
299
- connectedCallback() {
300
- // If we have item data, render it first
301
- if (this.#itemData) {
302
- this.#render();
303
- }
304
-
305
- // Find child elements
306
- this.content = this.querySelector('cart-item-content');
307
- this.processing = this.querySelector('cart-item-processing');
308
-
309
- // Update line price elements in case of pre-rendered content
310
- this.#updateLinePriceElements();
311
-
312
- // Attach event listeners
313
- this.#attachListeners();
314
-
315
- // If we started with 'appearing' state, handle the entry animation
316
- if (this.#currentState === 'appearing') {
317
- // Set the state attribute
318
- this.setAttribute('state', 'appearing');
319
- this.#isAppearing = true;
320
-
321
- // Get the natural height after rendering
322
- requestAnimationFrame(() => {
323
- const naturalHeight = this.scrollHeight;
324
-
325
- // Set explicit height for animation
326
- this.style.height = `${naturalHeight}px`;
327
-
328
- // Transition to ready state after a brief delay
329
- requestAnimationFrame(() => {
330
- this.setState('ready');
331
- });
332
- });
333
- }
334
- }
335
-
336
- disconnectedCallback() {
337
- // Cleanup event listeners
338
- this.#detachListeners();
339
- }
340
-
341
- /**
342
- * Attach event listeners
343
- */
344
- #attachListeners() {
345
- this.addEventListener('click', this.#handlers.click);
346
- this.addEventListener('change', this.#handlers.change);
347
- this.addEventListener('quantity-modifier:change', this.#handlers.change);
348
- this.addEventListener('transitionend', this.#handlers.transitionEnd);
349
- }
350
-
351
- /**
352
- * Detach event listeners
353
- */
354
- #detachListeners() {
355
- this.removeEventListener('click', this.#handlers.click);
356
- this.removeEventListener('change', this.#handlers.change);
357
- this.removeEventListener('quantity-modifier:change', this.#handlers.change);
358
- this.removeEventListener('transitionend', this.#handlers.transitionEnd);
359
- }
360
-
361
- /**
362
- * Get the current state
363
- */
364
- get state() {
365
- return this.#currentState;
366
- }
367
-
368
- /**
369
- * Get the cart key for this item
370
- */
371
- get cartKey() {
372
- return this.getAttribute('key');
373
- }
374
-
375
- /**
376
- * Handle click events (for Remove buttons, etc.)
377
- */
378
- #handleClick(e) {
379
- // Check if clicked element is a remove button
380
- const removeButton = e.target.closest('[data-action-remove-item]');
381
- if (removeButton) {
382
- e.preventDefault();
383
- this.#emitRemoveEvent();
384
- }
385
- }
386
-
387
- /**
388
- * Handle change events (for quantity inputs and quantity-modifier)
389
- */
390
- #handleChange(e) {
391
- // Check if event is from quantity-modifier component
392
- if (e.type === 'quantity-modifier:change') {
393
- this.#emitQuantityChangeEvent(e.detail.value);
394
- return;
395
- }
396
-
397
- // Check if changed element is a quantity input
398
- const quantityInput = e.target.closest('[data-cart-quantity]');
399
- if (quantityInput) {
400
- this.#emitQuantityChangeEvent(quantityInput.value);
401
- }
402
- }
403
-
404
- /**
405
- * Handle transition end events for destroy animation and appearing animation
406
- */
407
- #handleTransitionEnd(e) {
408
- if (e.propertyName === 'height' && this.#isDestroying) {
409
- // Remove from DOM after height animation completes
410
- this.remove();
411
- } else if (e.propertyName === 'height' && this.#isAppearing) {
412
- // Remove explicit height after appearing animation completes
413
- this.style.height = '';
414
- this.#isAppearing = false;
415
- }
416
- }
417
-
418
- /**
419
- * Emit remove event
420
- */
421
- #emitRemoveEvent() {
422
- this.dispatchEvent(
423
- new CustomEvent('cart-item:remove', {
424
- bubbles: true,
425
- detail: {
426
- cartKey: this.cartKey,
427
- element: this,
428
- },
429
- })
430
- );
431
- }
432
-
433
- /**
434
- * Emit quantity change event
435
- */
436
- #emitQuantityChangeEvent(quantity) {
437
- this.dispatchEvent(
438
- new CustomEvent('cart-item:quantity-change', {
439
- bubbles: true,
440
- detail: {
441
- cartKey: this.cartKey,
442
- quantity: parseInt(quantity),
443
- element: this,
444
- },
445
- })
446
- );
447
- }
448
-
449
- /**
450
- * Render cart item from data using the appropriate template
451
- */
452
- #render() {
453
- if (!this.#itemData || CartItem.#templates.size === 0) {
454
- console.log('no item data or no template', this.#itemData, CartItem.#templates);
455
- return;
456
- }
457
-
458
- // Set the key attribute from item data
459
- const key = this.#itemData.key || this.#itemData.id;
460
- if (key) {
461
- this.setAttribute('key', key);
462
- }
463
-
464
- // Determine which template to use
465
- const templateName = this.#itemData.properties?._cart_template || 'default';
466
- const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
467
-
468
- if (!templateFn) {
469
- console.warn(`Cart item template '${templateName}' not found and no default template set`);
470
- return;
471
- }
472
-
473
- // Generate HTML from template with both item and cart data
474
- const templateHTML = templateFn(this.#itemData, this.#cartData);
475
-
476
- // Generate processing HTML from template or use default
477
- const processingHTML = CartItem.#processingTemplate
478
- ? CartItem.#processingTemplate()
479
- : '<div class="cart-item-loader"></div>';
480
-
481
- // Create the cart-item structure with template content inside cart-item-content
482
- this.innerHTML = `
415
+ }
416
+ }
417
+
418
+ // Attach click and input event listeners to buttons and input field
419
+ attachEventListeners() {
420
+ const decrementBtn = this.querySelector('[data-action-decrement]');
421
+ const incrementBtn = this.querySelector('[data-action-increment]');
422
+ const input = this.querySelector('[data-quantity-modifier-field]');
423
+
424
+ if (decrementBtn) decrementBtn.addEventListener('click', this.handleDecrement);
425
+ if (incrementBtn) incrementBtn.addEventListener('click', this.handleIncrement);
426
+ if (input) input.addEventListener('change', this.handleInputChange);
427
+ }
428
+
429
+ // Remove event listeners to prevent memory leaks
430
+ removeEventListeners() {
431
+ const decrementBtn = this.querySelector('[data-action-decrement]');
432
+ const incrementBtn = this.querySelector('[data-action-increment]');
433
+ const input = this.querySelector('[data-quantity-modifier-field]');
434
+
435
+ if (decrementBtn) decrementBtn.removeEventListener('click', this.handleDecrement);
436
+ if (incrementBtn) incrementBtn.removeEventListener('click', this.handleIncrement);
437
+ if (input) input.removeEventListener('change', this.handleInputChange);
438
+ }
439
+
440
+ // Handle decrement button click, respects minimum value
441
+ handleDecrement() {
442
+ const currentValue = this.value;
443
+ const newValue = Math.max(currentValue - 1, this.min);
444
+ this.updateValue(newValue);
445
+ }
446
+
447
+ // Handle increment button click, respects maximum value
448
+ handleIncrement() {
449
+ const currentValue = this.value;
450
+ const newValue = Math.min(currentValue + 1, this.max);
451
+ this.updateValue(newValue);
452
+ }
453
+
454
+ // Handle direct input changes, clamps value between min and max
455
+ handleInputChange(event) {
456
+ const inputValue = parseInt(event.target.value);
457
+
458
+ if (!isNaN(inputValue)) {
459
+ const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
460
+ // Update the DOM input if the value was clamped
461
+ if (clampedValue !== inputValue) {
462
+ event.target.value = clampedValue;
463
+ }
464
+ this.updateValue(clampedValue);
465
+ }
466
+ }
467
+
468
+ // Update the component value and dispatch change event if value changed
469
+ updateValue(newValue) {
470
+ if (newValue !== this.value) {
471
+ this.value = newValue;
472
+ this.updateInput();
473
+ this.dispatchChangeEvent(newValue);
474
+ }
475
+ }
476
+
477
+ // Sync the input field with current component state
478
+ updateInput() {
479
+ const input = this.querySelector('[data-quantity-modifier-field]');
480
+ if (input) {
481
+ input.value = this.value;
482
+ input.min = this.min;
483
+ input.max = this.max;
484
+ }
485
+ }
486
+
487
+ // Dispatch custom event when value changes for external listeners
488
+ dispatchChangeEvent(value) {
489
+ this.dispatchEvent(
490
+ new CustomEvent('quantity-modifier:change', {
491
+ detail: { value },
492
+ bubbles: true,
493
+ })
494
+ );
495
+ }
496
+ }
497
+
498
+ customElements.define('quantity-modifier', QuantityModifier);
499
+
500
+ /**
501
+ * CartItem class that handles the functionality of a cart item component
502
+ */
503
+ class CartItem extends HTMLElement {
504
+ // Static template functions shared across all instances
505
+ static #templates = new Map();
506
+ static #processingTemplate = null;
507
+
508
+ // Private fields
509
+ #currentState = 'ready';
510
+ #isDestroying = false;
511
+ #isAppearing = false;
512
+ #handlers = {};
513
+ #itemData = null;
514
+ #cartData = null;
515
+ #lastRenderedHTML = '';
516
+
517
+ /**
518
+ * Set the template function for rendering cart items
519
+ * @param {string} name - Template name ('default' for default template)
520
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
521
+ */
522
+ static setTemplate(name, templateFn) {
523
+ if (typeof name !== 'string') {
524
+ throw new Error('Template name must be a string');
525
+ }
526
+ if (typeof templateFn !== 'function') {
527
+ throw new Error('Template must be a function');
528
+ }
529
+ CartItem.#templates.set(name, templateFn);
530
+ }
531
+
532
+ /**
533
+ * Set the processing template function for rendering processing overlay
534
+ * @param {Function} templateFn - Function that returns HTML string for processing state
535
+ */
536
+ static setProcessingTemplate(templateFn) {
537
+ if (typeof templateFn !== 'function') {
538
+ throw new Error('Processing template must be a function');
539
+ }
540
+ CartItem.#processingTemplate = templateFn;
541
+ }
542
+
543
+ /**
544
+ * Create a cart item with appearing animation
545
+ * @param {Object} itemData - Shopify cart item data
546
+ * @param {Object} cartData - Full Shopify cart object
547
+ * @returns {CartItem} Cart item instance that will animate in
548
+ */
549
+ static createAnimated(itemData, cartData) {
550
+ return new CartItem(itemData, cartData, { animate: true });
551
+ }
552
+
553
+ /**
554
+ * Define which attributes should be observed for changes
555
+ */
556
+ static get observedAttributes() {
557
+ return ['state', 'key'];
558
+ }
559
+
560
+ /**
561
+ * Called when observed attributes change
562
+ */
563
+ attributeChangedCallback(name, oldValue, newValue) {
564
+ if (oldValue === newValue) return;
565
+
566
+ if (name === 'state') {
567
+ this.#currentState = newValue || 'ready';
568
+ }
569
+ }
570
+
571
+ constructor(itemData = null, cartData = null, options = {}) {
572
+ super();
573
+
574
+ // Store item and cart data if provided
575
+ this.#itemData = itemData;
576
+ this.#cartData = cartData;
577
+
578
+ // Set initial state - start with 'appearing' only if explicitly requested
579
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
580
+ this.#currentState =
581
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
582
+
583
+ // Bind event handlers
584
+ this.#handlers = {
585
+ click: this.#handleClick.bind(this),
586
+ change: this.#handleChange.bind(this),
587
+ transitionEnd: this.#handleTransitionEnd.bind(this),
588
+ };
589
+ }
590
+
591
+ connectedCallback() {
592
+ // If we have item data, render it first
593
+ if (this.#itemData) {
594
+ this.#render();
595
+ }
596
+
597
+ // Find child elements
598
+ this.content = this.querySelector('cart-item-content');
599
+ this.processing = this.querySelector('cart-item-processing');
600
+
601
+ // Update line price elements in case of pre-rendered content
602
+ this.#updateLinePriceElements();
603
+
604
+ // Attach event listeners
605
+ this.#attachListeners();
606
+
607
+ // If we started with 'appearing' state, handle the entry animation
608
+ if (this.#currentState === 'appearing') {
609
+ // Set the state attribute
610
+ this.setAttribute('state', 'appearing');
611
+ this.#isAppearing = true;
612
+
613
+ // Get the natural height after rendering
614
+ requestAnimationFrame(() => {
615
+ const naturalHeight = this.scrollHeight;
616
+
617
+ // Set explicit height for animation
618
+ this.style.height = `${naturalHeight}px`;
619
+
620
+ // Transition to ready state after a brief delay
621
+ requestAnimationFrame(() => {
622
+ this.setState('ready');
623
+ });
624
+ });
625
+ }
626
+ }
627
+
628
+ disconnectedCallback() {
629
+ // Cleanup event listeners
630
+ this.#detachListeners();
631
+ }
632
+
633
+ /**
634
+ * Attach event listeners
635
+ */
636
+ #attachListeners() {
637
+ this.addEventListener('click', this.#handlers.click);
638
+ this.addEventListener('change', this.#handlers.change);
639
+ this.addEventListener('quantity-modifier:change', this.#handlers.change);
640
+ this.addEventListener('transitionend', this.#handlers.transitionEnd);
641
+ }
642
+
643
+ /**
644
+ * Detach event listeners
645
+ */
646
+ #detachListeners() {
647
+ this.removeEventListener('click', this.#handlers.click);
648
+ this.removeEventListener('change', this.#handlers.change);
649
+ this.removeEventListener('quantity-modifier:change', this.#handlers.change);
650
+ this.removeEventListener('transitionend', this.#handlers.transitionEnd);
651
+ }
652
+
653
+ /**
654
+ * Get the current state
655
+ */
656
+ get state() {
657
+ return this.#currentState;
658
+ }
659
+
660
+ /**
661
+ * Get the cart key for this item
662
+ */
663
+ get cartKey() {
664
+ return this.getAttribute('key');
665
+ }
666
+
667
+ /**
668
+ * Handle click events (for Remove buttons, etc.)
669
+ */
670
+ #handleClick(e) {
671
+ // Check if clicked element is a remove button
672
+ const removeButton = e.target.closest('[data-action-remove-item]');
673
+ if (removeButton) {
674
+ e.preventDefault();
675
+ this.#emitRemoveEvent();
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Handle change events (for quantity inputs and quantity-modifier)
681
+ */
682
+ #handleChange(e) {
683
+ // Check if event is from quantity-modifier component
684
+ if (e.type === 'quantity-modifier:change') {
685
+ this.#emitQuantityChangeEvent(e.detail.value);
686
+ return;
687
+ }
688
+
689
+ // Check if changed element is a quantity input
690
+ const quantityInput = e.target.closest('[data-cart-quantity]');
691
+ if (quantityInput) {
692
+ this.#emitQuantityChangeEvent(quantityInput.value);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Handle transition end events for destroy animation and appearing animation
698
+ */
699
+ #handleTransitionEnd(e) {
700
+ if (e.propertyName === 'height' && this.#isDestroying) {
701
+ // Remove from DOM after height animation completes
702
+ this.remove();
703
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
704
+ // Remove explicit height after appearing animation completes
705
+ this.style.height = '';
706
+ this.#isAppearing = false;
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Emit remove event
712
+ */
713
+ #emitRemoveEvent() {
714
+ this.dispatchEvent(
715
+ new CustomEvent('cart-item:remove', {
716
+ bubbles: true,
717
+ detail: {
718
+ cartKey: this.cartKey,
719
+ element: this,
720
+ },
721
+ })
722
+ );
723
+ }
724
+
725
+ /**
726
+ * Emit quantity change event
727
+ */
728
+ #emitQuantityChangeEvent(quantity) {
729
+ this.dispatchEvent(
730
+ new CustomEvent('cart-item:quantity-change', {
731
+ bubbles: true,
732
+ detail: {
733
+ cartKey: this.cartKey,
734
+ quantity: parseInt(quantity),
735
+ element: this,
736
+ },
737
+ })
738
+ );
739
+ }
740
+
741
+ /**
742
+ * Render cart item from data using the appropriate template
743
+ */
744
+ #render() {
745
+ if (!this.#itemData || CartItem.#templates.size === 0) {
746
+ console.log('no item data or no template', this.#itemData, CartItem.#templates);
747
+ return;
748
+ }
749
+
750
+ // Set the key attribute from item data
751
+ const key = this.#itemData.key || this.#itemData.id;
752
+ if (key) {
753
+ this.setAttribute('key', key);
754
+ }
755
+
756
+ // Generate HTML from template and store for future comparisons
757
+ const templateHTML = this.#generateTemplateHTML();
758
+ this.#lastRenderedHTML = templateHTML;
759
+
760
+ // Generate processing HTML from template or use default
761
+ const processingHTML = CartItem.#processingTemplate
762
+ ? CartItem.#processingTemplate()
763
+ : '<div class="cart-item-loader"></div>';
764
+
765
+ // Create the cart-item structure with template content inside cart-item-content
766
+ this.innerHTML = `
483
767
  <cart-item-content>
484
768
  ${templateHTML}
485
769
  </cart-item-content>
@@ -487,1177 +771,955 @@
487
771
  ${processingHTML}
488
772
  </cart-item-processing>
489
773
  `;
490
- }
491
-
492
- /**
493
- * Update the cart item with new data
494
- * @param {Object} itemData - Shopify cart item data
495
- * @param {Object} cartData - Full Shopify cart object
496
- */
497
- setData(itemData, cartData = null) {
498
- this.#itemData = itemData;
499
- if (cartData) {
500
- this.#cartData = cartData;
501
- }
502
- this.#render();
503
-
504
- // Re-find child elements after re-rendering
505
- this.content = this.querySelector('cart-item-content');
506
- this.processing = this.querySelector('cart-item-processing');
507
-
508
- // Update line price elements
509
- this.#updateLinePriceElements();
510
- }
511
-
512
- /**
513
- * Update elements with data-content-line-price attribute
514
- * @private
515
- */
516
- #updateLinePriceElements() {
517
- if (!this.#itemData) return;
518
-
519
- const linePriceElements = this.querySelectorAll('[data-content-line-price]');
520
- const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
521
-
522
- linePriceElements.forEach((element) => {
523
- element.textContent = formattedLinePrice;
524
- });
525
- }
526
-
527
- /**
528
- * Format currency value from cents to dollar string
529
- * @param {number} cents - Price in cents
530
- * @returns {string} Formatted currency string (e.g., "$29.99")
531
- * @private
532
- */
533
- #formatCurrency(cents) {
534
- if (typeof cents !== 'number') return '$0.00';
535
- return `$${(cents / 100).toFixed(2)}`;
536
- }
537
-
538
- /**
539
- * Get the current item data
540
- */
541
- get itemData() {
542
- return this.#itemData;
543
- }
544
-
545
- /**
546
- * Set the state of the cart item
547
- * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
548
- */
549
- setState(state) {
550
- if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
551
- this.setAttribute('state', state);
552
- }
553
- }
554
-
555
- /**
556
- * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
557
- *
558
- * @returns {void}
559
- */
560
- destroyYourself() {
561
- // bail if already in the middle of a destroy cycle
562
- if (this.#isDestroying) return;
563
-
564
- this.#isDestroying = true;
565
-
566
- // snapshot the current rendered height before applying any "destroying" styles
567
- const initialHeight = this.offsetHeight;
568
-
569
- // switch to 'destroying' state so css can fade / slide visuals
570
- this.setState('destroying');
571
-
572
- // lock the measured height on the next animation frame to ensure layout is fully flushed
573
- requestAnimationFrame(() => {
574
- this.style.height = `${initialHeight}px`;
575
- // this.offsetHeight; // force a reflow so the browser registers the fixed height
576
-
577
- // read the css custom property for timing, defaulting to 400ms
578
- const elementStyle = getComputedStyle(this);
579
- const destroyDuration =
580
- elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
581
-
582
- // animate only the height to zero; other properties stay under stylesheet control
583
- this.style.transition = `height ${destroyDuration} ease`;
584
- this.style.height = '0px';
585
-
586
- // setTimeout(() => {
587
- // this.style.height = '0px';
588
- // }, 1);
589
-
590
- setTimeout(() => {
591
- // make sure item is removed
592
- this.remove();
593
- }, 600);
594
- });
595
- }
596
- }
597
-
598
- /**
599
- * Supporting component classes for cart item
600
- */
601
- class CartItemContent extends HTMLElement {
602
- constructor() {
603
- super();
604
- }
605
- }
606
-
607
- class CartItemProcessing extends HTMLElement {
608
- constructor() {
609
- super();
610
- }
611
- }
612
-
613
- // Define custom elements (check if not already defined)
614
- if (!customElements.get('cart-item')) {
615
- customElements.define('cart-item', CartItem);
616
- }
617
- if (!customElements.get('cart-item-content')) {
618
- customElements.define('cart-item-content', CartItemContent);
619
- }
620
- if (!customElements.get('cart-item-processing')) {
621
- customElements.define('cart-item-processing', CartItemProcessing);
622
- }
623
-
624
- /**
625
- * Retrieves all focusable elements within a given container.
626
- *
627
- * @param {HTMLElement} container - The container element to search for focusable elements.
628
- * @returns {HTMLElement[]} An array of focusable elements found within the container.
629
- */
630
- const getFocusableElements = (container) => {
631
- const focusableSelectors =
632
- 'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
633
- return Array.from(container.querySelectorAll(focusableSelectors));
634
- };
635
-
636
- class FocusTrap extends HTMLElement {
637
- /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
638
- static styleInjected = false;
639
-
640
- constructor() {
641
- super();
642
- this.trapStart = null;
643
- this.trapEnd = null;
644
-
645
- // Inject styles only once, when the first FocusTrap instance is created.
646
- if (!FocusTrap.styleInjected) {
647
- this.injectStyles();
648
- FocusTrap.styleInjected = true;
649
- }
650
- }
651
-
652
- /**
653
- * Injects necessary styles for the focus trap into the document's head.
654
- * This ensures that focus-trap-start and focus-trap-end elements are hidden.
655
- */
656
- injectStyles() {
657
- const style = document.createElement('style');
658
- style.textContent = `
659
- focus-trap-start,
660
- focus-trap-end {
661
- position: absolute;
662
- width: 1px;
663
- height: 1px;
664
- margin: -1px;
665
- padding: 0;
666
- border: 0;
667
- clip: rect(0, 0, 0, 0);
668
- overflow: hidden;
669
- white-space: nowrap;
670
- }
671
- `;
672
- document.head.appendChild(style);
673
- }
674
-
675
- /**
676
- * Called when the element is connected to the DOM.
677
- * Sets up the focus trap and adds the keydown event listener.
678
- */
679
- connectedCallback() {
680
- this.setupTrap();
681
- this.addEventListener('keydown', this.handleKeyDown);
682
- }
683
-
684
- /**
685
- * Called when the element is disconnected from the DOM.
686
- * Removes the keydown event listener.
687
- */
688
- disconnectedCallback() {
689
- this.removeEventListener('keydown', this.handleKeyDown);
690
- }
691
-
692
- /**
693
- * Sets up the focus trap by adding trap start and trap end elements.
694
- * Focuses the trap start element to initiate the focus trap.
695
- */
696
- setupTrap() {
697
- // check to see it there are any focusable children
698
- const focusableElements = getFocusableElements(this);
699
- // exit if there aren't any
700
- if (focusableElements.length === 0) return;
701
-
702
- // create trap start and end elements
703
- this.trapStart = document.createElement('focus-trap-start');
704
- this.trapEnd = document.createElement('focus-trap-end');
705
-
706
- // add to DOM
707
- this.prepend(this.trapStart);
708
- this.append(this.trapEnd);
709
- }
710
-
711
- /**
712
- * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
713
- *
714
- * @param {KeyboardEvent} e - The keyboard event object.
715
- */
716
- handleKeyDown = (e) => {
717
- if (e.key === 'Escape') {
718
- e.preventDefault();
719
- this.exitTrap();
720
- }
721
- };
722
-
723
- /**
724
- * Exits the focus trap by hiding the current container and shifting focus
725
- * back to the trigger element that opened the trap.
726
- */
727
- exitTrap() {
728
- const container = this.closest('[aria-hidden="false"]');
729
- if (!container) return;
730
-
731
- container.setAttribute('aria-hidden', 'true');
732
-
733
- const trigger = document.querySelector(
734
- `[aria-expanded="true"][aria-controls="${container.id}"]`
735
- );
736
- if (trigger) {
737
- trigger.setAttribute('aria-expanded', 'false');
738
- trigger.focus();
739
- }
740
- }
741
- }
742
-
743
- class FocusTrapStart extends HTMLElement {
744
- /**
745
- * Called when the element is connected to the DOM.
746
- * Sets the tabindex and adds the focus event listener.
747
- */
748
- connectedCallback() {
749
- this.setAttribute('tabindex', '0');
750
- this.addEventListener('focus', this.handleFocus);
751
- }
752
-
753
- /**
754
- * Called when the element is disconnected from the DOM.
755
- * Removes the focus event listener.
756
- */
757
- disconnectedCallback() {
758
- this.removeEventListener('focus', this.handleFocus);
759
- }
760
-
761
- /**
762
- * Handles the focus event. If focus moves backwards from the first focusable element,
763
- * it is cycled to the last focusable element, and vice versa.
764
- *
765
- * @param {FocusEvent} e - The focus event object.
766
- */
767
- handleFocus = (e) => {
768
- const trap = this.closest('focus-trap');
769
- const focusableElements = getFocusableElements(trap);
770
-
771
- if (focusableElements.length === 0) return;
772
-
773
- const firstElement = focusableElements[0];
774
- const lastElement =
775
- focusableElements[focusableElements.length - 1];
776
-
777
- if (e.relatedTarget === firstElement) {
778
- lastElement.focus();
779
- } else {
780
- firstElement.focus();
781
- }
782
- };
783
- }
784
-
785
- class FocusTrapEnd extends HTMLElement {
786
- /**
787
- * Called when the element is connected to the DOM.
788
- * Sets the tabindex and adds the focus event listener.
789
- */
790
- connectedCallback() {
791
- this.setAttribute('tabindex', '0');
792
- this.addEventListener('focus', this.handleFocus);
793
- }
794
-
795
- /**
796
- * Called when the element is disconnected from the DOM.
797
- * Removes the focus event listener.
798
- */
799
- disconnectedCallback() {
800
- this.removeEventListener('focus', this.handleFocus);
801
- }
802
-
803
- /**
804
- * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
805
- */
806
- handleFocus = () => {
807
- const trap = this.closest('focus-trap');
808
- const trapStart = trap.querySelector('focus-trap-start');
809
- trapStart.focus();
810
- };
811
- }
812
-
813
- if (!customElements.get('focus-trap')) {
814
- customElements.define('focus-trap', FocusTrap);
815
- }
816
- if (!customElements.get('focus-trap-start')) {
817
- customElements.define('focus-trap-start', FocusTrapStart);
818
- }
819
- if (!customElements.get('focus-trap-end')) {
820
- customElements.define('focus-trap-end', FocusTrapEnd);
821
- }
822
-
823
- class EventEmitter {
824
- #events;
825
-
826
- constructor() {
827
- this.#events = new Map();
828
- }
829
-
830
- /**
831
- * Binds a listener to an event.
832
- * @param {string} event - The event to bind the listener to.
833
- * @param {Function} listener - The listener function to bind.
834
- * @returns {EventEmitter} The current instance for chaining.
835
- * @throws {TypeError} If the listener is not a function.
836
- */
837
- on(event, listener) {
838
- if (typeof listener !== "function") {
839
- throw new TypeError("Listener must be a function");
840
- }
841
-
842
- const listeners = this.#events.get(event) || [];
843
- if (!listeners.includes(listener)) {
844
- listeners.push(listener);
845
- }
846
- this.#events.set(event, listeners);
847
-
848
- return this;
849
- }
850
-
851
- /**
852
- * Unbinds a listener from an event.
853
- * @param {string} event - The event to unbind the listener from.
854
- * @param {Function} listener - The listener function to unbind.
855
- * @returns {EventEmitter} The current instance for chaining.
856
- */
857
- off(event, listener) {
858
- const listeners = this.#events.get(event);
859
- if (!listeners) return this;
860
-
861
- const index = listeners.indexOf(listener);
862
- if (index !== -1) {
863
- listeners.splice(index, 1);
864
- if (listeners.length === 0) {
865
- this.#events.delete(event);
866
- } else {
867
- this.#events.set(event, listeners);
868
- }
869
- }
870
-
871
- return this;
872
- }
873
-
874
- /**
875
- * Triggers an event and calls all bound listeners.
876
- * @param {string} event - The event to trigger.
877
- * @param {...*} args - Arguments to pass to the listener functions.
878
- * @returns {boolean} True if the event had listeners, false otherwise.
879
- */
880
- emit(event, ...args) {
881
- const listeners = this.#events.get(event);
882
- if (!listeners || listeners.length === 0) return false;
883
-
884
- for (let i = 0, n = listeners.length; i < n; ++i) {
885
- try {
886
- listeners[i].apply(this, args);
887
- } catch (error) {
888
- console.error(`Error in listener for event '${event}':`, error);
889
- }
890
- }
891
-
892
- return true;
893
- }
894
-
895
- /**
896
- * Removes all listeners for a specific event or all events.
897
- * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
898
- * @returns {EventEmitter} The current instance for chaining.
899
- */
900
- removeAllListeners(event) {
901
- if (event) {
902
- this.#events.delete(event);
903
- } else {
904
- this.#events.clear();
905
- }
906
- return this;
907
- }
908
- }
909
-
910
- /**
911
- * Custom element that creates an accessible modal cart dialog with focus management
912
- * @extends HTMLElement
913
- */
914
- class CartDialog extends HTMLElement {
915
- #handleTransitionEnd;
916
- #currentCart = null;
917
- #eventEmitter;
918
- #isInitialRender = true;
919
-
920
- /**
921
- * Clean up event listeners when component is removed from DOM
922
- */
923
- disconnectedCallback() {
924
- const _ = this;
925
- if (_.contentPanel) {
926
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
927
- }
928
-
929
- // Ensure body scroll is restored if component is removed while open
930
- document.body.classList.remove('overflow-hidden');
931
- this.#restoreScroll();
932
-
933
- // Detach event listeners
934
- this.#detachListeners();
935
- }
936
-
937
- /**
938
- * Locks body scrolling
939
- * @private
940
- */
941
- #lockScroll() {
942
- // Apply overflow hidden to body
943
- document.body.classList.add('overflow-hidden');
944
- }
945
-
946
- /**
947
- * Restores body scrolling when cart dialog is closed
948
- * @private
949
- */
950
- #restoreScroll() {
951
- // Remove overflow hidden from body
952
- document.body.classList.remove('overflow-hidden');
953
- }
954
-
955
- /**
956
- * Initializes the cart dialog, sets up focus trap and overlay
957
- */
958
- constructor() {
959
- super();
960
- const _ = this;
961
- _.id = _.getAttribute('id');
962
- _.setAttribute('role', 'dialog');
963
- _.setAttribute('aria-modal', 'true');
964
- _.setAttribute('aria-hidden', 'true');
965
-
966
- _.triggerEl = null;
967
-
968
- // Initialize event emitter
969
- _.#eventEmitter = new EventEmitter();
970
-
971
- // Create a handler for transition end events
972
- _.#handleTransitionEnd = (e) => {
973
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
974
- _.contentPanel.classList.add('hidden');
975
-
976
- // Emit afterHide event - cart dialog has completed its transition
977
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
978
- }
979
- };
980
- }
981
-
982
- connectedCallback() {
983
- const _ = this;
984
-
985
- // Now that we're in the DOM, find the content panel and set up focus trap
986
- _.contentPanel = _.querySelector('cart-panel');
987
-
988
- if (!_.contentPanel) {
989
- console.error('cart-panel element not found inside cart-dialog');
990
- return;
991
- }
992
-
993
- // Check if focus-trap already exists, if not create one
994
- _.focusTrap = _.contentPanel.querySelector('focus-trap');
995
- if (!_.focusTrap) {
996
- _.focusTrap = document.createElement('focus-trap');
997
-
998
- // Move all existing cart-panel content into the focus trap
999
- const existingContent = Array.from(_.contentPanel.childNodes);
1000
- existingContent.forEach((child) => _.focusTrap.appendChild(child));
1001
-
1002
- // Insert focus trap inside the cart-panel
1003
- _.contentPanel.appendChild(_.focusTrap);
1004
- }
1005
-
1006
- // Ensure we have labelledby and describedby references
1007
- if (!_.getAttribute('aria-labelledby')) {
1008
- const heading = _.querySelector('h1, h2, h3');
1009
- if (heading && !heading.id) {
1010
- heading.id = `${_.id}-title`;
1011
- }
1012
- if (heading?.id) {
1013
- _.setAttribute('aria-labelledby', heading.id);
1014
- }
1015
- }
1016
-
1017
- // Add modal overlay if it doesn't already exist
1018
- if (!_.querySelector('cart-overlay')) {
1019
- _.prepend(document.createElement('cart-overlay'));
1020
- }
1021
- _.#attachListeners();
1022
- _.#bindKeyboard();
1023
-
1024
- // Load cart data immediately after component initialization
1025
- _.refreshCart();
1026
- }
1027
-
1028
- /**
1029
- * Event emitter method - Add an event listener with a cleaner API
1030
- * @param {string} eventName - Name of the event to listen for
1031
- * @param {Function} callback - Callback function to execute when event is fired
1032
- * @returns {CartDialog} Returns this for method chaining
1033
- */
1034
- on(eventName, callback) {
1035
- this.#eventEmitter.on(eventName, callback);
1036
- return this;
1037
- }
1038
-
1039
- /**
1040
- * Event emitter method - Remove an event listener
1041
- * @param {string} eventName - Name of the event to stop listening for
1042
- * @param {Function} callback - Callback function to remove
1043
- * @returns {CartDialog} Returns this for method chaining
1044
- */
1045
- off(eventName, callback) {
1046
- this.#eventEmitter.off(eventName, callback);
1047
- return this;
1048
- }
1049
-
1050
- /**
1051
- * Internal method to emit events via the event emitter
1052
- * @param {string} eventName - Name of the event to emit
1053
- * @param {*} [data] - Optional data to include with the event
1054
- * @private
1055
- */
1056
- #emit(eventName, data = null) {
1057
- this.#eventEmitter.emit(eventName, data);
1058
-
1059
- // Also emit as native DOM events for better compatibility
1060
- this.dispatchEvent(
1061
- new CustomEvent(eventName, {
1062
- detail: data,
1063
- bubbles: true,
1064
- })
1065
- );
1066
- }
1067
-
1068
- /**
1069
- * Attach event listeners for cart dialog functionality
1070
- * @private
1071
- */
1072
- #attachListeners() {
1073
- const _ = this;
1074
-
1075
- // Handle trigger buttons
1076
- document.addEventListener('click', (e) => {
1077
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
1078
- if (!trigger) return;
1079
-
1080
- if (trigger.getAttribute('data-prevent-default') === 'true') {
1081
- e.preventDefault();
1082
- }
1083
-
1084
- _.show(trigger);
1085
- });
1086
-
1087
- // Handle close buttons
1088
- _.addEventListener('click', (e) => {
1089
- if (!e.target.closest('[data-action-hide-cart]')) return;
1090
- _.hide();
1091
- });
1092
-
1093
- // Handle cart item remove events
1094
- _.addEventListener('cart-item:remove', (e) => {
1095
- _.#handleCartItemRemove(e);
1096
- });
1097
-
1098
- // Handle cart item quantity change events
1099
- _.addEventListener('cart-item:quantity-change', (e) => {
1100
- _.#handleCartItemQuantityChange(e);
1101
- });
1102
-
1103
- // Add transition end listener
1104
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
1105
- }
1106
-
1107
- /**
1108
- * Detach event listeners
1109
- * @private
1110
- */
1111
- #detachListeners() {
1112
- const _ = this;
1113
- if (_.contentPanel) {
1114
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
1115
- }
1116
- }
1117
-
1118
- /**
1119
- * Binds keyboard events for accessibility
1120
- * @private
1121
- */
1122
- #bindKeyboard() {
1123
- this.addEventListener('keydown', (e) => {
1124
- if (e.key === 'Escape') {
1125
- this.hide();
1126
- }
1127
- });
1128
- }
1129
-
1130
- /**
1131
- * Handle cart item removal
1132
- * @private
1133
- */
1134
- #handleCartItemRemove(e) {
1135
- const { cartKey, element } = e.detail;
1136
-
1137
- // Set item to processing state
1138
- element.setState('processing');
1139
-
1140
- // Remove item by setting quantity to 0
1141
- this.updateCartItem(cartKey, 0)
1142
- .then((updatedCart) => {
1143
- if (updatedCart && !updatedCart.error) {
1144
- // Success - let smart comparison handle the removal animation
1145
- this.#currentCart = updatedCart;
1146
- this.#renderCartItems(updatedCart);
1147
- this.#renderCartPanel(updatedCart);
1148
-
1149
- // Emit cart updated and data changed events
1150
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1151
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1152
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1153
- } else {
1154
- // Error - reset to ready state
1155
- element.setState('ready');
1156
- console.error('Failed to remove cart item:', cartKey);
1157
- }
1158
- })
1159
- .catch((error) => {
1160
- // Error - reset to ready state
1161
- element.setState('ready');
1162
- console.error('Error removing cart item:', error);
1163
- });
1164
- }
1165
-
1166
- /**
1167
- * Handle cart item quantity change
1168
- * @private
1169
- */
1170
- #handleCartItemQuantityChange(e) {
1171
- const { cartKey, quantity, element } = e.detail;
1172
-
1173
- // Set item to processing state
1174
- element.setState('processing');
1175
-
1176
- // Update item quantity
1177
- this.updateCartItem(cartKey, quantity)
1178
- .then((updatedCart) => {
1179
- if (updatedCart && !updatedCart.error) {
1180
- // Success - update cart data and refresh items
1181
- this.#currentCart = updatedCart;
1182
- this.#renderCartItems(updatedCart);
1183
- this.#renderCartPanel(updatedCart);
1184
- element.setState('ready');
1185
-
1186
- // Emit cart updated and data changed events
1187
- const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1188
- this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1189
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1190
- } else {
1191
- // Error - reset to ready state
1192
- element.setState('ready');
1193
- console.error('Failed to update cart item quantity:', cartKey, quantity);
1194
- }
1195
- })
1196
- .catch((error) => {
1197
- // Error - reset to ready state
1198
- element.setState('ready');
1199
- console.error('Error updating cart item quantity:', error);
1200
- });
1201
- }
1202
-
1203
- /**
1204
- * Update cart count elements across the site
1205
- * @private
1206
- */
1207
- #renderCartCount(cartData) {
1208
- if (!cartData) return;
1209
-
1210
- // Calculate visible item count (excluding _hide_in_cart items)
1211
- const visibleItems = this.#getVisibleCartItems(cartData);
1212
- const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
1213
-
1214
- // Update all cart count elements across the site
1215
- const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
1216
- cartCountElements.forEach((element) => {
1217
- element.textContent = visibleItemCount;
1218
- });
1219
- }
1220
-
1221
- /**
1222
- * Update cart subtotal elements across the site
1223
- * @private
1224
- */
1225
- #renderCartSubtotal(cartData) {
1226
- if (!cartData) return;
1227
-
1228
- // Calculate subtotal from all items except those marked to ignore pricing
1229
- const pricedItems = cartData.items.filter(item => {
1230
- const ignorePrice = item.properties?._ignore_price_in_subtotal;
1231
- return !ignorePrice;
1232
- });
1233
- const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
1234
-
1235
- // Update all cart subtotal elements across the site
1236
- const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
1237
- cartSubtotalElements.forEach((element) => {
1238
- // Format as currency (assuming cents, convert to dollars)
1239
- const formatted = (subtotal / 100).toFixed(2);
1240
- element.textContent = `$${formatted}`;
1241
- });
1242
- }
1243
-
1244
- /**
1245
- * Update cart items display based on cart data
1246
- * @private
1247
- */
1248
- #renderCartPanel(cart = null) {
1249
- const cartData = cart || this.#currentCart;
1250
- if (!cartData) return;
1251
-
1252
- // Get cart sections
1253
- const hasItemsSection = this.querySelector('[data-cart-has-items]');
1254
- const emptySection = this.querySelector('[data-cart-is-empty]');
1255
- const itemsContainer = this.querySelector('[data-content-cart-items]');
1256
-
1257
- if (!hasItemsSection || !emptySection || !itemsContainer) {
1258
- console.warn(
1259
- 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
1260
- );
1261
- return;
1262
- }
1263
-
1264
- // Check visible item count for showing/hiding sections
1265
- const visibleItems = this.#getVisibleCartItems(cartData);
1266
- const hasVisibleItems = visibleItems.length > 0;
1267
-
1268
- // Show/hide sections based on visible item count
1269
- if (hasVisibleItems) {
1270
- hasItemsSection.style.display = '';
1271
- emptySection.style.display = 'none';
1272
- } else {
1273
- hasItemsSection.style.display = 'none';
1274
- emptySection.style.display = '';
1275
- }
1276
-
1277
- // Update cart count and subtotal across the site
1278
- this.#renderCartCount(cartData);
1279
- this.#renderCartSubtotal(cartData);
1280
- }
1281
-
1282
- /**
1283
- * Fetch current cart data from server
1284
- * @returns {Promise<Object>} Cart data object
1285
- */
1286
- getCart() {
1287
- return fetch('/cart.json', {
1288
- crossDomain: true,
1289
- credentials: 'same-origin',
1290
- })
1291
- .then((response) => {
1292
- if (!response.ok) {
1293
- throw Error(response.statusText);
1294
- }
1295
- return response.json();
1296
- })
1297
- .catch((error) => {
1298
- console.error('Error fetching cart:', error);
1299
- return { error: true, message: error.message };
1300
- });
1301
- }
1302
-
1303
- /**
1304
- * Update cart item quantity on server
1305
- * @param {string|number} key - Cart item key/ID
1306
- * @param {number} quantity - New quantity (0 to remove)
1307
- * @returns {Promise<Object>} Updated cart data object
1308
- */
1309
- updateCartItem(key, quantity) {
1310
- return fetch('/cart/change.json', {
1311
- crossDomain: true,
1312
- method: 'POST',
1313
- credentials: 'same-origin',
1314
- body: JSON.stringify({ id: key, quantity: quantity }),
1315
- headers: { 'Content-Type': 'application/json' },
1316
- })
1317
- .then((response) => {
1318
- if (!response.ok) {
1319
- throw Error(response.statusText);
1320
- }
1321
- return response.json();
1322
- })
1323
- .catch((error) => {
1324
- console.error('Error updating cart item:', error);
1325
- return { error: true, message: error.message };
1326
- });
1327
- }
1328
-
1329
- /**
1330
- * Refresh cart data from server and update components
1331
- * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1332
- * @returns {Promise<Object>} Cart data object
1333
- */
1334
- refreshCart(cartObj = null) {
1335
- // If cart object is provided, use it directly
1336
- if (cartObj && !cartObj.error) {
1337
- // console.log('Using provided cart data:', cartObj);
1338
- this.#currentCart = cartObj;
1339
- this.#renderCartItems(cartObj);
1340
- this.#renderCartPanel(cartObj);
1341
-
1342
- // Emit cart refreshed and data changed events
1343
- const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
1344
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1345
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1346
-
1347
- return Promise.resolve(cartObj);
1348
- }
1349
-
1350
- // Otherwise fetch from server
1351
- return this.getCart().then((cartData) => {
1352
- // console.log('Cart data received:', cartData);
1353
- if (cartData && !cartData.error) {
1354
- this.#currentCart = cartData;
1355
- this.#renderCartItems(cartData);
1356
- this.#renderCartPanel(cartData);
1357
-
1358
- // Emit cart refreshed and data changed events
1359
- const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1360
- this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1361
- this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1362
- } else {
1363
- console.warn('Cart data has error or is null:', cartData);
1364
- }
1365
- return cartData;
1366
- });
1367
- }
1368
-
1369
- /**
1370
- * Remove items from DOM that are no longer in cart data
1371
- * @private
1372
- */
1373
- #removeItemsFromDOM(itemsContainer, newKeysSet) {
1374
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1375
-
1376
- const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1377
-
1378
- itemsToRemove.forEach((item) => {
1379
- console.log('destroy yourself', item);
1380
- item.destroyYourself();
1381
- });
1382
- }
1383
-
1384
- /**
1385
- * Add new items to DOM with animation delay
1386
- * @private
1387
- */
1388
- #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1389
- // Delay adding new items by 300ms to let cart slide open first
1390
- setTimeout(() => {
1391
- itemsToAdd.forEach((itemData) => {
1392
- const cartItem = CartItem.createAnimated(itemData);
1393
- const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1394
-
1395
- // Find the correct position to insert the new item
1396
- if (targetIndex === 0) {
1397
- // Insert at the beginning
1398
- itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1399
- } else {
1400
- // Find the item that should come before this one
1401
- let insertAfter = null;
1402
- for (let i = targetIndex - 1; i >= 0; i--) {
1403
- const prevKey = newKeys[i];
1404
- const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1405
- if (prevItem) {
1406
- insertAfter = prevItem;
1407
- break;
1408
- }
1409
- }
1410
-
1411
- if (insertAfter) {
1412
- insertAfter.insertAdjacentElement('afterend', cartItem);
1413
- } else {
1414
- itemsContainer.appendChild(cartItem);
1415
- }
1416
- }
1417
- });
1418
- }, 100);
1419
- }
1420
-
1421
- /**
1422
- * Filter cart items to exclude those with _hide_in_cart property
1423
- * @private
1424
- */
1425
- #getVisibleCartItems(cartData) {
1426
- if (!cartData || !cartData.items) return [];
1427
- return cartData.items.filter((item) => {
1428
- // Check for _hide_in_cart in various possible locations
1429
- const hidden = item.properties?._hide_in_cart;
1430
-
1431
- return !hidden;
1432
- });
1433
- }
1434
-
1435
- /**
1436
- * Add calculated fields to cart object for events
1437
- * @private
1438
- */
1439
- #addCalculatedFields(cartData) {
1440
- if (!cartData) return cartData;
1441
-
1442
- const visibleItems = this.#getVisibleCartItems(cartData);
1443
- const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
1444
- const calculated_subtotal = visibleItems.reduce(
1445
- (total, item) => total + (item.line_price || 0),
1446
- 0
1447
- );
1448
-
1449
- return {
1450
- ...cartData,
1451
- calculated_count,
1452
- calculated_subtotal,
1453
- };
1454
- }
1455
-
1456
- /**
1457
- * Render cart items from Shopify cart data with smart comparison
1458
- * @private
1459
- */
1460
- #renderCartItems(cartData) {
1461
- const itemsContainer = this.querySelector('[data-content-cart-items]');
1462
-
1463
- if (!itemsContainer || !cartData || !cartData.items) {
1464
- console.warn('Cannot render cart items:', {
1465
- itemsContainer: !!itemsContainer,
1466
- cartData: !!cartData,
1467
- items: cartData?.items?.length,
1468
- });
1469
- return;
1470
- }
1471
-
1472
- // Filter out items with _hide_in_cart property
1473
- const visibleItems = this.#getVisibleCartItems(cartData);
1474
-
1475
- // Handle initial render - load all items without animation
1476
- if (this.#isInitialRender) {
1477
- // console.log('Initial cart render:', visibleItems.length, 'visible items');
1478
-
1479
- // Clear existing items
1480
- itemsContainer.innerHTML = '';
1481
-
1482
- // Create cart-item elements without animation
1483
- visibleItems.forEach((itemData) => {
1484
- const cartItem = new CartItem(itemData); // No animation
1485
- // const cartItem = document.createElement('cart-item');
1486
- // cartItem.setData(itemData);
1487
- itemsContainer.appendChild(cartItem);
1488
- });
1489
-
1490
- this.#isInitialRender = false;
1491
-
1492
- return;
1493
- }
1494
-
1495
- // Get current DOM items and their keys
1496
- const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1497
- const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1498
-
1499
- // Get new cart data keys in order (only visible items)
1500
- const newKeys = visibleItems.map((item) => item.key || item.id);
1501
- const newKeysSet = new Set(newKeys);
1502
-
1503
- // Step 1: Remove items that are no longer in cart data
1504
- this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1505
-
1506
- // Step 2: Add new items that weren't in DOM (with animation delay)
1507
- const itemsToAdd = visibleItems.filter(
1508
- (itemData) => !currentKeys.has(itemData.key || itemData.id)
1509
- );
1510
- this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1511
- }
1512
-
1513
- /**
1514
- * Set the template function for cart items
1515
- * @param {Function} templateFn - Function that takes item data and returns HTML string
1516
- */
1517
- setCartItemTemplate(templateName, templateFn) {
1518
- CartItem.setTemplate(templateName, templateFn);
1519
- }
1520
-
1521
- /**
1522
- * Set the processing template function for cart items
1523
- * @param {Function} templateFn - Function that returns HTML string for processing state
1524
- */
1525
- setCartItemProcessingTemplate(templateFn) {
1526
- CartItem.setProcessingTemplate(templateFn);
1527
- }
1528
-
1529
- /**
1530
- * Shows the cart dialog and traps focus within it
1531
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1532
- * @fires CartDialog#show - Fired when the cart dialog has been shown
1533
- */
1534
- show(triggerEl = null, cartObj) {
1535
- const _ = this;
1536
- _.triggerEl = triggerEl || false;
1537
-
1538
- // Lock body scrolling
1539
- _.#lockScroll();
1540
-
1541
- // Remove the hidden class first to ensure content is rendered
1542
- _.contentPanel.classList.remove('hidden');
1543
-
1544
- // Give the browser a moment to process before starting animation
1545
- requestAnimationFrame(() => {
1546
- // Update ARIA states
1547
- _.setAttribute('aria-hidden', 'false');
1548
-
1549
- if (_.triggerEl) {
1550
- _.triggerEl.setAttribute('aria-expanded', 'true');
1551
- }
1552
-
1553
- // Focus management
1554
- const firstFocusable = _.querySelector(
1555
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1556
- );
1557
-
1558
- if (firstFocusable) {
1559
- requestAnimationFrame(() => {
1560
- firstFocusable.focus();
1561
- });
1562
- }
1563
-
1564
- // Refresh cart data when showing
1565
- _.refreshCart(cartObj);
1566
-
1567
- // Emit show event - cart dialog is now visible
1568
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1569
- });
1570
- }
1571
-
1572
- /**
1573
- * Hides the cart dialog and restores focus
1574
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1575
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1576
- */
1577
- hide() {
1578
- const _ = this;
1579
-
1580
- // Update ARIA states
1581
- if (_.triggerEl) {
1582
- // remove focus from modal panel first
1583
- _.triggerEl.focus();
1584
- // mark trigger as no longer expanded
1585
- _.triggerEl.setAttribute('aria-expanded', 'false');
1586
- } else {
1587
- // If no trigger element, blur any focused element inside the panel
1588
- const activeElement = document.activeElement;
1589
- if (activeElement && _.contains(activeElement)) {
1590
- activeElement.blur();
1591
- }
1592
- }
1593
-
1594
- requestAnimationFrame(() => {
1595
- // Set aria-hidden to start transition
1596
- // The transitionend event handler will add display:none when complete
1597
- _.setAttribute('aria-hidden', 'true');
1598
-
1599
- // Emit hide event - cart dialog is now starting to hide
1600
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1601
-
1602
- // Restore body scroll
1603
- _.#restoreScroll();
1604
- });
1605
- }
1606
- }
1607
-
1608
- /**
1609
- * Custom element that creates a clickable overlay for the cart dialog
1610
- * @extends HTMLElement
1611
- */
1612
- class CartOverlay extends HTMLElement {
1613
- constructor() {
1614
- super();
1615
- this.setAttribute('tabindex', '-1');
1616
- this.setAttribute('aria-hidden', 'true');
1617
- this.cartDialog = this.closest('cart-dialog');
1618
- this.#attachListeners();
1619
- }
1620
-
1621
- #attachListeners() {
1622
- this.addEventListener('click', () => {
1623
- this.cartDialog.hide();
1624
- });
1625
- }
1626
- }
1627
-
1628
- /**
1629
- * Custom element that wraps the content of the cart dialog
1630
- * @extends HTMLElement
1631
- */
1632
- class CartPanel extends HTMLElement {
1633
- constructor() {
1634
- super();
1635
- this.setAttribute('role', 'document');
1636
- }
1637
- }
1638
-
1639
- if (!customElements.get('cart-dialog')) {
1640
- customElements.define('cart-dialog', CartDialog);
1641
- }
1642
- if (!customElements.get('cart-overlay')) {
1643
- customElements.define('cart-overlay', CartOverlay);
1644
- }
1645
- if (!customElements.get('cart-panel')) {
1646
- customElements.define('cart-panel', CartPanel);
1647
- }
1648
-
1649
- // Make CartItem available globally for Shopify themes
1650
- if (typeof window !== 'undefined') {
1651
- window.CartItem = CartItem;
1652
- }
1653
-
1654
- exports.CartDialog = CartDialog;
1655
- exports.CartItem = CartItem;
1656
- exports.CartOverlay = CartOverlay;
1657
- exports.CartPanel = CartPanel;
1658
- exports.default = CartDialog;
1659
-
1660
- Object.defineProperty(exports, '__esModule', { value: true });
774
+ }
775
+
776
+ /**
777
+ * Update the cart item with new data
778
+ * @param {Object} itemData - Shopify cart item data
779
+ * @param {Object} cartData - Full Shopify cart object
780
+ */
781
+ setData(itemData, cartData = null) {
782
+ // Update internal data
783
+ this.#itemData = itemData;
784
+ if (cartData) {
785
+ this.#cartData = cartData;
786
+ }
787
+
788
+ // Generate new HTML with updated data
789
+ const newHTML = this.#generateTemplateHTML();
790
+
791
+ // Compare with previously rendered HTML
792
+ if (newHTML === this.#lastRenderedHTML) {
793
+ // HTML hasn't changed, just reset processing state
794
+ this.setState('ready');
795
+ return;
796
+ }
797
+
798
+ // HTML is different, proceed with full update
799
+ this.setState('ready');
800
+ this.#render();
801
+
802
+ // Re-find child elements after re-rendering
803
+ this.content = this.querySelector('cart-item-content');
804
+ this.processing = this.querySelector('cart-item-processing');
805
+
806
+ // Update line price elements
807
+ this.#updateLinePriceElements();
808
+ }
809
+
810
+ /**
811
+ * Generate HTML from the current template with current data
812
+ * @returns {string} Generated HTML string or empty string if no template
813
+ * @private
814
+ */
815
+ #generateTemplateHTML() {
816
+ // If no templates are available, return empty string
817
+ if (!this.#itemData || CartItem.#templates.size === 0) {
818
+ return '';
819
+ }
820
+
821
+ // Determine which template to use
822
+ const templateName = this.#itemData.properties?._cart_template || 'default';
823
+ const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
824
+
825
+ if (!templateFn) {
826
+ return '';
827
+ }
828
+
829
+ // Generate and return HTML from template
830
+ return templateFn(this.#itemData, this.#cartData);
831
+ }
832
+
833
+ /**
834
+ * Update elements with data-content-line-price attribute
835
+ * @private
836
+ */
837
+ #updateLinePriceElements() {
838
+ if (!this.#itemData) return;
839
+
840
+ const linePriceElements = this.querySelectorAll('[data-content-line-price]');
841
+ const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
842
+
843
+ linePriceElements.forEach((element) => {
844
+ element.textContent = formattedLinePrice;
845
+ });
846
+ }
847
+
848
+ /**
849
+ * Format currency value from cents to dollar string
850
+ * @param {number} cents - Price in cents
851
+ * @returns {string} Formatted currency string (e.g., "$29.99")
852
+ * @private
853
+ */
854
+ #formatCurrency(cents) {
855
+ if (typeof cents !== 'number') return '$0.00';
856
+ return `$${(cents / 100).toFixed(2)}`;
857
+ }
858
+
859
+ /**
860
+ * Get the current item data
861
+ */
862
+ get itemData() {
863
+ return this.#itemData;
864
+ }
865
+
866
+ /**
867
+ * Set the state of the cart item
868
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
869
+ */
870
+ setState(state) {
871
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
872
+ this.setAttribute('state', state);
873
+ }
874
+ }
875
+
876
+ /**
877
+ * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
878
+ *
879
+ * @returns {void}
880
+ */
881
+ destroyYourself() {
882
+ // bail if already in the middle of a destroy cycle
883
+ if (this.#isDestroying) return;
884
+
885
+ this.#isDestroying = true;
886
+
887
+ // snapshot the current rendered height before applying any "destroying" styles
888
+ const initialHeight = this.offsetHeight;
889
+
890
+ // switch to 'destroying' state so css can fade / slide visuals
891
+ this.setState('destroying');
892
+
893
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
894
+ requestAnimationFrame(() => {
895
+ this.style.height = `${initialHeight}px`;
896
+ // this.offsetHeight; // force a reflow so the browser registers the fixed height
897
+
898
+ // read the css custom property for timing, defaulting to 400ms
899
+ const elementStyle = getComputedStyle(this);
900
+ const destroyDuration =
901
+ elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
902
+
903
+ // animate only the height to zero; other properties stay under stylesheet control
904
+ this.style.transition = `height ${destroyDuration} ease`;
905
+ this.style.height = '0px';
906
+
907
+ // setTimeout(() => {
908
+ // this.style.height = '0px';
909
+ // }, 1);
910
+
911
+ setTimeout(() => {
912
+ // make sure item is removed
913
+ this.remove();
914
+ }, 600);
915
+ });
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Supporting component classes for cart item
921
+ */
922
+ class CartItemContent extends HTMLElement {
923
+ constructor() {
924
+ super();
925
+ }
926
+ }
927
+
928
+ class CartItemProcessing extends HTMLElement {
929
+ constructor() {
930
+ super();
931
+ }
932
+ }
933
+
934
+ // Define custom elements (check if not already defined)
935
+ if (!customElements.get('cart-item')) {
936
+ customElements.define('cart-item', CartItem);
937
+ }
938
+ if (!customElements.get('cart-item-content')) {
939
+ customElements.define('cart-item-content', CartItemContent);
940
+ }
941
+ if (!customElements.get('cart-item-processing')) {
942
+ customElements.define('cart-item-processing', CartItemProcessing);
943
+ }
944
+
945
+ /**
946
+ * Custom element that creates an accessible modal cart dialog with focus management
947
+ * @extends HTMLElement
948
+ */
949
+ class CartDialog extends HTMLElement {
950
+ #handleTransitionEnd;
951
+ #currentCart = null;
952
+ #eventEmitter;
953
+ #isInitialRender = true;
954
+
955
+ /**
956
+ * Clean up event listeners when component is removed from DOM
957
+ */
958
+ disconnectedCallback() {
959
+ const _ = this;
960
+ if (_.contentPanel) {
961
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
962
+ }
963
+
964
+ // Ensure body scroll is restored if component is removed while open
965
+ document.body.classList.remove('overflow-hidden');
966
+ this.#restoreScroll();
967
+
968
+ // Detach event listeners
969
+ this.#detachListeners();
970
+ }
971
+
972
+ /**
973
+ * Locks body scrolling
974
+ * @private
975
+ */
976
+ #lockScroll() {
977
+ // Apply overflow hidden to body
978
+ document.body.classList.add('overflow-hidden');
979
+ }
980
+
981
+ /**
982
+ * Restores body scrolling when cart dialog is closed
983
+ * @private
984
+ */
985
+ #restoreScroll() {
986
+ // Remove overflow hidden from body
987
+ document.body.classList.remove('overflow-hidden');
988
+ }
989
+
990
+ /**
991
+ * Initializes the cart dialog, sets up focus trap and overlay
992
+ */
993
+ constructor() {
994
+ super();
995
+ const _ = this;
996
+ _.id = _.getAttribute('id');
997
+ _.setAttribute('role', 'dialog');
998
+ _.setAttribute('aria-modal', 'true');
999
+ _.setAttribute('aria-hidden', 'true');
1000
+
1001
+ _.triggerEl = null;
1002
+
1003
+ // Initialize event emitter
1004
+ _.#eventEmitter = new EventEmitter();
1005
+
1006
+ // Create a handler for transition end events
1007
+ _.#handleTransitionEnd = (e) => {
1008
+ if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
1009
+ _.contentPanel.classList.add('hidden');
1010
+
1011
+ // Emit afterHide event - cart dialog has completed its transition
1012
+ _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
1013
+ }
1014
+ };
1015
+ }
1016
+
1017
+ connectedCallback() {
1018
+ const _ = this;
1019
+
1020
+ // Now that we're in the DOM, find the content panel and set up focus trap
1021
+ _.contentPanel = _.querySelector('cart-panel');
1022
+
1023
+ if (!_.contentPanel) {
1024
+ console.error('cart-panel element not found inside cart-dialog');
1025
+ return;
1026
+ }
1027
+
1028
+ // Check if focus-trap already exists, if not create one
1029
+ _.focusTrap = _.contentPanel.querySelector('focus-trap');
1030
+ if (!_.focusTrap) {
1031
+ _.focusTrap = document.createElement('focus-trap');
1032
+
1033
+ // Move all existing cart-panel content into the focus trap
1034
+ const existingContent = Array.from(_.contentPanel.childNodes);
1035
+ existingContent.forEach((child) => _.focusTrap.appendChild(child));
1036
+
1037
+ // Insert focus trap inside the cart-panel
1038
+ _.contentPanel.appendChild(_.focusTrap);
1039
+ }
1040
+
1041
+ // Ensure we have labelledby and describedby references
1042
+ if (!_.getAttribute('aria-labelledby')) {
1043
+ const heading = _.querySelector('h1, h2, h3');
1044
+ if (heading && !heading.id) {
1045
+ heading.id = `${_.id}-title`;
1046
+ }
1047
+ if (heading?.id) {
1048
+ _.setAttribute('aria-labelledby', heading.id);
1049
+ }
1050
+ }
1051
+
1052
+ // Add modal overlay if it doesn't already exist
1053
+ if (!_.querySelector('cart-overlay')) {
1054
+ _.prepend(document.createElement('cart-overlay'));
1055
+ }
1056
+ _.#attachListeners();
1057
+ _.#bindKeyboard();
1058
+
1059
+ // Load cart data immediately after component initialization
1060
+ _.refreshCart();
1061
+ }
1062
+
1063
+ /**
1064
+ * Event emitter method - Add an event listener with a cleaner API
1065
+ * @param {string} eventName - Name of the event to listen for
1066
+ * @param {Function} callback - Callback function to execute when event is fired
1067
+ * @returns {CartDialog} Returns this for method chaining
1068
+ */
1069
+ on(eventName, callback) {
1070
+ this.#eventEmitter.on(eventName, callback);
1071
+ return this;
1072
+ }
1073
+
1074
+ /**
1075
+ * Event emitter method - Remove an event listener
1076
+ * @param {string} eventName - Name of the event to stop listening for
1077
+ * @param {Function} callback - Callback function to remove
1078
+ * @returns {CartDialog} Returns this for method chaining
1079
+ */
1080
+ off(eventName, callback) {
1081
+ this.#eventEmitter.off(eventName, callback);
1082
+ return this;
1083
+ }
1084
+
1085
+ /**
1086
+ * Internal method to emit events via the event emitter
1087
+ * @param {string} eventName - Name of the event to emit
1088
+ * @param {*} [data] - Optional data to include with the event
1089
+ * @private
1090
+ */
1091
+ #emit(eventName, data = null) {
1092
+ this.#eventEmitter.emit(eventName, data);
1093
+
1094
+ // Also emit as native DOM events for better compatibility
1095
+ this.dispatchEvent(
1096
+ new CustomEvent(eventName, {
1097
+ detail: data,
1098
+ bubbles: true,
1099
+ })
1100
+ );
1101
+ }
1102
+
1103
+ /**
1104
+ * Attach event listeners for cart dialog functionality
1105
+ * @private
1106
+ */
1107
+ #attachListeners() {
1108
+ const _ = this;
1109
+
1110
+ // Handle trigger buttons
1111
+ document.addEventListener('click', (e) => {
1112
+ const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
1113
+ if (!trigger) return;
1114
+
1115
+ if (trigger.getAttribute('data-prevent-default') === 'true') {
1116
+ e.preventDefault();
1117
+ }
1118
+
1119
+ _.show(trigger);
1120
+ });
1121
+
1122
+ // Handle close buttons
1123
+ _.addEventListener('click', (e) => {
1124
+ if (!e.target.closest('[data-action-hide-cart]')) return;
1125
+ _.hide();
1126
+ });
1127
+
1128
+ // Handle cart item remove events
1129
+ _.addEventListener('cart-item:remove', (e) => {
1130
+ _.#handleCartItemRemove(e);
1131
+ });
1132
+
1133
+ // Handle cart item quantity change events
1134
+ _.addEventListener('cart-item:quantity-change', (e) => {
1135
+ _.#handleCartItemQuantityChange(e);
1136
+ });
1137
+
1138
+ // Add transition end listener
1139
+ _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
1140
+ }
1141
+
1142
+ /**
1143
+ * Detach event listeners
1144
+ * @private
1145
+ */
1146
+ #detachListeners() {
1147
+ const _ = this;
1148
+ if (_.contentPanel) {
1149
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Binds keyboard events for accessibility
1155
+ * @private
1156
+ */
1157
+ #bindKeyboard() {
1158
+ this.addEventListener('keydown', (e) => {
1159
+ if (e.key === 'Escape') {
1160
+ this.hide();
1161
+ }
1162
+ });
1163
+ }
1164
+
1165
+ /**
1166
+ * Handle cart item removal
1167
+ * @private
1168
+ */
1169
+ #handleCartItemRemove(e) {
1170
+ const { cartKey, element } = e.detail;
1171
+
1172
+ // Set item to processing state
1173
+ element.setState('processing');
1174
+
1175
+ // Remove item by setting quantity to 0
1176
+ this.updateCartItem(cartKey, 0)
1177
+ .then((updatedCart) => {
1178
+ if (updatedCart && !updatedCart.error) {
1179
+ // Success - let smart comparison handle the removal animation
1180
+ this.#currentCart = updatedCart;
1181
+ this.#renderCartItems(updatedCart);
1182
+ this.#renderCartPanel(updatedCart);
1183
+
1184
+ // Emit cart updated and data changed events
1185
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1186
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1187
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1188
+ } else {
1189
+ // Error - reset to ready state
1190
+ element.setState('ready');
1191
+ console.error('Failed to remove cart item:', cartKey);
1192
+ }
1193
+ })
1194
+ .catch((error) => {
1195
+ // Error - reset to ready state
1196
+ element.setState('ready');
1197
+ console.error('Error removing cart item:', error);
1198
+ });
1199
+ }
1200
+
1201
+ /**
1202
+ * Handle cart item quantity change
1203
+ * @private
1204
+ */
1205
+ #handleCartItemQuantityChange(e) {
1206
+ const { cartKey, quantity, element } = e.detail;
1207
+
1208
+ // Set item to processing state
1209
+ element.setState('processing');
1210
+
1211
+ // Update item quantity
1212
+ this.updateCartItem(cartKey, quantity)
1213
+ .then((updatedCart) => {
1214
+ if (updatedCart && !updatedCart.error) {
1215
+ // Success - update cart data and refresh items
1216
+ this.#currentCart = updatedCart;
1217
+ this.#renderCartItems(updatedCart);
1218
+ this.#renderCartPanel(updatedCart);
1219
+
1220
+ // Emit cart updated and data changed events
1221
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1222
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1223
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1224
+ } else {
1225
+ // Error - reset to ready state
1226
+ element.setState('ready');
1227
+ console.error('Failed to update cart item quantity:', cartKey, quantity);
1228
+ }
1229
+ })
1230
+ .catch((error) => {
1231
+ // Error - reset to ready state
1232
+ element.setState('ready');
1233
+ console.error('Error updating cart item quantity:', error);
1234
+ });
1235
+ }
1236
+
1237
+ /**
1238
+ * Update cart count elements across the site
1239
+ * @private
1240
+ */
1241
+ #renderCartCount(cartData) {
1242
+ if (!cartData) return;
1243
+
1244
+ // Calculate visible item count (excluding _hide_in_cart items)
1245
+ const visibleItems = this.#getVisibleCartItems(cartData);
1246
+ const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
1247
+
1248
+ // Update all cart count elements across the site
1249
+ const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
1250
+ cartCountElements.forEach((element) => {
1251
+ element.textContent = visibleItemCount;
1252
+ });
1253
+ }
1254
+
1255
+ /**
1256
+ * Update cart subtotal elements across the site
1257
+ * @private
1258
+ */
1259
+ #renderCartSubtotal(cartData) {
1260
+ if (!cartData) return;
1261
+
1262
+ // Calculate subtotal from all items except those marked to ignore pricing
1263
+ const pricedItems = cartData.items.filter((item) => {
1264
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
1265
+ return !ignorePrice;
1266
+ });
1267
+ const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
1268
+
1269
+ // Update all cart subtotal elements across the site
1270
+ const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
1271
+ cartSubtotalElements.forEach((element) => {
1272
+ // Format as currency (assuming cents, convert to dollars)
1273
+ const formatted = (subtotal / 100).toFixed(2);
1274
+ element.textContent = `$${formatted}`;
1275
+ });
1276
+ }
1277
+
1278
+ /**
1279
+ * Update cart items display based on cart data
1280
+ * @private
1281
+ */
1282
+ #renderCartPanel(cart = null) {
1283
+ const cartData = cart || this.#currentCart;
1284
+ if (!cartData) return;
1285
+
1286
+ // Get cart sections
1287
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
1288
+ const emptySection = this.querySelector('[data-cart-is-empty]');
1289
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1290
+
1291
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
1292
+ console.warn(
1293
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
1294
+ );
1295
+ return;
1296
+ }
1297
+
1298
+ // Check visible item count for showing/hiding sections
1299
+ const visibleItems = this.#getVisibleCartItems(cartData);
1300
+ const hasVisibleItems = visibleItems.length > 0;
1301
+
1302
+ // Show/hide sections based on visible item count
1303
+ if (hasVisibleItems) {
1304
+ hasItemsSection.style.display = '';
1305
+ emptySection.style.display = 'none';
1306
+ } else {
1307
+ hasItemsSection.style.display = 'none';
1308
+ emptySection.style.display = '';
1309
+ }
1310
+
1311
+ // Update cart count and subtotal across the site
1312
+ this.#renderCartCount(cartData);
1313
+ this.#renderCartSubtotal(cartData);
1314
+ }
1315
+
1316
+ /**
1317
+ * Fetch current cart data from server
1318
+ * @returns {Promise<Object>} Cart data object
1319
+ */
1320
+ getCart() {
1321
+ return fetch('/cart.json', {
1322
+ crossDomain: true,
1323
+ credentials: 'same-origin',
1324
+ })
1325
+ .then((response) => {
1326
+ if (!response.ok) {
1327
+ throw Error(response.statusText);
1328
+ }
1329
+ return response.json();
1330
+ })
1331
+ .catch((error) => {
1332
+ console.error('Error fetching cart:', error);
1333
+ return { error: true, message: error.message };
1334
+ });
1335
+ }
1336
+
1337
+ /**
1338
+ * Update cart item quantity on server
1339
+ * @param {string|number} key - Cart item key/ID
1340
+ * @param {number} quantity - New quantity (0 to remove)
1341
+ * @returns {Promise<Object>} Updated cart data object
1342
+ */
1343
+ updateCartItem(key, quantity) {
1344
+ return fetch('/cart/change.json', {
1345
+ crossDomain: true,
1346
+ method: 'POST',
1347
+ credentials: 'same-origin',
1348
+ body: JSON.stringify({ id: key, quantity: quantity }),
1349
+ headers: { 'Content-Type': 'application/json' },
1350
+ })
1351
+ .then((response) => {
1352
+ if (!response.ok) {
1353
+ throw Error(response.statusText);
1354
+ }
1355
+ return response.json();
1356
+ })
1357
+ .catch((error) => {
1358
+ console.error('Error updating cart item:', error);
1359
+ return { error: true, message: error.message };
1360
+ });
1361
+ }
1362
+
1363
+ /**
1364
+ * Refresh cart data from server and update components
1365
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1366
+ * @returns {Promise<Object>} Cart data object
1367
+ */
1368
+ refreshCart(cartObj = null) {
1369
+ // If cart object is provided, use it directly
1370
+ if (cartObj && !cartObj.error) {
1371
+ // console.log('Using provided cart data:', cartObj);
1372
+ this.#currentCart = cartObj;
1373
+ this.#renderCartItems(cartObj);
1374
+ this.#renderCartPanel(cartObj);
1375
+
1376
+ // Emit cart refreshed and data changed events
1377
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
1378
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1379
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1380
+
1381
+ return Promise.resolve(cartObj);
1382
+ }
1383
+
1384
+ // Otherwise fetch from server
1385
+ return this.getCart().then((cartData) => {
1386
+ // console.log('Cart data received:', cartData);
1387
+ if (cartData && !cartData.error) {
1388
+ this.#currentCart = cartData;
1389
+ this.#renderCartItems(cartData);
1390
+ this.#renderCartPanel(cartData);
1391
+
1392
+ // Emit cart refreshed and data changed events
1393
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1394
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1395
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1396
+ } else {
1397
+ console.warn('Cart data has error or is null:', cartData);
1398
+ }
1399
+ return cartData;
1400
+ });
1401
+ }
1402
+
1403
+ /**
1404
+ * Remove items from DOM that are no longer in cart data
1405
+ * @private
1406
+ */
1407
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
1408
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1409
+
1410
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1411
+
1412
+ itemsToRemove.forEach((item) => {
1413
+ console.log('destroy yourself', item);
1414
+ item.destroyYourself();
1415
+ });
1416
+ }
1417
+
1418
+ /**
1419
+ * Update existing cart-item elements with fresh cart data
1420
+ * @private
1421
+ */
1422
+ #updateItemsInDOM(itemsContainer, cartData) {
1423
+ const visibleItems = this.#getVisibleCartItems(cartData);
1424
+ const existingItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1425
+
1426
+ existingItems.forEach((cartItemEl) => {
1427
+ const key = cartItemEl.getAttribute('key');
1428
+ const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
1429
+
1430
+ if (updatedItemData) {
1431
+ // Update cart-item with fresh data and full cart context
1432
+ // The cart-item will handle HTML comparison and only re-render if needed
1433
+ cartItemEl.setData(updatedItemData, cartData);
1434
+ }
1435
+ });
1436
+ }
1437
+
1438
+ /**
1439
+ * Add new items to DOM with animation delay
1440
+ * @private
1441
+ */
1442
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1443
+ // Delay adding new items by 300ms to let cart slide open first
1444
+ setTimeout(() => {
1445
+ itemsToAdd.forEach((itemData) => {
1446
+ const cartItem = CartItem.createAnimated(itemData);
1447
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1448
+
1449
+ // Find the correct position to insert the new item
1450
+ if (targetIndex === 0) {
1451
+ // Insert at the beginning
1452
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1453
+ } else {
1454
+ // Find the item that should come before this one
1455
+ let insertAfter = null;
1456
+ for (let i = targetIndex - 1; i >= 0; i--) {
1457
+ const prevKey = newKeys[i];
1458
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1459
+ if (prevItem) {
1460
+ insertAfter = prevItem;
1461
+ break;
1462
+ }
1463
+ }
1464
+
1465
+ if (insertAfter) {
1466
+ insertAfter.insertAdjacentElement('afterend', cartItem);
1467
+ } else {
1468
+ itemsContainer.appendChild(cartItem);
1469
+ }
1470
+ }
1471
+ });
1472
+ }, 100);
1473
+ }
1474
+
1475
+ /**
1476
+ * Filter cart items to exclude those with _hide_in_cart property
1477
+ * @private
1478
+ */
1479
+ #getVisibleCartItems(cartData) {
1480
+ if (!cartData || !cartData.items) return [];
1481
+ return cartData.items.filter((item) => {
1482
+ // Check for _hide_in_cart in various possible locations
1483
+ const hidden = item.properties?._hide_in_cart;
1484
+
1485
+ return !hidden;
1486
+ });
1487
+ }
1488
+
1489
+ /**
1490
+ * Add calculated fields to cart object for events
1491
+ * @private
1492
+ */
1493
+ #addCalculatedFields(cartData) {
1494
+ if (!cartData) return cartData;
1495
+
1496
+ // For display counts: use visible items (excludes _hide_in_cart)
1497
+ const visibleItems = this.#getVisibleCartItems(cartData);
1498
+ const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
1499
+
1500
+ // For pricing: use all items except those marked to ignore pricing
1501
+ const pricedItems = cartData.items.filter((item) => {
1502
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
1503
+ return !ignorePrice;
1504
+ });
1505
+ const calculated_subtotal = pricedItems.reduce(
1506
+ (total, item) => total + (item.line_price || 0),
1507
+ 0
1508
+ );
1509
+
1510
+ return {
1511
+ ...cartData,
1512
+ calculated_count,
1513
+ calculated_subtotal,
1514
+ };
1515
+ }
1516
+
1517
+ /**
1518
+ * Render cart items from Shopify cart data with smart comparison
1519
+ * @private
1520
+ */
1521
+ #renderCartItems(cartData) {
1522
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1523
+
1524
+ if (!itemsContainer || !cartData || !cartData.items) {
1525
+ console.warn('Cannot render cart items:', {
1526
+ itemsContainer: !!itemsContainer,
1527
+ cartData: !!cartData,
1528
+ items: cartData?.items?.length,
1529
+ });
1530
+ return;
1531
+ }
1532
+
1533
+ // Filter out items with _hide_in_cart property
1534
+ const visibleItems = this.#getVisibleCartItems(cartData);
1535
+
1536
+ // Handle initial render - load all items without animation
1537
+ if (this.#isInitialRender) {
1538
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
1539
+
1540
+ // Clear existing items
1541
+ itemsContainer.innerHTML = '';
1542
+
1543
+ // Create cart-item elements without animation
1544
+ visibleItems.forEach((itemData) => {
1545
+ const cartItem = new CartItem(itemData); // No animation
1546
+ itemsContainer.appendChild(cartItem);
1547
+ });
1548
+
1549
+ this.#isInitialRender = false;
1550
+
1551
+ return;
1552
+ }
1553
+
1554
+ // Get current DOM items and their keys
1555
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1556
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1557
+
1558
+ // Get new cart data keys in order (only visible items)
1559
+ const newKeys = visibleItems.map((item) => item.key || item.id);
1560
+ const newKeysSet = new Set(newKeys);
1561
+
1562
+ // Step 1: Remove items that are no longer in cart data
1563
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1564
+
1565
+ // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
1566
+ this.#updateItemsInDOM(itemsContainer, cartData);
1567
+
1568
+ // Step 3: Add new items that weren't in DOM (with animation delay)
1569
+ const itemsToAdd = visibleItems.filter(
1570
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
1571
+ );
1572
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1573
+ }
1574
+
1575
+ /**
1576
+ * Set the template function for cart items
1577
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
1578
+ */
1579
+ setCartItemTemplate(templateName, templateFn) {
1580
+ CartItem.setTemplate(templateName, templateFn);
1581
+ }
1582
+
1583
+ /**
1584
+ * Set the processing template function for cart items
1585
+ * @param {Function} templateFn - Function that returns HTML string for processing state
1586
+ */
1587
+ setCartItemProcessingTemplate(templateFn) {
1588
+ CartItem.setProcessingTemplate(templateFn);
1589
+ }
1590
+
1591
+ /**
1592
+ * Shows the cart dialog and traps focus within it
1593
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1594
+ * @fires CartDialog#show - Fired when the cart dialog has been shown
1595
+ */
1596
+ show(triggerEl = null, cartObj) {
1597
+ const _ = this;
1598
+ _.triggerEl = triggerEl || false;
1599
+
1600
+ // Lock body scrolling
1601
+ _.#lockScroll();
1602
+
1603
+ // Remove the hidden class first to ensure content is rendered
1604
+ _.contentPanel.classList.remove('hidden');
1605
+
1606
+ // Give the browser a moment to process before starting animation
1607
+ requestAnimationFrame(() => {
1608
+ // Update ARIA states
1609
+ _.setAttribute('aria-hidden', 'false');
1610
+
1611
+ if (_.triggerEl) {
1612
+ _.triggerEl.setAttribute('aria-expanded', 'true');
1613
+ }
1614
+
1615
+ // Focus management
1616
+ const firstFocusable = _.querySelector(
1617
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1618
+ );
1619
+
1620
+ if (firstFocusable) {
1621
+ requestAnimationFrame(() => {
1622
+ firstFocusable.focus();
1623
+ });
1624
+ }
1625
+
1626
+ // Refresh cart data when showing
1627
+ _.refreshCart(cartObj);
1628
+
1629
+ // Emit show event - cart dialog is now visible
1630
+ _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1631
+ });
1632
+ }
1633
+
1634
+ /**
1635
+ * Hides the cart dialog and restores focus
1636
+ * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1637
+ * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1638
+ */
1639
+ hide() {
1640
+ const _ = this;
1641
+
1642
+ // Update ARIA states
1643
+ if (_.triggerEl) {
1644
+ // remove focus from modal panel first
1645
+ _.triggerEl.focus();
1646
+ // mark trigger as no longer expanded
1647
+ _.triggerEl.setAttribute('aria-expanded', 'false');
1648
+ } else {
1649
+ // If no trigger element, blur any focused element inside the panel
1650
+ const activeElement = document.activeElement;
1651
+ if (activeElement && _.contains(activeElement)) {
1652
+ activeElement.blur();
1653
+ }
1654
+ }
1655
+
1656
+ requestAnimationFrame(() => {
1657
+ // Set aria-hidden to start transition
1658
+ // The transitionend event handler will add display:none when complete
1659
+ _.setAttribute('aria-hidden', 'true');
1660
+
1661
+ // Emit hide event - cart dialog is now starting to hide
1662
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1663
+
1664
+ // Restore body scroll
1665
+ _.#restoreScroll();
1666
+ });
1667
+ }
1668
+ }
1669
+
1670
+ /**
1671
+ * Custom element that creates a clickable overlay for the cart dialog
1672
+ * @extends HTMLElement
1673
+ */
1674
+ class CartOverlay extends HTMLElement {
1675
+ constructor() {
1676
+ super();
1677
+ this.setAttribute('tabindex', '-1');
1678
+ this.setAttribute('aria-hidden', 'true');
1679
+ this.cartDialog = this.closest('cart-dialog');
1680
+ this.#attachListeners();
1681
+ }
1682
+
1683
+ #attachListeners() {
1684
+ this.addEventListener('click', () => {
1685
+ this.cartDialog.hide();
1686
+ });
1687
+ }
1688
+ }
1689
+
1690
+ /**
1691
+ * Custom element that wraps the content of the cart dialog
1692
+ * @extends HTMLElement
1693
+ */
1694
+ class CartPanel extends HTMLElement {
1695
+ constructor() {
1696
+ super();
1697
+ this.setAttribute('role', 'document');
1698
+ }
1699
+ }
1700
+
1701
+ if (!customElements.get('cart-dialog')) {
1702
+ customElements.define('cart-dialog', CartDialog);
1703
+ }
1704
+ if (!customElements.get('cart-overlay')) {
1705
+ customElements.define('cart-overlay', CartOverlay);
1706
+ }
1707
+ if (!customElements.get('cart-panel')) {
1708
+ customElements.define('cart-panel', CartPanel);
1709
+ }
1710
+
1711
+ // Make CartItem available globally for Shopify themes
1712
+ if (typeof window !== 'undefined') {
1713
+ window.CartItem = CartItem;
1714
+ }
1715
+
1716
+ exports.CartDialog = CartDialog;
1717
+ exports.CartItem = CartItem;
1718
+ exports.CartOverlay = CartOverlay;
1719
+ exports.CartPanel = CartPanel;
1720
+ exports.default = CartDialog;
1721
+
1722
+ Object.defineProperty(exports, '__esModule', { value: true });
1661
1723
 
1662
1724
  }));
1663
1725
  //# sourceMappingURL=cart-panel.js.map