@magic-spells/cart-panel 0.1.0 → 0.1.2

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,258 +1,386 @@
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
- /**
8
- * CartItem class that handles the functionality of a cart item component
9
- */
10
- class CartItem extends HTMLElement {
11
- // Private fields
12
- #currentState = 'ready';
13
- #isDestroying = false;
14
- #handlers = {};
15
-
16
- /**
17
- * Define which attributes should be observed for changes
18
- */
19
- static get observedAttributes() {
20
- return ['data-state', 'data-key'];
21
- }
22
-
23
- /**
24
- * Called when observed attributes change
25
- */
26
- attributeChangedCallback(name, oldValue, newValue) {
27
- if (oldValue === newValue) return;
28
-
29
- if (name === 'data-state') {
30
- this.#currentState = newValue || 'ready';
31
- }
32
- }
33
-
34
- constructor() {
35
- super();
36
-
37
- // Set initial state
38
- this.#currentState = this.getAttribute('data-state') || 'ready';
39
-
40
- // Bind event handlers
41
- this.#handlers = {
42
- click: this.#handleClick.bind(this),
43
- change: this.#handleChange.bind(this),
44
- transitionEnd: this.#handleTransitionEnd.bind(this),
45
- };
46
- }
47
-
48
- connectedCallback() {
49
- // Find child elements
50
- this.content = this.querySelector('cart-item-content');
51
- this.processing = this.querySelector('cart-item-processing');
52
-
53
- // Attach event listeners
54
- this.#attachListeners();
55
- }
56
-
57
- disconnectedCallback() {
58
- // Cleanup event listeners
59
- this.#detachListeners();
60
- }
61
-
62
- /**
63
- * Attach event listeners
64
- */
65
- #attachListeners() {
66
- this.addEventListener('click', this.#handlers.click);
67
- this.addEventListener('change', this.#handlers.change);
68
- this.addEventListener('transitionend', this.#handlers.transitionEnd);
69
- }
70
-
71
- /**
72
- * Detach event listeners
73
- */
74
- #detachListeners() {
75
- this.removeEventListener('click', this.#handlers.click);
76
- this.removeEventListener('change', this.#handlers.change);
77
- this.removeEventListener('transitionend', this.#handlers.transitionEnd);
78
- }
79
-
80
- /**
81
- * Get the current state
82
- */
83
- get state() {
84
- return this.#currentState;
85
- }
86
-
87
- /**
88
- * Get the cart key for this item
89
- */
90
- get cartKey() {
91
- return this.getAttribute('data-key');
92
- }
93
-
94
- /**
95
- * Handle click events (for Remove buttons, etc.)
96
- */
97
- #handleClick(e) {
98
- // Check if clicked element is a remove button
99
- const removeButton = e.target.closest('[data-action="remove"]');
100
- if (removeButton) {
101
- e.preventDefault();
102
- this.#emitRemoveEvent();
103
- }
104
- }
105
-
106
- /**
107
- * Handle change events (for quantity inputs)
108
- */
109
- #handleChange(e) {
110
- // Check if changed element is a quantity input
111
- const quantityInput = e.target.closest('[data-cart-quantity]');
112
- if (quantityInput) {
113
- this.#emitQuantityChangeEvent(quantityInput.value);
114
- }
115
- }
116
-
117
- /**
118
- * Handle transition end events for destroy animation
119
- */
120
- #handleTransitionEnd(e) {
121
- if (e.propertyName === 'height' && this.#isDestroying) {
122
- // Remove from DOM after height animation completes
123
- this.remove();
124
- }
125
- }
126
-
127
- /**
128
- * Emit remove event
129
- */
130
- #emitRemoveEvent() {
131
- this.dispatchEvent(
132
- new CustomEvent('cart-item:remove', {
133
- bubbles: true,
134
- detail: {
135
- cartKey: this.cartKey,
136
- element: this,
137
- },
138
- })
139
- );
140
- }
141
-
142
- /**
143
- * Emit quantity change event
144
- */
145
- #emitQuantityChangeEvent(quantity) {
146
- this.dispatchEvent(
147
- new CustomEvent('cart-item:quantity-change', {
148
- bubbles: true,
149
- detail: {
150
- cartKey: this.cartKey,
151
- quantity: parseInt(quantity),
152
- element: this,
153
- },
154
- })
155
- );
156
- }
157
-
158
- /**
159
- * Set the state of the cart item
160
- * @param {string} state - 'ready', 'processing', or 'destroying'
161
- */
162
- setState(state) {
163
- if (['ready', 'processing', 'destroying'].includes(state)) {
164
- this.setAttribute('data-state', state);
165
- }
166
- }
167
-
168
- /**
169
- * Destroy this cart item with animation
170
- */
171
- destroyYourself() {
172
- if (this.#isDestroying) return; // Prevent multiple calls
173
-
174
- this.#isDestroying = true;
175
-
176
- // First set to destroying state for visual effects
177
- this.setState('destroying');
178
-
179
- // Get current height for animation
180
- const currentHeight = this.offsetHeight;
181
-
182
- // Force height to current value (removes auto)
183
- this.style.height = `${currentHeight}px`;
184
-
185
- // Trigger reflow to ensure height is set
186
- this.offsetHeight;
187
-
188
- // Get the destroying duration from CSS custom property
189
- const computedStyle = getComputedStyle(this);
190
- const destroyingDuration =
191
- computedStyle.getPropertyValue('--cart-item-destroying-duration') || '400ms';
192
-
193
- // Add transition and animate to 0 height
194
- this.style.transition = `all ${destroyingDuration} ease`;
195
- this.style.height = '0px';
196
-
197
- // The actual removal happens in #handleTransitionEnd
198
- }
199
- }
200
-
201
- /**
202
- * Supporting component classes for cart item
203
- */
204
- class CartItemContent extends HTMLElement {
205
- constructor() {
206
- super();
207
- }
208
- }
209
-
210
- class CartItemProcessing extends HTMLElement {
211
- constructor() {
212
- super();
213
- }
214
- }
215
-
216
- // Define custom elements
217
- customElements.define('cart-item', CartItem);
218
- customElements.define('cart-item-content', CartItemContent);
219
- customElements.define('cart-item-processing', CartItemProcessing);
220
-
221
- /**
222
- * Retrieves all focusable elements within a given container.
223
- *
224
- * @param {HTMLElement} container - The container element to search for focusable elements.
225
- * @returns {HTMLElement[]} An array of focusable elements found within the container.
226
- */
227
- const getFocusableElements = (container) => {
228
- const focusableSelectors =
229
- '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';
230
- return Array.from(container.querySelectorAll(focusableSelectors));
231
- };
232
-
233
- class FocusTrap extends HTMLElement {
234
- /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
235
- static styleInjected = false;
236
-
237
- constructor() {
238
- super();
239
- this.trapStart = null;
240
- this.trapEnd = null;
241
-
242
- // Inject styles only once, when the first FocusTrap instance is created.
243
- if (!FocusTrap.styleInjected) {
244
- this.injectStyles();
245
- FocusTrap.styleInjected = true;
246
- }
247
- }
248
-
249
- /**
250
- * Injects necessary styles for the focus trap into the document's head.
251
- * This ensures that focus-trap-start and focus-trap-end elements are hidden.
252
- */
253
- injectStyles() {
254
- const style = document.createElement('style');
255
- style.textContent = `
7
+ /**
8
+ * CartItem class that handles the functionality of a cart item component
9
+ */
10
+ class CartItem extends HTMLElement {
11
+ // Static template functions shared across all instances
12
+ static #template = null;
13
+ static #processingTemplate = null;
14
+
15
+ // Private fields
16
+ #currentState = 'ready';
17
+ #isDestroying = false;
18
+ #handlers = {};
19
+ #itemData = null;
20
+
21
+ /**
22
+ * Set the template function for rendering cart items
23
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
24
+ */
25
+ static setTemplate(templateFn) {
26
+ if (typeof templateFn !== 'function') {
27
+ throw new Error('Template must be a function');
28
+ }
29
+ CartItem.#template = templateFn;
30
+ }
31
+
32
+ /**
33
+ * Set the processing template function for rendering processing overlay
34
+ * @param {Function} templateFn - Function that returns HTML string for processing state
35
+ */
36
+ static setProcessingTemplate(templateFn) {
37
+ if (typeof templateFn !== 'function') {
38
+ throw new Error('Processing template must be a function');
39
+ }
40
+ CartItem.#processingTemplate = templateFn;
41
+ }
42
+
43
+ /**
44
+ * Create a cart item with appearing animation
45
+ * @param {Object} itemData - Shopify cart item data
46
+ * @returns {CartItem} Cart item instance that will animate in
47
+ */
48
+ static createAnimated(itemData) {
49
+ return new CartItem(itemData, { animate: true });
50
+ }
51
+
52
+ /**
53
+ * Define which attributes should be observed for changes
54
+ */
55
+ static get observedAttributes() {
56
+ return ['state', 'key'];
57
+ }
58
+
59
+ /**
60
+ * Called when observed attributes change
61
+ */
62
+ attributeChangedCallback(name, oldValue, newValue) {
63
+ if (oldValue === newValue) return;
64
+
65
+ if (name === 'state') {
66
+ this.#currentState = newValue || 'ready';
67
+ }
68
+ }
69
+
70
+ constructor(itemData = null, options = {}) {
71
+ super();
72
+
73
+ // Store item data if provided
74
+ this.#itemData = itemData;
75
+
76
+ // Set initial state - start with 'appearing' only if explicitly requested
77
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
78
+ this.#currentState =
79
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
80
+
81
+ // Bind event handlers
82
+ this.#handlers = {
83
+ click: this.#handleClick.bind(this),
84
+ change: this.#handleChange.bind(this),
85
+ transitionEnd: this.#handleTransitionEnd.bind(this),
86
+ };
87
+ }
88
+
89
+ connectedCallback() {
90
+ // If we have item data, render it first
91
+ if (this.#itemData) {
92
+ this.#renderFromData();
93
+ }
94
+
95
+ // Find child elements
96
+ this.content = this.querySelector('cart-item-content');
97
+ this.processing = this.querySelector('cart-item-processing');
98
+
99
+ // Attach event listeners
100
+ this.#attachListeners();
101
+
102
+ // If we started with 'appearing' state, handle the entry animation
103
+ if (this.#currentState === 'appearing') {
104
+ // Set the state attribute
105
+ this.setAttribute('state', 'appearing');
106
+
107
+ // Get the natural height after rendering
108
+ requestAnimationFrame(() => {
109
+ const naturalHeight = this.scrollHeight;
110
+
111
+ // Set explicit height for animation
112
+ this.style.height = `${naturalHeight}px`;
113
+
114
+ // Transition to ready state after a brief delay
115
+ requestAnimationFrame(() => {
116
+ this.setState('ready');
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ disconnectedCallback() {
123
+ // Cleanup event listeners
124
+ this.#detachListeners();
125
+ }
126
+
127
+ /**
128
+ * Attach event listeners
129
+ */
130
+ #attachListeners() {
131
+ this.addEventListener('click', this.#handlers.click);
132
+ this.addEventListener('change', this.#handlers.change);
133
+ this.addEventListener('transitionend', this.#handlers.transitionEnd);
134
+ }
135
+
136
+ /**
137
+ * Detach event listeners
138
+ */
139
+ #detachListeners() {
140
+ this.removeEventListener('click', this.#handlers.click);
141
+ this.removeEventListener('change', this.#handlers.change);
142
+ this.removeEventListener('transitionend', this.#handlers.transitionEnd);
143
+ }
144
+
145
+ /**
146
+ * Get the current state
147
+ */
148
+ get state() {
149
+ return this.#currentState;
150
+ }
151
+
152
+ /**
153
+ * Get the cart key for this item
154
+ */
155
+ get cartKey() {
156
+ return this.getAttribute('key');
157
+ }
158
+
159
+ /**
160
+ * Handle click events (for Remove buttons, etc.)
161
+ */
162
+ #handleClick(e) {
163
+ // Check if clicked element is a remove button
164
+ const removeButton = e.target.closest('[data-action-remove-item]');
165
+ if (removeButton) {
166
+ e.preventDefault();
167
+ this.#emitRemoveEvent();
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Handle change events (for quantity inputs)
173
+ */
174
+ #handleChange(e) {
175
+ // Check if changed element is a quantity input
176
+ const quantityInput = e.target.closest('[data-cart-quantity]');
177
+ if (quantityInput) {
178
+ this.#emitQuantityChangeEvent(quantityInput.value);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Handle transition end events for destroy animation and appearing animation
184
+ */
185
+ #handleTransitionEnd(e) {
186
+ if (e.propertyName === 'height' && this.#isDestroying) {
187
+ // Remove from DOM after height animation completes
188
+ this.remove();
189
+ } else if (e.propertyName === 'height' && this.#currentState === 'ready') {
190
+ // Remove explicit height after appearing animation completes
191
+ this.style.height = '';
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Emit remove event
197
+ */
198
+ #emitRemoveEvent() {
199
+ this.dispatchEvent(
200
+ new CustomEvent('cart-item:remove', {
201
+ bubbles: true,
202
+ detail: {
203
+ cartKey: this.cartKey,
204
+ element: this,
205
+ },
206
+ })
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Emit quantity change event
212
+ */
213
+ #emitQuantityChangeEvent(quantity) {
214
+ this.dispatchEvent(
215
+ new CustomEvent('cart-item:quantity-change', {
216
+ bubbles: true,
217
+ detail: {
218
+ cartKey: this.cartKey,
219
+ quantity: parseInt(quantity),
220
+ element: this,
221
+ },
222
+ })
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Render cart item from data using the static template
228
+ */
229
+ #renderFromData() {
230
+ if (!this.#itemData || !CartItem.#template) {
231
+ return;
232
+ }
233
+
234
+ // Set the key attribute from item data
235
+ const key = this.#itemData.key || this.#itemData.id;
236
+ if (key) {
237
+ this.setAttribute('key', key);
238
+ }
239
+
240
+ // Generate HTML from template
241
+ const templateHTML = CartItem.#template(this.#itemData);
242
+
243
+ // Generate processing HTML from template or use default
244
+ const processingHTML = CartItem.#processingTemplate
245
+ ? CartItem.#processingTemplate()
246
+ : '<div class="cart-item-loader"></div>';
247
+
248
+ // Create the cart-item structure with template content inside cart-item-content
249
+ this.innerHTML = `
250
+ <cart-item-content>
251
+ ${templateHTML}
252
+ </cart-item-content>
253
+ <cart-item-processing>
254
+ ${processingHTML}
255
+ </cart-item-processing>
256
+ `;
257
+ }
258
+
259
+ /**
260
+ * Update the cart item with new data
261
+ * @param {Object} itemData - Shopify cart item data
262
+ */
263
+ setData(itemData) {
264
+ this.#itemData = itemData;
265
+ this.#renderFromData();
266
+
267
+ // Re-find child elements after re-rendering
268
+ this.content = this.querySelector('cart-item-content');
269
+ this.processing = this.querySelector('cart-item-processing');
270
+ }
271
+
272
+ /**
273
+ * Get the current item data
274
+ */
275
+ get itemData() {
276
+ return this.#itemData;
277
+ }
278
+
279
+ /**
280
+ * Set the state of the cart item
281
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
282
+ */
283
+ setState(state) {
284
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
285
+ this.setAttribute('state', state);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * gracefully animate this cart item closed, then let #handleTransitionEnd remove it
291
+ *
292
+ * @returns {void}
293
+ */
294
+ destroyYourself() {
295
+ // bail if already in the middle of a destroy cycle
296
+ if (this.#isDestroying) return;
297
+
298
+ this.#isDestroying = true;
299
+
300
+ // snapshot the current rendered height before applying any "destroying" styles
301
+ const initialHeight = this.offsetHeight;
302
+
303
+ // switch to 'destroying' state so css can fade / slide visuals
304
+ this.setState('destroying');
305
+
306
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
307
+ requestAnimationFrame(() => {
308
+ this.style.height = `${initialHeight}px`;
309
+ this.offsetHeight; // force a reflow so the browser registers the fixed height
310
+
311
+ // read the css custom property for timing, defaulting to 400ms
312
+ const elementStyle = getComputedStyle(this);
313
+ const destroyDuration =
314
+ elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
315
+
316
+ // animate only the height to zero; other properties stay under stylesheet control
317
+ this.style.transition = `height ${destroyDuration} ease`;
318
+ this.style.height = '0px';
319
+ });
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Supporting component classes for cart item
325
+ */
326
+ class CartItemContent extends HTMLElement {
327
+ constructor() {
328
+ super();
329
+ }
330
+ }
331
+
332
+ class CartItemProcessing extends HTMLElement {
333
+ constructor() {
334
+ super();
335
+ }
336
+ }
337
+
338
+ // Define custom elements (check if not already defined)
339
+ if (!customElements.get('cart-item')) {
340
+ customElements.define('cart-item', CartItem);
341
+ }
342
+ if (!customElements.get('cart-item-content')) {
343
+ customElements.define('cart-item-content', CartItemContent);
344
+ }
345
+ if (!customElements.get('cart-item-processing')) {
346
+ customElements.define('cart-item-processing', CartItemProcessing);
347
+ }
348
+
349
+ /**
350
+ * Retrieves all focusable elements within a given container.
351
+ *
352
+ * @param {HTMLElement} container - The container element to search for focusable elements.
353
+ * @returns {HTMLElement[]} An array of focusable elements found within the container.
354
+ */
355
+ const getFocusableElements = (container) => {
356
+ const focusableSelectors =
357
+ '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';
358
+ return Array.from(container.querySelectorAll(focusableSelectors));
359
+ };
360
+
361
+ class FocusTrap extends HTMLElement {
362
+ /** @type {boolean} Indicates whether the styles have been injected into the DOM. */
363
+ static styleInjected = false;
364
+
365
+ constructor() {
366
+ super();
367
+ this.trapStart = null;
368
+ this.trapEnd = null;
369
+
370
+ // Inject styles only once, when the first FocusTrap instance is created.
371
+ if (!FocusTrap.styleInjected) {
372
+ this.injectStyles();
373
+ FocusTrap.styleInjected = true;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Injects necessary styles for the focus trap into the document's head.
379
+ * This ensures that focus-trap-start and focus-trap-end elements are hidden.
380
+ */
381
+ injectStyles() {
382
+ const style = document.createElement('style');
383
+ style.textContent = `
256
384
  focus-trap-start,
257
385
  focus-trap-end {
258
386
  position: absolute;
@@ -266,704 +394,887 @@
266
394
  white-space: nowrap;
267
395
  }
268
396
  `;
269
- document.head.appendChild(style);
270
- }
271
-
272
- /**
273
- * Called when the element is connected to the DOM.
274
- * Sets up the focus trap and adds the keydown event listener.
275
- */
276
- connectedCallback() {
277
- this.setupTrap();
278
- this.addEventListener('keydown', this.handleKeyDown);
279
- }
280
-
281
- /**
282
- * Called when the element is disconnected from the DOM.
283
- * Removes the keydown event listener.
284
- */
285
- disconnectedCallback() {
286
- this.removeEventListener('keydown', this.handleKeyDown);
287
- }
288
-
289
- /**
290
- * Sets up the focus trap by adding trap start and trap end elements.
291
- * Focuses the trap start element to initiate the focus trap.
292
- */
293
- setupTrap() {
294
- // check to see it there are any focusable children
295
- const focusableElements = getFocusableElements(this);
296
- // exit if there aren't any
297
- if (focusableElements.length === 0) return;
298
-
299
- // create trap start and end elements
300
- this.trapStart = document.createElement('focus-trap-start');
301
- this.trapEnd = document.createElement('focus-trap-end');
302
-
303
- // add to DOM
304
- this.prepend(this.trapStart);
305
- this.append(this.trapEnd);
306
- }
307
-
308
- /**
309
- * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
310
- *
311
- * @param {KeyboardEvent} e - The keyboard event object.
312
- */
313
- handleKeyDown = (e) => {
314
- if (e.key === 'Escape') {
315
- e.preventDefault();
316
- this.exitTrap();
317
- }
318
- };
319
-
320
- /**
321
- * Exits the focus trap by hiding the current container and shifting focus
322
- * back to the trigger element that opened the trap.
323
- */
324
- exitTrap() {
325
- const container = this.closest('[aria-hidden="false"]');
326
- if (!container) return;
327
-
328
- container.setAttribute('aria-hidden', 'true');
329
-
330
- const trigger = document.querySelector(
331
- `[aria-expanded="true"][aria-controls="${container.id}"]`
332
- );
333
- if (trigger) {
334
- trigger.setAttribute('aria-expanded', 'false');
335
- trigger.focus();
336
- }
337
- }
338
- }
339
-
340
- class FocusTrapStart extends HTMLElement {
341
- /**
342
- * Called when the element is connected to the DOM.
343
- * Sets the tabindex and adds the focus event listener.
344
- */
345
- connectedCallback() {
346
- this.setAttribute('tabindex', '0');
347
- this.addEventListener('focus', this.handleFocus);
348
- }
349
-
350
- /**
351
- * Called when the element is disconnected from the DOM.
352
- * Removes the focus event listener.
353
- */
354
- disconnectedCallback() {
355
- this.removeEventListener('focus', this.handleFocus);
356
- }
357
-
358
- /**
359
- * Handles the focus event. If focus moves backwards from the first focusable element,
360
- * it is cycled to the last focusable element, and vice versa.
361
- *
362
- * @param {FocusEvent} e - The focus event object.
363
- */
364
- handleFocus = (e) => {
365
- const trap = this.closest('focus-trap');
366
- const focusableElements = getFocusableElements(trap);
367
-
368
- if (focusableElements.length === 0) return;
369
-
370
- const firstElement = focusableElements[0];
371
- const lastElement =
372
- focusableElements[focusableElements.length - 1];
373
-
374
- if (e.relatedTarget === firstElement) {
375
- lastElement.focus();
376
- } else {
377
- firstElement.focus();
378
- }
379
- };
380
- }
381
-
382
- class FocusTrapEnd extends HTMLElement {
383
- /**
384
- * Called when the element is connected to the DOM.
385
- * Sets the tabindex and adds the focus event listener.
386
- */
387
- connectedCallback() {
388
- this.setAttribute('tabindex', '0');
389
- this.addEventListener('focus', this.handleFocus);
390
- }
391
-
392
- /**
393
- * Called when the element is disconnected from the DOM.
394
- * Removes the focus event listener.
395
- */
396
- disconnectedCallback() {
397
- this.removeEventListener('focus', this.handleFocus);
398
- }
399
-
400
- /**
401
- * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
402
- */
403
- handleFocus = () => {
404
- const trap = this.closest('focus-trap');
405
- const trapStart = trap.querySelector('focus-trap-start');
406
- trapStart.focus();
407
- };
408
- }
409
-
410
- customElements.define('focus-trap', FocusTrap);
411
- customElements.define('focus-trap-start', FocusTrapStart);
412
- customElements.define('focus-trap-end', FocusTrapEnd);
413
-
414
- class EventEmitter {
415
- #events;
416
-
417
- constructor() {
418
- this.#events = new Map();
419
- }
420
-
421
- /**
422
- * Binds a listener to an event.
423
- * @param {string} event - The event to bind the listener to.
424
- * @param {Function} listener - The listener function to bind.
425
- * @returns {EventEmitter} The current instance for chaining.
426
- * @throws {TypeError} If the listener is not a function.
427
- */
428
- on(event, listener) {
429
- if (typeof listener !== "function") {
430
- throw new TypeError("Listener must be a function");
431
- }
432
-
433
- const listeners = this.#events.get(event) || [];
434
- if (!listeners.includes(listener)) {
435
- listeners.push(listener);
436
- }
437
- this.#events.set(event, listeners);
438
-
439
- return this;
440
- }
441
-
442
- /**
443
- * Unbinds a listener from an event.
444
- * @param {string} event - The event to unbind the listener from.
445
- * @param {Function} listener - The listener function to unbind.
446
- * @returns {EventEmitter} The current instance for chaining.
447
- */
448
- off(event, listener) {
449
- const listeners = this.#events.get(event);
450
- if (!listeners) return this;
451
-
452
- const index = listeners.indexOf(listener);
453
- if (index !== -1) {
454
- listeners.splice(index, 1);
455
- if (listeners.length === 0) {
456
- this.#events.delete(event);
457
- } else {
458
- this.#events.set(event, listeners);
459
- }
460
- }
461
-
462
- return this;
463
- }
464
-
465
- /**
466
- * Triggers an event and calls all bound listeners.
467
- * @param {string} event - The event to trigger.
468
- * @param {...*} args - Arguments to pass to the listener functions.
469
- * @returns {boolean} True if the event had listeners, false otherwise.
470
- */
471
- emit(event, ...args) {
472
- const listeners = this.#events.get(event);
473
- if (!listeners || listeners.length === 0) return false;
474
-
475
- for (let i = 0, n = listeners.length; i < n; ++i) {
476
- try {
477
- listeners[i].apply(this, args);
478
- } catch (error) {
479
- console.error(`Error in listener for event '${event}':`, error);
480
- }
481
- }
482
-
483
- return true;
484
- }
485
-
486
- /**
487
- * Removes all listeners for a specific event or all events.
488
- * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
489
- * @returns {EventEmitter} The current instance for chaining.
490
- */
491
- removeAllListeners(event) {
492
- if (event) {
493
- this.#events.delete(event);
494
- } else {
495
- this.#events.clear();
496
- }
497
- return this;
498
- }
499
- }
500
-
501
- /**
502
- * Custom element that creates an accessible modal cart dialog with focus management
503
- * @extends HTMLElement
504
- */
505
- class CartDialog extends HTMLElement {
506
- #handleTransitionEnd;
507
- #scrollPosition = 0;
508
- #currentCart = null;
509
- #eventEmitter;
510
-
511
- /**
512
- * Clean up event listeners when component is removed from DOM
513
- */
514
- disconnectedCallback() {
515
- const _ = this;
516
- if (_.contentPanel) {
517
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
518
- }
519
-
520
- // Ensure body scroll is restored if component is removed while open
521
- document.body.classList.remove('overflow-hidden');
522
- this.#restoreScroll();
523
-
524
- // Detach event listeners
525
- this.#detachListeners();
526
- }
527
-
528
- /**
529
- * Saves current scroll position and locks body scrolling
530
- * @private
531
- */
532
- #lockScroll() {
533
- const _ = this;
534
- // Save current scroll position
535
- _.#scrollPosition = window.pageYOffset;
536
-
537
- // Apply fixed position to body
538
- document.body.classList.add('overflow-hidden');
539
- document.body.style.top = `-${_.#scrollPosition}px`;
540
- }
541
-
542
- /**
543
- * Restores scroll position when cart dialog is closed
544
- * @private
545
- */
546
- #restoreScroll() {
547
- const _ = this;
548
- // Remove fixed positioning
549
- document.body.classList.remove('overflow-hidden');
550
- document.body.style.removeProperty('top');
551
-
552
- // Restore scroll position
553
- window.scrollTo(0, _.#scrollPosition);
554
- }
555
-
556
- /**
557
- * Initializes the cart dialog, sets up focus trap and overlay
558
- */
559
- constructor() {
560
- super();
561
- const _ = this;
562
- _.id = _.getAttribute('id');
563
- _.setAttribute('role', 'dialog');
564
- _.setAttribute('aria-modal', 'true');
565
- _.setAttribute('aria-hidden', 'true');
566
-
567
- _.triggerEl = null;
568
-
569
- // Initialize event emitter
570
- _.#eventEmitter = new EventEmitter();
571
-
572
- // Create a handler for transition end events
573
- _.#handleTransitionEnd = (e) => {
574
- if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
575
- _.contentPanel.classList.add('hidden');
576
-
577
- // Emit afterHide event - cart dialog has completed its transition
578
- _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
579
- }
580
- };
581
- }
582
-
583
- connectedCallback() {
584
- const _ = this;
585
-
586
- // Now that we're in the DOM, find the content panel and set up focus trap
587
- _.contentPanel = _.querySelector('cart-panel');
588
-
589
- if (!_.contentPanel) {
590
- console.error('cart-panel element not found inside cart-dialog');
591
- return;
592
- }
593
-
594
- _.focusTrap = document.createElement('focus-trap');
595
-
596
- // Ensure we have labelledby and describedby references
597
- if (!_.getAttribute('aria-labelledby')) {
598
- const heading = _.querySelector('h1, h2, h3');
599
- if (heading && !heading.id) {
600
- heading.id = `${_.id}-title`;
601
- }
602
- if (heading?.id) {
603
- _.setAttribute('aria-labelledby', heading.id);
604
- }
605
- }
606
-
607
- _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
608
- _.focusTrap.appendChild(_.contentPanel);
609
-
610
- _.focusTrap.setupTrap();
611
-
612
- // Add modal overlay
613
- _.prepend(document.createElement('cart-overlay'));
614
- _.#attachListeners();
615
- _.#bindKeyboard();
616
- }
617
-
618
- /**
619
- * Event emitter method - Add an event listener with a cleaner API
620
- * @param {string} eventName - Name of the event to listen for
621
- * @param {Function} callback - Callback function to execute when event is fired
622
- * @returns {CartDialog} Returns this for method chaining
623
- */
624
- on(eventName, callback) {
625
- this.#eventEmitter.on(eventName, callback);
626
- return this;
627
- }
628
-
629
- /**
630
- * Event emitter method - Remove an event listener
631
- * @param {string} eventName - Name of the event to stop listening for
632
- * @param {Function} callback - Callback function to remove
633
- * @returns {CartDialog} Returns this for method chaining
634
- */
635
- off(eventName, callback) {
636
- this.#eventEmitter.off(eventName, callback);
637
- return this;
638
- }
639
-
640
- /**
641
- * Internal method to emit events via the event emitter
642
- * @param {string} eventName - Name of the event to emit
643
- * @param {*} [data] - Optional data to include with the event
644
- * @private
645
- */
646
- #emit(eventName, data = null) {
647
- this.#eventEmitter.emit(eventName, data);
648
- }
649
-
650
- /**
651
- * Attach event listeners for cart dialog functionality
652
- * @private
653
- */
654
- #attachListeners() {
655
- const _ = this;
656
-
657
- // Handle trigger buttons
658
- document.addEventListener('click', (e) => {
659
- const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
660
- if (!trigger) return;
661
-
662
- if (trigger.getAttribute('data-prevent-default') === 'true') {
663
- e.preventDefault();
664
- }
665
-
666
- _.show(trigger);
667
- });
668
-
669
- // Handle close buttons
670
- _.addEventListener('click', (e) => {
671
- if (!e.target.closest('[data-action="hide-cart"]')) return;
672
- _.hide();
673
- });
674
-
675
- // Handle cart item remove events
676
- _.addEventListener('cart-item:remove', (e) => {
677
- _.#handleCartItemRemove(e);
678
- });
679
-
680
- // Handle cart item quantity change events
681
- _.addEventListener('cart-item:quantity-change', (e) => {
682
- _.#handleCartItemQuantityChange(e);
683
- });
684
-
685
- // Add transition end listener
686
- _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
687
- }
688
-
689
- /**
690
- * Detach event listeners
691
- * @private
692
- */
693
- #detachListeners() {
694
- const _ = this;
695
- if (_.contentPanel) {
696
- _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
697
- }
698
- }
699
-
700
- /**
701
- * Binds keyboard events for accessibility
702
- * @private
703
- */
704
- #bindKeyboard() {
705
- this.addEventListener('keydown', (e) => {
706
- if (e.key === 'Escape') {
707
- this.hide();
708
- }
709
- });
710
- }
711
-
712
- /**
713
- * Handle cart item removal
714
- * @private
715
- */
716
- #handleCartItemRemove(e) {
717
- const { cartKey, element } = e.detail;
718
-
719
- // Set item to processing state
720
- element.setState('processing');
721
-
722
- // Remove item by setting quantity to 0
723
- this.updateCartItem(cartKey, 0)
724
- .then((updatedCart) => {
725
- if (updatedCart && !updatedCart.error) {
726
- // Success - remove with animation
727
- element.destroyYourself();
728
- this.#currentCart = updatedCart;
729
- this.#updateCartItems(updatedCart);
730
-
731
- // Emit cart updated and data changed events
732
- this.#emit('cart-dialog:updated', { cart: updatedCart });
733
- this.#emit('cart-dialog:data-changed', updatedCart);
734
- } else {
735
- // Error - reset to ready state
736
- element.setState('ready');
737
- console.error('Failed to remove cart item:', cartKey);
738
- }
739
- })
740
- .catch((error) => {
741
- // Error - reset to ready state
742
- element.setState('ready');
743
- console.error('Error removing cart item:', error);
744
- });
745
- }
746
-
747
- /**
748
- * Handle cart item quantity change
749
- * @private
750
- */
751
- #handleCartItemQuantityChange(e) {
752
- const { cartKey, quantity, element } = e.detail;
753
-
754
- // Set item to processing state
755
- element.setState('processing');
756
-
757
- // Update item quantity
758
- this.updateCartItem(cartKey, quantity)
759
- .then((updatedCart) => {
760
- if (updatedCart && !updatedCart.error) {
761
- // Success - update cart data
762
- this.#currentCart = updatedCart;
763
- this.#updateCartItems(updatedCart);
764
- element.setState('ready');
765
-
766
- // Emit cart updated and data changed events
767
- this.#emit('cart-dialog:updated', { cart: updatedCart });
768
- this.#emit('cart-dialog:data-changed', updatedCart);
769
- } else {
770
- // Error - reset to ready state
771
- element.setState('ready');
772
- console.error('Failed to update cart item quantity:', cartKey, quantity);
773
- }
774
- })
775
- .catch((error) => {
776
- // Error - reset to ready state
777
- element.setState('ready');
778
- console.error('Error updating cart item quantity:', error);
779
- });
780
- }
781
-
782
- /**
783
- * Update cart items
784
- * @private
785
- */
786
- #updateCartItems(cart = null) {
787
- // Placeholder for cart item updates
788
- // Could be used to sync cart items with server data
789
- cart || this.#currentCart;
790
- }
791
-
792
- /**
793
- * Fetch current cart data from server
794
- * @returns {Promise<Object>} Cart data object
795
- */
796
- getCart() {
797
- return fetch('/cart.json', {
798
- crossDomain: true,
799
- credentials: 'same-origin',
800
- })
801
- .then((response) => {
802
- if (!response.ok) {
803
- throw Error(response.statusText);
804
- }
805
- return response.json();
806
- })
807
- .catch((error) => {
808
- console.error('Error fetching cart:', error);
809
- return { error: true, message: error.message };
810
- });
811
- }
812
-
813
- /**
814
- * Update cart item quantity on server
815
- * @param {string|number} key - Cart item key/ID
816
- * @param {number} quantity - New quantity (0 to remove)
817
- * @returns {Promise<Object>} Updated cart data object
818
- */
819
- updateCartItem(key, quantity) {
820
- return fetch('/cart/change.json', {
821
- crossDomain: true,
822
- method: 'POST',
823
- credentials: 'same-origin',
824
- body: JSON.stringify({ id: key, quantity: quantity }),
825
- headers: { 'Content-Type': 'application/json' },
826
- })
827
- .then((response) => {
828
- if (!response.ok) {
829
- throw Error(response.statusText);
830
- }
831
- return response.json();
832
- })
833
- .catch((error) => {
834
- console.error('Error updating cart item:', error);
835
- return { error: true, message: error.message };
836
- });
837
- }
838
-
839
- /**
840
- * Refresh cart data from server and update components
841
- * @returns {Promise<Object>} Cart data object
842
- */
843
- refreshCart() {
844
- return this.getCart().then((cartData) => {
845
- if (cartData && !cartData.error) {
846
- this.#currentCart = cartData;
847
- this.#updateCartItems(cartData);
848
-
849
- // Emit cart refreshed and data changed events
850
- this.#emit('cart-dialog:refreshed', { cart: cartData });
851
- this.#emit('cart-dialog:data-changed', cartData);
852
- }
853
- return cartData;
854
- });
855
- }
856
-
857
- /**
858
- * Shows the cart dialog and traps focus within it
859
- * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
860
- * @fires CartDialog#show - Fired when the cart dialog has been shown
861
- */
862
- show(triggerEl = null) {
863
- const _ = this;
864
- _.triggerEl = triggerEl || false;
865
-
866
- // Remove the hidden class first to ensure content is rendered
867
- _.contentPanel.classList.remove('hidden');
868
-
869
- // Give the browser a moment to process before starting animation
870
- requestAnimationFrame(() => {
871
- // Update ARIA states
872
- _.setAttribute('aria-hidden', 'false');
873
- if (_.triggerEl) {
874
- _.triggerEl.setAttribute('aria-expanded', 'true');
875
- }
876
-
877
- // Lock body scrolling and save scroll position
878
- _.#lockScroll();
879
-
880
- // Focus management
881
- const firstFocusable = _.querySelector(
882
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
883
- );
884
- if (firstFocusable) {
885
- requestAnimationFrame(() => {
886
- firstFocusable.focus();
887
- });
888
- }
889
-
890
- // Refresh cart data when showing
891
- _.refreshCart();
892
-
893
- // Emit show event - cart dialog is now visible
894
- _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
895
- });
896
- }
897
-
898
- /**
899
- * Hides the cart dialog and restores focus
900
- * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
901
- * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
902
- */
903
- hide() {
904
- const _ = this;
905
-
906
- // Restore body scroll and scroll position
907
- _.#restoreScroll();
908
-
909
- // Update ARIA states
910
- if (_.triggerEl) {
911
- // remove focus from modal panel first
912
- _.triggerEl.focus();
913
- // mark trigger as no longer expanded
914
- _.triggerEl.setAttribute('aria-expanded', 'false');
915
- }
916
-
917
- // Set aria-hidden to start transition
918
- // The transitionend event handler will add display:none when complete
919
- _.setAttribute('aria-hidden', 'true');
920
-
921
- // Emit hide event - cart dialog is now starting to hide
922
- _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
923
- }
924
- }
925
-
926
- /**
927
- * Custom element that creates a clickable overlay for the cart dialog
928
- * @extends HTMLElement
929
- */
930
- class CartOverlay extends HTMLElement {
931
- constructor() {
932
- super();
933
- this.setAttribute('tabindex', '-1');
934
- this.setAttribute('aria-hidden', 'true');
935
- this.cartDialog = this.closest('cart-dialog');
936
- this.#attachListeners();
937
- }
938
-
939
- #attachListeners() {
940
- this.addEventListener('click', () => {
941
- this.cartDialog.hide();
942
- });
943
- }
944
- }
945
-
946
- /**
947
- * Custom element that wraps the content of the cart dialog
948
- * @extends HTMLElement
949
- */
950
- class CartPanel extends HTMLElement {
951
- constructor() {
952
- super();
953
- this.setAttribute('role', 'document');
954
- }
955
- }
956
-
957
- customElements.define('cart-dialog', CartDialog);
958
- customElements.define('cart-overlay', CartOverlay);
959
- customElements.define('cart-panel', CartPanel);
960
-
961
- exports.CartDialog = CartDialog;
962
- exports.CartOverlay = CartOverlay;
963
- exports.CartPanel = CartPanel;
964
- exports.default = CartDialog;
965
-
966
- Object.defineProperty(exports, '__esModule', { value: true });
397
+ document.head.appendChild(style);
398
+ }
399
+
400
+ /**
401
+ * Called when the element is connected to the DOM.
402
+ * Sets up the focus trap and adds the keydown event listener.
403
+ */
404
+ connectedCallback() {
405
+ this.setupTrap();
406
+ this.addEventListener('keydown', this.handleKeyDown);
407
+ }
408
+
409
+ /**
410
+ * Called when the element is disconnected from the DOM.
411
+ * Removes the keydown event listener.
412
+ */
413
+ disconnectedCallback() {
414
+ this.removeEventListener('keydown', this.handleKeyDown);
415
+ }
416
+
417
+ /**
418
+ * Sets up the focus trap by adding trap start and trap end elements.
419
+ * Focuses the trap start element to initiate the focus trap.
420
+ */
421
+ setupTrap() {
422
+ // check to see it there are any focusable children
423
+ const focusableElements = getFocusableElements(this);
424
+ // exit if there aren't any
425
+ if (focusableElements.length === 0) return;
426
+
427
+ // create trap start and end elements
428
+ this.trapStart = document.createElement('focus-trap-start');
429
+ this.trapEnd = document.createElement('focus-trap-end');
430
+
431
+ // add to DOM
432
+ this.prepend(this.trapStart);
433
+ this.append(this.trapEnd);
434
+ }
435
+
436
+ /**
437
+ * Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
438
+ *
439
+ * @param {KeyboardEvent} e - The keyboard event object.
440
+ */
441
+ handleKeyDown = (e) => {
442
+ if (e.key === 'Escape') {
443
+ e.preventDefault();
444
+ this.exitTrap();
445
+ }
446
+ };
447
+
448
+ /**
449
+ * Exits the focus trap by hiding the current container and shifting focus
450
+ * back to the trigger element that opened the trap.
451
+ */
452
+ exitTrap() {
453
+ const container = this.closest('[aria-hidden="false"]');
454
+ if (!container) return;
455
+
456
+ container.setAttribute('aria-hidden', 'true');
457
+
458
+ const trigger = document.querySelector(
459
+ `[aria-expanded="true"][aria-controls="${container.id}"]`
460
+ );
461
+ if (trigger) {
462
+ trigger.setAttribute('aria-expanded', 'false');
463
+ trigger.focus();
464
+ }
465
+ }
466
+ }
467
+
468
+ class FocusTrapStart extends HTMLElement {
469
+ /**
470
+ * Called when the element is connected to the DOM.
471
+ * Sets the tabindex and adds the focus event listener.
472
+ */
473
+ connectedCallback() {
474
+ this.setAttribute('tabindex', '0');
475
+ this.addEventListener('focus', this.handleFocus);
476
+ }
477
+
478
+ /**
479
+ * Called when the element is disconnected from the DOM.
480
+ * Removes the focus event listener.
481
+ */
482
+ disconnectedCallback() {
483
+ this.removeEventListener('focus', this.handleFocus);
484
+ }
485
+
486
+ /**
487
+ * Handles the focus event. If focus moves backwards from the first focusable element,
488
+ * it is cycled to the last focusable element, and vice versa.
489
+ *
490
+ * @param {FocusEvent} e - The focus event object.
491
+ */
492
+ handleFocus = (e) => {
493
+ const trap = this.closest('focus-trap');
494
+ const focusableElements = getFocusableElements(trap);
495
+
496
+ if (focusableElements.length === 0) return;
497
+
498
+ const firstElement = focusableElements[0];
499
+ const lastElement =
500
+ focusableElements[focusableElements.length - 1];
501
+
502
+ if (e.relatedTarget === firstElement) {
503
+ lastElement.focus();
504
+ } else {
505
+ firstElement.focus();
506
+ }
507
+ };
508
+ }
509
+
510
+ class FocusTrapEnd extends HTMLElement {
511
+ /**
512
+ * Called when the element is connected to the DOM.
513
+ * Sets the tabindex and adds the focus event listener.
514
+ */
515
+ connectedCallback() {
516
+ this.setAttribute('tabindex', '0');
517
+ this.addEventListener('focus', this.handleFocus);
518
+ }
519
+
520
+ /**
521
+ * Called when the element is disconnected from the DOM.
522
+ * Removes the focus event listener.
523
+ */
524
+ disconnectedCallback() {
525
+ this.removeEventListener('focus', this.handleFocus);
526
+ }
527
+
528
+ /**
529
+ * Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
530
+ */
531
+ handleFocus = () => {
532
+ const trap = this.closest('focus-trap');
533
+ const trapStart = trap.querySelector('focus-trap-start');
534
+ trapStart.focus();
535
+ };
536
+ }
537
+
538
+ if (!customElements.get('focus-trap')) {
539
+ customElements.define('focus-trap', FocusTrap);
540
+ }
541
+ if (!customElements.get('focus-trap-start')) {
542
+ customElements.define('focus-trap-start', FocusTrapStart);
543
+ }
544
+ if (!customElements.get('focus-trap-end')) {
545
+ customElements.define('focus-trap-end', FocusTrapEnd);
546
+ }
547
+
548
+ class EventEmitter {
549
+ #events;
550
+
551
+ constructor() {
552
+ this.#events = new Map();
553
+ }
554
+
555
+ /**
556
+ * Binds a listener to an event.
557
+ * @param {string} event - The event to bind the listener to.
558
+ * @param {Function} listener - The listener function to bind.
559
+ * @returns {EventEmitter} The current instance for chaining.
560
+ * @throws {TypeError} If the listener is not a function.
561
+ */
562
+ on(event, listener) {
563
+ if (typeof listener !== "function") {
564
+ throw new TypeError("Listener must be a function");
565
+ }
566
+
567
+ const listeners = this.#events.get(event) || [];
568
+ if (!listeners.includes(listener)) {
569
+ listeners.push(listener);
570
+ }
571
+ this.#events.set(event, listeners);
572
+
573
+ return this;
574
+ }
575
+
576
+ /**
577
+ * Unbinds a listener from an event.
578
+ * @param {string} event - The event to unbind the listener from.
579
+ * @param {Function} listener - The listener function to unbind.
580
+ * @returns {EventEmitter} The current instance for chaining.
581
+ */
582
+ off(event, listener) {
583
+ const listeners = this.#events.get(event);
584
+ if (!listeners) return this;
585
+
586
+ const index = listeners.indexOf(listener);
587
+ if (index !== -1) {
588
+ listeners.splice(index, 1);
589
+ if (listeners.length === 0) {
590
+ this.#events.delete(event);
591
+ } else {
592
+ this.#events.set(event, listeners);
593
+ }
594
+ }
595
+
596
+ return this;
597
+ }
598
+
599
+ /**
600
+ * Triggers an event and calls all bound listeners.
601
+ * @param {string} event - The event to trigger.
602
+ * @param {...*} args - Arguments to pass to the listener functions.
603
+ * @returns {boolean} True if the event had listeners, false otherwise.
604
+ */
605
+ emit(event, ...args) {
606
+ const listeners = this.#events.get(event);
607
+ if (!listeners || listeners.length === 0) return false;
608
+
609
+ for (let i = 0, n = listeners.length; i < n; ++i) {
610
+ try {
611
+ listeners[i].apply(this, args);
612
+ } catch (error) {
613
+ console.error(`Error in listener for event '${event}':`, error);
614
+ }
615
+ }
616
+
617
+ return true;
618
+ }
619
+
620
+ /**
621
+ * Removes all listeners for a specific event or all events.
622
+ * @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
623
+ * @returns {EventEmitter} The current instance for chaining.
624
+ */
625
+ removeAllListeners(event) {
626
+ if (event) {
627
+ this.#events.delete(event);
628
+ } else {
629
+ this.#events.clear();
630
+ }
631
+ return this;
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Custom element that creates an accessible modal cart dialog with focus management
637
+ * @extends HTMLElement
638
+ */
639
+ class CartDialog extends HTMLElement {
640
+ #handleTransitionEnd;
641
+ #scrollPosition = 0;
642
+ #currentCart = null;
643
+ #eventEmitter;
644
+ #isInitialRender = true;
645
+
646
+ /**
647
+ * Clean up event listeners when component is removed from DOM
648
+ */
649
+ disconnectedCallback() {
650
+ const _ = this;
651
+ if (_.contentPanel) {
652
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
653
+ }
654
+
655
+ // Ensure body scroll is restored if component is removed while open
656
+ document.body.classList.remove('overflow-hidden');
657
+ this.#restoreScroll();
658
+
659
+ // Detach event listeners
660
+ this.#detachListeners();
661
+ }
662
+
663
+ /**
664
+ * Saves current scroll position and locks body scrolling
665
+ * @private
666
+ */
667
+ #lockScroll() {
668
+ const _ = this;
669
+ // Save current scroll position
670
+ _.#scrollPosition = window.pageYOffset;
671
+
672
+ // Apply fixed position to body
673
+ document.body.classList.add('overflow-hidden');
674
+ document.body.style.top = `-${_.#scrollPosition}px`;
675
+ }
676
+
677
+ /**
678
+ * Restores scroll position when cart dialog is closed
679
+ * @private
680
+ */
681
+ #restoreScroll() {
682
+ const _ = this;
683
+ // Remove fixed positioning
684
+ document.body.classList.remove('overflow-hidden');
685
+ document.body.style.removeProperty('top');
686
+
687
+ // Restore scroll position
688
+ window.scrollTo(0, _.#scrollPosition);
689
+ }
690
+
691
+ /**
692
+ * Initializes the cart dialog, sets up focus trap and overlay
693
+ */
694
+ constructor() {
695
+ super();
696
+ const _ = this;
697
+ _.id = _.getAttribute('id');
698
+ _.setAttribute('role', 'dialog');
699
+ _.setAttribute('aria-modal', 'true');
700
+ _.setAttribute('aria-hidden', 'true');
701
+
702
+ _.triggerEl = null;
703
+
704
+ // Initialize event emitter
705
+ _.#eventEmitter = new EventEmitter();
706
+
707
+ // Create a handler for transition end events
708
+ _.#handleTransitionEnd = (e) => {
709
+ if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
710
+ _.contentPanel.classList.add('hidden');
711
+
712
+ // Emit afterHide event - cart dialog has completed its transition
713
+ _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
714
+ }
715
+ };
716
+ }
717
+
718
+ connectedCallback() {
719
+ const _ = this;
720
+
721
+ // Now that we're in the DOM, find the content panel and set up focus trap
722
+ _.contentPanel = _.querySelector('cart-panel');
723
+
724
+ if (!_.contentPanel) {
725
+ console.error('cart-panel element not found inside cart-dialog');
726
+ return;
727
+ }
728
+
729
+ _.focusTrap = document.createElement('focus-trap');
730
+
731
+ // Ensure we have labelledby and describedby references
732
+ if (!_.getAttribute('aria-labelledby')) {
733
+ const heading = _.querySelector('h1, h2, h3');
734
+ if (heading && !heading.id) {
735
+ heading.id = `${_.id}-title`;
736
+ }
737
+ if (heading?.id) {
738
+ _.setAttribute('aria-labelledby', heading.id);
739
+ }
740
+ }
741
+
742
+ // Insert focus trap before the cart-panel
743
+ _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
744
+ // Move cart-panel inside the focus trap
745
+ _.focusTrap.appendChild(_.contentPanel);
746
+
747
+ // Setup the trap - this will add focus-trap-start/end elements around the content
748
+ _.focusTrap.setupTrap();
749
+
750
+ // Add modal overlay if it doesn't already exist
751
+ if (!_.querySelector('cart-overlay')) {
752
+ _.prepend(document.createElement('cart-overlay'));
753
+ }
754
+ _.#attachListeners();
755
+ _.#bindKeyboard();
756
+ }
757
+
758
+ /**
759
+ * Event emitter method - Add an event listener with a cleaner API
760
+ * @param {string} eventName - Name of the event to listen for
761
+ * @param {Function} callback - Callback function to execute when event is fired
762
+ * @returns {CartDialog} Returns this for method chaining
763
+ */
764
+ on(eventName, callback) {
765
+ this.#eventEmitter.on(eventName, callback);
766
+ return this;
767
+ }
768
+
769
+ /**
770
+ * Event emitter method - Remove an event listener
771
+ * @param {string} eventName - Name of the event to stop listening for
772
+ * @param {Function} callback - Callback function to remove
773
+ * @returns {CartDialog} Returns this for method chaining
774
+ */
775
+ off(eventName, callback) {
776
+ this.#eventEmitter.off(eventName, callback);
777
+ return this;
778
+ }
779
+
780
+ /**
781
+ * Internal method to emit events via the event emitter
782
+ * @param {string} eventName - Name of the event to emit
783
+ * @param {*} [data] - Optional data to include with the event
784
+ * @private
785
+ */
786
+ #emit(eventName, data = null) {
787
+ this.#eventEmitter.emit(eventName, data);
788
+ }
789
+
790
+ /**
791
+ * Attach event listeners for cart dialog functionality
792
+ * @private
793
+ */
794
+ #attachListeners() {
795
+ const _ = this;
796
+
797
+ // Handle trigger buttons
798
+ document.addEventListener('click', (e) => {
799
+ const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
800
+ if (!trigger) return;
801
+
802
+ if (trigger.getAttribute('data-prevent-default') === 'true') {
803
+ e.preventDefault();
804
+ }
805
+
806
+ _.show(trigger);
807
+ });
808
+
809
+ // Handle close buttons
810
+ _.addEventListener('click', (e) => {
811
+ if (!e.target.closest('[data-action-hide-cart]')) return;
812
+ _.hide();
813
+ });
814
+
815
+ // Handle cart item remove events
816
+ _.addEventListener('cart-item:remove', (e) => {
817
+ _.#handleCartItemRemove(e);
818
+ });
819
+
820
+ // Handle cart item quantity change events
821
+ _.addEventListener('cart-item:quantity-change', (e) => {
822
+ _.#handleCartItemQuantityChange(e);
823
+ });
824
+
825
+ // Add transition end listener
826
+ _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
827
+ }
828
+
829
+ /**
830
+ * Detach event listeners
831
+ * @private
832
+ */
833
+ #detachListeners() {
834
+ const _ = this;
835
+ if (_.contentPanel) {
836
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Binds keyboard events for accessibility
842
+ * @private
843
+ */
844
+ #bindKeyboard() {
845
+ this.addEventListener('keydown', (e) => {
846
+ if (e.key === 'Escape') {
847
+ this.hide();
848
+ }
849
+ });
850
+ }
851
+
852
+ /**
853
+ * Handle cart item removal
854
+ * @private
855
+ */
856
+ #handleCartItemRemove(e) {
857
+ const { cartKey, element } = e.detail;
858
+
859
+ // Set item to processing state
860
+ element.setState('processing');
861
+
862
+ // Remove item by setting quantity to 0
863
+ this.updateCartItem(cartKey, 0)
864
+ .then((updatedCart) => {
865
+ if (updatedCart && !updatedCart.error) {
866
+ // Success - let smart comparison handle the removal animation
867
+ this.#currentCart = updatedCart;
868
+ this.#renderCartItems(updatedCart);
869
+ this.#updateCartItems(updatedCart);
870
+
871
+ // Emit cart updated and data changed events
872
+ this.#emit('cart-dialog:updated', { cart: updatedCart });
873
+ this.#emit('cart-dialog:data-changed', updatedCart);
874
+ } else {
875
+ // Error - reset to ready state
876
+ element.setState('ready');
877
+ console.error('Failed to remove cart item:', cartKey);
878
+ }
879
+ })
880
+ .catch((error) => {
881
+ // Error - reset to ready state
882
+ element.setState('ready');
883
+ console.error('Error removing cart item:', error);
884
+ });
885
+ }
886
+
887
+ /**
888
+ * Handle cart item quantity change
889
+ * @private
890
+ */
891
+ #handleCartItemQuantityChange(e) {
892
+ const { cartKey, quantity, element } = e.detail;
893
+
894
+ // Set item to processing state
895
+ element.setState('processing');
896
+
897
+ // Update item quantity
898
+ this.updateCartItem(cartKey, quantity)
899
+ .then((updatedCart) => {
900
+ if (updatedCart && !updatedCart.error) {
901
+ // Success - update cart data and refresh items
902
+ this.#currentCart = updatedCart;
903
+ this.#renderCartItems(updatedCart);
904
+ this.#updateCartItems(updatedCart);
905
+ element.setState('ready');
906
+
907
+ // Emit cart updated and data changed events
908
+ this.#emit('cart-dialog:updated', { cart: updatedCart });
909
+ this.#emit('cart-dialog:data-changed', updatedCart);
910
+ } else {
911
+ // Error - reset to ready state
912
+ element.setState('ready');
913
+ console.error('Failed to update cart item quantity:', cartKey, quantity);
914
+ }
915
+ })
916
+ .catch((error) => {
917
+ // Error - reset to ready state
918
+ element.setState('ready');
919
+ console.error('Error updating cart item quantity:', error);
920
+ });
921
+ }
922
+
923
+ /**
924
+ * Update cart items display based on cart data
925
+ * @private
926
+ */
927
+ #updateCartItems(cart = null) {
928
+ const cartData = cart || this.#currentCart;
929
+ if (!cartData) return;
930
+
931
+ // Get cart sections
932
+ const hasItemsSection = this.querySelector('[data-cart-has-items]');
933
+ const emptySection = this.querySelector('[data-cart-is-empty]');
934
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
935
+
936
+ if (!hasItemsSection || !emptySection || !itemsContainer) {
937
+ console.warn(
938
+ 'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
939
+ );
940
+ return;
941
+ }
942
+
943
+ // Show/hide sections based on item count
944
+ if (cartData.item_count > 0) {
945
+ hasItemsSection.style.display = 'block';
946
+ emptySection.style.display = 'none';
947
+ } else {
948
+ hasItemsSection.style.display = 'none';
949
+ emptySection.style.display = 'block';
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Fetch current cart data from server
955
+ * @returns {Promise<Object>} Cart data object
956
+ */
957
+ getCart() {
958
+ return fetch('/cart.json', {
959
+ crossDomain: true,
960
+ credentials: 'same-origin',
961
+ })
962
+ .then((response) => {
963
+ if (!response.ok) {
964
+ throw Error(response.statusText);
965
+ }
966
+ return response.json();
967
+ })
968
+ .catch((error) => {
969
+ console.error('Error fetching cart:', error);
970
+ return { error: true, message: error.message };
971
+ });
972
+ }
973
+
974
+ /**
975
+ * Update cart item quantity on server
976
+ * @param {string|number} key - Cart item key/ID
977
+ * @param {number} quantity - New quantity (0 to remove)
978
+ * @returns {Promise<Object>} Updated cart data object
979
+ */
980
+ updateCartItem(key, quantity) {
981
+ return fetch('/cart/change.json', {
982
+ crossDomain: true,
983
+ method: 'POST',
984
+ credentials: 'same-origin',
985
+ body: JSON.stringify({ id: key, quantity: quantity }),
986
+ headers: { 'Content-Type': 'application/json' },
987
+ })
988
+ .then((response) => {
989
+ if (!response.ok) {
990
+ throw Error(response.statusText);
991
+ }
992
+ return response.json();
993
+ })
994
+ .catch((error) => {
995
+ console.error('Error updating cart item:', error);
996
+ return { error: true, message: error.message };
997
+ });
998
+ }
999
+
1000
+ /**
1001
+ * Refresh cart data from server and update components
1002
+ * @returns {Promise<Object>} Cart data object
1003
+ */
1004
+ refreshCart() {
1005
+ console.log('Refreshing cart...');
1006
+ return this.getCart().then((cartData) => {
1007
+ console.log('Cart data received:', cartData);
1008
+ if (cartData && !cartData.error) {
1009
+ this.#currentCart = cartData;
1010
+ this.#renderCartItems(cartData);
1011
+ this.#updateCartItems(cartData);
1012
+
1013
+ // Emit cart refreshed and data changed events
1014
+ this.#emit('cart-dialog:refreshed', { cart: cartData });
1015
+ this.#emit('cart-dialog:data-changed', cartData);
1016
+ } else {
1017
+ console.warn('Cart data has error or is null:', cartData);
1018
+ }
1019
+ return cartData;
1020
+ });
1021
+ }
1022
+
1023
+ /**
1024
+ * Remove items from DOM that are no longer in cart data
1025
+ * @private
1026
+ */
1027
+ #removeItemsFromDOM(itemsContainer, newKeysSet) {
1028
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1029
+ const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
1030
+
1031
+ console.log(
1032
+ `Removing ${itemsToRemove.length} items:`,
1033
+ itemsToRemove.map((item) => item.getAttribute('key'))
1034
+ );
1035
+
1036
+ itemsToRemove.forEach((item) => {
1037
+ item.destroyYourself();
1038
+ });
1039
+ }
1040
+
1041
+ /**
1042
+ * Add new items to DOM with animation delay
1043
+ * @private
1044
+ */
1045
+ #addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
1046
+ console.log(
1047
+ `Adding ${itemsToAdd.length} items:`,
1048
+ itemsToAdd.map((item) => item.key || item.id)
1049
+ );
1050
+
1051
+ // Delay adding new items by 300ms to let cart slide open first
1052
+ setTimeout(() => {
1053
+ itemsToAdd.forEach((itemData) => {
1054
+ const cartItem = CartItem.createAnimated(itemData);
1055
+ const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
1056
+
1057
+ // Find the correct position to insert the new item
1058
+ if (targetIndex === 0) {
1059
+ // Insert at the beginning
1060
+ itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
1061
+ } else {
1062
+ // Find the item that should come before this one
1063
+ let insertAfter = null;
1064
+ for (let i = targetIndex - 1; i >= 0; i--) {
1065
+ const prevKey = newKeys[i];
1066
+ const prevItem = itemsContainer.querySelector(`cart-item[key="${prevKey}"]`);
1067
+ if (prevItem) {
1068
+ insertAfter = prevItem;
1069
+ break;
1070
+ }
1071
+ }
1072
+
1073
+ if (insertAfter) {
1074
+ insertAfter.insertAdjacentElement('afterend', cartItem);
1075
+ } else {
1076
+ itemsContainer.appendChild(cartItem);
1077
+ }
1078
+ }
1079
+ });
1080
+ }, 100);
1081
+ }
1082
+
1083
+ /**
1084
+ * Render cart items from Shopify cart data with smart comparison
1085
+ * @private
1086
+ */
1087
+ #renderCartItems(cartData) {
1088
+ const itemsContainer = this.querySelector('[data-content-cart-items]');
1089
+
1090
+ if (!itemsContainer || !cartData || !cartData.items) {
1091
+ console.warn('Cannot render cart items:', {
1092
+ itemsContainer: !!itemsContainer,
1093
+ cartData: !!cartData,
1094
+ items: cartData?.items?.length,
1095
+ });
1096
+ return;
1097
+ }
1098
+
1099
+ // Handle initial render - load all items without animation
1100
+ if (this.#isInitialRender) {
1101
+ console.log('Initial cart render:', cartData.items.length, 'items');
1102
+
1103
+ // Clear existing items
1104
+ itemsContainer.innerHTML = '';
1105
+
1106
+ // Create cart-item elements without animation
1107
+ cartData.items.forEach((itemData) => {
1108
+ const cartItem = new CartItem(itemData); // No animation
1109
+ itemsContainer.appendChild(cartItem);
1110
+ });
1111
+
1112
+ this.#isInitialRender = false;
1113
+ console.log('Initial render complete, container children:', itemsContainer.children.length);
1114
+ return;
1115
+ }
1116
+
1117
+ console.log('Smart rendering cart items:', cartData.items.length, 'items');
1118
+
1119
+ // Get current DOM items and their keys
1120
+ const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
1121
+ const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
1122
+
1123
+ // Get new cart data keys in order
1124
+ const newKeys = cartData.items.map((item) => item.key || item.id);
1125
+ const newKeysSet = new Set(newKeys);
1126
+
1127
+ // Step 1: Remove items that are no longer in cart data
1128
+ this.#removeItemsFromDOM(itemsContainer, newKeysSet);
1129
+
1130
+ // Step 2: Add new items that weren't in DOM (with animation delay)
1131
+ const itemsToAdd = cartData.items.filter(
1132
+ (itemData) => !currentKeys.has(itemData.key || itemData.id)
1133
+ );
1134
+
1135
+ this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
1136
+
1137
+ console.log('Smart rendering complete, container children:', itemsContainer.children.length);
1138
+ }
1139
+
1140
+ /**
1141
+ * Set the template function for cart items
1142
+ * @param {Function} templateFn - Function that takes item data and returns HTML string
1143
+ */
1144
+ setCartItemTemplate(templateFn) {
1145
+ CartItem.setTemplate(templateFn);
1146
+ }
1147
+
1148
+ /**
1149
+ * Set the processing template function for cart items
1150
+ * @param {Function} templateFn - Function that returns HTML string for processing state
1151
+ */
1152
+ setCartItemProcessingTemplate(templateFn) {
1153
+ CartItem.setProcessingTemplate(templateFn);
1154
+ }
1155
+
1156
+ /**
1157
+ * Shows the cart dialog and traps focus within it
1158
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
1159
+ * @fires CartDialog#show - Fired when the cart dialog has been shown
1160
+ */
1161
+ show(triggerEl = null) {
1162
+ const _ = this;
1163
+ _.triggerEl = triggerEl || false;
1164
+
1165
+ // Remove the hidden class first to ensure content is rendered
1166
+ _.contentPanel.classList.remove('hidden');
1167
+
1168
+ // Give the browser a moment to process before starting animation
1169
+ requestAnimationFrame(() => {
1170
+ // Update ARIA states
1171
+ _.setAttribute('aria-hidden', 'false');
1172
+ if (_.triggerEl) {
1173
+ _.triggerEl.setAttribute('aria-expanded', 'true');
1174
+ }
1175
+
1176
+ // Lock body scrolling and save scroll position
1177
+ _.#lockScroll();
1178
+
1179
+ // Focus management
1180
+ const firstFocusable = _.querySelector(
1181
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1182
+ );
1183
+ if (firstFocusable) {
1184
+ requestAnimationFrame(() => {
1185
+ firstFocusable.focus();
1186
+ });
1187
+ }
1188
+
1189
+ // Refresh cart data when showing
1190
+ _.refreshCart();
1191
+
1192
+ // Emit show event - cart dialog is now visible
1193
+ _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
1194
+ });
1195
+ }
1196
+
1197
+ /**
1198
+ * Hides the cart dialog and restores focus
1199
+ * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
1200
+ * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
1201
+ */
1202
+ hide() {
1203
+ const _ = this;
1204
+
1205
+ // Restore body scroll and scroll position
1206
+ _.#restoreScroll();
1207
+
1208
+ // Update ARIA states
1209
+ if (_.triggerEl) {
1210
+ // remove focus from modal panel first
1211
+ _.triggerEl.focus();
1212
+ // mark trigger as no longer expanded
1213
+ _.triggerEl.setAttribute('aria-expanded', 'false');
1214
+ }
1215
+
1216
+ // Set aria-hidden to start transition
1217
+ // The transitionend event handler will add display:none when complete
1218
+ _.setAttribute('aria-hidden', 'true');
1219
+
1220
+ // Emit hide event - cart dialog is now starting to hide
1221
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Custom element that creates a clickable overlay for the cart dialog
1227
+ * @extends HTMLElement
1228
+ */
1229
+ class CartOverlay extends HTMLElement {
1230
+ constructor() {
1231
+ super();
1232
+ this.setAttribute('tabindex', '-1');
1233
+ this.setAttribute('aria-hidden', 'true');
1234
+ this.cartDialog = this.closest('cart-dialog');
1235
+ this.#attachListeners();
1236
+ }
1237
+
1238
+ #attachListeners() {
1239
+ this.addEventListener('click', () => {
1240
+ this.cartDialog.hide();
1241
+ });
1242
+ }
1243
+ }
1244
+
1245
+ /**
1246
+ * Custom element that wraps the content of the cart dialog
1247
+ * @extends HTMLElement
1248
+ */
1249
+ class CartPanel extends HTMLElement {
1250
+ constructor() {
1251
+ super();
1252
+ this.setAttribute('role', 'document');
1253
+ }
1254
+ }
1255
+
1256
+ if (!customElements.get('cart-dialog')) {
1257
+ customElements.define('cart-dialog', CartDialog);
1258
+ }
1259
+ if (!customElements.get('cart-overlay')) {
1260
+ customElements.define('cart-overlay', CartOverlay);
1261
+ }
1262
+ if (!customElements.get('cart-panel')) {
1263
+ customElements.define('cart-panel', CartPanel);
1264
+ }
1265
+
1266
+ // Make CartItem available globally for Shopify themes
1267
+ if (typeof window !== 'undefined') {
1268
+ window.CartItem = CartItem;
1269
+ }
1270
+
1271
+ exports.CartDialog = CartDialog;
1272
+ exports.CartItem = CartItem;
1273
+ exports.CartOverlay = CartOverlay;
1274
+ exports.CartPanel = CartPanel;
1275
+ exports.default = CartDialog;
1276
+
1277
+ Object.defineProperty(exports, '__esModule', { value: true });
967
1278
 
968
1279
  }));
969
1280
  //# sourceMappingURL=cart-panel.js.map