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