@lime-bundles/widget 2.4.1 → 2.5.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/dist/index.js CHANGED
@@ -21,6 +21,383 @@ import {
21
21
  resolveBundleQty
22
22
  } from "@lime-bundles/core";
23
23
 
24
+ // src/dropdown/bind-dropdown.ts
25
+ import { dropdown } from "@lime-bundles/core";
26
+ var { computePosition, emptyTypeAheadState, handleKey, pushTypeAheadChar } = dropdown;
27
+ var ITEM_HEIGHT_PX = 32;
28
+ var LIST_PAD_Y = 8;
29
+ var MAX_VISIBLE_ITEMS = 8;
30
+ var openInstances = [];
31
+ function closeOutsideEvent(event) {
32
+ const path = event.composedPath();
33
+ for (let i = openInstances.length - 1; i >= 0; i--) {
34
+ const inst = openInstances[i];
35
+ if (!path.includes(inst.shell) && !path.includes(inst.listbox)) {
36
+ inst.close();
37
+ }
38
+ }
39
+ }
40
+ function onDocResize() {
41
+ for (let i = openInstances.length - 1; i >= 0; i--) openInstances[i].close();
42
+ }
43
+ var docListenersAttached = false;
44
+ function attachDocumentListeners() {
45
+ if (docListenersAttached) return;
46
+ document.addEventListener("pointerdown", closeOutsideEvent, true);
47
+ window.addEventListener("scroll", closeOutsideEvent, true);
48
+ window.addEventListener("resize", onDocResize);
49
+ docListenersAttached = true;
50
+ }
51
+ function detachDocumentListeners() {
52
+ if (!docListenersAttached || openInstances.length > 0) return;
53
+ document.removeEventListener("pointerdown", closeOutsideEvent, true);
54
+ window.removeEventListener("scroll", closeOutsideEvent, true);
55
+ window.removeEventListener("resize", onDocResize);
56
+ docListenersAttached = false;
57
+ }
58
+ function readOptions(select) {
59
+ const out = [];
60
+ for (let i = 0; i < select.options.length; i++) {
61
+ const o = select.options[i];
62
+ out.push({ disabled: o.disabled, label: o.textContent || o.value });
63
+ }
64
+ return out;
65
+ }
66
+ function firstEnabled(opts) {
67
+ for (let i = 0; i < opts.length; i++) if (!opts[i].disabled) return i;
68
+ return -1;
69
+ }
70
+ var VARIANT_SELECT_CLASSES = [
71
+ "lb-bundle-variant-select",
72
+ "lb-mix-match__variant-select"
73
+ ];
74
+ var BIND_SELECTOR = VARIANT_SELECT_CLASSES.map(
75
+ (c) => `select.${c}:not(.lb-dropdown-state)`
76
+ ).join(", ");
77
+ function bindDropdown(select) {
78
+ const slot = select;
79
+ if (select.classList.contains("lb-dropdown-state")) {
80
+ return slot.__lbDropdownInstance ?? null;
81
+ }
82
+ const doc = select.ownerDocument;
83
+ const rootNode = select.getRootNode();
84
+ const labelText = select.getAttribute("aria-label") ?? "";
85
+ const idBase = `lb-dd-${Math.random().toString(36).slice(2, 9)}`;
86
+ select.classList.add("lb-dropdown-state");
87
+ select.setAttribute("aria-hidden", "true");
88
+ select.setAttribute("tabindex", "-1");
89
+ const shell = doc.createElement("div");
90
+ shell.className = "lb-dropdown";
91
+ shell.setAttribute("data-lb-dropdown", "");
92
+ const trigger = doc.createElement("button");
93
+ trigger.type = "button";
94
+ trigger.className = "lb-dropdown-trigger";
95
+ trigger.setAttribute("role", "combobox");
96
+ trigger.setAttribute("aria-haspopup", "listbox");
97
+ trigger.setAttribute("aria-expanded", "false");
98
+ const listboxId = `${idBase}-listbox`;
99
+ trigger.setAttribute("aria-controls", listboxId);
100
+ if (labelText) trigger.setAttribute("aria-label", labelText);
101
+ const triggerLabel = doc.createElement("span");
102
+ triggerLabel.className = "lb-dropdown-trigger-value";
103
+ const chevron = doc.createElement("span");
104
+ chevron.className = "lb-dropdown-chevron";
105
+ chevron.setAttribute("aria-hidden", "true");
106
+ trigger.appendChild(triggerLabel);
107
+ trigger.appendChild(chevron);
108
+ const listbox = doc.createElement("ul");
109
+ listbox.id = listboxId;
110
+ listbox.className = "lb-dropdown-listbox";
111
+ listbox.setAttribute("role", "listbox");
112
+ if (labelText) listbox.setAttribute("aria-label", labelText);
113
+ listbox.hidden = true;
114
+ shell.appendChild(trigger);
115
+ select.parentNode?.insertBefore(shell, select.nextSibling);
116
+ const modalOverlay = select.closest("[data-modal-overlay]");
117
+ if (modalOverlay) {
118
+ modalOverlay.appendChild(listbox);
119
+ listbox.setAttribute("data-lb-dropdown-portal", "");
120
+ } else {
121
+ shell.appendChild(listbox);
122
+ }
123
+ let isOpen = false;
124
+ let activeIndex = -1;
125
+ let typeAhead = emptyTypeAheadState();
126
+ let optionEls = [];
127
+ let instance;
128
+ function syncFromSelect() {
129
+ const opts = readOptions(select);
130
+ const idx = select.selectedIndex;
131
+ triggerLabel.textContent = idx >= 0 && opts[idx] ? opts[idx].label : "";
132
+ while (listbox.firstChild) listbox.removeChild(listbox.firstChild);
133
+ optionEls = [];
134
+ for (let i = 0; i < opts.length; i++) {
135
+ const li = doc.createElement("li");
136
+ li.id = `${idBase}-opt-${i}`;
137
+ li.className = "lb-dropdown-option";
138
+ li.setAttribute("role", "option");
139
+ li.setAttribute("aria-selected", i === idx ? "true" : "false");
140
+ if (opts[i].disabled) li.setAttribute("aria-disabled", "true");
141
+ li.setAttribute("data-value", select.options[i].value);
142
+ li.setAttribute("data-index", String(i));
143
+ li.textContent = opts[i].label;
144
+ listbox.appendChild(li);
145
+ optionEls.push(li);
146
+ }
147
+ }
148
+ function setActive(newIndex) {
149
+ if (activeIndex >= 0 && optionEls[activeIndex]) {
150
+ optionEls[activeIndex].classList.remove("is-active");
151
+ }
152
+ activeIndex = newIndex;
153
+ if (newIndex >= 0 && optionEls[newIndex]) {
154
+ const li = optionEls[newIndex];
155
+ li.classList.add("is-active");
156
+ trigger.setAttribute("aria-activedescendant", li.id);
157
+ const liTop = li.offsetTop;
158
+ const liBottom = liTop + li.offsetHeight;
159
+ const visTop = listbox.scrollTop;
160
+ const visBottom = visTop + listbox.clientHeight;
161
+ if (liTop < visTop) {
162
+ listbox.scrollTop = liTop;
163
+ } else if (liBottom > visBottom) {
164
+ listbox.scrollTop = liBottom - listbox.clientHeight;
165
+ }
166
+ } else {
167
+ trigger.setAttribute("aria-activedescendant", "");
168
+ }
169
+ }
170
+ function position() {
171
+ const rect = trigger.getBoundingClientRect();
172
+ if (rect.width === 0) return false;
173
+ const visibleCount = Math.min(optionEls.length || 1, MAX_VISIBLE_ITEMS);
174
+ const desiredHeight = visibleCount * ITEM_HEIGHT_PX + LIST_PAD_Y;
175
+ const result = computePosition({
176
+ trigger: {
177
+ top: rect.top,
178
+ bottom: rect.bottom,
179
+ left: rect.left,
180
+ width: rect.width
181
+ },
182
+ viewportHeight: window.innerHeight,
183
+ desiredHeight
184
+ });
185
+ listbox.setAttribute("data-placement", result.placement);
186
+ listbox.style.maxHeight = `${result.maxHeight}px`;
187
+ if (listbox.hasAttribute("data-lb-dropdown-portal")) {
188
+ listbox.style.top = `${result.offsetTop}px`;
189
+ listbox.style.left = `${result.offsetLeft}px`;
190
+ listbox.style.width = `${result.width}px`;
191
+ }
192
+ return true;
193
+ }
194
+ function open() {
195
+ if (isOpen) return;
196
+ for (let i = openInstances.length - 1; i >= 0; i--) {
197
+ if (openInstances[i] !== instance) openInstances[i].close();
198
+ }
199
+ isOpen = true;
200
+ listbox.hidden = false;
201
+ trigger.setAttribute("aria-expanded", "true");
202
+ if (!position()) {
203
+ requestAnimationFrame(() => position());
204
+ }
205
+ const opts = readOptions(select);
206
+ const selIdx = select.selectedIndex;
207
+ if (selIdx >= 0 && opts[selIdx] && !opts[selIdx].disabled) {
208
+ setActive(selIdx);
209
+ } else {
210
+ setActive(firstEnabled(opts));
211
+ }
212
+ openInstances.push(instance);
213
+ if (openInstances.length === 1) attachDocumentListeners();
214
+ }
215
+ function close(restoreFocus) {
216
+ if (!isOpen) return;
217
+ isOpen = false;
218
+ listbox.hidden = true;
219
+ trigger.setAttribute("aria-expanded", "false");
220
+ trigger.setAttribute("aria-activedescendant", "");
221
+ if (activeIndex >= 0 && optionEls[activeIndex]) {
222
+ optionEls[activeIndex].classList.remove("is-active");
223
+ }
224
+ activeIndex = -1;
225
+ const idx = openInstances.indexOf(instance);
226
+ if (idx >= 0) openInstances.splice(idx, 1);
227
+ if (openInstances.length === 0) detachDocumentListeners();
228
+ if (restoreFocus) trigger.focus();
229
+ }
230
+ function commit(index) {
231
+ const opt = select.options[index];
232
+ if (!opt || opt.disabled) return;
233
+ if (select.value !== opt.value) {
234
+ select.value = opt.value;
235
+ const event = new Event("change", { bubbles: true });
236
+ select.dispatchEvent(event);
237
+ }
238
+ syncFromSelect();
239
+ close(true);
240
+ }
241
+ function applyAction(action) {
242
+ switch (action.type) {
243
+ case "open":
244
+ open();
245
+ if (action.activeIndex >= 0) setActive(action.activeIndex);
246
+ return;
247
+ case "close":
248
+ close(action.restoreFocus);
249
+ return;
250
+ case "move-active":
251
+ setActive(action.activeIndex);
252
+ return;
253
+ case "commit":
254
+ commit(action.index);
255
+ return;
256
+ case "type-ahead": {
257
+ const opts = readOptions(select);
258
+ const result = pushTypeAheadChar(
259
+ typeAhead,
260
+ action.char,
261
+ Date.now(),
262
+ opts
263
+ );
264
+ typeAhead = result.newState;
265
+ if (result.matchedIndex !== null) {
266
+ if (!isOpen) open();
267
+ setActive(result.matchedIndex);
268
+ }
269
+ return;
270
+ }
271
+ case "passthrough":
272
+ return;
273
+ default: {
274
+ const _exhaustive = action;
275
+ void _exhaustive;
276
+ }
277
+ }
278
+ }
279
+ function onKeydown(event) {
280
+ const opts = readOptions(select);
281
+ const action = handleKey(
282
+ {
283
+ key: event.key,
284
+ ctrlKey: event.ctrlKey,
285
+ metaKey: event.metaKey,
286
+ altKey: event.altKey,
287
+ shiftKey: event.shiftKey
288
+ },
289
+ {
290
+ isOpen,
291
+ activeIndex,
292
+ selectedIndex: select.selectedIndex,
293
+ options: opts
294
+ }
295
+ );
296
+ if (action.preventDefault) event.preventDefault();
297
+ applyAction(action);
298
+ }
299
+ function onTriggerClick(event) {
300
+ event.preventDefault();
301
+ if (isOpen) close(false);
302
+ else open();
303
+ }
304
+ function onListboxClick(event) {
305
+ let target = event.target;
306
+ while (target && target !== listbox) {
307
+ if (target.classList?.contains("lb-dropdown-option")) {
308
+ const idx = parseInt(target.getAttribute("data-index") ?? "", 10);
309
+ if (!Number.isNaN(idx)) {
310
+ commit(idx);
311
+ return;
312
+ }
313
+ }
314
+ target = target.parentElement;
315
+ }
316
+ }
317
+ function onListboxMousemove(event) {
318
+ let target = event.target;
319
+ while (target && target !== listbox) {
320
+ if (target.classList?.contains("lb-dropdown-option")) {
321
+ if (target.getAttribute("aria-disabled") === "true") return;
322
+ const idx = parseInt(target.getAttribute("data-index") ?? "", 10);
323
+ if (!Number.isNaN(idx) && idx !== activeIndex) setActive(idx);
324
+ return;
325
+ }
326
+ target = target.parentElement;
327
+ }
328
+ }
329
+ function onShellFocusout() {
330
+ setTimeout(() => {
331
+ if (!isOpen) return;
332
+ const active = rootNode.activeElement ?? doc.activeElement;
333
+ if (!shell.contains(active)) close(false);
334
+ }, 0);
335
+ }
336
+ function onSelectChange() {
337
+ syncFromSelect();
338
+ }
339
+ const observer = new MutationObserver(() => {
340
+ syncFromSelect();
341
+ });
342
+ observer.observe(select, {
343
+ childList: true,
344
+ subtree: true,
345
+ attributes: true,
346
+ attributeFilter: ["disabled", "value", "selected"]
347
+ });
348
+ const onListboxMousedown = (event) => event.preventDefault();
349
+ trigger.addEventListener("click", onTriggerClick);
350
+ trigger.addEventListener("keydown", onKeydown);
351
+ shell.addEventListener("focusout", onShellFocusout);
352
+ listbox.addEventListener("mousedown", onListboxMousedown);
353
+ listbox.addEventListener("click", onListboxClick);
354
+ listbox.addEventListener("mousemove", onListboxMousemove);
355
+ select.addEventListener("change", onSelectChange);
356
+ function destroy() {
357
+ if (isOpen) close(false);
358
+ observer.disconnect();
359
+ trigger.removeEventListener("click", onTriggerClick);
360
+ trigger.removeEventListener("keydown", onKeydown);
361
+ shell.removeEventListener("focusout", onShellFocusout);
362
+ listbox.removeEventListener("mousedown", onListboxMousedown);
363
+ listbox.removeEventListener("click", onListboxClick);
364
+ listbox.removeEventListener("mousemove", onListboxMousemove);
365
+ select.removeEventListener("change", onSelectChange);
366
+ if (shell.parentNode) shell.parentNode.removeChild(shell);
367
+ if (listbox.parentNode) listbox.parentNode.removeChild(listbox);
368
+ select.classList.remove("lb-dropdown-state");
369
+ select.removeAttribute("aria-hidden");
370
+ select.removeAttribute("tabindex");
371
+ delete slot.__lbDropdownInstance;
372
+ }
373
+ instance = {
374
+ shell,
375
+ listbox,
376
+ select,
377
+ close: () => close(false),
378
+ destroy
379
+ };
380
+ slot.__lbDropdownInstance = instance;
381
+ syncFromSelect();
382
+ return instance;
383
+ }
384
+ function bindAllDropdowns(root) {
385
+ const selects = root.querySelectorAll(BIND_SELECTOR);
386
+ const instances = [];
387
+ selects.forEach((sel) => {
388
+ const inst = bindDropdown(sel);
389
+ if (inst) instances.push(inst);
390
+ });
391
+ return instances;
392
+ }
393
+ function unbindAllDropdowns(root) {
394
+ const bound = root.querySelectorAll("select.lb-dropdown-state");
395
+ bound.forEach((sel) => {
396
+ const inst = sel.__lbDropdownInstance;
397
+ if (inst) inst.destroy();
398
+ });
399
+ }
400
+
24
401
  // src/renderers/pricing.ts
