@magic-spells/cart-panel 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,353 @@
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('input', 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('input', 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
+ if (!isNaN(inputValue)) {
458
+ const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
459
+ this.updateValue(clampedValue);
460
+ }
461
+ }
462
+
463
+ // Update the component value and dispatch change event if value changed
464
+ updateValue(newValue) {
465
+ if (newValue !== this.value) {
466
+ this.value = newValue;
467
+ this.updateInput();
468
+ this.dispatchChangeEvent(newValue);
469
+ }
470
+ }
471
+
472
+ // Sync the input field with current component state
473
+ updateInput() {
474
+ const input = this.querySelector('[data-quantity-modifier-field]');
475
+ if (input) {
476
+ input.value = this.value;
477
+ input.min = this.min;
478
+ input.max = this.max;
479
+ }
480
+ }
481
+
482
+ // Dispatch custom event when value changes for external listeners
483
+ dispatchChangeEvent(value) {
484
+ this.dispatchEvent(
485
+ new CustomEvent('quantity-modifier:change', {
486
+ detail: { value },
487
+ bubbles: true,
488
+ })
489
+ );
490
+ }
491
+ }
492
+
493
+ customElements.define('quantity-modifier', QuantityModifier);
494
+
495
+ /**
496
+ * CartItem class that handles the functionality of a cart item component
497
+ */
498
+ class CartItem extends HTMLElement {
499
+ // Static template functions shared across all instances
500
+ static #templates = new Map();
501
+ static #processingTemplate = null;
502
+
503
+ // Private fields
504
+ #currentState = 'ready';
505
+ #isDestroying = false;
506
+ #isAppearing = false;
507
+ #handlers = {};
508
+ #itemData = null;
509
+ #cartData = null;
510
+ #lastRenderedHTML = '';
511
+
512
+ /**
513
+ * Set the template function for rendering cart items
514
+ * @param {string} name - Template name ('default' for default template)
515
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
516
+ */
517
+ static setTemplate(name, templateFn) {
518
+ if (typeof name !== 'string') {
519
+ throw new Error('Template name must be a string');
520
+ }
521
+ if (typeof templateFn !== 'function') {
522
+ throw new Error('Template must be a function');
523
+ }
524
+ CartItem.#templates.set(name, templateFn);
525
+ }
526
+
527
+ /**
528
+ * Set the processing template function for rendering processing overlay
529
+ * @param {Function} templateFn - Function that returns HTML string for processing state
530
+ */
531
+ static setProcessingTemplate(templateFn) {
532
+ if (typeof templateFn !== 'function') {
533
+ throw new Error('Processing template must be a function');
534
+ }
535
+ CartItem.#processingTemplate = templateFn;
536
+ }
537
+
538
+ /**
539
+ * Create a cart item with appearing animation
540
+ * @param {Object} itemData - Shopify cart item data
541
+ * @param {Object} cartData - Full Shopify cart object
542
+ * @returns {CartItem} Cart item instance that will animate in
543
+ */
544
+ static createAnimated(itemData, cartData) {
545
+ return new CartItem(itemData, cartData, { animate: true });
546
+ }
547
+
548
+ /**
549
+ * Define which attributes should be observed for changes
550
+ */
551
+ static get observedAttributes() {
552
+ return ['state', 'key'];
553
+ }
554
+
555
+ /**
556
+ * Called when observed attributes change
557
+ */
558
+ attributeChangedCallback(name, oldValue, newValue) {
559
+ if (oldValue === newValue) return;
560
+
561
+ if (name === 'state') {
562
+ this.#currentState = newValue || 'ready';
563
+ }
564
+ }
565
+
566
+ constructor(itemData = null, cartData = null, options = {}) {
567
+ super();
568
+
569
+ // Store item and cart data if provided
570
+ this.#itemData = itemData;
571
+ this.#cartData = cartData;
572
+
573
+ // Set initial state - start with 'appearing' only if explicitly requested
574
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
575
+ this.#currentState =
576
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
577
+
578
+ // Bind event handlers
579
+ this.#handlers = {
580
+ click: this.#handleClick.bind(this),
581
+ change: this.#handleChange.bind(this),
582
+ transitionEnd: this.#handleTransitionEnd.bind(this),
583
+ };
584
+ }
585
+
586
+ connectedCallback() {
587
+ // If we have item data, render it first
588
+ if (this.#itemData) {
589
+ this.#render();
590
+ }
591
+
592
+ // Find child elements
593
+ this.content = this.querySelector('cart-item-content');
594
+ this.processing = this.querySelector('cart-item-processing');
595
+
596
+ // Update line price elements in case of pre-rendered content
597
+ this.#updateLinePriceElements();
598
+
599
+ // Attach event listeners
600
+ this.#attachListeners();
601
+
602
+ // If we started with 'appearing' state, handle the entry animation
603
+ if (this.#currentState === 'appearing') {
604
+ // Set the state attribute
605
+ this.setAttribute('state', 'appearing');
606
+ this.#isAppearing = true;
607
+
608
+ // Get the natural height after rendering
609
+ requestAnimationFrame(() => {
610
+ const naturalHeight = this.scrollHeight;
611
+
612
+ // Set explicit height for animation
613
+ this.style.height = `${naturalHeight}px`;
614
+
615
+ // Transition to ready state after a brief delay
616
+ requestAnimationFrame(() => {
617
+ this.setState('ready');
618
+ });
619
+ });
620
+ }
621
+ }
622
+
623
+ disconnectedCallback() {
624
+ // Cleanup event listeners
625
+ this.#detachListeners();
626
+ }
627
+
628
+ /**
629
+ * Attach event listeners
630
+ */
631
+ #attachListeners() {
632
+ this.addEventListener('click', this.#handlers.click);
633
+ this.addEventListener('change', this.#handlers.change);
634
+ this.addEventListener('quantity-modifier:change', this.#handlers.change);
635
+ this.addEventListener('transitionend', this.#handlers.transitionEnd);
636
+ }
637
+
638
+ /**
639
+ * Detach event listeners
640
+ */
641
+ #detachListeners() {
642
+ this.removeEventListener('click', this.#handlers.click);
643
+ this.removeEventListener('change', this.#handlers.change);
644
+ this.removeEventListener('quantity-modifier:change', this.#handlers.change);
645
+ this.removeEventListener('transitionend', this.#handlers.transitionEnd);
646
+ }
647
+
648
+ /**
649
+ * Get the current state
650
+ */
651
+ get state() {
652
+ return this.#currentState;
653
+ }
654
+
655
+ /**
656
+ * Get the cart key for this item
657
+ */
658
+ get cartKey() {
659
+ return this.getAttribute('key');
660
+ }
661
+
662
+ /**
663
+ * Handle click events (for Remove buttons, etc.)
664
+ */
665
+ #handleClick(e) {
666
+ // Check if clicked element is a remove button
667
+ const removeButton = e.target.closest('[data-action-remove-item]');
668
+ if (removeButton) {
669
+ e.preventDefault();
670
+ this.#emitRemoveEvent();
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Handle change events (for quantity inputs and quantity-modifier)
676
+ */
677
+ #handleChange(e) {
678
+ // Check if event is from quantity-modifier component
679
+ if (e.type === 'quantity-modifier:change') {
680
+ this.#emitQuantityChangeEvent(e.detail.value);
681
+ return;
682
+ }
683
+
684
+ // Check if changed element is a quantity input
685
+ const quantityInput = e.target.closest('[data-cart-quantity]');
686
+ if (quantityInput) {
687
+ this.#emitQuantityChangeEvent(quantityInput.value);
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Handle transition end events for destroy animation and appearing animation
693
+ */
694
+ #handleTransitionEnd(e) {
695
+ if (e.propertyName === 'height' && this.#isDestroying) {
696
+ // Remove from DOM after height animation completes
697
+ this.remove();
698
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
699
+ // Remove explicit height after appearing animation completes
700
+ this.style.height = '';
701
+ this.#isAppearing = false;
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Emit remove event
707
+ */
708
+ #emitRemoveEvent() {
709
+ this.dispatchEvent(
710
+ new CustomEvent('cart-item:remove', {
711
+ bubbles: true,
712
+ detail: {
713
+ cartKey: this.cartKey,
714
+ element: this,
715
+ },
716
+ })
717
+ );
718
+ }
719
+
720
+ /**
721
+ * Emit quantity change event
722
+ */
723
+ #emitQuantityChangeEvent(quantity) {
724
+ this.dispatchEvent(
725
+ new CustomEvent('cart-item:quantity-change', {
726
+ bubbles: true,
727
+ detail: {
728
+ cartKey: this.cartKey,
729
+ quantity: parseInt(quantity),
730
+ element: this,
731
+ },
732
+ })
733
+ );
734
+ }
735
+
736
+ /**
737
+ * Render cart item from data using the appropriate template
738
+ */
739
+ #render() {
740
+ if (!this.#itemData || CartItem.#templates.size === 0) {
741
+ console.log('no item data or no template', this.#itemData, CartItem.#templates);
742
+ return;
743
+ }
744
+
745
+ // Set the key attribute from item data
746
+ const key = this.#itemData.key || this.#itemData.id;
747
+ if (key) {
748
+ this.setAttribute('key', key);
749
+ }
750
+
751
+ // Generate HTML from template and store for future comparisons
752
+ const templateHTML = this.#generateTemplateHTML();
753
+ this.#lastRenderedHTML = templateHTML;
754
+
755
+ // Generate processing HTML from template or use default
756
+ const processingHTML = CartItem.#processingTemplate
757
+ ? CartItem.#processingTemplate()
758
+ : '<div class="cart-item-loader"></div>';
759
+
760
+ // Create the cart-item structure with template content inside cart-item-content
761
+ this.innerHTML = `
483
762
  <cart-item-content>
484
763
  ${templateHTML}
485
764
  </cart-item-content>
@@ -487,1177 +766,955 @@
487
766
  ${processingHTML}
488
767
  </cart-item-processing>
489
768
  `;
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 });
769
+ }
770
+
771
+ /**
772
+ * Update the cart item with new data
773
+ * @param {Object} itemData - Shopify cart item data
774
+ * @param {Object} cartData - Full Shopify cart object
775
+ */
776
+ setData(itemData, cartData = null) {
777
+ // Update internal data
778
+ this.#itemData = itemData;
779
+ if (cartData) {
780
+ this.#cartData = cartData;
781
+ }
782
+
783
+ // Generate new HTML with updated data
784
+ const newHTML = this.#generateTemplateHTML();
785
+
786
+ // Compare with previously rendered HTML
787
+ if (newHTML === this.#lastRenderedHTML) {
788
+ // HTML hasn't changed, just reset processing state
789
+ this.setState('ready');
790
+ return;
791
+ }
792
+
793
+ // HTML is different, proceed with full update
794
+ this.setState('ready');
795
+ this.#render();
796
+
797
+ // Re-find child elements after re-rendering
798
+ this.content = this.querySelector('cart-item-content');
799
+ this.processing = this.querySelector('cart-item-processing');
800
+
801
+ // Update line price elements
802
+ this.#updateLinePriceElements();
803
+ }
804
+
805
+ /**
806
+ * Generate HTML from the current template with current data
807
+ * @returns {string} Generated HTML string or empty string if no template
808
+ * @private
809
+ */
810
+ #generateTemplateHTML() {
811
+ // If no templates are available, return empty string
812
+ if (!this.#itemData || CartItem.#templates.size === 0) {
813
+ return '';
814
+ }
815
+
816
+ // Determine which template to use
817
+ const templateName = this.#itemData.properties?._cart_template || 'default';
818
+ const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
819
+
820
+ if (!templateFn) {
821
+ return '';
822
+ }
823
+
824
+ // Generate and return HTML from template
825
+ return templateFn(this.#itemData, this.#cartData);
826
+ }
827
+
828
+ /**
829
+ * Update elements with data-content-line-price attribute
830
+ * @private
831
+ */
832
+ #updateLinePriceElements() {
833
+ if (!this.#itemData) return;
834
+
835
+ const linePriceElements = this.querySelectorAll('[data-content-line-price]');
836
+ const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
837
+
838
+ linePriceElements.forEach((element) => {
839
+ element.textContent = formattedLinePrice;
840
+ });
841
+ }
842
+
843
+ /**
844
+ * Format currency value from cents to dollar string
845
+ * @param {number} cents - Price in cents
846
+ * @returns {string} Formatted currency string (e.g., "$29.99")
847
+ * @private
848
+ */
849
+ #formatCurrency(cents) {
850
+ if (typeof cents !== 'number') return '$0.00';
851
+ return `$${(cents / 100).toFixed(2)}`;
852
+ }
853
+
854
+ /**
855
+ * Get the current item data
856
+ */
857
+ get itemData() {
858
+ return this.#itemData;
859
+ }
860
+
861
+ /**
862
+ * Set the state of the cart item
863
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
864
+ */
865
+ setState(state) {
866
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
867
+ this.setAttribute('state', state);
868
+ }
869
+ }
870
+
871
+ /**
872
+ * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
873
+ *
874
+ * @returns {void}
875
+ */
876
+ destroyYourself() {
877
+ // bail if already in the middle of a destroy cycle
878
+ if (this.#isDestroying) return;
879
+
880
+ this.#isDestroying = true;
881
+
882
+ // snapshot the current rendered height before applying any "destroying" styles
883
+ const initialHeight = this.offsetHeight;
884
+
885
+ // switch to 'destroying' state so css can fade / slide visuals
886
+ this.setState('destroying');
887
+
888
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
889
+ requestAnimationFrame(() => {
890
+ this.style.height = `${initialHeight}px`;
891
+ // this.offsetHeight; // force a reflow so the browser registers the fixed height
892
+
893
+ // read the css custom property for timing, defaulting to 400ms
894
+ const elementStyle = getComputedStyle(this);
895
+ const destroyDuration =
896
+ elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
897
+
898
+ // animate only the height to zero; other properties stay under stylesheet control
899
+ this.style.transition = `height ${destroyDuration} ease`;
900
+ this.style.height = '0px';
901
+
902
+ // setTimeout(() => {
903
+ // this.style.height = '0px';
904
+ // }, 1);
905
+
906
+ setTimeout(() => {
907
+ // make sure item is removed
908
+ this.remove();
909
+ }, 600);
910
+ });
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Supporting component classes for cart item
916
+ */
917
+ class CartItemContent extends HTMLElement {
918
+ constructor() {
919
+ super();
920
+ }
921
+ }
922
+
923
+ class CartItemProcessing extends HTMLElement {
924
+ constructor() {
925
+ super();
926
+ }
927
+ }
928
+
929
+ // Define custom elements (check if not already defined)
930
+ if (!customElements.get('cart-item')) {
931
+ customElements.define('cart-item', CartItem);
932
+ }
933
+ if (!customElements.get('cart-item-content')) {
934
+ customElements.define('cart-item-content', CartItemContent);
935
+ }
936
+ if (!customElements.get('cart-item-processing')) {
937
+ customElements.define('cart-item-processing', CartItemProcessing);
938
+ }
939
+
940
+ /**
941
+ * Custom element that creates an accessible modal cart dialog with focus management
942
+ * @extends HTMLElement
943
+ */
944
+ class CartDialog extends HTMLElement {
945
+ #handleTransitionEnd;
946
+ #currentCart = null;
947
+ #eventEmitter;
948
+ #isInitialRender = true;
949
+
950
+ /**
951
+ * Clean up event listeners when component is removed from DOM
952
+ */
953
+ disconnectedCallback() {
954
+ const _ = this;
955
+ if (_.contentPanel) {
956
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
957
+ }
958
+
959
+ // Ensure body scroll is restored if component is removed while open
960
+ document.body.classList.remove('overflow-hidden');
961
+ this.#restoreScroll();
962
+
963
+ // Detach event listeners
964
+ this.#detachListeners();
965
+ }
966
+
967
+ /**
968
+ * Locks body scrolling
969
+ * @private
970
+ */
971
+ #lockScroll() {
972
+ // Apply overflow hidden to body
973
+ document.body.classList.add('overflow-hidden');
974
+ }
975
+
976
+ /**
977
+ * Restores body scrolling when cart dialog is closed
978
+ * @private
979
+ */
980
+ #restoreScroll() {
981
+ // Remove overflow hidden from body
982
+ document.body.classList.remove('overflow-hidden');
983
+ }
984
+
985
+ /**
986
+ * Initializes the cart dialog, sets up focus trap and overlay
987
+ */
988
+ constructor() {
989
+ super();
990
+ const _ = this;
991
+ _.id = _.getAttribute('id');
992
+ _.setAttribute('role', 'dialog');
993
+ _.setAttribute('aria-modal', 'true');
994
+ _.setAttribute('aria-hidden', 'true');
995
+
996
+ _.triggerEl = null;
997
+
998
+ // Initialize event emitter
999
+ _.#eventEmitter = new EventEmitter();
1000
+
1001
+ // Create a handler for transition end events
1002
+ _.#handleTransitionEnd = (e) => {
1003
+ if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
1004
+ _.contentPanel.classList.add('hidden');
1005
+
1006
+ // Emit afterHide event - cart dialog has completed its transition
1007
+ _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
1008
+ }
1009
+ };
1010
+ }
1011
+
1012
+ connectedCallback() {
1013
+ const _ = this;
1014
+
1015
+ // Now that we're in the DOM, find the content panel and set up focus trap
1016
+ _.contentPanel = _.querySelector('cart-panel');
1017
+
1018
+ if (!_.contentPanel) {
1019
+ console.error('cart-panel element not found inside cart-dialog');
1020
+ return;
1021
+ }
1022
+
1023
+ // Check if focus-trap already exists, if not create one
1024
+ _.focusTrap = _.contentPanel.querySelector('focus-trap');
1025
+ if (!_.focusTrap) {
1026
+ _.focusTrap = document.createElement('focus-trap');
1027
+
1028
+ // Move all existing cart-panel content into the focus trap
1029
+ const existingContent = Array.from(_.contentPanel.childNodes);
1030
+ existingContent.forEach((child) => _.focusTrap.appendChild(child));
1031
+
1032
+ // Insert focus trap inside the cart-panel
1033
+ _.contentPanel.appendChild(_.focusTrap);
1034
+ }
1035
+
1036
+ // Ensure we have labelledby and describedby references
1037
+ if (!_.getAttribute('aria-labelledby')) {
1038
+ const heading = _.querySelector('h1, h2, h3');
1039
+ if (heading && !heading.id) {
1040
+ heading.id = `${_.id}-title`;
1041
+ }
1042
+ if (heading?.id) {
1043
+ _.setAttribute('aria-labelledby', heading.id);
1044
+ }
1045
+ }
1046
+
1047
+ // Add modal overlay if it doesn't already exist
1048
+ if (!_.querySelector('cart-overlay')) {
1049
+ _.prepend(document.createElement('cart-overlay'));
1050
+ }
1051
+ _.#attachListeners();
1052
+ _.#bindKeyboard();
1053
+
1054
+ // Load cart data immediately after component initialization
1055
+ _.refreshCart();
1056
+ }
1057
+
1058
+ /**
1059
+ * Event emitter method - Add an event listener with a cleaner API
1060
+ * @param {string} eventName - Name of the event to listen for
1061
+ * @param {Function} callback - Callback function to execute when event is fired
1062
+ * @returns {CartDialog} Returns this for method chaining
1063
+ */
1064
+ on(eventName, callback) {
1065
+ this.#eventEmitter.on(eventName, callback);
1066
+ return this;
1067
+ }
1068
+
1069
+ /**
1070
+ * Event emitter method - Remove an event listener
1071
+ * @param {string} eventName - Name of the event to stop listening for
1072
+ * @param {Function} callback - Callback function to remove
1073
+ * @returns {CartDialog} Returns this for method chaining
1074
+ */
1075
+ off(eventName, callback) {
1076
+ this.#eventEmitter.off(eventName, callback);
1077
+ return this;
1078
+ }
1079
+
1080
+ /**
1081
+ * Internal method to emit events via the event emitter
1082
+ * @param {string} eventName - Name of the event to emit
1083
+ * @param {*} [data] - Optional data to include with the event
1084
+ * @private
1085
+ */
1086
+ #emit(eventName, data = null) {
1087
+ this.#eventEmitter.emit(eventName, data);
1088
+
1089
+ // Also emit as native DOM events for better compatibility
1090
+ this.dispatchEvent(
1091
+ new CustomEvent(eventName, {
1092
+ detail: data,
1093
+ bubbles: true,
1094
+ })
1095
+ );
1096
+ }
1097
+
1098
+ /**
1099
+ * Attach event listeners for cart dialog functionality
1100
+ * @private
1101
+ */
1102
+ #attachListeners() {
1103
+ const _ = this;
1104
+
1105
+ // Handle trigger buttons
1106
+ document.addEventListener('click', (e) => {
1107
+ const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
1108
+ if (!trigger) return;
1109
+
1110
+ if (trigger.getAttribute('data-prevent-default') === 'true') {
1111
+ e.preventDefault();
1112
+ }
1113
+
1114
+ _.show(trigger);
1115
+ });
1116
+
1117
+ // Handle close buttons
1118
+ _.addEventListener('click', (e) => {
1119
+ if (!e.target.closest('[data-action-hide-cart]')) return;
1120
+ _.hide();
1121
+ });
1122
+
1123
+ // Handle cart item remove events
1124
+ _.addEventListener('cart-item:remove', (e) => {
1125
+ _.#handleCartItemRemove(e);
1126
+ });
1127
+
1128
+ // Handle cart item quantity change events
1129
+ _.addEventListener('cart-item:quantity-change', (e) => {
1130
+ _.#handleCartItemQuantityChange(e);
1131
+ });
1132
+
1133
+ // Add transition end listener
1134
+ _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
1135
+ }
1136
+
1137
+ /**
1138
+ * Detach event listeners
1139
+ * @private
1140
+ */
1141
+ #detachListeners() {
1142
+ const _ = this;
1143
+ if (_.contentPanel) {
1144
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * Binds keyboard events for accessibility
1150
+ * @private
1151
+ */
1152
+ #bindKeyboard() {
1153
+ this.addEventListener('keydown', (e) => {
1154
+ if (e.key === 'Escape') {
1155
+ this.hide();
1156
+ }
1157
+ });
1158
+ }
1159
+
1160
+ /**
1161
+ * Handle cart item removal
1162
+ * @private
1163
+ */
1164
+ #handleCartItemRemove(e) {
1165
+ const { cartKey, element } = e.detail;
1166
+
1167
+ // Set item to processing state
1168
+ element.setState('processing');
1169
+
1170
+ // Remove item by setting quantity to 0
1171
+ this.updateCartItem(cartKey, 0)
1172
+ .then((updatedCart) => {
1173
+ if (updatedCart && !updatedCart.error) {
1174
+ // Success - let smart comparison handle the removal animation
1175
+ this.#currentCart = updatedCart;
1176
+ this.#renderCartItems(updatedCart);
1177
+ this.#renderCartPanel(updatedCart);
1178
+
1179
+ // Emit cart updated and data changed events
1180
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1181
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1182
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1183
+ } else {
1184
+ // Error - reset to ready state
1185
+ element.setState('ready');
1186
+ console.error('Failed to remove cart item:', cartKey);
1187
+ }
1188
+ })
1189
+ .catch((error) => {
1190
+ // Error - reset to ready state
1191
+ element.setState('ready');
1192
+ console.error('Error removing cart item:', error);
1193
+ });
1194
+ }
1195
+
1196
+ /**
1197
+ * Handle cart item quantity change
1198
+ * @private
1199
+ */
1200
+ #handleCartItemQuantityChange(e) {
1201
+ const { cartKey, quantity, element } = e.detail;
1202
+
1203
+ // Set item to processing state
1204
+ element.setState('processing');
1205
+
1206
+ // Update item quantity
1207
+ this.updateCartItem(cartKey, quantity)
1208
+ .then((updatedCart) => {
1209
+ if (updatedCart && !updatedCart.error) {
1210
+ // Success - update cart data and refresh items
1211
+ this.#currentCart = updatedCart;
1212
+ this.#renderCartItems(updatedCart);
1213
+ this.#renderCartPanel(updatedCart);
1214
+
1215
+ // Emit cart updated and data changed events
1216
+ const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
1217
+ this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
1218
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1219
+ } else {
1220
+ // Error - reset to ready state
1221
+ element.setState('ready');
1222
+ console.error('Failed to update cart item quantity:', cartKey, quantity);
1223
+ }
1224
+ })
1225
+ .catch((error) => {
1226
+ // Error - reset to ready state
1227
+ element.setState('ready');
1228
+ console.error('Error updating cart item quantity:', error);
1229
+ });
1230
+ }
1231
+
1232
+ /**
1233
+ * Update cart count elements across the site
1234
+ * @private
1235
+ */
1236
+ #renderCartCount(cartData) {
1237
+ if (!cartData) return;
1238
+
1239
+ // Calculate visible item count (excluding _hide_in_cart items)
1240
+ const visibleItems = this.#getVisibleCartItems(cartData);
1241
+ const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
1242
+
1243
+ // Update all cart count elements across the site
1244
+ const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
1245
+ cartCountElements.forEach((element) => {
1246
+ element.textContent = visibleItemCount;
1247
+ });
1248
+ }
1249
+
1250
+ /**
1251
+ * Update cart subtotal elements across the site
1252
+ * @private
1253
+ */
1254
+ #renderCartSubtotal(cartData) {
1255
+ if (!cartData) return;
1256
+
1257
+ // Calculate subtotal from all items except those marked to ignore pricing
1258
+ const pricedItems = cartData.items.filter((item) => {
1259
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
1260
+ return !ignorePrice;
1261
+ });
1262
+ const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
1263
+
1264
+ // Update all cart subtotal elements across the site
1265
+ const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
1266
+ cartSubtotalElements.forEach((element) => {
1267
+ // Format as currency (assuming cents, convert to dollars)
1268
+ const formatted = (subtotal / 100).toFixed(2);
1269
+ element.textContent = `$${formatted}`;
1270
+ });
1271
+ }
1272
+
1273
+ /**
1274
+ * Update cart items display based on cart data
1275
+ * @private
1276
+ */
1277
+ #renderCartPanel(cart = null) {
1278
+ const cartData = cart || this.#currentCart;
1279
+ if (!cartData) return;
1280
+
1281
+ // Get cart sections
1282
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
1283
+ const emptySection = this.querySelector('[data-cart-is-empty]');
1284
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1285
+
1286
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
1287
+ console.warn(
1288
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
1289
+ );
1290
+ return;
1291
+ }
1292
+
1293
+ // Check visible item count for showing/hiding sections
1294
+ const visibleItems = this.#getVisibleCartItems(cartData);
1295
+ const hasVisibleItems = visibleItems.length > 0;
1296
+
1297
+ // Show/hide sections based on visible item count
1298
+ if (hasVisibleItems) {
1299
+ hasItemsSection.style.display = '';
1300
+ emptySection.style.display = 'none';
1301
+ } else {
1302
+ hasItemsSection.style.display = 'none';
1303
+ emptySection.style.display = '';
1304
+ }
1305
+
1306
+ // Update cart count and subtotal across the site
1307
+ this.#renderCartCount(cartData);
1308
+ this.#renderCartSubtotal(cartData);
1309
+ }
1310
+
1311
+ /**
1312
+ * Fetch current cart data from server
1313
+ * @returns {Promise<Object>} Cart data object
1314
+ */
1315
+ getCart() {
1316
+ return fetch('/cart.json', {
1317
+ crossDomain: true,
1318
+ credentials: 'same-origin',
1319
+ })
1320
+ .then((response) => {
1321
+ if (!response.ok) {
1322
+ throw Error(response.statusText);
1323
+ }
1324
+ return response.json();
1325
+ })
1326
+ .catch((error) => {
1327
+ console.error('Error fetching cart:', error);
1328
+ return { error: true, message: error.message };
1329
+ });
1330
+ }
1331
+
1332
+ /**
1333
+ * Update cart item quantity on server
1334
+ * @param {string|number} key - Cart item key/ID
1335
+ * @param {number} quantity - New quantity (0 to remove)
1336
+ * @returns {Promise<Object>} Updated cart data object
1337
+ */
1338
+ updateCartItem(key, quantity) {
1339
+ return fetch('/cart/change.json', {
1340
+ crossDomain: true,
1341
+ method: 'POST',
1342
+ credentials: 'same-origin',
1343
+ body: JSON.stringify({ id: key, quantity: quantity }),
1344
+ headers: { 'Content-Type': 'application/json' },
1345
+ })
1346
+ .then((response) => {
1347
+ if (!response.ok) {
1348
+ throw Error(response.statusText);
1349
+ }
1350
+ return response.json();
1351
+ })
1352
+ .catch((error) => {
1353
+ console.error('Error updating cart item:', error);
1354
+ return { error: true, message: error.message };
1355
+ });
1356
+ }
1357
+
1358
+ /**
1359
+ * Refresh cart data from server and update components
1360
+ * @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
1361
+ * @returns {Promise<Object>} Cart data object
1362
+ */
1363
+ refreshCart(cartObj = null) {
1364
+ // If cart object is provided, use it directly
1365
+ if (cartObj && !cartObj.error) {
1366
+ // console.log('Using provided cart data:', cartObj);
1367
+ this.#currentCart = cartObj;
1368
+ this.#renderCartItems(cartObj);
1369
+ this.#renderCartPanel(cartObj);
1370
+
1371
+ // Emit cart refreshed and data changed events
1372
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
1373
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1374
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1375
+
1376
+ return Promise.resolve(cartObj);
1377
+ }
1378
+
1379
+ // Otherwise fetch from server
1380
+ return this.getCart().then((cartData) => {
1381
+ // console.log('Cart data received:', cartData);
1382
+ if (cartData && !cartData.error) {
1383
+ this.#currentCart = cartData;
1384
+ this.#renderCartItems(cartData);
1385
+ this.#renderCartPanel(cartData);
1386
+
1387
+ // Emit cart refreshed and data changed events
1388
+ const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
1389
+ this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
1390
+ this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
1391
+ } else {
1392
+ console.warn('Cart data has error or is null:', cartData);
1393
+ }
1394
+ return cartData;
1395
+ });
1396
+ }
1397
+
1398
+ /**
1399
+ * Remove items from DOM that are no longer in cart data
1400
+ * @private
1401
+ */
1402
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
1403
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1404
+
1405
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1406
+
1407
+ itemsToRemove.forEach((item) => {
1408
+ console.log('destroy yourself', item);
1409
+ item.destroyYourself();
1410
+ });
1411
+ }
1412
+
1413
+ /**
1414
+ * Update existing cart-item elements with fresh cart data
1415
+ * @private
1416
+ */
1417
+ #updateItemsInDOM(itemsContainer, cartData) {
1418
+ const visibleItems = this.#getVisibleCartItems(cartData);
1419
+ const existingItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1420
+
1421
+ existingItems.forEach((cartItemEl) => {
1422
+ const key = cartItemEl.getAttribute('key');
1423
+ const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
1424
+
1425
+ if (updatedItemData) {
1426
+ // Update cart-item with fresh data and full cart context
1427
+ // The cart-item will handle HTML comparison and only re-render if needed
1428
+ cartItemEl.setData(updatedItemData, cartData);
1429
+ }
1430
+ });
1431
+ }
1432
+
1433
+ /**
1434
+ * Add new items to DOM with animation delay
1435
+ * @private
1436
+ */
1437
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1438
+ // Delay adding new items by 300ms to let cart slide open first
1439
+ setTimeout(() => {
1440
+ itemsToAdd.forEach((itemData) => {
1441
+ const cartItem = CartItem.createAnimated(itemData);
1442
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1443
+
1444
+ // Find the correct position to insert the new item
1445
+ if (targetIndex === 0) {
1446
+ // Insert at the beginning
1447
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1448
+ } else {
1449
+ // Find the item that should come before this one
1450
+ let insertAfter = null;
1451
+ for (let i = targetIndex - 1; i >= 0; i--) {
1452
+ const prevKey = newKeys[i];
1453
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1454
+ if (prevItem) {
1455
+ insertAfter = prevItem;
1456
+ break;
1457
+ }
1458
+ }
1459
+
1460
+ if (insertAfter) {
1461
+ insertAfter.insertAdjacentElement('afterend', cartItem);
1462
+ } else {
1463
+ itemsContainer.appendChild(cartItem);
1464
+ }
1465
+ }
1466
+ });
1467
+ }, 100);
1468
+ }
1469
+
1470
+ /**
1471
+ * Filter cart items to exclude those with _hide_in_cart property
1472
+ * @private
1473
+ */
1474
+ #getVisibleCartItems(cartData) {
1475
+ if (!cartData || !cartData.items) return [];
1476
+ return cartData.items.filter((item) => {
1477
+ // Check for _hide_in_cart in various possible locations
1478
+ const hidden = item.properties?._hide_in_cart;
1479
+
1480
+ return !hidden;
1481
+ });
1482
+ }
1483
+
1484
+ /**
1485
+ * Add calculated fields to cart object for events
1486
+ * @private
1487
+ */
1488
+ #addCalculatedFields(cartData) {
1489
+ if (!cartData) return cartData;
1490
+
1491
+ // For display counts: use visible items (excludes _hide_in_cart)
1492
+ const visibleItems = this.#getVisibleCartItems(cartData);
1493
+ const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
1494
+
1495
+ // For pricing: use all items except those marked to ignore pricing
1496
+ const pricedItems = cartData.items.filter((item) => {
1497
+ const ignorePrice = item.properties?._ignore_price_in_subtotal;
1498
+ return !ignorePrice;
1499
+ });
1500
+ const calculated_subtotal = pricedItems.reduce(
1501
+ (total, item) => total + (item.line_price || 0),
1502
+ 0
1503
+ );
1504
+
1505
+ return {
1506
+ ...cartData,
1507
+ calculated_count,
1508
+ calculated_subtotal,
1509
+ };
1510
+ }
1511
+
1512
+ /**
1513
+ * Render cart items from Shopify cart data with smart comparison
1514
+ * @private
1515
+ */
1516
+ #renderCartItems(cartData) {
1517
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1518
+
1519
+ if (!itemsContainer || !cartData || !cartData.items) {
1520
+ console.warn('Cannot render cart items:', {
1521
+ itemsContainer: !!itemsContainer,
1522
+ cartData: !!cartData,
1523
+ items: cartData?.items?.length,
1524
+ });
1525
+ return;
1526
+ }
1527
+
1528
+ // Filter out items with _hide_in_cart property
1529
+ const visibleItems = this.#getVisibleCartItems(cartData);
1530
+
1531
+ // Handle initial render - load all items without animation
1532
+ if (this.#isInitialRender) {
1533
+ // console.log('Initial cart render:', visibleItems.length, 'visible items');
1534
+
1535
+ // Clear existing items
1536
+ itemsContainer.innerHTML = '';
1537
+
1538
+ // Create cart-item elements without animation
1539
+ visibleItems.forEach((itemData) => {
1540
+ const cartItem = new CartItem(itemData); // No animation
1541
+ itemsContainer.appendChild(cartItem);
1542
+ });
1543
+
1544
+ this.#isInitialRender = false;
1545
+
1546
+ return;
1547
+ }
1548
+
1549
+ // Get current DOM items and their keys
1550
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1551
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1552
+
1553
+ // Get new cart data keys in order (only visible items)
1554
+ const newKeys = visibleItems.map((item) => item.key || item.id);
1555
+ const newKeysSet = new Set(newKeys);
1556
+
1557
+ // Step 1: Remove items that are no longer in cart data
1558
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1559
+
1560
+ // Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
1561
+ this.#updateItemsInDOM(itemsContainer, cartData);
1562
+
1563
+ // Step 3: Add new items that weren't in DOM (with animation delay)
1564
+ const itemsToAdd = visibleItems.filter(
1565
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
1566
+ );
1567
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1568
+ }
1569
+
1570
+ /**
1571
+ * Set the template function for cart items
1572
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
1573
+ */
1574
+ setCartItemTemplate(templateName, templateFn) {
1575
+ CartItem.setTemplate(templateName, templateFn);
1576
+ }
1577
+
1578
+ /**
1579
+ * Set the processing template function for cart items
1580
+ * @param {Function} templateFn - Function that returns HTML string for processing state
1581
+ */
1582
+ setCartItemProcessingTemplate(templateFn) {
1583
+ CartItem.setProcessingTemplate(templateFn);
1584
+ }
1585
+
1586
+ /**
1587
+ * Shows the cart dialog and traps focus within it
1588
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1589
+ * @fires CartDialog#show - Fired when the cart dialog has been shown
1590
+ */
1591
+ show(triggerEl = null, cartObj) {
1592
+ const _ = this;
1593
+ _.triggerEl = triggerEl || false;
1594
+
1595
+ // Lock body scrolling
1596
+ _.#lockScroll();
1597
+
1598
+ // Remove the hidden class first to ensure content is rendered
1599
+ _.contentPanel.classList.remove('hidden');
1600
+
1601
+ // Give the browser a moment to process before starting animation
1602
+ requestAnimationFrame(() => {
1603
+ // Update ARIA states
1604
+ _.setAttribute('aria-hidden', 'false');
1605
+
1606
+ if (_.triggerEl) {
1607
+ _.triggerEl.setAttribute('aria-expanded', 'true');
1608
+ }
1609
+
1610
+ // Focus management
1611
+ const firstFocusable = _.querySelector(
1612
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1613
+ );
1614
+
1615
+ if (firstFocusable) {
1616
+ requestAnimationFrame(() => {
1617
+ firstFocusable.focus();
1618
+ });
1619
+ }
1620
+
1621
+ // Refresh cart data when showing
1622
+ _.refreshCart(cartObj);
1623
+
1624
+ // Emit show event - cart dialog is now visible
1625
+ _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1626
+ });
1627
+ }
1628
+
1629
+ /**
1630
+ * Hides the cart dialog and restores focus
1631
+ * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1632
+ * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1633
+ */
1634
+ hide() {
1635
+ const _ = this;
1636
+
1637
+ // Update ARIA states
1638
+ if (_.triggerEl) {
1639
+ // remove focus from modal panel first
1640
+ _.triggerEl.focus();
1641
+ // mark trigger as no longer expanded
1642
+ _.triggerEl.setAttribute('aria-expanded', 'false');
1643
+ } else {
1644
+ // If no trigger element, blur any focused element inside the panel
1645
+ const activeElement = document.activeElement;
1646
+ if (activeElement && _.contains(activeElement)) {
1647
+ activeElement.blur();
1648
+ }
1649
+ }
1650
+
1651
+ requestAnimationFrame(() => {
1652
+ // Set aria-hidden to start transition
1653
+ // The transitionend event handler will add display:none when complete
1654
+ _.setAttribute('aria-hidden', 'true');
1655
+
1656
+ // Emit hide event - cart dialog is now starting to hide
1657
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1658
+
1659
+ // Restore body scroll
1660
+ _.#restoreScroll();
1661
+ });
1662
+ }
1663
+ }
1664
+
1665
+ /**
1666
+ * Custom element that creates a clickable overlay for the cart dialog
1667
+ * @extends HTMLElement
1668
+ */
1669
+ class CartOverlay extends HTMLElement {
1670
+ constructor() {
1671
+ super();
1672
+ this.setAttribute('tabindex', '-1');
1673
+ this.setAttribute('aria-hidden', 'true');
1674
+ this.cartDialog = this.closest('cart-dialog');
1675
+ this.#attachListeners();
1676
+ }
1677
+
1678
+ #attachListeners() {
1679
+ this.addEventListener('click', () => {
1680
+ this.cartDialog.hide();
1681
+ });
1682
+ }
1683
+ }
1684
+
1685
+ /**
1686
+ * Custom element that wraps the content of the cart dialog
1687
+ * @extends HTMLElement
1688
+ */
1689
+ class CartPanel extends HTMLElement {
1690
+ constructor() {
1691
+ super();
1692
+ this.setAttribute('role', 'document');
1693
+ }
1694
+ }
1695
+
1696
+ if (!customElements.get('cart-dialog')) {
1697
+ customElements.define('cart-dialog', CartDialog);
1698
+ }
1699
+ if (!customElements.get('cart-overlay')) {
1700
+ customElements.define('cart-overlay', CartOverlay);
1701
+ }
1702
+ if (!customElements.get('cart-panel')) {
1703
+ customElements.define('cart-panel', CartPanel);
1704
+ }
1705
+
1706
+ // Make CartItem available globally for Shopify themes
1707
+ if (typeof window !== 'undefined') {
1708
+ window.CartItem = CartItem;
1709
+ }
1710
+
1711
+ exports.CartDialog = CartDialog;
1712
+ exports.CartItem = CartItem;
1713
+ exports.CartOverlay = CartOverlay;
1714
+ exports.CartPanel = CartPanel;
1715
+ exports.default = CartDialog;
1716
+
1717
+ Object.defineProperty(exports, '__esModule', { value: true });
1661
1718
 
1662
1719
  }));
1663
1720
  //# sourceMappingURL=cart-panel.js.map