@magic-spells/cart-panel 0.1.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.
@@ -0,0 +1,969 @@
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 = {}));
5
+ })(this, (function (exports) { 'use strict';
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 = `
256
+ focus-trap-start,
257
+ focus-trap-end {
258
+ position: absolute;
259
+ width: 1px;
260
+ height: 1px;
261
+ margin: -1px;
262
+ padding: 0;
263
+ border: 0;
264
+ clip: rect(0, 0, 0, 0);
265
+ overflow: hidden;
266
+ white-space: nowrap;
267
+ }
268
+ `;
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 });
967
+
968
+ }));
969
+ //# sourceMappingURL=cart-panel.js.map