@magic-spells/cart-panel 0.3.1 → 1.0.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,448 @@
1
+ // =============================================================================
2
+ // CartItem Component
3
+ // =============================================================================
4
+
5
+ /**
6
+ * CartItem class that handles the functionality of a cart item component
7
+ */
8
+ class CartItem extends HTMLElement {
9
+ // Static template functions shared across all instances
10
+ static #templates = new Map();
11
+ static #processingTemplate = null;
12
+
13
+ // Private fields
14
+ #currentState = 'ready';
15
+ #isDestroying = false;
16
+ #isAppearing = false;
17
+ #handlers = {};
18
+ #itemData = null;
19
+ #cartData = null;
20
+ #lastRenderedHTML = '';
21
+
22
+ /**
23
+ * Set the template function for rendering cart items
24
+ * @param {string} name - Template name ('default' for default template)
25
+ * @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
26
+ */
27
+ static setTemplate(name, templateFn) {
28
+ if (typeof name !== 'string') {
29
+ throw new Error('Template name must be a string');
30
+ }
31
+ if (typeof templateFn !== 'function') {
32
+ throw new Error('Template must be a function');
33
+ }
34
+ CartItem.#templates.set(name, templateFn);
35
+ }
36
+
37
+ /**
38
+ * Set the processing template function for rendering processing overlay
39
+ * @param {Function} templateFn - Function that returns HTML string for processing state
40
+ */
41
+ static setProcessingTemplate(templateFn) {
42
+ if (typeof templateFn !== 'function') {
43
+ throw new Error('Processing template must be a function');
44
+ }
45
+ CartItem.#processingTemplate = templateFn;
46
+ }
47
+
48
+ /**
49
+ * Create a cart item with appearing animation
50
+ * @param {Object} itemData - Shopify cart item data
51
+ * @param {Object} cartData - Full Shopify cart object
52
+ * @returns {CartItem} Cart item instance that will animate in
53
+ */
54
+ static createAnimated(itemData, cartData) {
55
+ return new CartItem(itemData, cartData, { animate: true });
56
+ }
57
+
58
+ /**
59
+ * Define which attributes should be observed for changes
60
+ */
61
+ static get observedAttributes() {
62
+ return ['state', 'key'];
63
+ }
64
+
65
+ /**
66
+ * Called when observed attributes change
67
+ */
68
+ attributeChangedCallback(name, oldValue, newValue) {
69
+ if (oldValue === newValue) return;
70
+
71
+ if (name === 'state') {
72
+ this.#currentState = newValue || 'ready';
73
+ }
74
+ }
75
+
76
+ constructor(itemData = null, cartData = null, options = {}) {
77
+ super();
78
+
79
+ // Store item and cart data if provided
80
+ this.#itemData = itemData;
81
+ this.#cartData = cartData;
82
+
83
+ // Set initial state - start with 'appearing' only if explicitly requested
84
+ const shouldAnimate = options.animate || this.hasAttribute('animate-in');
85
+ this.#currentState =
86
+ itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
87
+
88
+ // Bind event handlers
89
+ this.#handlers = {
90
+ click: this.#handleClick.bind(this),
91
+ change: this.#handleChange.bind(this),
92
+ transitionEnd: this.#handleTransitionEnd.bind(this),
93
+ };
94
+ }
95
+
96
+ connectedCallback() {
97
+ const _ = this;
98
+
99
+ // If we have item data, render it first
100
+ if (_.#itemData) _.#render();
101
+
102
+ // Find child elements and attach listeners
103
+ _.#queryDOM();
104
+ _.#updateLinePriceElements();
105
+ _.#attachListeners();
106
+
107
+ // If we started with 'appearing' state, handle the entry animation
108
+ if (_.#currentState === 'appearing') {
109
+ _.setAttribute('state', 'appearing');
110
+ _.#isAppearing = true;
111
+
112
+ requestAnimationFrame(() => {
113
+ _.style.height = `${_.scrollHeight}px`;
114
+ requestAnimationFrame(() => _.setState('ready'));
115
+ });
116
+ }
117
+ }
118
+
119
+ disconnectedCallback() {
120
+ // Cleanup event listeners
121
+ this.#detachListeners();
122
+ }
123
+
124
+ /**
125
+ * Query and cache DOM elements
126
+ */
127
+ #queryDOM() {
128
+ this.content = this.querySelector('cart-item-content');
129
+ this.processing = this.querySelector('cart-item-processing');
130
+ }
131
+
132
+ /**
133
+ * Attach event listeners
134
+ */
135
+ #attachListeners() {
136
+ const _ = this;
137
+ _.addEventListener('click', _.#handlers.click);
138
+ _.addEventListener('change', _.#handlers.change);
139
+ _.addEventListener('quantity-input:change', _.#handlers.change);
140
+ _.addEventListener('transitionend', _.#handlers.transitionEnd);
141
+ }
142
+
143
+ /**
144
+ * Detach event listeners
145
+ */
146
+ #detachListeners() {
147
+ const _ = this;
148
+ _.removeEventListener('click', _.#handlers.click);
149
+ _.removeEventListener('change', _.#handlers.change);
150
+ _.removeEventListener('quantity-input:change', _.#handlers.change);
151
+ _.removeEventListener('transitionend', _.#handlers.transitionEnd);
152
+ }
153
+
154
+ /**
155
+ * Get the current state
156
+ */
157
+ get state() {
158
+ return this.#currentState;
159
+ }
160
+
161
+ /**
162
+ * Get the cart key for this item
163
+ */
164
+ get cartKey() {
165
+ return this.getAttribute('key');
166
+ }
167
+
168
+ /**
169
+ * Handle click events (for Remove buttons, etc.)
170
+ */
171
+ #handleClick(e) {
172
+ // Check if clicked element is a remove button
173
+ const removeButton = e.target.closest('[data-action-remove-item]');
174
+ if (removeButton) {
175
+ e.preventDefault();
176
+ this.#emitRemoveEvent();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Handle change events (for quantity inputs and quantity-input component)
182
+ */
183
+ #handleChange(e) {
184
+ // Check if event is from quantity-input component
185
+ if (e.type === 'quantity-input:change') {
186
+ this.#emitQuantityChangeEvent(e.detail.value);
187
+ return;
188
+ }
189
+
190
+ // Check if changed element is a quantity input
191
+ const quantityInput = e.target.closest('[data-cart-quantity]');
192
+ if (quantityInput) {
193
+ this.#emitQuantityChangeEvent(quantityInput.value);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Handle transition end events for destroy animation and appearing animation
199
+ */
200
+ #handleTransitionEnd(e) {
201
+ if (e.propertyName === 'height' && this.#isDestroying) {
202
+ // Remove from DOM after height animation completes
203
+ this.remove();
204
+ } else if (e.propertyName === 'height' && this.#isAppearing) {
205
+ // Remove explicit height after appearing animation completes
206
+ this.style.height = '';
207
+ this.#isAppearing = false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Emit remove event
213
+ */
214
+ #emitRemoveEvent() {
215
+ this.dispatchEvent(
216
+ new CustomEvent('cart-item:remove', {
217
+ bubbles: true,
218
+ detail: {
219
+ cartKey: this.cartKey,
220
+ element: this,
221
+ },
222
+ })
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Emit quantity change event
228
+ */
229
+ #emitQuantityChangeEvent(quantity) {
230
+ this.dispatchEvent(
231
+ new CustomEvent('cart-item:quantity-change', {
232
+ bubbles: true,
233
+ detail: {
234
+ cartKey: this.cartKey,
235
+ quantity: parseInt(quantity),
236
+ element: this,
237
+ },
238
+ })
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Render cart item from data using the appropriate template
244
+ */
245
+ #render() {
246
+ const _ = this;
247
+ if (!_.#itemData || CartItem.#templates.size === 0) return;
248
+
249
+ // Set the key attribute from item data
250
+ const key = _.#itemData.key || _.#itemData.id;
251
+ if (key) _.setAttribute('key', key);
252
+
253
+ // Generate HTML from template and store for future comparisons
254
+ const templateHTML = _.#generateTemplateHTML();
255
+ _.#lastRenderedHTML = templateHTML;
256
+
257
+ // Generate processing HTML from template or use default
258
+ const processingHTML = CartItem.#processingTemplate
259
+ ? CartItem.#processingTemplate()
260
+ : '<div class="cart-item-loader"></div>';
261
+
262
+ // Create the cart-item structure with template content inside cart-item-content
263
+ _.innerHTML = `
264
+ <cart-item-content>
265
+ ${templateHTML}
266
+ </cart-item-content>
267
+ <cart-item-processing>
268
+ ${processingHTML}
269
+ </cart-item-processing>
270
+ `;
271
+ }
272
+
273
+ /**
274
+ * Update the cart item with new data
275
+ * @param {Object} itemData - Shopify cart item data
276
+ * @param {Object} cartData - Full Shopify cart object
277
+ */
278
+ setData(itemData, cartData = null) {
279
+ const _ = this;
280
+
281
+ // Update internal data
282
+ _.#itemData = itemData;
283
+ if (cartData) _.#cartData = cartData;
284
+
285
+ // Generate new HTML with updated data
286
+ const newHTML = _.#generateTemplateHTML();
287
+
288
+ // Compare with previously rendered HTML
289
+ if (newHTML === _.#lastRenderedHTML) {
290
+ // HTML hasn't changed, just reset processing state
291
+ _.setState('ready');
292
+ _.#updateQuantityInput();
293
+ return;
294
+ }
295
+
296
+ // HTML is different, proceed with full update
297
+ _.setState('ready');
298
+ _.#render();
299
+ _.#queryDOM();
300
+ _.#updateLinePriceElements();
301
+ }
302
+
303
+ /**
304
+ * Generate HTML from the current template with current data
305
+ * @returns {string} Generated HTML string or empty string if no template
306
+ * @private
307
+ */
308
+ #generateTemplateHTML() {
309
+ // If no templates are available, return empty string
310
+ if (!this.#itemData || CartItem.#templates.size === 0) {
311
+ return '';
312
+ }
313
+
314
+ // Determine which template to use
315
+ const templateName = this.#itemData.properties?._cart_template || 'default';
316
+ const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
317
+
318
+ if (!templateFn) {
319
+ return '';
320
+ }
321
+
322
+ // Generate and return HTML from template
323
+ return templateFn(this.#itemData, this.#cartData);
324
+ }
325
+
326
+ /**
327
+ * Update quantity input component to match server data
328
+ * @private
329
+ */
330
+ #updateQuantityInput() {
331
+ if (!this.#itemData) return;
332
+
333
+ const quantityInput = this.querySelector('quantity-input');
334
+ if (quantityInput) {
335
+ quantityInput.value = this.#itemData.quantity;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Update elements with data-content-line-price attribute
341
+ * @private
342
+ */
343
+ #updateLinePriceElements() {
344
+ if (!this.#itemData) return;
345
+
346
+ const linePriceElements = this.querySelectorAll('[data-content-line-price]');
347
+ const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
348
+
349
+ linePriceElements.forEach((element) => {
350
+ element.textContent = formattedLinePrice;
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Format currency value from cents to dollar string
356
+ * @param {number} cents - Price in cents
357
+ * @returns {string} Formatted currency string (e.g., "$29.99")
358
+ * @private
359
+ */
360
+ #formatCurrency(cents) {
361
+ if (typeof cents !== 'number') return '$0.00';
362
+ return `$${(cents / 100).toFixed(2)}`;
363
+ }
364
+
365
+ /**
366
+ * Get the current item data
367
+ */
368
+ get itemData() {
369
+ return this.#itemData;
370
+ }
371
+
372
+ /**
373
+ * Set the state of the cart item
374
+ * @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
375
+ */
376
+ setState(state) {
377
+ if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
378
+ this.setAttribute('state', state);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Gracefully animate this cart item closed, then remove it
384
+ */
385
+ destroyYourself() {
386
+ const _ = this;
387
+
388
+ // bail if already in the middle of a destroy cycle
389
+ if (_.#isDestroying) return;
390
+ _.#isDestroying = true;
391
+
392
+ // snapshot the current rendered height before applying any "destroying" styles
393
+ const initialHeight = _.offsetHeight;
394
+ _.setState('destroying');
395
+
396
+ // lock the measured height on the next animation frame to ensure layout is fully flushed
397
+ requestAnimationFrame(() => {
398
+ _.style.height = `${initialHeight}px`;
399
+
400
+ // read the css custom property for timing, defaulting to 400ms
401
+ const destroyDuration =
402
+ getComputedStyle(_).getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
403
+
404
+ // animate only the height to zero; other properties stay under stylesheet control
405
+ _.style.transition = `height ${destroyDuration} ease`;
406
+ _.style.height = '0px';
407
+
408
+ setTimeout(() => _.remove(), 600);
409
+ });
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Supporting component classes for cart item
415
+ */
416
+ class CartItemContent extends HTMLElement {
417
+ constructor() {
418
+ super();
419
+ }
420
+ }
421
+
422
+ class CartItemProcessing extends HTMLElement {
423
+ constructor() {
424
+ super();
425
+ }
426
+ }
427
+
428
+ // =============================================================================
429
+ // Register Custom Elements
430
+ // =============================================================================
431
+
432
+ if (!customElements.get('cart-item')) {
433
+ customElements.define('cart-item', CartItem);
434
+ }
435
+ if (!customElements.get('cart-item-content')) {
436
+ customElements.define('cart-item-content', CartItemContent);
437
+ }
438
+ if (!customElements.get('cart-item-processing')) {
439
+ customElements.define('cart-item-processing', CartItemProcessing);
440
+ }
441
+
442
+ export { CartItem, CartItemContent, CartItemProcessing };
443
+ export default CartItem;
444
+
445
+ // Make CartItem available globally for Shopify themes
446
+ if (typeof window !== 'undefined') {
447
+ window.CartItem = CartItem;
448
+ }
@@ -0,0 +1,231 @@
1
+ /* =============================================================================
2
+ Cart Panel Component
3
+ ============================================================================= */
4
+
5
+ cart-panel {
6
+ display: block;
7
+ height: 100%;
8
+ overflow-y: auto;
9
+ overflow-x: hidden;
10
+ }
11
+
12
+ /* =============================================================================
13
+ Cart Item Component
14
+ ============================================================================= */
15
+
16
+ cart-item {
17
+ /* CSS Custom Properties for customization */
18
+ --cart-item-processing-duration: 250ms;
19
+ --cart-item-destroying-duration: 600ms;
20
+ --cart-item-appearing-duration: 400ms;
21
+ --cart-item-shadow-color: rgba(0, 0, 0, 0.15);
22
+ --cart-item-shadow-color-strong: rgba(0, 0, 0, 0.5);
23
+ --cart-item-destroying-bg: rgba(0, 0, 0, 0.1);
24
+ --cart-item-processing-scale: 0.98;
25
+ --cart-item-destroying-scale: 0.85;
26
+ --cart-item-appearing-scale: 0.9;
27
+ --cart-item-processing-blur: 1px;
28
+ --cart-item-destroying-blur: 10px;
29
+ --cart-item-appearing-blur: 2px;
30
+ --cart-item-destroying-opacity: 0.2;
31
+ --cart-item-appearing-opacity: 0.5;
32
+ --cart-item-destroying-brightness: 0.6;
33
+ --cart-item-destroying-saturate: 0.3;
34
+
35
+ display: block;
36
+ position: relative;
37
+ overflow: hidden;
38
+ padding: 0px;
39
+ box-shadow: inset 0px 0px 0px rgba(0, 0, 0, 0);
40
+ transition:
41
+ filter var(--cart-item-processing-duration) ease-out,
42
+ background-color var(--cart-item-processing-duration) ease-out,
43
+ box-shadow var(--cart-item-processing-duration) ease-out;
44
+ }
45
+
46
+ cart-item::after {
47
+ content: '';
48
+ display: block;
49
+ position: absolute;
50
+ background: rgba(0, 0, 0, 0);
51
+ width: 100%;
52
+ pointer-events: none;
53
+ height: 100%;
54
+ top: 0px;
55
+ left: 0px;
56
+ transition: background-color var(--cart-item-processing-duration) ease;
57
+ }
58
+
59
+ /* Ready state (default) */
60
+ cart-item[state='ready'] {
61
+ transition:
62
+ filter var(--cart-item-processing-duration) ease-out,
63
+ background-color var(--cart-item-processing-duration) ease-out,
64
+ box-shadow var(--cart-item-processing-duration) ease-out,
65
+ height var(--cart-item-appearing-duration) ease-out;
66
+ }
67
+
68
+ cart-item[state='ready'] cart-item-content {
69
+ transform: scale(1);
70
+ filter: blur(0px);
71
+ opacity: 1;
72
+ transition:
73
+ transform var(--cart-item-appearing-duration) ease-out,
74
+ filter var(--cart-item-appearing-duration) ease-out,
75
+ opacity var(--cart-item-appearing-duration) ease-out;
76
+ }
77
+
78
+ cart-item[state='ready'] cart-item-processing {
79
+ opacity: 0;
80
+ visibility: hidden;
81
+ }
82
+
83
+ /* Processing state - content slightly scaled and blurred, processing overlay visible */
84
+ cart-item[state='processing'] {
85
+ box-shadow: inset 0px 2px 10px var(--cart-item-shadow-color);
86
+ }
87
+
88
+ cart-item[state='processing']::after {
89
+ background: rgba(0, 0, 0, 0.15);
90
+ }
91
+
92
+ cart-item[state='processing'] cart-item-content {
93
+ transform: scale(var(--cart-item-processing-scale));
94
+ filter: blur(var(--cart-item-processing-blur));
95
+ opacity: 0.9;
96
+ pointer-events: none;
97
+ transition:
98
+ transform var(--cart-item-processing-duration) ease-out,
99
+ filter var(--cart-item-processing-duration) ease-out,
100
+ opacity var(--cart-item-processing-duration) ease-out;
101
+ }
102
+
103
+ cart-item[state='processing'] cart-item-processing {
104
+ opacity: 1;
105
+ visibility: visible;
106
+ }
107
+
108
+ /* Destroying state - heavy effects on content, darker background */
109
+ cart-item[state='destroying'] {
110
+ background-color: var(--cart-item-destroying-bg);
111
+ box-shadow: inset 0px 2px 20px var(--cart-item-shadow-color-strong);
112
+ margin-top: 0px;
113
+ margin-bottom: 0px;
114
+ transition:
115
+ filter var(--cart-item-destroying-duration) ease,
116
+ background-color var(--cart-item-destroying-duration) ease,
117
+ box-shadow var(--cart-item-destroying-duration) ease,
118
+ margin var(--cart-item-destroying-duration) ease;
119
+ }
120
+
121
+ cart-item[state='destroying']::after {
122
+ background: rgba(0, 0, 0, 0.9);
123
+ transition: background-color var(--cart-item-destroying-duration) ease;
124
+ }
125
+
126
+ cart-item[state='destroying'] cart-item-content {
127
+ transition:
128
+ transform var(--cart-item-destroying-duration) ease,
129
+ filter var(--cart-item-destroying-duration) ease,
130
+ opacity var(--cart-item-destroying-duration) ease;
131
+ transform: scale(var(--cart-item-destroying-scale));
132
+ filter: blur(var(--cart-item-destroying-blur)) saturate(var(--cart-item-destroying-saturate));
133
+ opacity: var(--cart-item-destroying-opacity);
134
+ pointer-events: none;
135
+ }
136
+
137
+ cart-item[state='destroying'] cart-item-processing {
138
+ opacity: 0;
139
+ transition: opacity var(--cart-item-processing-duration) ease;
140
+ }
141
+
142
+ /* Appearing state - reverse of destroying for smooth entry animation */
143
+ cart-item[state='appearing'] {
144
+ height: 0px;
145
+ overflow: hidden;
146
+ transition:
147
+ height var(--cart-item-appearing-duration) ease-out,
148
+ filter var(--cart-item-appearing-duration) ease-out,
149
+ opacity var(--cart-item-appearing-duration) ease-out;
150
+ }
151
+
152
+ cart-item[state='appearing'] cart-item-content {
153
+ transform: scale(var(--cart-item-appearing-scale));
154
+ filter: blur(var(--cart-item-appearing-blur));
155
+ opacity: var(--cart-item-appearing-opacity);
156
+ transition:
157
+ transform var(--cart-item-appearing-duration) ease-out,
158
+ filter var(--cart-item-appearing-duration) ease-out,
159
+ opacity var(--cart-item-appearing-duration) ease-out;
160
+ }
161
+
162
+ cart-item[state='appearing'] cart-item-processing {
163
+ opacity: 0;
164
+ visibility: hidden;
165
+ }
166
+
167
+ /* =============================================================================
168
+ Cart Item Child Components
169
+ ============================================================================= */
170
+
171
+ cart-item-content {
172
+ display: block;
173
+ }
174
+
175
+ cart-item-processing {
176
+ position: absolute;
177
+ top: 0;
178
+ left: 0;
179
+ right: 0;
180
+ bottom: 0;
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ background: transparent;
185
+ opacity: 0;
186
+ visibility: hidden;
187
+ transition:
188
+ opacity var(--cart-item-processing-duration) ease-out,
189
+ visibility var(--cart-item-processing-duration) ease-out;
190
+ z-index: 10;
191
+ }
192
+
193
+ /* Default loader animation */
194
+ cart-item-processing .cart-item-loader {
195
+ width: 60px;
196
+ aspect-ratio: 2;
197
+ --_g: no-repeat radial-gradient(circle closest-side, #000 90%, #0000);
198
+ background:
199
+ var(--_g) 0% 50%,
200
+ var(--_g) 50% 50%,
201
+ var(--_g) 100% 50%;
202
+ background-size: calc(100% / 3) 50%;
203
+ animation: cart-item-loader 1s infinite linear;
204
+ }
205
+
206
+ @keyframes cart-item-loader {
207
+ 20% {
208
+ background-position:
209
+ 0% 0%,
210
+ 50% 50%,
211
+ 100% 50%;
212
+ }
213
+ 40% {
214
+ background-position:
215
+ 0% 100%,
216
+ 50% 0%,
217
+ 100% 50%;
218
+ }
219
+ 60% {
220
+ background-position:
221
+ 0% 50%,
222
+ 50% 100%,
223
+ 100% 0%;
224
+ }
225
+ 80% {
226
+ background-position:
227
+ 0% 50%,
228
+ 50% 50%,
229
+ 100% 100%;
230
+ }
231
+ }