25
402
  import {
26
403
  parseCents,
@@ -192,6 +569,8 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
192
569
  })
193
570
  );
194
571
  container.appendChild(root);
572
+ bindAllDropdowns(root);
573
+ onCleanup?.(() => unbindAllDropdowns(root));
195
574
  updatePricing();
196
575
  function updatePricing() {
197
576
  const totalCents = rows.reduce((sum, r) => {
@@ -287,18 +666,20 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
287
666
  }
288
667
  );
289
668
  const thumb = el("div", "lb-bundle-thumbnail", { "data-thumbnail": "" });
290
- if (state.product.featuredImage) {
291
- const img = document.createElement("img");
292
- img.src = transformImageUrl(state.product.featuredImage.url, {
669
+ const initialThumbImage = state.selected?.image ?? state.product.featuredImage ?? null;
670
+ let thumbImg = null;
671
+ if (initialThumbImage) {
672
+ thumbImg = document.createElement("img");
673
+ thumbImg.src = transformImageUrl(initialThumbImage.url, {
293
674
  width: THUMB_PX,
294
675
  height: THUMB_PX,
295
676
  crop: "center"
296
677
  });
297
- img.alt = state.product.featuredImage.altText ?? state.product.title;
298
- img.width = THUMB_PX;
299
- img.height = THUMB_PX;
300
- img.loading = "lazy";
301
- thumb.appendChild(img);
678
+ thumbImg.alt = initialThumbImage.altText ?? state.product.title;
679
+ thumbImg.width = THUMB_PX;
680
+ thumbImg.height = THUMB_PX;
681
+ thumbImg.loading = "lazy";
682
+ thumb.appendChild(thumbImg);
302
683
  } else {
303
684
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG);
304
685
  }
@@ -362,6 +743,15 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
362
743
  } else {
363
744
  unitPriceEl.setAttribute("hidden", "");
364
745
  }
746
+ const nextImage = variant.image ?? state.product.featuredImage;
747
+ if (thumbImg && nextImage) {
748
+ thumbImg.src = transformImageUrl(nextImage.url, {
749
+ width: THUMB_PX,
750
+ height: THUMB_PX,
751
+ crop: "center"
752
+ });
753
+ thumbImg.alt = nextImage.altText ?? state.product.title;
754
+ }
365
755
  };
366
756
  applyVariantToRow(state.selected);
367
757
  if (state.eligibleVariants.length > 1) {
@@ -628,6 +1018,7 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
628
1018
  })
