@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,470 @@
1
+ import './cart-panel.scss';
2
+ import '@magic-spells/cart-item';
3
+ import '@magic-spells/focus-trap';
4
+ import EventEmitter from '@magic-spells/event-emitter';
5
+
6
+ /**
7
+ * Custom element that creates an accessible modal cart dialog with focus management
8
+ * @extends HTMLElement
9
+ */
10
+ class CartDialog extends HTMLElement {
11
+ #handleTransitionEnd;
12
+ #scrollPosition = 0;
13
+ #currentCart = null;
14
+ #eventEmitter;
15
+
16
+ /**
17
+ * Clean up event listeners when component is removed from DOM
18
+ */
19
+ disconnectedCallback() {
20
+ const _ = this;
21
+ if (_.contentPanel) {
22
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
23
+ }
24
+
25
+ // Ensure body scroll is restored if component is removed while open
26
+ document.body.classList.remove('overflow-hidden');
27
+ this.#restoreScroll();
28
+
29
+ // Detach event listeners
30
+ this.#detachListeners();
31
+ }
32
+
33
+ /**
34
+ * Saves current scroll position and locks body scrolling
35
+ * @private
36
+ */
37
+ #lockScroll() {
38
+ const _ = this;
39
+ // Save current scroll position
40
+ _.#scrollPosition = window.pageYOffset;
41
+
42
+ // Apply fixed position to body
43
+ document.body.classList.add('overflow-hidden');
44
+ document.body.style.top = `-${_.#scrollPosition}px`;
45
+ }
46
+
47
+ /**
48
+ * Restores scroll position when cart dialog is closed
49
+ * @private
50
+ */
51
+ #restoreScroll() {
52
+ const _ = this;
53
+ // Remove fixed positioning
54
+ document.body.classList.remove('overflow-hidden');
55
+ document.body.style.removeProperty('top');
56
+
57
+ // Restore scroll position
58
+ window.scrollTo(0, _.#scrollPosition);
59
+ }
60
+
61
+ /**
62
+ * Initializes the cart dialog, sets up focus trap and overlay
63
+ */
64
+ constructor() {
65
+ super();
66
+ const _ = this;
67
+ _.id = _.getAttribute('id');
68
+ _.setAttribute('role', 'dialog');
69
+ _.setAttribute('aria-modal', 'true');
70
+ _.setAttribute('aria-hidden', 'true');
71
+
72
+ _.triggerEl = null;
73
+
74
+ // Initialize event emitter
75
+ _.#eventEmitter = new EventEmitter();
76
+
77
+ // Create a handler for transition end events
78
+ _.#handleTransitionEnd = (e) => {
79
+ if (e.propertyName === 'opacity' && _.getAttribute('aria-hidden') === 'true') {
80
+ _.contentPanel.classList.add('hidden');
81
+
82
+ // Emit afterHide event - cart dialog has completed its transition
83
+ _.#emit('cart-dialog:afterHide', { triggerElement: _.triggerEl });
84
+ }
85
+ };
86
+ }
87
+
88
+ connectedCallback() {
89
+ const _ = this;
90
+
91
+ // Now that we're in the DOM, find the content panel and set up focus trap
92
+ _.contentPanel = _.querySelector('cart-panel');
93
+
94
+ if (!_.contentPanel) {
95
+ console.error('cart-panel element not found inside cart-dialog');
96
+ return;
97
+ }
98
+
99
+ _.focusTrap = document.createElement('focus-trap');
100
+
101
+ // Ensure we have labelledby and describedby references
102
+ if (!_.getAttribute('aria-labelledby')) {
103
+ const heading = _.querySelector('h1, h2, h3');
104
+ if (heading && !heading.id) {
105
+ heading.id = `${_.id}-title`;
106
+ }
107
+ if (heading?.id) {
108
+ _.setAttribute('aria-labelledby', heading.id);
109
+ }
110
+ }
111
+
112
+ _.contentPanel.parentNode.insertBefore(_.focusTrap, _.contentPanel);
113
+ _.focusTrap.appendChild(_.contentPanel);
114
+
115
+ _.focusTrap.setupTrap();
116
+
117
+ // Add modal overlay
118
+ _.prepend(document.createElement('cart-overlay'));
119
+ _.#attachListeners();
120
+ _.#bindKeyboard();
121
+ }
122
+
123
+ /**
124
+ * Event emitter method - Add an event listener with a cleaner API
125
+ * @param {string} eventName - Name of the event to listen for
126
+ * @param {Function} callback - Callback function to execute when event is fired
127
+ * @returns {CartDialog} Returns this for method chaining
128
+ */
129
+ on(eventName, callback) {
130
+ this.#eventEmitter.on(eventName, callback);
131
+ return this;
132
+ }
133
+
134
+ /**
135
+ * Event emitter method - Remove an event listener
136
+ * @param {string} eventName - Name of the event to stop listening for
137
+ * @param {Function} callback - Callback function to remove
138
+ * @returns {CartDialog} Returns this for method chaining
139
+ */
140
+ off(eventName, callback) {
141
+ this.#eventEmitter.off(eventName, callback);
142
+ return this;
143
+ }
144
+
145
+ /**
146
+ * Internal method to emit events via the event emitter
147
+ * @param {string} eventName - Name of the event to emit
148
+ * @param {*} [data] - Optional data to include with the event
149
+ * @private
150
+ */
151
+ #emit(eventName, data = null) {
152
+ this.#eventEmitter.emit(eventName, data);
153
+ }
154
+
155
+ /**
156
+ * Attach event listeners for cart dialog functionality
157
+ * @private
158
+ */
159
+ #attachListeners() {
160
+ const _ = this;
161
+
162
+ // Handle trigger buttons
163
+ document.addEventListener('click', (e) => {
164
+ const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
165
+ if (!trigger) return;
166
+
167
+ if (trigger.getAttribute('data-prevent-default') === 'true') {
168
+ e.preventDefault();
169
+ }
170
+
171
+ _.show(trigger);
172
+ });
173
+
174
+ // Handle close buttons
175
+ _.addEventListener('click', (e) => {
176
+ if (!e.target.closest('[data-action="hide-cart"]')) return;
177
+ _.hide();
178
+ });
179
+
180
+ // Handle cart item remove events
181
+ _.addEventListener('cart-item:remove', (e) => {
182
+ _.#handleCartItemRemove(e);
183
+ });
184
+
185
+ // Handle cart item quantity change events
186
+ _.addEventListener('cart-item:quantity-change', (e) => {
187
+ _.#handleCartItemQuantityChange(e);
188
+ });
189
+
190
+ // Add transition end listener
191
+ _.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
192
+ }
193
+
194
+ /**
195
+ * Detach event listeners
196
+ * @private
197
+ */
198
+ #detachListeners() {
199
+ const _ = this;
200
+ if (_.contentPanel) {
201
+ _.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Binds keyboard events for accessibility
207
+ * @private
208
+ */
209
+ #bindKeyboard() {
210
+ this.addEventListener('keydown', (e) => {
211
+ if (e.key === 'Escape') {
212
+ this.hide();
213
+ }
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Handle cart item removal
219
+ * @private
220
+ */
221
+ #handleCartItemRemove(e) {
222
+ const { cartKey, element } = e.detail;
223
+
224
+ // Set item to processing state
225
+ element.setState('processing');
226
+
227
+ // Remove item by setting quantity to 0
228
+ this.updateCartItem(cartKey, 0)
229
+ .then((updatedCart) => {
230
+ if (updatedCart && !updatedCart.error) {
231
+ // Success - remove with animation
232
+ element.destroyYourself();
233
+ this.#currentCart = updatedCart;
234
+ this.#updateCartItems(updatedCart);
235
+
236
+ // Emit cart updated and data changed events
237
+ this.#emit('cart-dialog:updated', { cart: updatedCart });
238
+ this.#emit('cart-dialog:data-changed', updatedCart);
239
+ } else {
240
+ // Error - reset to ready state
241
+ element.setState('ready');
242
+ console.error('Failed to remove cart item:', cartKey);
243
+ }
244
+ })
245
+ .catch((error) => {
246
+ // Error - reset to ready state
247
+ element.setState('ready');
248
+ console.error('Error removing cart item:', error);
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Handle cart item quantity change
254
+ * @private
255
+ */
256
+ #handleCartItemQuantityChange(e) {
257
+ const { cartKey, quantity, element } = e.detail;
258
+
259
+ // Set item to processing state
260
+ element.setState('processing');
261
+
262
+ // Update item quantity
263
+ this.updateCartItem(cartKey, quantity)
264
+ .then((updatedCart) => {
265
+ if (updatedCart && !updatedCart.error) {
266
+ // Success - update cart data
267
+ this.#currentCart = updatedCart;
268
+ this.#updateCartItems(updatedCart);
269
+ element.setState('ready');
270
+
271
+ // Emit cart updated and data changed events
272
+ this.#emit('cart-dialog:updated', { cart: updatedCart });
273
+ this.#emit('cart-dialog:data-changed', updatedCart);
274
+ } else {
275
+ // Error - reset to ready state
276
+ element.setState('ready');
277
+ console.error('Failed to update cart item quantity:', cartKey, quantity);
278
+ }
279
+ })
280
+ .catch((error) => {
281
+ // Error - reset to ready state
282
+ element.setState('ready');
283
+ console.error('Error updating cart item quantity:', error);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Update cart items
289
+ * @private
290
+ */
291
+ #updateCartItems(cart = null) {
292
+ // Placeholder for cart item updates
293
+ // Could be used to sync cart items with server data
294
+ const cartData = cart || this.#currentCart;
295
+ if (cartData) {
296
+ // Future implementation: update cart item components
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Fetch current cart data from server
302
+ * @returns {Promise<Object>} Cart data object
303
+ */
304
+ getCart() {
305
+ return fetch('/cart.json', {
306
+ crossDomain: true,
307
+ credentials: 'same-origin',
308
+ })
309
+ .then((response) => {
310
+ if (!response.ok) {
311
+ throw Error(response.statusText);
312
+ }
313
+ return response.json();
314
+ })
315
+ .catch((error) => {
316
+ console.error('Error fetching cart:', error);
317
+ return { error: true, message: error.message };
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Update cart item quantity on server
323
+ * @param {string|number} key - Cart item key/ID
324
+ * @param {number} quantity - New quantity (0 to remove)
325
+ * @returns {Promise<Object>} Updated cart data object
326
+ */
327
+ updateCartItem(key, quantity) {
328
+ return fetch('/cart/change.json', {
329
+ crossDomain: true,
330
+ method: 'POST',
331
+ credentials: 'same-origin',
332
+ body: JSON.stringify({ id: key, quantity: quantity }),
333
+ headers: { 'Content-Type': 'application/json' },
334
+ })
335
+ .then((response) => {
336
+ if (!response.ok) {
337
+ throw Error(response.statusText);
338
+ }
339
+ return response.json();
340
+ })
341
+ .catch((error) => {
342
+ console.error('Error updating cart item:', error);
343
+ return { error: true, message: error.message };
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Refresh cart data from server and update components
349
+ * @returns {Promise<Object>} Cart data object
350
+ */
351
+ refreshCart() {
352
+ return this.getCart().then((cartData) => {
353
+ if (cartData && !cartData.error) {
354
+ this.#currentCart = cartData;
355
+ this.#updateCartItems(cartData);
356
+
357
+ // Emit cart refreshed and data changed events
358
+ this.#emit('cart-dialog:refreshed', { cart: cartData });
359
+ this.#emit('cart-dialog:data-changed', cartData);
360
+ }
361
+ return cartData;
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Shows the cart dialog and traps focus within it
367
+ * @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
368
+ * @fires CartDialog#show - Fired when the cart dialog has been shown
369
+ */
370
+ show(triggerEl = null) {
371
+ const _ = this;
372
+ _.triggerEl = triggerEl || false;
373
+
374
+ // Remove the hidden class first to ensure content is rendered
375
+ _.contentPanel.classList.remove('hidden');
376
+
377
+ // Give the browser a moment to process before starting animation
378
+ requestAnimationFrame(() => {
379
+ // Update ARIA states
380
+ _.setAttribute('aria-hidden', 'false');
381
+ if (_.triggerEl) {
382
+ _.triggerEl.setAttribute('aria-expanded', 'true');
383
+ }
384
+
385
+ // Lock body scrolling and save scroll position
386
+ _.#lockScroll();
387
+
388
+ // Focus management
389
+ const firstFocusable = _.querySelector(
390
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
391
+ );
392
+ if (firstFocusable) {
393
+ requestAnimationFrame(() => {
394
+ firstFocusable.focus();
395
+ });
396
+ }
397
+
398
+ // Refresh cart data when showing
399
+ _.refreshCart();
400
+
401
+ // Emit show event - cart dialog is now visible
402
+ _.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Hides the cart dialog and restores focus
408
+ * @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
409
+ * @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
410
+ */
411
+ hide() {
412
+ const _ = this;
413
+
414
+ // Restore body scroll and scroll position
415
+ _.#restoreScroll();
416
+
417
+ // Update ARIA states
418
+ if (_.triggerEl) {
419
+ // remove focus from modal panel first
420
+ _.triggerEl.focus();
421
+ // mark trigger as no longer expanded
422
+ _.triggerEl.setAttribute('aria-expanded', 'false');
423
+ }
424
+
425
+ // Set aria-hidden to start transition
426
+ // The transitionend event handler will add display:none when complete
427
+ _.setAttribute('aria-hidden', 'true');
428
+
429
+ // Emit hide event - cart dialog is now starting to hide
430
+ _.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Custom element that creates a clickable overlay for the cart dialog
436
+ * @extends HTMLElement
437
+ */
438
+ class CartOverlay extends HTMLElement {
439
+ constructor() {
440
+ super();
441
+ this.setAttribute('tabindex', '-1');
442
+ this.setAttribute('aria-hidden', 'true');
443
+ this.cartDialog = this.closest('cart-dialog');
444
+ this.#attachListeners();
445
+ }
446
+
447
+ #attachListeners() {
448
+ this.addEventListener('click', () => {
449
+ this.cartDialog.hide();
450
+ });
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Custom element that wraps the content of the cart dialog
456
+ * @extends HTMLElement
457
+ */
458
+ class CartPanel extends HTMLElement {
459
+ constructor() {
460
+ super();
461
+ this.setAttribute('role', 'document');
462
+ }
463
+ }
464
+
465
+ customElements.define('cart-dialog', CartDialog);
466
+ customElements.define('cart-overlay', CartOverlay);
467
+ customElements.define('cart-panel', CartPanel);
468
+
469
+ export { CartDialog, CartOverlay, CartPanel };
470
+ export default CartDialog;
@@ -0,0 +1,117 @@
1
+ // Cart Dialog SCSS Variables
2
+ // These can be customized by importing this file and overriding variables
3
+
4
+ // Import cart-item styles since cart items are used within the cart panel
5
+ @use '@magic-spells/cart-item/dist/cart-item.scss';
6
+
7
+ // Layout and positioning
8
+ $cart-dialog-z-index: 1000 !default;
9
+ $cart-overlay-z-index: 1000 !default;
10
+ $cart-panel-z-index: 1001 !default;
11
+ $cart-panel-width: min(400px, 90vw) !default;
12
+
13
+ // Overlay styling
14
+ $cart-overlay-background: rgba(0, 0, 0, 0.15) !default;
15
+ $cart-overlay-backdrop-filter: blur(4px) !default;
16
+
17
+ // Panel styling
18
+ $cart-panel-background: #ffffff !default;
19
+ $cart-panel-shadow: -5px 0 25px rgba(0, 0, 0, 0.15) !default;
20
+ $cart-panel-border-radius: 0 !default;
21
+
22
+ // Animation
23
+ $cart-transition-duration: 350ms !default;
24
+ $cart-transition-timing: cubic-bezier(0.4, 0, 0.2, 1) !default;
25
+
26
+ // Define CSS Custom Properties using SCSS values
27
+ :root {
28
+ // Layout
29
+ --cart-dialog-z-index: #{$cart-dialog-z-index};
30
+ --cart-overlay-z-index: #{$cart-overlay-z-index};
31
+ --cart-panel-z-index: #{$cart-panel-z-index};
32
+ --cart-panel-width: #{$cart-panel-width};
33
+
34
+ // Overlay
35
+ --cart-overlay-background: #{$cart-overlay-background};
36
+ --cart-overlay-backdrop-filter: #{$cart-overlay-backdrop-filter};
37
+
38
+ // Panel
39
+ --cart-panel-background: #{$cart-panel-background};
40
+ --cart-panel-shadow: #{$cart-panel-shadow};
41
+ --cart-panel-border-radius: #{$cart-panel-border-radius};
42
+
43
+ // Animation
44
+ --cart-transition-duration: #{$cart-transition-duration};
45
+ --cart-transition-timing: #{$cart-transition-timing};
46
+ }
47
+
48
+ // Cart Dialog - Main container
49
+ cart-dialog {
50
+ display: contents;
51
+
52
+ &[aria-hidden='false'] {
53
+ cart-overlay,
54
+ cart-panel {
55
+ pointer-events: auto;
56
+ opacity: 1;
57
+ }
58
+
59
+ cart-panel {
60
+ transform: translateX(0);
61
+ }
62
+ }
63
+ }
64
+
65
+ // Cart Overlay - Backdrop
66
+ cart-overlay {
67
+ position: fixed;
68
+ top: 0;
69
+ left: 0;
70
+ width: 100vw;
71
+ height: 100vh;
72
+ opacity: 0;
73
+ pointer-events: none;
74
+ z-index: var(--cart-overlay-z-index);
75
+ background-color: var(--cart-overlay-background);
76
+ backdrop-filter: var(--cart-overlay-backdrop-filter);
77
+ transition:
78
+ opacity var(--cart-transition-duration) var(--cart-transition-timing),
79
+ backdrop-filter var(--cart-transition-duration) var(--cart-transition-timing);
80
+ }
81
+
82
+ // Cart Panel - Sliding content area
83
+ cart-panel {
84
+ position: fixed;
85
+ top: 0;
86
+ right: 0;
87
+ width: var(--cart-panel-width);
88
+ height: 100vh;
89
+ opacity: 0;
90
+ transform: translateX(100%);
91
+ pointer-events: none;
92
+ z-index: var(--cart-panel-z-index);
93
+ background: var(--cart-panel-background);
94
+ box-shadow: var(--cart-panel-shadow);
95
+ border-radius: var(--cart-panel-border-radius);
96
+ overflow: hidden;
97
+ transition:
98
+ opacity var(--cart-transition-duration) var(--cart-transition-timing),
99
+ transform var(--cart-transition-duration) var(--cart-transition-timing);
100
+
101
+ // When explicitly hidden, remove from layout
102
+ &.hidden {
103
+ display: none;
104
+ }
105
+ }
106
+
107
+ // Body scroll lock when cart is open
108
+ body.overflow-hidden {
109
+ overflow: hidden;
110
+ position: fixed;
111
+ width: 100%;
112
+ height: 100%;
113
+ left: 0;
114
+ right: 0;
115
+ margin: 0;
116
+ // The top property will be set dynamically by the component
117
+ }