@magic-spells/cart-panel 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +381 -190
- package/dist/cart-panel.cjs.css +2 -9
- package/dist/cart-panel.cjs.js +215 -80
- package/dist/cart-panel.cjs.js.map +1 -1
- package/dist/cart-panel.css +2 -9
- package/dist/cart-panel.esm.css +2 -9
- package/dist/cart-panel.esm.js +216 -81
- package/dist/cart-panel.esm.js.map +1 -1
- package/dist/cart-panel.js +869 -429
- package/dist/cart-panel.js.map +1 -1
- package/dist/cart-panel.min.css +1 -1
- package/dist/cart-panel.min.js +1 -1
- package/dist/cart-panel.scss +2 -12
- package/package.json +2 -2
- package/src/cart-panel.js +216 -81
- package/src/cart-panel.scss +2 -12
package/dist/cart-panel.js
CHANGED
|
@@ -4,29 +4,524 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CartDialog = {}));
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Retrieves all focusable elements within a given container.
|
|
9
|
+
*
|
|
10
|
+
* @param {HTMLElement} container - The container element to search for focusable elements.
|
|
11
|
+
* @returns {HTMLElement[]} An array of focusable elements found within the container.
|
|
12
|
+
*/
|
|
13
|
+
const getFocusableElements = (container) => {
|
|
14
|
+
const focusableSelectors =
|
|
15
|
+
'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
|
|
16
|
+
return Array.from(container.querySelectorAll(focusableSelectors));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class FocusTrap extends HTMLElement {
|
|
20
|
+
/** @type {boolean} Indicates whether the styles have been injected into the DOM. */
|
|
21
|
+
static styleInjected = false;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
this.trapStart = null;
|
|
26
|
+
this.trapEnd = null;
|
|
27
|
+
|
|
28
|
+
// Inject styles only once, when the first FocusTrap instance is created.
|
|
29
|
+
if (!FocusTrap.styleInjected) {
|
|
30
|
+
this.injectStyles();
|
|
31
|
+
FocusTrap.styleInjected = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Injects necessary styles for the focus trap into the document's head.
|
|
37
|
+
* This ensures that focus-trap-start and focus-trap-end elements are hidden.
|
|
38
|
+
*/
|
|
39
|
+
injectStyles() {
|
|
40
|
+
const style = document.createElement('style');
|
|
41
|
+
style.textContent = `
|
|
42
|
+
focus-trap-start,
|
|
43
|
+
focus-trap-end {
|
|
44
|
+
position: absolute;
|
|
45
|
+
width: 1px;
|
|
46
|
+
height: 1px;
|
|
47
|
+
margin: -1px;
|
|
48
|
+
padding: 0;
|
|
49
|
+
border: 0;
|
|
50
|
+
clip: rect(0, 0, 0, 0);
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
document.head.appendChild(style);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when the element is connected to the DOM.
|
|
60
|
+
* Sets up the focus trap and adds the keydown event listener.
|
|
61
|
+
*/
|
|
62
|
+
connectedCallback() {
|
|
63
|
+
this.setupTrap();
|
|
64
|
+
this.addEventListener('keydown', this.handleKeyDown);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Called when the element is disconnected from the DOM.
|
|
69
|
+
* Removes the keydown event listener.
|
|
70
|
+
*/
|
|
71
|
+
disconnectedCallback() {
|
|
72
|
+
this.removeEventListener('keydown', this.handleKeyDown);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sets up the focus trap by adding trap start and trap end elements.
|
|
77
|
+
* Focuses the trap start element to initiate the focus trap.
|
|
78
|
+
*/
|
|
79
|
+
setupTrap() {
|
|
80
|
+
// check to see it there are any focusable children
|
|
81
|
+
const focusableElements = getFocusableElements(this);
|
|
82
|
+
// exit if there aren't any
|
|
83
|
+
if (focusableElements.length === 0) return;
|
|
84
|
+
|
|
85
|
+
// create trap start and end elements
|
|
86
|
+
this.trapStart = document.createElement('focus-trap-start');
|
|
87
|
+
this.trapEnd = document.createElement('focus-trap-end');
|
|
88
|
+
|
|
89
|
+
// add to DOM
|
|
90
|
+
this.prepend(this.trapStart);
|
|
91
|
+
this.append(this.trapEnd);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
|
|
96
|
+
*
|
|
97
|
+
* @param {KeyboardEvent} e - The keyboard event object.
|
|
98
|
+
*/
|
|
99
|
+
handleKeyDown = (e) => {
|
|
100
|
+
if (e.key === 'Escape') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
this.exitTrap();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Exits the focus trap by hiding the current container and shifting focus
|
|
108
|
+
* back to the trigger element that opened the trap.
|
|
109
|
+
*/
|
|
110
|
+
exitTrap() {
|
|
111
|
+
const container = this.closest('[aria-hidden="false"]');
|
|
112
|
+
if (!container) return;
|
|
113
|
+
|
|
114
|
+
container.setAttribute('aria-hidden', 'true');
|
|
115
|
+
|
|
116
|
+
const trigger = document.querySelector(
|
|
117
|
+
`[aria-expanded="true"][aria-controls="${container.id}"]`
|
|
118
|
+
);
|
|
119
|
+
if (trigger) {
|
|
120
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
121
|
+
trigger.focus();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class FocusTrapStart extends HTMLElement {
|
|
127
|
+
/**
|
|
128
|
+
* Called when the element is connected to the DOM.
|
|
129
|
+
* Sets the tabindex and adds the focus event listener.
|
|
130
|
+
*/
|
|
131
|
+
connectedCallback() {
|
|
132
|
+
this.setAttribute('tabindex', '0');
|
|
133
|
+
this.addEventListener('focus', this.handleFocus);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Called when the element is disconnected from the DOM.
|
|
138
|
+
* Removes the focus event listener.
|
|
139
|
+
*/
|
|
140
|
+
disconnectedCallback() {
|
|
141
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Handles the focus event. If focus moves backwards from the first focusable element,
|
|
146
|
+
* it is cycled to the last focusable element, and vice versa.
|
|
147
|
+
*
|
|
148
|
+
* @param {FocusEvent} e - The focus event object.
|
|
149
|
+
*/
|
|
150
|
+
handleFocus = (e) => {
|
|
151
|
+
const trap = this.closest('focus-trap');
|
|
152
|
+
const focusableElements = getFocusableElements(trap);
|
|
153
|
+
|
|
154
|
+
if (focusableElements.length === 0) return;
|
|
155
|
+
|
|
156
|
+
const firstElement = focusableElements[0];
|
|
157
|
+
const lastElement =
|
|
158
|
+
focusableElements[focusableElements.length - 1];
|
|
159
|
+
|
|
160
|
+
if (e.relatedTarget === firstElement) {
|
|
161
|
+
lastElement.focus();
|
|
162
|
+
} else {
|
|
163
|
+
firstElement.focus();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class FocusTrapEnd extends HTMLElement {
|
|
169
|
+
/**
|
|
170
|
+
* Called when the element is connected to the DOM.
|
|
171
|
+
* Sets the tabindex and adds the focus event listener.
|
|
172
|
+
*/
|
|
173
|
+
connectedCallback() {
|
|
174
|
+
this.setAttribute('tabindex', '0');
|
|
175
|
+
this.addEventListener('focus', this.handleFocus);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Called when the element is disconnected from the DOM.
|
|
180
|
+
* Removes the focus event listener.
|
|
181
|
+
*/
|
|
182
|
+
disconnectedCallback() {
|
|
183
|
+
this.removeEventListener('focus', this.handleFocus);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
|
|
188
|
+
*/
|
|
189
|
+
handleFocus = () => {
|
|
190
|
+
const trap = this.closest('focus-trap');
|
|
191
|
+
const trapStart = trap.querySelector('focus-trap-start');
|
|
192
|
+
trapStart.focus();
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!customElements.get('focus-trap')) {
|
|
197
|
+
customElements.define('focus-trap', FocusTrap);
|
|
198
|
+
}
|
|
199
|
+
if (!customElements.get('focus-trap-start')) {
|
|
200
|
+
customElements.define('focus-trap-start', FocusTrapStart);
|
|
201
|
+
}
|
|
202
|
+
if (!customElements.get('focus-trap-end')) {
|
|
203
|
+
customElements.define('focus-trap-end', FocusTrapEnd);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
class EventEmitter {
|
|
207
|
+
#events;
|
|
208
|
+
|
|
209
|
+
constructor() {
|
|
210
|
+
this.#events = new Map();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Binds a listener to an event.
|
|
215
|
+
* @param {string} event - The event to bind the listener to.
|
|
216
|
+
* @param {Function} listener - The listener function to bind.
|
|
217
|
+
* @returns {EventEmitter} The current instance for chaining.
|
|
218
|
+
* @throws {TypeError} If the listener is not a function.
|
|
219
|
+
*/
|
|
220
|
+
on(event, listener) {
|
|
221
|
+
if (typeof listener !== "function") {
|
|
222
|
+
throw new TypeError("Listener must be a function");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const listeners = this.#events.get(event) || [];
|
|
226
|
+
if (!listeners.includes(listener)) {
|
|
227
|
+
listeners.push(listener);
|
|
228
|
+
}
|
|
229
|
+
this.#events.set(event, listeners);
|
|
230
|
+
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Unbinds a listener from an event.
|
|
236
|
+
* @param {string} event - The event to unbind the listener from.
|
|
237
|
+
* @param {Function} listener - The listener function to unbind.
|
|
238
|
+
* @returns {EventEmitter} The current instance for chaining.
|
|
239
|
+
*/
|
|
240
|
+
off(event, listener) {
|
|
241
|
+
const listeners = this.#events.get(event);
|
|
242
|
+
if (!listeners) return this;
|
|
243
|
+
|
|
244
|
+
const index = listeners.indexOf(listener);
|
|
245
|
+
if (index !== -1) {
|
|
246
|
+
listeners.splice(index, 1);
|
|
247
|
+
if (listeners.length === 0) {
|
|
248
|
+
this.#events.delete(event);
|
|
249
|
+
} else {
|
|
250
|
+
this.#events.set(event, listeners);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Triggers an event and calls all bound listeners.
|
|
259
|
+
* @param {string} event - The event to trigger.
|
|
260
|
+
* @param {...*} args - Arguments to pass to the listener functions.
|
|
261
|
+
* @returns {boolean} True if the event had listeners, false otherwise.
|
|
262
|
+
*/
|
|
263
|
+
emit(event, ...args) {
|
|
264
|
+
const listeners = this.#events.get(event);
|
|
265
|
+
if (!listeners || listeners.length === 0) return false;
|
|
266
|
+
|
|
267
|
+
for (let i = 0, n = listeners.length; i < n; ++i) {
|
|
268
|
+
try {
|
|
269
|
+
listeners[i].apply(this, args);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error(`Error in listener for event '${event}':`, error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Removes all listeners for a specific event or all events.
|
|
280
|
+
* @param {string} [event] - The event to remove listeners from. If not provided, removes all listeners.
|
|
281
|
+
* @returns {EventEmitter} The current instance for chaining.
|
|
282
|
+
*/
|
|
283
|
+
removeAllListeners(event) {
|
|
284
|
+
if (event) {
|
|
285
|
+
this.#events.delete(event);
|
|
286
|
+
} else {
|
|
287
|
+
this.#events.clear();
|
|
288
|
+
}
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
class QuantityModifier extends HTMLElement {
|
|
294
|
+
// Static flag to track if styles have been injected
|
|
295
|
+
static #stylesInjected = false;
|
|
296
|
+
|
|
297
|
+
constructor() {
|
|
298
|
+
super();
|
|
299
|
+
this.handleDecrement = this.handleDecrement.bind(this);
|
|
300
|
+
this.handleIncrement = this.handleIncrement.bind(this);
|
|
301
|
+
this.handleInputChange = this.handleInputChange.bind(this);
|
|
302
|
+
|
|
303
|
+
// Inject styles once when first component is created
|
|
304
|
+
QuantityModifier.#injectStyles();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Inject global styles for hiding number input spin buttons
|
|
309
|
+
* Only runs once regardless of how many components exist
|
|
310
|
+
*/
|
|
311
|
+
static #injectStyles() {
|
|
312
|
+
if (QuantityModifier.#stylesInjected) return;
|
|
313
|
+
|
|
314
|
+
// this will hide the arrow buttons in the number input field
|
|
315
|
+
const style = document.createElement('style');
|
|
316
|
+
style.textContent = `
|
|
317
|
+
/* Hide number input spin buttons for quantity-modifier */
|
|
318
|
+
quantity-modifier input::-webkit-outer-spin-button,
|
|
319
|
+
quantity-modifier input::-webkit-inner-spin-button {
|
|
320
|
+
-webkit-appearance: none;
|
|
321
|
+
margin: 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
quantity-modifier input[type="number"] {
|
|
325
|
+
-moz-appearance: textfield;
|
|
326
|
+
}
|
|
327
|
+
`;
|
|
328
|
+
|
|
329
|
+
document.head.appendChild(style);
|
|
330
|
+
QuantityModifier.#stylesInjected = true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Define which attributes trigger attributeChangedCallback when modified
|
|
334
|
+
static get observedAttributes() {
|
|
335
|
+
return ['min', 'max', 'value'];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Called when element is added to the DOM
|
|
339
|
+
connectedCallback() {
|
|
340
|
+
this.render();
|
|
341
|
+
this.attachEventListeners();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Called when element is removed from the DOM
|
|
345
|
+
disconnectedCallback() {
|
|
346
|
+
this.removeEventListeners();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Called when observed attributes change
|
|
350
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
351
|
+
if (oldValue !== newValue) {
|
|
352
|
+
this.updateInput();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Get minimum value allowed, defaults to 1
|
|
357
|
+
get min() {
|
|
358
|
+
return parseInt(this.getAttribute('min')) || 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Get maximum value allowed, defaults to 99
|
|
362
|
+
get max() {
|
|
363
|
+
return parseInt(this.getAttribute('max')) || 99;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Get current value, defaults to 1
|
|
367
|
+
get value() {
|
|
368
|
+
return parseInt(this.getAttribute('value')) || 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Set current value by updating the attribute
|
|
372
|
+
set value(val) {
|
|
373
|
+
this.setAttribute('value', val);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Render the quantity modifier HTML structure
|
|
377
|
+
render() {
|
|
378
|
+
const min = this.min;
|
|
379
|
+
const max = this.max;
|
|
380
|
+
const value = this.value;
|
|
381
|
+
|
|
382
|
+
// check to see if these fields already exist
|
|
383
|
+
const existingDecrement = this.querySelector('[data-action-decrement]');
|
|
384
|
+
const existingIncrement = this.querySelector('[data-action-increment]');
|
|
385
|
+
const existingInput = this.querySelector('[data-quantity-modifier-field]');
|
|
386
|
+
|
|
387
|
+
// if they already exist, just set the values
|
|
388
|
+
if (existingDecrement && existingIncrement && existingInput) {
|
|
389
|
+
existingInput.value = value;
|
|
390
|
+
existingInput.min = min;
|
|
391
|
+
existingInput.max = max;
|
|
392
|
+
existingInput.type = 'number';
|
|
393
|
+
} else {
|
|
394
|
+
// if they don't exist, inject the template
|
|
395
|
+
this.innerHTML = `
|
|
396
|
+
<button data-action-decrement type="button">
|
|
397
|
+
<svg class="svg-decrement" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
|
398
|
+
<title>decrement</title>
|
|
399
|
+
<path fill="currentColor" d="M368 224H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h352c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"></path>
|
|
400
|
+
</svg>
|
|
401
|
+
</button>
|
|
402
|
+
<input
|
|
403
|
+
type="number"
|
|
404
|
+
inputmode="numeric"
|
|
405
|
+
pattern="[0-9]*"
|
|
406
|
+
data-quantity-modifier-field
|
|
407
|
+
value="${value}" min="${min}" max="${max}">
|
|
408
|
+
<button data-action-increment type="button">
|
|
409
|
+
<svg class="svg-increment" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
|
410
|
+
<title>increment</title>
|
|
411
|
+
<path fill="currentColor" d="M368 224H224V80c0-8.84-7.16-16-16-16h-32c-8.84 0-16 7.16-16 16v144H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h144v144c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V288h144c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"></path>
|
|
412
|
+
</svg>
|
|
413
|
+
</button>
|
|
414
|
+
`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Attach click and input event listeners to buttons and input field
|
|
419
|
+
attachEventListeners() {
|
|
420
|
+
const decrementBtn = this.querySelector('[data-action-decrement]');
|
|
421
|
+
const incrementBtn = this.querySelector('[data-action-increment]');
|
|
422
|
+
const input = this.querySelector('[data-quantity-modifier-field]');
|
|
423
|
+
|
|
424
|
+
if (decrementBtn) decrementBtn.addEventListener('click', this.handleDecrement);
|
|
425
|
+
if (incrementBtn) incrementBtn.addEventListener('click', this.handleIncrement);
|
|
426
|
+
if (input) input.addEventListener('input', this.handleInputChange);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Remove event listeners to prevent memory leaks
|
|
430
|
+
removeEventListeners() {
|
|
431
|
+
const decrementBtn = this.querySelector('[data-action-decrement]');
|
|
432
|
+
const incrementBtn = this.querySelector('[data-action-increment]');
|
|
433
|
+
const input = this.querySelector('[data-quantity-modifier-field]');
|
|
434
|
+
|
|
435
|
+
if (decrementBtn) decrementBtn.removeEventListener('click', this.handleDecrement);
|
|
436
|
+
if (incrementBtn) incrementBtn.removeEventListener('click', this.handleIncrement);
|
|
437
|
+
if (input) input.removeEventListener('input', this.handleInputChange);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Handle decrement button click, respects minimum value
|
|
441
|
+
handleDecrement() {
|
|
442
|
+
const currentValue = this.value;
|
|
443
|
+
const newValue = Math.max(currentValue - 1, this.min);
|
|
444
|
+
this.updateValue(newValue);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Handle increment button click, respects maximum value
|
|
448
|
+
handleIncrement() {
|
|
449
|
+
const currentValue = this.value;
|
|
450
|
+
const newValue = Math.min(currentValue + 1, this.max);
|
|
451
|
+
this.updateValue(newValue);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Handle direct input changes, clamps value between min and max
|
|
455
|
+
handleInputChange(event) {
|
|
456
|
+
const inputValue = parseInt(event.target.value);
|
|
457
|
+
if (!isNaN(inputValue)) {
|
|
458
|
+
const clampedValue = Math.max(this.min, Math.min(inputValue, this.max));
|
|
459
|
+
this.updateValue(clampedValue);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Update the component value and dispatch change event if value changed
|
|
464
|
+
updateValue(newValue) {
|
|
465
|
+
if (newValue !== this.value) {
|
|
466
|
+
this.value = newValue;
|
|
467
|
+
this.updateInput();
|
|
468
|
+
this.dispatchChangeEvent(newValue);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Sync the input field with current component state
|
|
473
|
+
updateInput() {
|
|
474
|
+
const input = this.querySelector('[data-quantity-modifier-field]');
|
|
475
|
+
if (input) {
|
|
476
|
+
input.value = this.value;
|
|
477
|
+
input.min = this.min;
|
|
478
|
+
input.max = this.max;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Dispatch custom event when value changes for external listeners
|
|
483
|
+
dispatchChangeEvent(value) {
|
|
484
|
+
this.dispatchEvent(
|
|
485
|
+
new CustomEvent('quantity-modifier:change', {
|
|
486
|
+
detail: { value },
|
|
487
|
+
bubbles: true,
|
|
488
|
+
})
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
customElements.define('quantity-modifier', QuantityModifier);
|
|
494
|
+
|
|
7
495
|
/**
|
|
8
496
|
* CartItem class that handles the functionality of a cart item component
|
|
9
497
|
*/
|
|
10
498
|
class CartItem extends HTMLElement {
|
|
11
499
|
// Static template functions shared across all instances
|
|
12
|
-
static #
|
|
500
|
+
static #templates = new Map();
|
|
13
501
|
static #processingTemplate = null;
|
|
14
502
|
|
|
15
503
|
// Private fields
|
|
16
504
|
#currentState = 'ready';
|
|
17
505
|
#isDestroying = false;
|
|
506
|
+
#isAppearing = false;
|
|
18
507
|
#handlers = {};
|
|
19
508
|
#itemData = null;
|
|
509
|
+
#cartData = null;
|
|
510
|
+
#lastRenderedHTML = '';
|
|
20
511
|
|
|
21
512
|
/**
|
|
22
513
|
* Set the template function for rendering cart items
|
|
23
|
-
* @param {
|
|
514
|
+
* @param {string} name - Template name ('default' for default template)
|
|
515
|
+
* @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
|
|
24
516
|
*/
|
|
25
|
-
static setTemplate(templateFn) {
|
|
517
|
+
static setTemplate(name, templateFn) {
|
|
518
|
+
if (typeof name !== 'string') {
|
|
519
|
+
throw new Error('Template name must be a string');
|
|
520
|
+
}
|
|
26
521
|
if (typeof templateFn !== 'function') {
|
|
27
522
|
throw new Error('Template must be a function');
|
|
28
523
|
}
|
|
29
|
-
CartItem.#
|
|
524
|
+
CartItem.#templates.set(name, templateFn);
|
|
30
525
|
}
|
|
31
526
|
|
|
32
527
|
/**
|
|
@@ -43,10 +538,11 @@
|
|
|
43
538
|
/**
|
|
44
539
|
* Create a cart item with appearing animation
|
|
45
540
|
* @param {Object} itemData - Shopify cart item data
|
|
541
|
+
* @param {Object} cartData - Full Shopify cart object
|
|
46
542
|
* @returns {CartItem} Cart item instance that will animate in
|
|
47
543
|
*/
|
|
48
|
-
static createAnimated(itemData) {
|
|
49
|
-
return new CartItem(itemData, { animate: true });
|
|
544
|
+
static createAnimated(itemData, cartData) {
|
|
545
|
+
return new CartItem(itemData, cartData, { animate: true });
|
|
50
546
|
}
|
|
51
547
|
|
|
52
548
|
/**
|
|
@@ -67,11 +563,12 @@
|
|
|
67
563
|
}
|
|
68
564
|
}
|
|
69
565
|
|
|
70
|
-
constructor(itemData = null, options = {}) {
|
|
566
|
+
constructor(itemData = null, cartData = null, options = {}) {
|
|
71
567
|
super();
|
|
72
568
|
|
|
73
|
-
// Store item data if provided
|
|
569
|
+
// Store item and cart data if provided
|
|
74
570
|
this.#itemData = itemData;
|
|
571
|
+
this.#cartData = cartData;
|
|
75
572
|
|
|
76
573
|
// Set initial state - start with 'appearing' only if explicitly requested
|
|
77
574
|
const shouldAnimate = options.animate || this.hasAttribute('animate-in');
|
|
@@ -89,13 +586,16 @@
|
|
|
89
586
|
connectedCallback() {
|
|
90
587
|
// If we have item data, render it first
|
|
91
588
|
if (this.#itemData) {
|
|
92
|
-
this.#
|
|
589
|
+
this.#render();
|
|
93
590
|
}
|
|
94
591
|
|
|
95
592
|
// Find child elements
|
|
96
593
|
this.content = this.querySelector('cart-item-content');
|
|
97
594
|
this.processing = this.querySelector('cart-item-processing');
|
|
98
595
|
|
|
596
|
+
// Update line price elements in case of pre-rendered content
|
|
597
|
+
this.#updateLinePriceElements();
|
|
598
|
+
|
|
99
599
|
// Attach event listeners
|
|
100
600
|
this.#attachListeners();
|
|
101
601
|
|
|
@@ -103,6 +603,7 @@
|
|
|
103
603
|
if (this.#currentState === 'appearing') {
|
|
104
604
|
// Set the state attribute
|
|
105
605
|
this.setAttribute('state', 'appearing');
|
|
606
|
+
this.#isAppearing = true;
|
|
106
607
|
|
|
107
608
|
// Get the natural height after rendering
|
|
108
609
|
requestAnimationFrame(() => {
|
|
@@ -130,6 +631,7 @@
|
|
|
130
631
|
#attachListeners() {
|
|
131
632
|
this.addEventListener('click', this.#handlers.click);
|
|
132
633
|
this.addEventListener('change', this.#handlers.change);
|
|
634
|
+
this.addEventListener('quantity-modifier:change', this.#handlers.change);
|
|
133
635
|
this.addEventListener('transitionend', this.#handlers.transitionEnd);
|
|
134
636
|
}
|
|
135
637
|
|
|
@@ -139,6 +641,7 @@
|
|
|
139
641
|
#detachListeners() {
|
|
140
642
|
this.removeEventListener('click', this.#handlers.click);
|
|
141
643
|
this.removeEventListener('change', this.#handlers.change);
|
|
644
|
+
this.removeEventListener('quantity-modifier:change', this.#handlers.change);
|
|
142
645
|
this.removeEventListener('transitionend', this.#handlers.transitionEnd);
|
|
143
646
|
}
|
|
144
647
|
|
|
@@ -169,9 +672,15 @@
|
|
|
169
672
|
}
|
|
170
673
|
|
|
171
674
|
/**
|
|
172
|
-
* Handle change events (for quantity inputs)
|
|
675
|
+
* Handle change events (for quantity inputs and quantity-modifier)
|
|
173
676
|
*/
|
|
174
677
|
#handleChange(e) {
|
|
678
|
+
// Check if event is from quantity-modifier component
|
|
679
|
+
if (e.type === 'quantity-modifier:change') {
|
|
680
|
+
this.#emitQuantityChangeEvent(e.detail.value);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
175
684
|
// Check if changed element is a quantity input
|
|
176
685
|
const quantityInput = e.target.closest('[data-cart-quantity]');
|
|
177
686
|
if (quantityInput) {
|
|
@@ -186,9 +695,10 @@
|
|
|
186
695
|
if (e.propertyName === 'height' && this.#isDestroying) {
|
|
187
696
|
// Remove from DOM after height animation completes
|
|
188
697
|
this.remove();
|
|
189
|
-
} else if (e.propertyName === 'height' && this.#
|
|
698
|
+
} else if (e.propertyName === 'height' && this.#isAppearing) {
|
|
190
699
|
// Remove explicit height after appearing animation completes
|
|
191
700
|
this.style.height = '';
|
|
701
|
+
this.#isAppearing = false;
|
|
192
702
|
}
|
|
193
703
|
}
|
|
194
704
|
|
|
@@ -224,10 +734,11 @@
|
|
|
224
734
|
}
|
|
225
735
|
|
|
226
736
|
/**
|
|
227
|
-
* Render cart item from data using the
|
|
737
|
+
* Render cart item from data using the appropriate template
|
|
228
738
|
*/
|
|
229
|
-
#
|
|
230
|
-
if (!this.#itemData ||
|
|
739
|
+
#render() {
|
|
740
|
+
if (!this.#itemData || CartItem.#templates.size === 0) {
|
|
741
|
+
console.log('no item data or no template', this.#itemData, CartItem.#templates);
|
|
231
742
|
return;
|
|
232
743
|
}
|
|
233
744
|
|
|
@@ -237,8 +748,9 @@
|
|
|
237
748
|
this.setAttribute('key', key);
|
|
238
749
|
}
|
|
239
750
|
|
|
240
|
-
// Generate HTML from template
|
|
241
|
-
const templateHTML =
|
|
751
|
+
// Generate HTML from template and store for future comparisons
|
|
752
|
+
const templateHTML = this.#generateTemplateHTML();
|
|
753
|
+
this.#lastRenderedHTML = templateHTML;
|
|
242
754
|
|
|
243
755
|
// Generate processing HTML from template or use default
|
|
244
756
|
const processingHTML = CartItem.#processingTemplate
|
|
@@ -259,377 +771,170 @@
|
|
|
259
771
|
/**
|
|
260
772
|
* Update the cart item with new data
|
|
261
773
|
* @param {Object} itemData - Shopify cart item data
|
|
774
|
+
* @param {Object} cartData - Full Shopify cart object
|
|
262
775
|
*/
|
|
263
|
-
setData(itemData) {
|
|
776
|
+
setData(itemData, cartData = null) {
|
|
777
|
+
// Update internal data
|
|
264
778
|
this.#itemData = itemData;
|
|
265
|
-
|
|
779
|
+
if (cartData) {
|
|
780
|
+
this.#cartData = cartData;
|
|
781
|
+
}
|
|
266
782
|
|
|
783
|
+
// Generate new HTML with updated data
|
|
784
|
+
const newHTML = this.#generateTemplateHTML();
|
|
785
|
+
|
|
786
|
+
// Compare with previously rendered HTML
|
|
787
|
+
if (newHTML === this.#lastRenderedHTML) {
|
|
788
|
+
// HTML hasn't changed, just reset processing state
|
|
789
|
+
this.setState('ready');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// HTML is different, proceed with full update
|
|
794
|
+
this.setState('ready');
|
|
795
|
+
this.#render();
|
|
796
|
+
|
|
267
797
|
// Re-find child elements after re-rendering
|
|
268
798
|
this.content = this.querySelector('cart-item-content');
|
|
269
799
|
this.processing = this.querySelector('cart-item-processing');
|
|
800
|
+
|
|
801
|
+
// Update line price elements
|
|
802
|
+
this.#updateLinePriceElements();
|
|
270
803
|
}
|
|
271
804
|
|
|
272
805
|
/**
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
|
|
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 = `
|
|
384
|
-
focus-trap-start,
|
|
385
|
-
focus-trap-end {
|
|
386
|
-
position: absolute;
|
|
387
|
-
width: 1px;
|
|
388
|
-
height: 1px;
|
|
389
|
-
margin: -1px;
|
|
390
|
-
padding: 0;
|
|
391
|
-
border: 0;
|
|
392
|
-
clip: rect(0, 0, 0, 0);
|
|
393
|
-
overflow: hidden;
|
|
394
|
-
white-space: nowrap;
|
|
395
|
-
}
|
|
396
|
-
`;
|
|
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.
|
|
806
|
+
* Generate HTML from the current template with current data
|
|
807
|
+
* @returns {string} Generated HTML string or empty string if no template
|
|
808
|
+
* @private
|
|
451
809
|
*/
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (!
|
|
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();
|
|
810
|
+
#generateTemplateHTML() {
|
|
811
|
+
// If no templates are available, return empty string
|
|
812
|
+
if (!this.#itemData || CartItem.#templates.size === 0) {
|
|
813
|
+
return '';
|
|
464
814
|
}
|
|
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
815
|
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
|
|
504
|
-
} else {
|
|
505
|
-
firstElement.focus();
|
|
816
|
+
// Determine which template to use
|
|
817
|
+
const templateName = this.#itemData.properties?._cart_template || 'default';
|
|
818
|
+
const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
|
|
819
|
+
|
|
820
|
+
if (!templateFn) {
|
|
821
|
+
return '';
|
|
506
822
|
}
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
823
|
|
|
510
|
-
|
|
824
|
+
// Generate and return HTML from template
|
|
825
|
+
return templateFn(this.#itemData, this.#cartData);
|
|
826
|
+
}
|
|
827
|
+
|
|
511
828
|
/**
|
|
512
|
-
*
|
|
513
|
-
*
|
|
829
|
+
* Update elements with data-content-line-price attribute
|
|
830
|
+
* @private
|
|
514
831
|
*/
|
|
515
|
-
|
|
516
|
-
this
|
|
517
|
-
|
|
832
|
+
#updateLinePriceElements() {
|
|
833
|
+
if (!this.#itemData) return;
|
|
834
|
+
|
|
835
|
+
const linePriceElements = this.querySelectorAll('[data-content-line-price]');
|
|
836
|
+
const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
|
|
837
|
+
|
|
838
|
+
linePriceElements.forEach((element) => {
|
|
839
|
+
element.textContent = formattedLinePrice;
|
|
840
|
+
});
|
|
518
841
|
}
|
|
519
842
|
|
|
520
843
|
/**
|
|
521
|
-
*
|
|
522
|
-
*
|
|
844
|
+
* Format currency value from cents to dollar string
|
|
845
|
+
* @param {number} cents - Price in cents
|
|
846
|
+
* @returns {string} Formatted currency string (e.g., "$29.99")
|
|
847
|
+
* @private
|
|
523
848
|
*/
|
|
524
|
-
|
|
525
|
-
|
|
849
|
+
#formatCurrency(cents) {
|
|
850
|
+
if (typeof cents !== 'number') return '$0.00';
|
|
851
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
526
852
|
}
|
|
527
853
|
|
|
528
854
|
/**
|
|
529
|
-
*
|
|
855
|
+
* Get the current item data
|
|
530
856
|
*/
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
trapStart.focus();
|
|
535
|
-
};
|
|
536
|
-
}
|
|
857
|
+
get itemData() {
|
|
858
|
+
return this.#itemData;
|
|
859
|
+
}
|
|
537
860
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
861
|
+
/**
|
|
862
|
+
* Set the state of the cart item
|
|
863
|
+
* @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
|
|
864
|
+
*/
|
|
865
|
+
setState(state) {
|
|
866
|
+
if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
|
|
867
|
+
this.setAttribute('state', state);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
547
870
|
|
|
548
|
-
|
|
549
|
-
|
|
871
|
+
/**
|
|
872
|
+
* gracefully animate this cart item closed, then let #handleTransitionEnd remove it
|
|
873
|
+
*
|
|
874
|
+
* @returns {void}
|
|
875
|
+
*/
|
|
876
|
+
destroyYourself() {
|
|
877
|
+
// bail if already in the middle of a destroy cycle
|
|
878
|
+
if (this.#isDestroying) return;
|
|
550
879
|
|
|
551
|
-
|
|
552
|
-
this.#events = new Map();
|
|
553
|
-
}
|
|
880
|
+
this.#isDestroying = true;
|
|
554
881
|
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
}
|
|
882
|
+
// snapshot the current rendered height before applying any "destroying" styles
|
|
883
|
+
const initialHeight = this.offsetHeight;
|
|
566
884
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
listeners.push(listener);
|
|
570
|
-
}
|
|
571
|
-
this.#events.set(event, listeners);
|
|
885
|
+
// switch to 'destroying' state so css can fade / slide visuals
|
|
886
|
+
this.setState('destroying');
|
|
572
887
|
|
|
573
|
-
|
|
574
|
-
|
|
888
|
+
// lock the measured height on the next animation frame to ensure layout is fully flushed
|
|
889
|
+
requestAnimationFrame(() => {
|
|
890
|
+
this.style.height = `${initialHeight}px`;
|
|
891
|
+
// this.offsetHeight; // force a reflow so the browser registers the fixed height
|
|
575
892
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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;
|
|
893
|
+
// read the css custom property for timing, defaulting to 400ms
|
|
894
|
+
const elementStyle = getComputedStyle(this);
|
|
895
|
+
const destroyDuration =
|
|
896
|
+
elementStyle.getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
|
|
585
897
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (listeners.length === 0) {
|
|
590
|
-
this.#events.delete(event);
|
|
591
|
-
} else {
|
|
592
|
-
this.#events.set(event, listeners);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
898
|
+
// animate only the height to zero; other properties stay under stylesheet control
|
|
899
|
+
this.style.transition = `height ${destroyDuration} ease`;
|
|
900
|
+
this.style.height = '0px';
|
|
595
901
|
|
|
596
|
-
|
|
597
|
-
|
|
902
|
+
// setTimeout(() => {
|
|
903
|
+
// this.style.height = '0px';
|
|
904
|
+
// }, 1);
|
|
598
905
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const listeners = this.#events.get(event);
|
|
607
|
-
if (!listeners || listeners.length === 0) return false;
|
|
906
|
+
setTimeout(() => {
|
|
907
|
+
// make sure item is removed
|
|
908
|
+
this.remove();
|
|
909
|
+
}, 600);
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
608
913
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
914
|
+
/**
|
|
915
|
+
* Supporting component classes for cart item
|
|
916
|
+
*/
|
|
917
|
+
class CartItemContent extends HTMLElement {
|
|
918
|
+
constructor() {
|
|
919
|
+
super();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
616
922
|
|
|
617
|
-
|
|
618
|
-
|
|
923
|
+
class CartItemProcessing extends HTMLElement {
|
|
924
|
+
constructor() {
|
|
925
|
+
super();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
619
928
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
this.#events.clear();
|
|
630
|
-
}
|
|
631
|
-
return this;
|
|
632
|
-
}
|
|
929
|
+
// Define custom elements (check if not already defined)
|
|
930
|
+
if (!customElements.get('cart-item')) {
|
|
931
|
+
customElements.define('cart-item', CartItem);
|
|
932
|
+
}
|
|
933
|
+
if (!customElements.get('cart-item-content')) {
|
|
934
|
+
customElements.define('cart-item-content', CartItemContent);
|
|
935
|
+
}
|
|
936
|
+
if (!customElements.get('cart-item-processing')) {
|
|
937
|
+
customElements.define('cart-item-processing', CartItemProcessing);
|
|
633
938
|
}
|
|
634
939
|
|
|
635
940
|
/**
|
|
@@ -638,7 +943,6 @@
|
|
|
638
943
|
*/
|
|
639
944
|
class CartDialog extends HTMLElement {
|
|
640
945
|
#handleTransitionEnd;
|
|
641
|
-
#scrollPosition = 0;
|
|
642
946
|
#currentCart = null;
|
|
643
947
|
#eventEmitter;
|
|
644
948
|
#isInitialRender = true;
|
|
@@ -661,31 +965,21 @@
|
|
|
661
965
|
}
|
|
662
966
|
|
|
663
967
|
/**
|
|
664
|
-
*
|
|
968
|
+
* Locks body scrolling
|
|
665
969
|
* @private
|
|
666
970
|
*/
|
|
667
971
|
#lockScroll() {
|
|
668
|
-
|
|
669
|
-
// Save current scroll position
|
|
670
|
-
_.#scrollPosition = window.pageYOffset;
|
|
671
|
-
|
|
672
|
-
// Apply fixed position to body
|
|
972
|
+
// Apply overflow hidden to body
|
|
673
973
|
document.body.classList.add('overflow-hidden');
|
|
674
|
-
document.body.style.top = `-${_.#scrollPosition}px`;
|
|
675
974
|
}
|
|
676
975
|
|
|
677
976
|
/**
|
|
678
|
-
* Restores
|
|
977
|
+
* Restores body scrolling when cart dialog is closed
|
|
679
978
|
* @private
|
|
680
979
|
*/
|
|
681
980
|
#restoreScroll() {
|
|
682
|
-
|
|
683
|
-
// Remove fixed positioning
|
|
981
|
+
// Remove overflow hidden from body
|
|
684
982
|
document.body.classList.remove('overflow-hidden');
|
|
685
|
-
document.body.style.removeProperty('top');
|
|
686
|
-
|
|
687
|
-
// Restore scroll position
|
|
688
|
-
window.scrollTo(0, _.#scrollPosition);
|
|
689
983
|
}
|
|
690
984
|
|
|
691
985
|
/**
|
|
@@ -726,7 +1020,18 @@
|
|
|
726
1020
|
return;
|
|
727
1021
|
}
|
|
728
1022
|
|
|
729
|
-
|
|
1023
|
+
// Check if focus-trap already exists, if not create one
|
|
1024
|
+
_.focusTrap = _.contentPanel.querySelector('focus-trap');
|
|
1025
|
+
if (!_.focusTrap) {
|
|
1026
|
+
_.focusTrap = document.createElement('focus-trap');
|
|
1027
|
+
|
|
1028
|
+
// Move all existing cart-panel content into the focus trap
|
|
1029
|
+
const existingContent = Array.from(_.contentPanel.childNodes);
|
|
1030
|
+
existingContent.forEach((child) => _.focusTrap.appendChild(child));
|
|
1031
|
+
|
|
1032
|
+
// Insert focus trap inside the cart-panel
|
|
1033
|
+
_.contentPanel.appendChild(_.focusTrap);
|
|
1034
|
+
}
|
|
730
1035
|
|
|
731
1036
|
// Ensure we have labelledby and describedby references
|
|
732
1037
|
if (!_.getAttribute('aria-labelledby')) {
|
|
@@ -739,20 +1044,15 @@
|
|
|
739
1044
|
}
|
|
740
1045
|
}
|
|
741
1046
|
|
|
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
1047
|
// Add modal overlay if it doesn't already exist
|
|
751
1048
|
if (!_.querySelector('cart-overlay')) {
|
|
752
1049
|
_.prepend(document.createElement('cart-overlay'));
|
|
753
1050
|
}
|
|
754
1051
|
_.#attachListeners();
|
|
755
1052
|
_.#bindKeyboard();
|
|
1053
|
+
|
|
1054
|
+
// Load cart data immediately after component initialization
|
|
1055
|
+
_.refreshCart();
|
|
756
1056
|
}
|
|
757
1057
|
|
|
758
1058
|
/**
|
|
@@ -785,6 +1085,14 @@
|
|
|
785
1085
|
*/
|
|
786
1086
|
#emit(eventName, data = null) {
|
|
787
1087
|
this.#eventEmitter.emit(eventName, data);
|
|
1088
|
+
|
|
1089
|
+
// Also emit as native DOM events for better compatibility
|
|
1090
|
+
this.dispatchEvent(
|
|
1091
|
+
new CustomEvent(eventName, {
|
|
1092
|
+
detail: data,
|
|
1093
|
+
bubbles: true,
|
|
1094
|
+
})
|
|
1095
|
+
);
|
|
788
1096
|
}
|
|
789
1097
|
|
|
790
1098
|
/**
|
|
@@ -866,11 +1174,12 @@
|
|
|
866
1174
|
// Success - let smart comparison handle the removal animation
|
|
867
1175
|
this.#currentCart = updatedCart;
|
|
868
1176
|
this.#renderCartItems(updatedCart);
|
|
869
|
-
this.#
|
|
1177
|
+
this.#renderCartPanel(updatedCart);
|
|
870
1178
|
|
|
871
1179
|
// Emit cart updated and data changed events
|
|
872
|
-
this.#
|
|
873
|
-
this.#emit('cart-dialog:
|
|
1180
|
+
const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
|
|
1181
|
+
this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
|
|
1182
|
+
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
874
1183
|
} else {
|
|
875
1184
|
// Error - reset to ready state
|
|
876
1185
|
element.setState('ready');
|
|
@@ -901,12 +1210,12 @@
|
|
|
901
1210
|
// Success - update cart data and refresh items
|
|
902
1211
|
this.#currentCart = updatedCart;
|
|
903
1212
|
this.#renderCartItems(updatedCart);
|
|
904
|
-
this.#
|
|
905
|
-
element.setState('ready');
|
|
1213
|
+
this.#renderCartPanel(updatedCart);
|
|
906
1214
|
|
|
907
1215
|
// Emit cart updated and data changed events
|
|
908
|
-
this.#
|
|
909
|
-
this.#emit('cart-dialog:
|
|
1216
|
+
const cartWithCalculatedFields = this.#addCalculatedFields(updatedCart);
|
|
1217
|
+
this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
|
|
1218
|
+
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
910
1219
|
} else {
|
|
911
1220
|
// Error - reset to ready state
|
|
912
1221
|
element.setState('ready');
|
|
@@ -920,11 +1229,52 @@
|
|
|
920
1229
|
});
|
|
921
1230
|
}
|
|
922
1231
|
|
|
1232
|
+
/**
|
|
1233
|
+
* Update cart count elements across the site
|
|
1234
|
+
* @private
|
|
1235
|
+
*/
|
|
1236
|
+
#renderCartCount(cartData) {
|
|
1237
|
+
if (!cartData) return;
|
|
1238
|
+
|
|
1239
|
+
// Calculate visible item count (excluding _hide_in_cart items)
|
|
1240
|
+
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
1241
|
+
const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
|
|
1242
|
+
|
|
1243
|
+
// Update all cart count elements across the site
|
|
1244
|
+
const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
|
|
1245
|
+
cartCountElements.forEach((element) => {
|
|
1246
|
+
element.textContent = visibleItemCount;
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Update cart subtotal elements across the site
|
|
1252
|
+
* @private
|
|
1253
|
+
*/
|
|
1254
|
+
#renderCartSubtotal(cartData) {
|
|
1255
|
+
if (!cartData) return;
|
|
1256
|
+
|
|
1257
|
+
// Calculate subtotal from all items except those marked to ignore pricing
|
|
1258
|
+
const pricedItems = cartData.items.filter((item) => {
|
|
1259
|
+
const ignorePrice = item.properties?._ignore_price_in_subtotal;
|
|
1260
|
+
return !ignorePrice;
|
|
1261
|
+
});
|
|
1262
|
+
const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
|
|
1263
|
+
|
|
1264
|
+
// Update all cart subtotal elements across the site
|
|
1265
|
+
const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
|
|
1266
|
+
cartSubtotalElements.forEach((element) => {
|
|
1267
|
+
// Format as currency (assuming cents, convert to dollars)
|
|
1268
|
+
const formatted = (subtotal / 100).toFixed(2);
|
|
1269
|
+
element.textContent = `$${formatted}`;
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
923
1273
|
/**
|
|
924
1274
|
* Update cart items display based on cart data
|
|
925
1275
|
* @private
|
|
926
1276
|
*/
|
|
927
|
-
#
|
|
1277
|
+
#renderCartPanel(cart = null) {
|
|
928
1278
|
const cartData = cart || this.#currentCart;
|
|
929
1279
|
if (!cartData) return;
|
|
930
1280
|
|
|
@@ -940,14 +1290,22 @@
|
|
|
940
1290
|
return;
|
|
941
1291
|
}
|
|
942
1292
|
|
|
943
|
-
//
|
|
944
|
-
|
|
945
|
-
|
|
1293
|
+
// Check visible item count for showing/hiding sections
|
|
1294
|
+
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
1295
|
+
const hasVisibleItems = visibleItems.length > 0;
|
|
1296
|
+
|
|
1297
|
+
// Show/hide sections based on visible item count
|
|
1298
|
+
if (hasVisibleItems) {
|
|
1299
|
+
hasItemsSection.style.display = '';
|
|
946
1300
|
emptySection.style.display = 'none';
|
|
947
1301
|
} else {
|
|
948
1302
|
hasItemsSection.style.display = 'none';
|
|
949
|
-
emptySection.style.display = '
|
|
1303
|
+
emptySection.style.display = '';
|
|
950
1304
|
}
|
|
1305
|
+
|
|
1306
|
+
// Update cart count and subtotal across the site
|
|
1307
|
+
this.#renderCartCount(cartData);
|
|
1308
|
+
this.#renderCartSubtotal(cartData);
|
|
951
1309
|
}
|
|
952
1310
|
|
|
953
1311
|
/**
|
|
@@ -999,20 +1357,37 @@
|
|
|
999
1357
|
|
|
1000
1358
|
/**
|
|
1001
1359
|
* Refresh cart data from server and update components
|
|
1360
|
+
* @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
|
|
1002
1361
|
* @returns {Promise<Object>} Cart data object
|
|
1003
1362
|
*/
|
|
1004
|
-
refreshCart() {
|
|
1005
|
-
|
|
1363
|
+
refreshCart(cartObj = null) {
|
|
1364
|
+
// If cart object is provided, use it directly
|
|
1365
|
+
if (cartObj && !cartObj.error) {
|
|
1366
|
+
// console.log('Using provided cart data:', cartObj);
|
|
1367
|
+
this.#currentCart = cartObj;
|
|
1368
|
+
this.#renderCartItems(cartObj);
|
|
1369
|
+
this.#renderCartPanel(cartObj);
|
|
1370
|
+
|
|
1371
|
+
// Emit cart refreshed and data changed events
|
|
1372
|
+
const cartWithCalculatedFields = this.#addCalculatedFields(cartObj);
|
|
1373
|
+
this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
|
|
1374
|
+
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
1375
|
+
|
|
1376
|
+
return Promise.resolve(cartObj);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Otherwise fetch from server
|
|
1006
1380
|
return this.getCart().then((cartData) => {
|
|
1007
|
-
console.log('Cart data received:', cartData);
|
|
1381
|
+
// console.log('Cart data received:', cartData);
|
|
1008
1382
|
if (cartData && !cartData.error) {
|
|
1009
1383
|
this.#currentCart = cartData;
|
|
1010
1384
|
this.#renderCartItems(cartData);
|
|
1011
|
-
this.#
|
|
1385
|
+
this.#renderCartPanel(cartData);
|
|
1012
1386
|
|
|
1013
1387
|
// Emit cart refreshed and data changed events
|
|
1014
|
-
this.#
|
|
1015
|
-
this.#emit('cart-dialog:
|
|
1388
|
+
const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
|
|
1389
|
+
this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
|
|
1390
|
+
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
1016
1391
|
} else {
|
|
1017
1392
|
console.warn('Cart data has error or is null:', cartData);
|
|
1018
1393
|
}
|
|
@@ -1026,28 +1401,40 @@
|
|
|
1026
1401
|
*/
|
|
1027
1402
|
#removeItemsFromDOM(itemsContainer, newKeysSet) {
|
|
1028
1403
|
const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
1029
|
-
const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
|
|
1030
1404
|
|
|
1031
|
-
|
|
1032
|
-
`Removing ${itemsToRemove.length} items:`,
|
|
1033
|
-
itemsToRemove.map((item) => item.getAttribute('key'))
|
|
1034
|
-
);
|
|
1405
|
+
const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
|
|
1035
1406
|
|
|
1036
1407
|
itemsToRemove.forEach((item) => {
|
|
1408
|
+
console.log('destroy yourself', item);
|
|
1037
1409
|
item.destroyYourself();
|
|
1038
1410
|
});
|
|
1039
1411
|
}
|
|
1040
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* Update existing cart-item elements with fresh cart data
|
|
1415
|
+
* @private
|
|
1416
|
+
*/
|
|
1417
|
+
#updateItemsInDOM(itemsContainer, cartData) {
|
|
1418
|
+
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
1419
|
+
const existingItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
1420
|
+
|
|
1421
|
+
existingItems.forEach((cartItemEl) => {
|
|
1422
|
+
const key = cartItemEl.getAttribute('key');
|
|
1423
|
+
const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
|
|
1424
|
+
|
|
1425
|
+
if (updatedItemData) {
|
|
1426
|
+
// Update cart-item with fresh data and full cart context
|
|
1427
|
+
// The cart-item will handle HTML comparison and only re-render if needed
|
|
1428
|
+
cartItemEl.setData(updatedItemData, cartData);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1041
1433
|
/**
|
|
1042
1434
|
* Add new items to DOM with animation delay
|
|
1043
1435
|
* @private
|
|
1044
1436
|
*/
|
|
1045
1437
|
#addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
|
|
1046
|
-
console.log(
|
|
1047
|
-
`Adding ${itemsToAdd.length} items:`,
|
|
1048
|
-
itemsToAdd.map((item) => item.key || item.id)
|
|
1049
|
-
);
|
|
1050
|
-
|
|
1051
1438
|
// Delay adding new items by 300ms to let cart slide open first
|
|
1052
1439
|
setTimeout(() => {
|
|
1053
1440
|
itemsToAdd.forEach((itemData) => {
|
|
@@ -1080,6 +1467,48 @@
|
|
|
1080
1467
|
}, 100);
|
|
1081
1468
|
}
|
|
1082
1469
|
|
|
1470
|
+
/**
|
|
1471
|
+
* Filter cart items to exclude those with _hide_in_cart property
|
|
1472
|
+
* @private
|
|
1473
|
+
*/
|
|
1474
|
+
#getVisibleCartItems(cartData) {
|
|
1475
|
+
if (!cartData || !cartData.items) return [];
|
|
1476
|
+
return cartData.items.filter((item) => {
|
|
1477
|
+
// Check for _hide_in_cart in various possible locations
|
|
1478
|
+
const hidden = item.properties?._hide_in_cart;
|
|
1479
|
+
|
|
1480
|
+
return !hidden;
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Add calculated fields to cart object for events
|
|
1486
|
+
* @private
|
|
1487
|
+
*/
|
|
1488
|
+
#addCalculatedFields(cartData) {
|
|
1489
|
+
if (!cartData) return cartData;
|
|
1490
|
+
|
|
1491
|
+
// For display counts: use visible items (excludes _hide_in_cart)
|
|
1492
|
+
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
1493
|
+
const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
|
|
1494
|
+
|
|
1495
|
+
// For pricing: use all items except those marked to ignore pricing
|
|
1496
|
+
const pricedItems = cartData.items.filter((item) => {
|
|
1497
|
+
const ignorePrice = item.properties?._ignore_price_in_subtotal;
|
|
1498
|
+
return !ignorePrice;
|
|
1499
|
+
});
|
|
1500
|
+
const calculated_subtotal = pricedItems.reduce(
|
|
1501
|
+
(total, item) => total + (item.line_price || 0),
|
|
1502
|
+
0
|
|
1503
|
+
);
|
|
1504
|
+
|
|
1505
|
+
return {
|
|
1506
|
+
...cartData,
|
|
1507
|
+
calculated_count,
|
|
1508
|
+
calculated_subtotal,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1083
1512
|
/**
|
|
1084
1513
|
* Render cart items from Shopify cart data with smart comparison
|
|
1085
1514
|
* @private
|
|
@@ -1096,53 +1525,54 @@
|
|
|
1096
1525
|
return;
|
|
1097
1526
|
}
|
|
1098
1527
|
|
|
1528
|
+
// Filter out items with _hide_in_cart property
|
|
1529
|
+
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
1530
|
+
|
|
1099
1531
|
// Handle initial render - load all items without animation
|
|
1100
1532
|
if (this.#isInitialRender) {
|
|
1101
|
-
console.log('Initial cart render:',
|
|
1533
|
+
// console.log('Initial cart render:', visibleItems.length, 'visible items');
|
|
1102
1534
|
|
|
1103
1535
|
// Clear existing items
|
|
1104
1536
|
itemsContainer.innerHTML = '';
|
|
1105
1537
|
|
|
1106
1538
|
// Create cart-item elements without animation
|
|
1107
|
-
|
|
1539
|
+
visibleItems.forEach((itemData) => {
|
|
1108
1540
|
const cartItem = new CartItem(itemData); // No animation
|
|
1109
1541
|
itemsContainer.appendChild(cartItem);
|
|
1110
1542
|
});
|
|
1111
1543
|
|
|
1112
1544
|
this.#isInitialRender = false;
|
|
1113
|
-
|
|
1545
|
+
|
|
1114
1546
|
return;
|
|
1115
1547
|
}
|
|
1116
1548
|
|
|
1117
|
-
console.log('Smart rendering cart items:', cartData.items.length, 'items');
|
|
1118
|
-
|
|
1119
1549
|
// Get current DOM items and their keys
|
|
1120
1550
|
const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
1121
1551
|
const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
|
|
1122
1552
|
|
|
1123
|
-
// Get new cart data keys in order
|
|
1124
|
-
const newKeys =
|
|
1553
|
+
// Get new cart data keys in order (only visible items)
|
|
1554
|
+
const newKeys = visibleItems.map((item) => item.key || item.id);
|
|
1125
1555
|
const newKeysSet = new Set(newKeys);
|
|
1126
1556
|
|
|
1127
1557
|
// Step 1: Remove items that are no longer in cart data
|
|
1128
1558
|
this.#removeItemsFromDOM(itemsContainer, newKeysSet);
|
|
1129
1559
|
|
|
1130
|
-
// Step 2:
|
|
1131
|
-
|
|
1560
|
+
// Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
|
|
1561
|
+
this.#updateItemsInDOM(itemsContainer, cartData);
|
|
1562
|
+
|
|
1563
|
+
// Step 3: Add new items that weren't in DOM (with animation delay)
|
|
1564
|
+
const itemsToAdd = visibleItems.filter(
|
|
1132
1565
|
(itemData) => !currentKeys.has(itemData.key || itemData.id)
|
|
1133
1566
|
);
|
|
1134
|
-
|
|
1135
1567
|
this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
|
|
1136
|
-
|
|
1137
|
-
console.log('Smart rendering complete, container children:', itemsContainer.children.length);
|
|
1138
1568
|
}
|
|
1139
1569
|
|
|
1140
1570
|
/**
|
|
1141
1571
|
* Set the template function for cart items
|
|
1142
1572
|
* @param {Function} templateFn - Function that takes item data and returns HTML string
|
|
1143
1573
|
*/
|
|
1144
|
-
setCartItemTemplate(templateFn) {
|
|
1145
|
-
CartItem.setTemplate(templateFn);
|
|
1574
|
+
setCartItemTemplate(templateName, templateFn) {
|
|
1575
|
+
CartItem.setTemplate(templateName, templateFn);
|
|
1146
1576
|
}
|
|
1147
1577
|
|
|
1148
1578
|
/**
|
|
@@ -1158,10 +1588,13 @@
|
|
|
1158
1588
|
* @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
|
|
1159
1589
|
* @fires CartDialog#show - Fired when the cart dialog has been shown
|
|
1160
1590
|
*/
|
|
1161
|
-
show(triggerEl = null) {
|
|
1591
|
+
show(triggerEl = null, cartObj) {
|
|
1162
1592
|
const _ = this;
|
|
1163
1593
|
_.triggerEl = triggerEl || false;
|
|
1164
1594
|
|
|
1595
|
+
// Lock body scrolling
|
|
1596
|
+
_.#lockScroll();
|
|
1597
|
+
|
|
1165
1598
|
// Remove the hidden class first to ensure content is rendered
|
|
1166
1599
|
_.contentPanel.classList.remove('hidden');
|
|
1167
1600
|
|
|
@@ -1169,17 +1602,16 @@
|
|
|
1169
1602
|
requestAnimationFrame(() => {
|
|
1170
1603
|
// Update ARIA states
|
|
1171
1604
|
_.setAttribute('aria-hidden', 'false');
|
|
1605
|
+
|
|
1172
1606
|
if (_.triggerEl) {
|
|
1173
1607
|
_.triggerEl.setAttribute('aria-expanded', 'true');
|
|
1174
1608
|
}
|
|
1175
1609
|
|
|
1176
|
-
// Lock body scrolling and save scroll position
|
|
1177
|
-
_.#lockScroll();
|
|
1178
|
-
|
|
1179
1610
|
// Focus management
|
|
1180
1611
|
const firstFocusable = _.querySelector(
|
|
1181
1612
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
1182
1613
|
);
|
|
1614
|
+
|
|
1183
1615
|
if (firstFocusable) {
|
|
1184
1616
|
requestAnimationFrame(() => {
|
|
1185
1617
|
firstFocusable.focus();
|
|
@@ -1187,7 +1619,7 @@
|
|
|
1187
1619
|
}
|
|
1188
1620
|
|
|
1189
1621
|
// Refresh cart data when showing
|
|
1190
|
-
_.refreshCart();
|
|
1622
|
+
_.refreshCart(cartObj);
|
|
1191
1623
|
|
|
1192
1624
|
// Emit show event - cart dialog is now visible
|
|
1193
1625
|
_.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
|
|
@@ -1202,23 +1634,31 @@
|
|
|
1202
1634
|
hide() {
|
|
1203
1635
|
const _ = this;
|
|
1204
1636
|
|
|
1205
|
-
// Restore body scroll and scroll position
|
|
1206
|
-
_.#restoreScroll();
|
|
1207
|
-
|
|
1208
1637
|
// Update ARIA states
|
|
1209
1638
|
if (_.triggerEl) {
|
|
1210
1639
|
// remove focus from modal panel first
|
|
1211
1640
|
_.triggerEl.focus();
|
|
1212
1641
|
// mark trigger as no longer expanded
|
|
1213
1642
|
_.triggerEl.setAttribute('aria-expanded', 'false');
|
|
1643
|
+
} else {
|
|
1644
|
+
// If no trigger element, blur any focused element inside the panel
|
|
1645
|
+
const activeElement = document.activeElement;
|
|
1646
|
+
if (activeElement && _.contains(activeElement)) {
|
|
1647
|
+
activeElement.blur();
|
|
1648
|
+
}
|
|
1214
1649
|
}
|
|
1215
1650
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1651
|
+
requestAnimationFrame(() => {
|
|
1652
|
+
// Set aria-hidden to start transition
|
|
1653
|
+
// The transitionend event handler will add display:none when complete
|
|
1654
|
+
_.setAttribute('aria-hidden', 'true');
|
|
1219
1655
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1656
|
+
// Emit hide event - cart dialog is now starting to hide
|
|
1657
|
+
_.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
|
|
1658
|
+
|
|
1659
|
+
// Restore body scroll
|
|
1660
|
+
_.#restoreScroll();
|
|
1661
|
+
});
|
|
1222
1662
|
}
|
|
1223
1663
|
}
|
|
1224
1664
|
|