629
1019
  );
630
1020
  container.appendChild(root);
1021
+ onCleanup?.(() => unbindAllDropdowns(root));
631
1022
  const firstEligible = eligible.find((ep) => !ep.isOos);
632
1023
  const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
633
1024
  if (firstEligible && firstVariant) {
@@ -636,7 +1027,7 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
636
1027
  productTitle: firstEligible.product.title,
637
1028
  variantId: firstVariant.id,
638
1029
  variantTitle: firstVariant.title,
639
- imageUrl: firstEligible.product.featuredImage?.url ?? null,
1030
+ imageUrl: firstVariant.image?.url ?? firstEligible.product.featuredImage?.url ?? null,
640
1031
  priceCents: parseCents(firstVariant.price.amount),
641
1032
  compareCents: firstVariant.compareAtPrice ? parseCents(firstVariant.compareAtPrice.amount) : null,
642
1033
  unitPriceLabel: formatUnitPrice(
@@ -655,7 +1046,7 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
655
1046
  productTitle: product.title,
656
1047
  variantId: variant.id,
657
1048
  variantTitle: variant.title,
658
- imageUrl: product.featuredImage?.url ?? null,
1049
+ imageUrl: variant.image?.url ?? product.featuredImage?.url ?? null,
659
1050
  priceCents: parseCents(variant.price.amount),
660
1051
  compareCents: variant.compareAtPrice ? parseCents(variant.compareAtPrice.amount) : null,
661
1052
  unitPriceLabel: formatUnitPrice(
@@ -1030,18 +1421,21 @@ function renderModal(bundle, eligible, currency, handlers) {
1030
1421
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
1031
1422
  );
1032
1423
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
1033
- if (ep.product.featuredImage) {
1034
- const img = document.createElement("img");
1035
- img.src = transformImageUrl(ep.product.featuredImage.url, {
1424
+ const initialThumbVariant = ep.variants.find((v) => v.availableForSale) ?? ep.variants[0] ?? null;
1425
+ const initialThumbImage = initialThumbVariant?.image ?? ep.product.featuredImage ?? null;
1426
+ let thumbImg = null;
1427
+ if (initialThumbImage) {
1428
+ thumbImg = document.createElement("img");
1429
+ thumbImg.src = transformImageUrl(initialThumbImage.url, {
1036
1430
  width: THUMB_PX,
1037
1431
  height: THUMB_PX,
1038
1432
  crop: "center"
1039
1433
  });
1040
- img.alt = ep.product.featuredImage.altText ?? ep.product.title;
1041
- img.width = THUMB_PX;
1042
- img.height = THUMB_PX;
1043
- img.loading = "lazy";
1044
- thumb.appendChild(img);
1434
+ thumbImg.alt = initialThumbImage.altText ?? ep.product.title;
1435
+ thumbImg.width = THUMB_PX;
1436
+ thumbImg.height = THUMB_PX;
1437
+ thumbImg.loading = "lazy";
1438
+ thumb.appendChild(thumbImg);
1045
1439
  } else {
1046
1440
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1047
1441
  }
@@ -1145,6 +1539,15 @@ function renderModal(bundle, eligible, currency, handlers) {
1145
1539
  unitPrice.textContent = "";
1146
1540
  unitPrice.hidden = true;
1147
1541
  }
1542
+ const nextImage = currentVariant.image ?? ep.product.featuredImage;
1543
+ if (thumbImg && nextImage) {
1544
+ thumbImg.src = transformImageUrl(nextImage.url, {
1545
+ width: THUMB_PX,
1546
+ height: THUMB_PX,
1547
+ crop: "center"
1548
+ });
1549
+ thumbImg.alt = nextImage.altText ?? ep.product.title;
1550
+ }
1148
1551
  recomputeDisabled(next.selectedOptions.map((o) => o.value));
1149
1552
  rowUpdateCount();
1150
1553
  };
@@ -1215,6 +1618,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1215
1618
  productRows.push(row);
1216
1619
  list.appendChild(productEl);
1217
1620
  });
1621
+ bindAllDropdowns(list);
1218
1622
  refreshCounts();
1219
1623
  }
1220
1624
  function applySearch() {
@@ -1680,6 +2084,11 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1680
2084
  --lb-thumbnail-bg: #F0F0F0;
1681
2085
  --lb-widget-pad: 20px;
1682
2086
  --lb-progress-color: var(--lb-primary-color);
2087
+ /* Cap on the per-bundle product/slot/tier list height \u2014 keeps long
2088
+ bundles from pushing the CTA off-screen. The list scrolls
2089
+ internally with the same custom 4px scrollbar as the variant
2090
+ dropdown when content exceeds this. */
2091
+ --lb-list-max-height: 360px;
1683
2092
 
1684
2093
  font-family: inherit;
1685
2094
  font-size: 16px;
@@ -2234,6 +2643,22 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2234
2643
  flex-direction: column;
2235
2644
  gap: 0;
2236
2645
  margin: 0;
2646
+ max-height: var(--lb-list-max-height);
2647
+ overflow-y: auto;
2648
+ /* Custom scrollbar \u2014 text colour at 15% opacity (thumb) and 5% (track). */
2649
+ scrollbar-width: thin;
2650
+ scrollbar-color: color-mix(in srgb, var(--lb-text) 15%, transparent)
2651
+ color-mix(in srgb, var(--lb-text) 2%, transparent);
2652
+ }
2653
+
2654
+ .lb-fixed__products::-webkit-scrollbar { width: 4px; }
2655
+ .lb-fixed__products::-webkit-scrollbar-track {
2656
+ background: color-mix(in srgb, var(--lb-text) 2%, transparent);
2657
+ border-radius: 2px;
2658
+ }
2659
+ .lb-fixed__products::-webkit-scrollbar-thumb {
2660
+ background: color-mix(in srgb, var(--lb-text) 15%, transparent);
2661
+ border-radius: 2px;
2237
2662
  }
2238
2663
 
2239
2664
  /* Fixed bundles: product rows */
@@ -2289,6 +2714,28 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2289
2714
  `;
2290
2715
  var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2291
2716
 
2717
+ /* === Slot list ============================================================
2718
+ Caps the height of the slot stack so long bundles don't push the CTA off
2719
+ the page. Internal scroll with the same custom 4px scrollbar as the
2720
+ variant dropdown panel. */
2721
+ .lb-mix-match__slots {
2722
+ max-height: var(--lb-list-max-height);
2723
+ overflow-y: auto;
2724
+ scrollbar-width: thin;
2725
+ scrollbar-color: color-mix(in srgb, var(--lb-text) 15%, transparent)
2726
+ color-mix(in srgb, var(--lb-text) 2%, transparent);
2727
+ }
2728
+
2729
+ .lb-mix-match__slots::-webkit-scrollbar { width: 4px; }
2730
+ .lb-mix-match__slots::-webkit-scrollbar-track {
2731
+ background: color-mix(in srgb, var(--lb-text) 2%, transparent);
2732
+ border-radius: 2px;
2733
+ }
2734
+ .lb-mix-match__slots::-webkit-scrollbar-thumb {
2735
+ background: color-mix(in srgb, var(--lb-text) 15%, transparent);
2736
+ border-radius: 2px;
2737
+ }
2738
+
2292
2739
  /* === Progress Bar === */
2293
2740
  .lb-mix-match__progress {
2294
2741
  margin-bottom: 16px;
@@ -2805,6 +3252,22 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
2805
3252
  display: flex;
2806
3253
  flex-direction: column;
2807
3254
  gap: 12px;
3255
+ max-height: var(--lb-list-max-height);
3256
+ overflow-y: auto;
3257
+ /* Custom scrollbar \u2014 text colour at 15% opacity (thumb) and 5% (track). */
3258
+ scrollbar-width: thin;
3259
+ scrollbar-color: color-mix(in srgb, var(--lb-text) 15%, transparent)
3260
+ color-mix(in srgb, var(--lb-text) 2%, transparent);
3261
+ }
3262
+
3263
+ .lb-volume__tiers::-webkit-scrollbar { width: 4px; }
3264
+ .lb-volume__tiers::-webkit-scrollbar-track {
3265
+ background: color-mix(in srgb, var(--lb-text) 2%, transparent);
3266
+ border-radius: 2px;
3267
+ }
3268
+ .lb-volume__tiers::-webkit-scrollbar-thumb {
3269
+ background: color-mix(in srgb, var(--lb-text) 15%, transparent);
3270
+ border-radius: 2px;
2808
3271
  }
2809
3272
 
2810
3273
  .lb-volume__tier {
@@ -2909,6 +3372,259 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
2909
3372
  }
2910
3373
 
2911
3374
 
3375
+ `;
3376
+ var BUNDLE_DROPDOWN_CSS = `/**
3377
+ * Lime Bundles \u2014 Custom variant-picker dropdown styling.
3378
+ *
3379
+ * Reuses existing CSS variables: no new merchant-configurable surface.
3380
+ * --lb-variant-border-{width,color}, --lb-variant-radius, --lb-variant-chevron
3381
+ * --lb-bg, --lb-text, --lb-primary-color
3382
+ *
3383
+ * Mix-match modal context overrides via .lb-mix-match__modal scope to use
3384
+ * --lb-picker-variant-* and --lb-picker-bg.
3385
+ */
3386
+
3387
+ /* Hide the native <select> while keeping it form-serializable and focusable
3388
+ programmatically. The .lb-dropdown-state marker is added by JS at bind
3389
+ time, so this rule matches every variant-select class (main widget,
3390
+ mix-match modal, future bundle types). aria-hidden + tabindex=-1
3391
+ (also set in JS) remove it from the accessibility tree. */
3392
+ .lb-dropdown-state {
3393
+ position: absolute !important;
3394
+ width: 1px !important;
3395
+ height: 1px !important;
3396
+ padding: 0 !important;
3397
+ margin: -1px !important;
3398
+ overflow: hidden !important;
3399
+ clip: rect(0 0 0 0) !important;
3400
+ white-space: nowrap !important;
3401
+ border: 0 !important;
3402
+ pointer-events: none !important;
3403
+ }
3404
+
3405
+ /* Shell fills its parent column. */
3406
+ .lb-dropdown {
3407
+ position: relative;
3408
+ display: inline-block;
3409
+ width: 100%;
3410
+ max-width: 100%;
3411
+ font-family: inherit;
3412
+ }
3413
+
3414
+ /* Trigger styled identically to the closed-state native select */
3415
+ .lb-dropdown-trigger {
3416
+ display: inline-flex;
3417
+ align-items: center;
3418
+ justify-content: space-between;
3419
+ gap: 8px;
3420
+ width: 100%;
3421
+ border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
3422
+ border-radius: var(--lb-variant-radius);
3423
+ padding: 8px 12px;
3424
+ font-size: 12px;
3425
+ line-height: 16px;
3426
+ color: var(--lb-text);
3427
+ background: var(--lb-bg);
3428
+ font-family: inherit;
3429
+ cursor: pointer;
3430
+ appearance: none;
3431
+ -webkit-appearance: none;
3432
+ text-align: start;
3433
+ transition: border-color 120ms ease;
3434
+ }
3435
+
3436
+ .lb-dropdown-trigger:focus-visible {
3437
+ outline: 2px solid var(--lb-primary-color);
3438
+ outline-offset: 2px;
3439
+ }
3440
+
3441
+ .lb-dropdown-trigger[aria-expanded="true"] {
3442
+ border-color: var(--lb-text);
3443
+ }
3444
+
3445
+ .lb-dropdown-trigger-value {
3446
+ flex: 1 1 auto;
3447
+ white-space: nowrap;
3448
+ overflow: hidden;
3449
+ text-overflow: ellipsis;
3450
+ text-align: start;
3451
+ }
3452
+
3453
+ .lb-dropdown-chevron {
3454
+ flex: 0 0 auto;
3455
+ width: 12px;
3456
+ height: 12px;
3457
+ background: var(--lb-variant-chevron) center / contain no-repeat;
3458
+ transition: transform 120ms ease;
3459
+ }
3460
+
3461
+ .lb-dropdown-trigger[aria-expanded="true"] .lb-dropdown-chevron {
3462
+ transform: rotate(180deg);
3463
+ }
3464
+
3465
+ /* Popover panel \u2014 position: absolute against the .lb-dropdown shell
3466
+ (already position: relative). Top/left/width come from CSS so we
3467
+ never depend on JS having set inline coords by the time the panel
3468
+ becomes visible. JS only sets max-height. */
3469
+ .lb-dropdown-listbox {
3470
+ position: absolute;
3471
+ left: 0;
3472
+ /* Default to below-trigger placement so the panel doesn't overlap the
3473
+ trigger if data-placement is missing for any reason. The explicit
3474
+ [data-placement="down"|"up"] rules below override this. */
3475
+ top: calc(100% + 4px);
3476
+ width: 100%;
3477
+ z-index: 9999;
3478
+ margin: 0;
3479
+ padding: 4px 0;
3480
+ list-style: none;
3481
+ background: var(--lb-bg);
3482
+ color: var(--lb-text);
3483
+ border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
3484
+ border-radius: var(--lb-variant-radius);
3485
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
3486
+ overflow-y: auto;
3487
+ overflow-x: hidden;
3488
+ /* Custom scrollbar \u2014 text colour at 15% opacity (thumb) and 5% (track). */
3489
+ scrollbar-width: thin;
3490
+ scrollbar-color: color-mix(in srgb, var(--lb-text) 15%, transparent)
3491
+ color-mix(in srgb, var(--lb-text) 2%, transparent);
3492
+ animation: lb-dropdown-in-down 120ms ease-out;
3493
+ transform-origin: top center;
3494
+ }
3495
+
3496
+ .lb-dropdown-listbox[data-placement="down"] {
3497
+ top: calc(100% + 4px);
3498
+ }
3499
+
3500
+ .lb-dropdown-listbox[data-placement="up"] {
3501
+ top: auto;
3502
+ bottom: calc(100% + 4px);
3503
+ animation-name: lb-dropdown-in-up;
3504
+ transform-origin: bottom center;
3505
+ }
3506
+
3507
+ /* When portaled out of the .lb-dropdown shell (mix-match modal context:
3508
+ .lb-mix-match__modal applies translateY which would otherwise trap
3509
+ position:fixed), switch to fixed and let JS set viewport coords. */
3510
+ .lb-dropdown-listbox[data-lb-dropdown-portal] {
3511
+ position: fixed;
3512
+ top: auto;
3513
+ left: auto;
3514
+ bottom: auto;
3515
+ width: auto;
3516
+ }
3517
+
3518
+ /* Custom scrollbar \u2014 Webkit/Blink: exact 4px width */
3519
+ .lb-dropdown-listbox::-webkit-scrollbar {
3520
+ width: 4px;
3521
+ }
3522
+ .lb-dropdown-listbox::-webkit-scrollbar-track {
3523
+ background: color-mix(in srgb, var(--lb-text) 2%, transparent);
3524
+ border-radius: 2px;
3525
+ }
3526
+ .lb-dropdown-listbox::-webkit-scrollbar-thumb {
3527
+ background: color-mix(in srgb, var(--lb-text) 15%, transparent);
3528
+ border-radius: 2px;
3529
+ }
3530
+
3531
+ /* Options */
3532
+ .lb-dropdown-option {
3533
+ padding: 8px 12px;
3534
+ font-size: 12px;
3535
+ line-height: 16px;
3536
+ cursor: pointer;
3537
+ white-space: nowrap;
3538
+ overflow: hidden;
3539
+ text-overflow: ellipsis;
3540
+ color: var(--lb-text);
3541
+ }
3542
+
3543
+ .lb-dropdown-option[aria-selected="true"] {
3544
+ font-weight: 600;
3545
+ }
3546
+
3547
+ .lb-dropdown-option.is-active,
3548
+ .lb-dropdown-option:hover:not([aria-disabled="true"]) {
3549
+ background: color-mix(in srgb, var(--lb-text) 2%, transparent);
3550
+ }
3551
+
3552
+ .lb-dropdown-option[aria-disabled="true"] {
3553
+ opacity: 0.4;
3554
+ cursor: not-allowed;
3555
+ }
3556
+
3557
+ /* Animations */
3558
+ @keyframes lb-dropdown-in-down {
3559
+ from { opacity: 0; transform: translateY(-4px) scale(0.98); }
3560
+ to { opacity: 1; transform: translateY(0) scale(1); }
3561
+ }
3562
+
3563
+ @keyframes lb-dropdown-in-up {
3564
+ from { opacity: 0; transform: translateY(4px) scale(0.98); }
3565
+ to { opacity: 1; transform: translateY(0) scale(1); }
3566
+ }
3567
+
3568
+ @media (prefers-reduced-motion: reduce) {
3569
+ .lb-dropdown-listbox { animation: none; }
3570
+ .lb-dropdown-chevron { transition: none; }
3571
+ .lb-dropdown-trigger { transition: none; }
3572
+ }
3573
+
3574
+ /* Forced-colors mode (Windows high-contrast) */
3575
+ @media (forced-colors: active) {
3576
+ .lb-dropdown-trigger {
3577
+ border-color: ButtonBorder;
3578
+ color: ButtonText;
3579
+ background: ButtonFace;
3580
+ }
3581
+ .lb-dropdown-listbox {
3582
+ border-color: ButtonBorder;
3583
+ background: Canvas;
3584
+ color: CanvasText;
3585
+ }
3586
+ .lb-dropdown-option.is-active {
3587
+ background: Highlight;
3588
+ color: HighlightText;
3589
+ }
3590
+ }
3591
+
3592
+ /* Mix-match modal context \u2014 use picker-scoped variables.
3593
+ No CSS fallbacks: --lb-picker-* are always emitted by bundle-widget.liquid
3594
+ because WidgetConfig.parse() fully hydrates the merchant config.
3595
+ See docs/solutions/ui-bugs/widget-css-single-source-defaults.md. */
3596
+ .lb-mix-match__modal .lb-dropdown-trigger {
3597
+ border-color: var(--lb-picker-variant-border-color);
3598
+ border-width: var(--lb-picker-variant-border-width);
3599
+ border-radius: var(--lb-picker-variant-radius);
3600
+ background: var(--lb-picker-bg);
3601
+ color: var(--lb-picker-text);
3602
+ }
3603
+
3604
+ .lb-mix-match__modal .lb-dropdown-chevron {
3605
+ background-image: var(--lb-picker-variant-chevron);
3606
+ }
3607
+
3608
+ /* Listbox is portaled out of the transformed .lb-mix-match__modal up to
3609
+ its [data-modal-overlay] parent, so picker-scoped rules anchor on the
3610
+ overlay attribute, not the modal class. */
3611
+ [data-modal-overlay] > .lb-dropdown-listbox {
3612
+ border-color: var(--lb-picker-variant-border-color);
3613
+ border-width: var(--lb-picker-variant-border-width);
3614
+ border-radius: var(--lb-picker-variant-radius);
3615
+ background: var(--lb-picker-bg);
3616
+ color: var(--lb-picker-text);
3617
+ scrollbar-color: color-mix(in srgb, var(--lb-picker-text) 15%, transparent)
3618
+ color-mix(in srgb, var(--lb-picker-text) 2%, transparent);
3619
+ }
3620
+
3621
+ [data-modal-overlay] > .lb-dropdown-listbox::-webkit-scrollbar-track {
3622
+ background: color-mix(in srgb, var(--lb-picker-text) 2%, transparent);
3623
+ border-radius: 2px;
3624
+ }
3625
+ [data-modal-overlay] > .lb-dropdown-listbox::-webkit-scrollbar-thumb {
3626
+ background: color-mix(in srgb, var(--lb-picker-text) 15%, transparent);
3627
+ }
2912
3628
  `;
2913
3629
  var BUNDLE_SKELETON_CSS = `/* Lime Bundles \u2014 web-component loading skeleton (not mirrored to theme assets) */
2914
3630
 
@@ -3344,7 +4060,8 @@ var LimeBundleElement = class extends HTMLElement {
3344
4060
  BUNDLE_BASE_CSS,
3345
4061
  BUNDLE_FIXED_CSS,
3346
4062
  BUNDLE_MIX_MATCH_CSS,
3347
- BUNDLE_VOLUME_CSS
4063
+ BUNDLE_VOLUME_CSS,
4064
+ BUNDLE_DROPDOWN_CSS
3348
4065
  ].join("\n");
3349
4066
  this.shadow.appendChild(style);
3350
4067
  if (this.shopCustomCss) {