@lime-bundles/widget 2.4.0 → 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) => {
@@ -212,12 +591,11 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
212
591
  }
213
592
  function buildRowState(bundle, product, productIndex) {
214
593
  const selectedVariantIds = bundle.selectedVariantIds?.[productIndex] ?? null;
215
- const available = product.variants.nodes.filter(
216
- (v) => v.availableForSale
217
- );
218
- const eligibleVariants = selectedVariantIds && selectedVariantIds.length > 0 ? available.filter((v) => selectedVariantIds.includes(v.id)) : available;
219
- const isOos = eligibleVariants.length === 0;
220
- const selected = eligibleVariants[0] ?? null;
594
+ const merchantScoped = selectedVariantIds && selectedVariantIds.length > 0 ? product.variants.nodes.filter((v) => selectedVariantIds.includes(v.id)) : product.variants.nodes;
595
+ const firstInStock = merchantScoped.find((v) => v.availableForSale) ?? null;
596
+ const eligibleVariants = merchantScoped;
597
+ const isOos = !firstInStock;
598
+ const selected = firstInStock ?? merchantScoped[0] ?? null;
221
599
  const qty = selected ? resolveBundleQty(bundle, product.id, selected.id) : 1;
222
600
  return { product, eligibleVariants, selected, qty, isOos };
223
601
  }
@@ -288,18 +666,20 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
288
666
  }
289
667
  );
290
668
  const thumb = el("div", "lb-bundle-thumbnail", { "data-thumbnail": "" });
291
- if (state.product.featuredImage) {
292
- const img = document.createElement("img");
293
- 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, {
294
674
  width: THUMB_PX,
295
675
  height: THUMB_PX,
296
676
  crop: "center"
297
677
  });
298
- img.alt = state.product.featuredImage.altText ?? state.product.title;
299
- img.width = THUMB_PX;
300
- img.height = THUMB_PX;
301
- img.loading = "lazy";
302
- 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);
303
683
  } else {
304
684
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG);
305
685
  }
@@ -363,36 +743,97 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
363
743
  } else {
364
744
  unitPriceEl.setAttribute("hidden", "");
365
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
+ }
366
755
  };
367
756
  applyVariantToRow(state.selected);
368
757
  if (state.eligibleVariants.length > 1) {
369
- const select = document.createElement("select");
370
- select.className = "lb-bundle-variant-select";
371
- select.setAttribute("data-variant-select", "");
372
- select.name = `lb-variant-${state.product.id.replace(/^.*\//, "")}`;
373
- select.setAttribute(
374
- "aria-label",
375
- `Select variant for ${state.product.title}`
758
+ const optionNames = state.eligibleVariants[0].selectedOptions.map(
759
+ (o) => o.name
376
760
  );
377
- state.eligibleVariants.forEach((variant) => {
378
- const opt = document.createElement("option");
379
- opt.value = variant.id;
380
- opt.textContent = variant.title;
381
- if (variant.id === state.selected?.id) opt.selected = true;
382
- select.appendChild(opt);
383
- });
384
- select.addEventListener("change", () => {
385
- const variant = state.eligibleVariants.find(
386
- (v) => v.id === select.value
761
+ const productIdTail = state.product.id.replace(/^.*\//, "");
762
+ const optionSelects = [];
763
+ const resolveVariant = (values) => state.eligibleVariants.find(
764
+ (v) => v.selectedOptions.every((o, i) => o.value === values[i]) && v.selectedOptions.length === values.length
765
+ ) ?? null;
766
+ const syncSelectsToVariant = (variant) => {
767
+ variant.selectedOptions.forEach((o, i) => {
768
+ const sel = optionSelects[i];
769
+ if (sel && sel.value !== o.value) sel.value = o.value;
770
+ });
771
+ };
772
+ const isValueAvailable = (optionIndex, value, selected) => state.eligibleVariants.some((v) => {
773
+ if (!v.availableForSale) return false;
774
+ if (v.selectedOptions[optionIndex]?.value !== value) return false;
775
+ return v.selectedOptions.every(
776
+ (o, i) => i === optionIndex || o.value === selected[i]
387
777
  );
388
- if (!variant) return;
778
+ });
779
+ const recomputeDisabled = (selected) => {
780
+ optionSelects.forEach((sel, i) => {
781
+ Array.from(sel.options).forEach((opt) => {
782
+ opt.disabled = !isValueAvailable(i, opt.value, selected);
783
+ });
784
+ });
785
+ };
786
+ const handleChange = () => {
787
+ const values = optionSelects.map((s) => s.value);
788
+ const variant = resolveVariant(values);
789
+ if (!variant) {
790
+ if (state.selected) {
791
+ syncSelectsToVariant(state.selected);
792
+ recomputeDisabled(state.selected.selectedOptions.map((o) => o.value));
793
+ }
794
+ return;
795
+ }
389
796
  state.selected = variant;
390
797
  state.qty = qtyFor(state.product.id, variant.id);
391
798
  if (qtyBadgeRef) qtyBadgeRef.textContent = String(state.qty);
392
799
  applyVariantToRow(variant);
800
+ recomputeDisabled(variant.selectedOptions.map((o) => o.value));
393
801
  onVariantChange();
802
+ };
803
+ const groupsContainer = el("div", "lb-bundle-variant-option-groups");
804
+ optionNames.forEach((name2, position) => {
805
+ const group = el("div", "lb-bundle-variant-option-group");
806
+ const label = el("span", "lb-bundle-variant-option-label");
807
+ label.textContent = name2;
808
+ group.appendChild(label);
809
+ const select = document.createElement("select");
810
+ select.className = "lb-bundle-variant-select";
811
+ select.setAttribute("data-variant-option", "");
812
+ select.setAttribute("data-option-position", String(position + 1));
813
+ select.name = `lb-variant-${productIdTail}-${position + 1}`;
814
+ select.setAttribute("aria-label", name2);
815
+ const seen = /* @__PURE__ */ new Set();
816
+ state.eligibleVariants.forEach((v) => {
817
+ const value = v.selectedOptions[position]?.value;
818
+ if (!value || seen.has(value)) return;
819
+ seen.add(value);
820
+ const opt = document.createElement("option");
821
+ opt.value = value;
822
+ opt.textContent = value;
823
+ if (state.selected?.selectedOptions[position]?.value === value) {
824
+ opt.selected = true;
825
+ }
826
+ select.appendChild(opt);
827
+ });
828
+ select.addEventListener("change", handleChange);
829
+ optionSelects.push(select);
830
+ group.appendChild(select);
831
+ groupsContainer.appendChild(group);
394
832
  });
395
- info.appendChild(select);
833
+ info.appendChild(groupsContainer);
834
+ if (state.selected) {
835
+ recomputeDisabled(state.selected.selectedOptions.map((o) => o.value));
836
+ }
396
837
  } else if (state.eligibleVariants.length === 1 && state.product.variants.nodes.length > 1) {
397
838
  const badge = el("span", "lb-bundle-variant-badge");
398
839
  badge.textContent = state.eligibleVariants[0].title;
@@ -481,6 +922,7 @@ function computeSale(totalCents, discount, rows) {
481
922
  }
482
923
 
483
924
  // src/renderers/mix-match.ts
925
+ import { resolveBundleQty as resolveBundleQty2 } from "@lime-bundles/core";
484
926
  var PLACEHOLDER_THUMB_SVG2 = `
485
927
  <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
486
928
  <rect x="4" y="4" width="20" height="20" rx="3"></rect>
@@ -548,9 +990,6 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
548
990
  showSearch: wc.showSearch,
549
991
  onAdd: (product, variant) => addSelection(product, variant),
550
992
  onRemove: (productId, variantId) => removeSelection(productId, variantId),
551
- countFor: (productId, variantId) => selections.filter(
552
- (s) => s.productId === productId && s.variantId === variantId
553
- ).length,
554
993
  isOverMax: () => selections.length >= maxQty
555
994
  });
556
995
  root.appendChild(modal.el);
@@ -560,7 +999,7 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
560
999
  if (cta.disabled) return;
561
1000
  const lines = selections.map((s) => ({
562
1001
  merchandiseId: s.variantId,
563
- quantity: 1,
1002
+ quantity: s.quantity,
564
1003
  attributes: [
565
1004
  { key: "_lime_bundle_gid", value: bundle.id },
566
1005
  { key: "_lime_bundle_type", value: bundle.bundleType }
@@ -579,6 +1018,7 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
579
1018
  })
580
1019
  );
581
1020
  container.appendChild(root);
1021
+ onCleanup?.(() => unbindAllDropdowns(root));
582
1022
  const firstEligible = eligible.find((ep) => !ep.isOos);
583
1023
  const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
584
1024
  if (firstEligible && firstVariant) {
@@ -587,14 +1027,15 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
587
1027
  productTitle: firstEligible.product.title,
588
1028
  variantId: firstVariant.id,
589
1029
  variantTitle: firstVariant.title,
590
- imageUrl: firstEligible.product.featuredImage?.url ?? null,
1030
+ imageUrl: firstVariant.image?.url ?? firstEligible.product.featuredImage?.url ?? null,
591
1031
  priceCents: parseCents(firstVariant.price.amount),
592
1032
  compareCents: firstVariant.compareAtPrice ? parseCents(firstVariant.compareAtPrice.amount) : null,
593
1033
  unitPriceLabel: formatUnitPrice(
594
1034
  firstVariant.unitPrice,
595
1035
  firstVariant.unitPriceMeasurement,
596
1036
  currency
597
- )
1037
+ ),
1038
+ quantity: resolveBundleQty2(bundle, firstEligible.product.id, firstVariant.id)
598
1039
  });
599
1040
  }
600
1041
  afterMutation();
@@ -605,14 +1046,15 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
605
1046
  productTitle: product.title,
606
1047
  variantId: variant.id,
607
1048
  variantTitle: variant.title,
608
- imageUrl: product.featuredImage?.url ?? null,
1049
+ imageUrl: variant.image?.url ?? product.featuredImage?.url ?? null,
609
1050
  priceCents: parseCents(variant.price.amount),
610
1051
  compareCents: variant.compareAtPrice ? parseCents(variant.compareAtPrice.amount) : null,
611
1052
  unitPriceLabel: formatUnitPrice(
612
1053
  variant.unitPrice,
613
1054
  variant.unitPriceMeasurement,
614
1055
  currency
615
- )
1056
+ ),
1057
+ quantity: resolveBundleQty2(bundle, product.id, variant.id)
616
1058
  });
617
1059
  afterMutation();
618
1060
  }
@@ -779,6 +1221,9 @@ function renderFilledSlot(selection, index, currency, onRemove) {
779
1221
  } else {
780
1222
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
781
1223
  }
1224
+ const qtyBadge = el("span", "lb-bundle-qty-badge");
1225
+ qtyBadge.textContent = String(selection.quantity);
1226
+ thumb.appendChild(qtyBadge);
782
1227
  slot.appendChild(thumb);
783
1228
  const info = el("div", "lb-mix-match__filled-info");
784
1229
  const title = el("span", "lb-mix-match__filled-title");
@@ -789,14 +1234,16 @@ function renderFilledSlot(selection, index, currency, onRemove) {
789
1234
  variant.textContent = selection.variantTitle;
790
1235
  info.appendChild(variant);
791
1236
  }
1237
+ const linePrice = selection.priceCents * selection.quantity;
1238
+ const lineCompare = selection.compareCents !== null ? selection.compareCents * selection.quantity : null;
792
1239
  const priceWrap = el("span", "lb-mix-match__filled-price");
793
- if (selection.compareCents && selection.compareCents > selection.priceCents) {
1240
+ if (lineCompare !== null && lineCompare > linePrice) {
794
1241
  const compare = el("span", "lb-mix-match__filled-compare");
795
- compare.textContent = formatCents(selection.compareCents, currency);
1242
+ compare.textContent = formatCents(lineCompare, currency);
796
1243
  priceWrap.appendChild(compare);
797
1244
  }
798
1245
  const priceEl = document.createElement("span");
799
- priceEl.textContent = formatCents(selection.priceCents, currency);
1246
+ priceEl.textContent = formatCents(linePrice, currency);
800
1247
  priceWrap.appendChild(priceEl);
801
1248
  info.appendChild(priceWrap);
802
1249
  if (selection.unitPriceLabel) {
@@ -837,7 +1284,10 @@ function renderPricingSection(showCompareAtPrice) {
837
1284
  return;
838
1285
  }
839
1286
  wrap.style.display = "";
840
- const totalCents = selections.reduce((s, sel) => s + sel.priceCents, 0);
1287
+ const totalCents = selections.reduce(
1288
+ (s, sel) => s + sel.priceCents * sel.quantity,
1289
+ 0
1290
+ );
841
1291
  const saleCents = computeBundleSaleCents(totalCents, bundle.discountConfig);
842
1292
  if (showCompareAtPrice && totalCents > saleCents) {
843
1293
  compare.textContent = formatCents(totalCents, currency);
@@ -864,7 +1314,10 @@ function renderSavingsBar2() {
864
1314
  wrap.style.display = "none";
865
1315
  return;
866
1316
  }
867
- const totalCents = selections.reduce((s, sel) => s + sel.priceCents, 0);
1317
+ const totalCents = selections.reduce(
1318
+ (s, sel) => s + sel.priceCents * sel.quantity,
1319
+ 0
1320
+ );
868
1321
  const saleCents = computeBundleSaleCents(totalCents, bundle.discountConfig);
869
1322
  const savings = Math.max(0, totalCents - saleCents);
870
1323
  if (savings <= 0) {
@@ -958,8 +1411,8 @@ function renderModal(bundle, eligible, currency, handlers) {
958
1411
  rowsBuilt = true;
959
1412
  list.innerHTML = "";
960
1413
  eligible.forEach((ep) => {
961
- const availableVariants = ep.variants.filter((v) => v.availableForSale);
962
- const firstAvailVariant = availableVariants[0] ?? ep.firstAvailableVariant ?? ep.variants[0];
1414
+ const availableVariants = ep.variants;
1415
+ const firstAvailVariant = ep.variants.find((v) => v.availableForSale) ?? ep.firstAvailableVariant ?? ep.variants[0];
963
1416
  if (!firstAvailVariant) return;
964
1417
  let currentVariant = firstAvailVariant;
965
1418
  const productEl = el(
@@ -968,23 +1421,28 @@ function renderModal(bundle, eligible, currency, handlers) {
968
1421
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
969
1422
  );
970
1423
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
971
- if (ep.product.featuredImage) {
972
- const img = document.createElement("img");
973
- 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, {
974
1430
  width: THUMB_PX,
975
1431
  height: THUMB_PX,
976
1432
  crop: "center"
977
1433
  });
978
- img.alt = ep.product.featuredImage.altText ?? ep.product.title;
979
- img.width = THUMB_PX;
980
- img.height = THUMB_PX;
981
- img.loading = "lazy";
982
- 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);
983
1439
  } else {
984
1440
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
985
1441
  }
986
1442
  const countBadge = el("span", "lb-bundle-qty-badge");
987
- countBadge.style.display = "none";
1443
+ countBadge.textContent = String(
1444
+ resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1445
+ );
988
1446
  thumb.appendChild(countBadge);
989
1447
  productEl.appendChild(thumb);
990
1448
  const info = el("div", "lb-mix-match__modal-product-info");
@@ -993,7 +1451,7 @@ function renderModal(bundle, eligible, currency, handlers) {
993
1451
  info.appendChild(title);
994
1452
  const price = el("p", "lb-mix-match__modal-product-price");
995
1453
  price.textContent = formatCents(
996
- parseCents(currentVariant.price.amount),
1454
+ parseCents(currentVariant.price.amount) * resolveBundleQty2(bundle, ep.product.id, currentVariant.id),
997
1455
  currency
998
1456
  );
999
1457
  info.appendChild(price);
@@ -1020,28 +1478,53 @@ function renderModal(bundle, eligible, currency, handlers) {
1020
1478
  }
1021
1479
  };
1022
1480
  if (availableVariants.length > 1) {
1023
- const select = document.createElement("select");
1024
- select.className = "lb-mix-match__variant-select";
1025
- select.setAttribute("data-variant-select", "");
1026
- select.name = `lb-variant-${ep.product.id.replace(/^.*\//, "")}`;
1027
- select.setAttribute(
1028
- "aria-label",
1029
- `Select variant for ${ep.product.title}`
1481
+ const optionNames = availableVariants[0].selectedOptions.map(
1482
+ (o) => o.name
1030
1483
  );
1031
- availableVariants.forEach((v) => {
1032
- const opt = document.createElement("option");
1033
- opt.value = v.id;
1034
- opt.textContent = v.title;
1035
- if (v.id === firstAvailVariant.id) opt.selected = true;
1036
- select.appendChild(opt);
1484
+ const productIdTail = ep.product.id.replace(/^.*\//, "");
1485
+ const optionSelects = [];
1486
+ const resolveVariant = (values) => availableVariants.find(
1487
+ (v) => v.selectedOptions.length === values.length && v.selectedOptions.every((o, i) => o.value === values[i])
1488
+ ) ?? null;
1489
+ const syncSelectsToVariant = (v) => {
1490
+ v.selectedOptions.forEach((o, i) => {
1491
+ const sel = optionSelects[i];
1492
+ if (sel && sel.value !== o.value) sel.value = o.value;
1493
+ });
1494
+ };
1495
+ const isValueAvailable = (optionIndex, value, selected) => availableVariants.some((v) => {
1496
+ if (!v.availableForSale) return false;
1497
+ if (v.selectedOptions[optionIndex]?.value !== value) return false;
1498
+ return v.selectedOptions.every(
1499
+ (o, i) => i === optionIndex || o.value === selected[i]
1500
+ );
1037
1501
  });
1038
- select.addEventListener("change", () => {
1039
- const next = availableVariants.find((v) => v.id === select.value);
1040
- if (!next) return;
1502
+ const recomputeDisabled = (selected) => {
1503
+ optionSelects.forEach((sel, i) => {
1504
+ Array.from(sel.options).forEach((opt) => {
1505
+ opt.disabled = !isValueAvailable(i, opt.value, selected);
1506
+ });
1507
+ });
1508
+ };
1509
+ const handleChange = () => {
1510
+ const values = optionSelects.map((s) => s.value);
1511
+ const next = resolveVariant(values);
1512
+ if (!next) {
1513
+ syncSelectsToVariant(currentVariant);
1514
+ recomputeDisabled(
1515
+ currentVariant.selectedOptions.map((o) => o.value)
1516
+ );
1517
+ return;
1518
+ }
1041
1519
  currentVariant = next;
1042
1520
  row.variant = next;
1521
+ const nextQty = resolveBundleQty2(
1522
+ bundle,
1523
+ ep.product.id,
1524
+ currentVariant.id
1525
+ );
1043
1526
  price.textContent = formatCents(
1044
- parseCents(currentVariant.price.amount),
1527
+ parseCents(currentVariant.price.amount) * nextQty,
1045
1528
  currency
1046
1529
  );
1047
1530
  const nextUnitText = formatUnitPrice(
@@ -1056,9 +1539,52 @@ function renderModal(bundle, eligible, currency, handlers) {
1056
1539
  unitPrice.textContent = "";
1057
1540
  unitPrice.hidden = true;
1058
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
+ }
1551
+ recomputeDisabled(next.selectedOptions.map((o) => o.value));
1059
1552
  rowUpdateCount();
1553
+ };
1554
+ const groupsContainer = el("div", "lb-bundle-variant-option-groups");
1555
+ optionNames.forEach((name, position) => {
1556
+ const group = el("div", "lb-bundle-variant-option-group");
1557
+ const label = el("span", "lb-bundle-variant-option-label");
1558
+ label.textContent = name;
1559
+ group.appendChild(label);
1560
+ const select = document.createElement("select");
1561
+ select.className = "lb-mix-match__variant-select";
1562
+ select.setAttribute("data-variant-option", "");
1563
+ select.setAttribute("data-option-position", String(position + 1));
1564
+ select.name = `lb-variant-${productIdTail}-${position + 1}`;
1565
+ select.setAttribute("aria-label", name);
1566
+ const seen = /* @__PURE__ */ new Set();
1567
+ availableVariants.forEach((v) => {
1568
+ const value = v.selectedOptions[position]?.value;
1569
+ if (!value || seen.has(value)) return;
1570
+ seen.add(value);
1571
+ const opt = document.createElement("option");
1572
+ opt.value = value;
1573
+ opt.textContent = value;
1574
+ if (firstAvailVariant.selectedOptions[position]?.value === value) {
1575
+ opt.selected = true;
1576
+ }
1577
+ select.appendChild(opt);
1578
+ });
1579
+ select.addEventListener("change", handleChange);
1580
+ optionSelects.push(select);
1581
+ group.appendChild(select);
1582
+ groupsContainer.appendChild(group);
1060
1583
  });
1061
- info.appendChild(select);
1584
+ info.appendChild(groupsContainer);
1585
+ recomputeDisabled(
1586
+ firstAvailVariant.selectedOptions.map((o) => o.value)
1587
+ );
1062
1588
  } else if (availableVariants.length === 1 && firstAvailVariant.title !== "Default Title") {
1063
1589
  const variantLabel = el("span", "lb-mix-match__filled-variant");
1064
1590
  variantLabel.textContent = firstAvailVariant.title;
@@ -1071,13 +1597,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1071
1597
  }
1072
1598
  productEl.appendChild(info);
1073
1599
  const rowUpdateCount = () => {
1074
- const count = handlers.countFor(ep.product.id, currentVariant.id);
1075
- if (count > 0) {
1076
- countBadge.textContent = String(count);
1077
- countBadge.style.display = "";
1078
- } else {
1079
- countBadge.style.display = "none";
1080
- }
1600
+ countBadge.textContent = String(
1601
+ resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1602
+ );
1081
1603
  };
1082
1604
  if (!ep.isOos) {
1083
1605
  const addBtn = document.createElement("button");
@@ -1087,6 +1609,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1087
1609
  addBtn.addEventListener("click", () => {
1088
1610
  if (handlers.isOverMax()) return;
1089
1611
  handlers.onAdd(ep.product, currentVariant);
1612
+ close();
1090
1613
  });
1091
1614
  productEl.appendChild(addBtn);
1092
1615
  }
@@ -1095,6 +1618,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1095
1618
  productRows.push(row);
1096
1619
  list.appendChild(productEl);
1097
1620
  });
1621
+ bindAllDropdowns(list);
1098
1622
  refreshCounts();
1099
1623
  }
1100
1624
  function applySearch() {
@@ -1492,6 +2016,61 @@ function clamp(n, min, max) {
1492
2016
  // src/lime-bundle.ts
1493
2017
  import { applyWidgetConfigVars } from "@lime-bundles/core";
1494
2018
 
2019
+ // src/utils/input-mode.ts
2020
+ var NAV_KEYS = /* @__PURE__ */ new Set([
2021
+ "Tab",
2022
+ "ArrowUp",
2023
+ "ArrowDown",
2024
+ "ArrowLeft",
2025
+ "ArrowRight",
2026
+ "Home",
2027
+ "End",
2028
+ "PageUp",
2029
+ "PageDown",
2030
+ "Enter",
2031
+ " ",
2032
+ "Escape"
2033
+ ]);
2034
+ var targets = /* @__PURE__ */ new Set();
2035
+ var listenersAttached = false;
2036
+ function setAll(on) {
2037
+ const off = on === "using-mouse" ? "using-keyboard" : "using-mouse";
2038
+ for (const el2 of targets) {
2039
+ el2.classList.add(on);
2040
+ el2.classList.remove(off);
2041
+ }
2042
+ }
2043
+ function onKeyDown(e) {
2044
+ if (NAV_KEYS.has(e.key)) setAll("using-keyboard");
2045
+ }
2046
+ function onPointerDown() {
2047
+ setAll("using-mouse");
2048
+ }
2049
+ function attachListeners() {
2050
+ if (listenersAttached) return;
2051
+ listenersAttached = true;
2052
+ document.addEventListener("keydown", onKeyDown, true);
2053
+ document.addEventListener("pointerdown", onPointerDown, true);
2054
+ }
2055
+ function detachListeners() {
2056
+ if (!listenersAttached) return;
2057
+ listenersAttached = false;
2058
+ document.removeEventListener("keydown", onKeyDown, true);
2059
+ document.removeEventListener("pointerdown", onPointerDown, true);
2060
+ }
2061
+ function trackInputMode(target) {
2062
+ target.classList.add("using-mouse");
2063
+ target.classList.remove("using-keyboard");
2064
+ targets.add(target);
2065
+ attachListeners();
2066
+ return () => {
2067
+ targets.delete(target);
2068
+ target.classList.remove("using-mouse");
2069
+ target.classList.remove("using-keyboard");
2070
+ if (targets.size === 0) detachListeners();
2071
+ };
2072
+ }
2073
+
1495
2074
  // src/styles/bundle-css.ts
1496
2075
  var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle widget types */
1497
2076
 
@@ -1505,6 +2084,11 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1505
2084
  --lb-thumbnail-bg: #F0F0F0;
1506
2085
  --lb-widget-pad: 20px;
1507
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;
1508
2092
 
1509
2093
  font-family: inherit;
1510
2094
  font-size: 16px;
@@ -1776,6 +2360,56 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1776
2360
  margin-top: 8px;
1777
2361
  }
1778
2362
 
2363
+ /* Per-option variant pickers. Each option's label + select sit inside a
2364
+ .lb-bundle-variant-option-group (flex column, 2px gap between label and
2365
+ select); the groups stack inside a .lb-bundle-variant-option-groups
2366
+ parent (flex column, 12px gap between groups). The parent owns the top
2367
+ offset from the preceding unit-price line, so individual labels and
2368
+ selects don't carry their own vertical margins. */
2369
+ .lb-bundle-variant-option-groups {
2370
+ display: flex;
2371
+ flex-direction: column;
2372
+ gap: 12px;
2373
+ margin-top: 8px;
2374
+ }
2375
+
2376
+ .lb-bundle-variant-option-group {
2377
+ display: flex;
2378
+ flex-direction: column;
2379
+ gap: 2px;
2380
+ }
2381
+
2382
+ .lb-bundle-variant-option-label {
2383
+ display: block;
2384
+ margin: 0;
2385
+ font-size: 12px;
2386
+ line-height: 16px;
2387
+ font-weight: 600;
2388
+ letter-spacing: 0.05em;
2389
+ text-transform: uppercase;
2390
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2391
+ }
2392
+
2393
+ .lb-mix-match__modal-product-info .lb-bundle-variant-option-groups {
2394
+ margin-top: 4px;
2395
+ }
2396
+
2397
+ /* Focus-ring suppression for mouse users. A small JS helper \u2014 see
2398
+ packages/widget/src/utils/input-mode.ts and bundle-widget.js \u2014 toggles
2399
+ .using-mouse / .using-keyboard on the widget root (or html in the Liquid
2400
+ path) based on the customer's current input device. Default is mouse, so
2401
+ click-to-focus doesn't leave a keyboard-style ring. The modal overlay
2402
+ gets its own selector because the Liquid path reparents it to body,
2403
+ outside the widget root. */
2404
+ .using-mouse .lb-bundle-widget :focus,
2405
+ .using-mouse .lb-bundle-widget :focus-visible,
2406
+ .using-mouse .lb-mix-match__modal-overlay :focus,
2407
+ .using-mouse .lb-mix-match__modal-overlay :focus-visible {
2408
+ outline: none;
2409
+ outline-offset: 0;
2410
+ box-shadow: none;
2411
+ }
2412
+
1779
2413
  .lb-bundle-quantity {
1780
2414
  font-size: 12px;
1781
2415
  line-height: 16px;
@@ -2009,6 +2643,22 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2009
2643
  flex-direction: column;
2010
2644
  gap: 0;
2011
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;
2012
2662
  }
2013
2663
 
2014
2664
  /* Fixed bundles: product rows */
@@ -2033,9 +2683,10 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2033
2683
  border: none;
2034
2684
  }
2035
2685
 
2036
- /* Variant picker select \u2014 styled to match the variant badge aesthetic */
2686
+ /* Variant picker select \u2014 styled to match the variant badge aesthetic.
2687
+ Sits inside .lb-bundle-variant-option-group so vertical spacing is owned
2688
+ by the group/groups flex gap, not the select itself. */
2037
2689
  .lb-bundle-variant-select {
2038
- margin-top: 8px;
2039
2690
  display: inline-block;
2040
2691
  border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
2041
2692
  border-radius: var(--lb-variant-radius);
@@ -2052,7 +2703,8 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2052
2703
  background-repeat: no-repeat;
2053
2704
  background-position: right 8px center;
2054
2705
  background-size: 12px;
2055
- max-width: 100%;
2706
+ width: 50%;
2707
+ max-width: 50%;
2056
2708
  }
2057
2709
 
2058
2710
  .lb-bundle-variant-select:focus-visible {
@@ -2062,6 +2714,28 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2062
2714
  `;
2063
2715
  var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2064
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
+
2065
2739
  /* === Progress Bar === */
2066
2740
  .lb-mix-match__progress {
2067
2741
  margin-bottom: 16px;
@@ -2459,7 +3133,6 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2459
3133
 
2460
3134
  .lb-mix-match__variant-select {
2461
3135
  font-size: 12px;
2462
- margin: 4px 0 0;
2463
3136
  padding: 4px 24px 4px 8px;
2464
3137
  border: var(--lb-picker-variant-border-width) solid var(--lb-picker-variant-border-color);
2465
3138
  border-radius: var(--lb-picker-variant-radius);
@@ -2472,7 +3145,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2472
3145
  font-family: inherit;
2473
3146
  min-height: 32px;
2474
3147
  cursor: pointer;
2475
- max-width: 120px;
3148
+ width: 50%;
3149
+ max-width: 50%;
2476
3150
  appearance: none;
2477
3151
  -webkit-appearance: none;
2478
3152
  }
@@ -2556,6 +3230,11 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2556
3230
  .lb-mix-match__modal-overlay--open .lb-mix-match__modal {
2557
3231
  transform: translateY(0);
2558
3232
  }
3233
+
3234
+ .lb-mix-match__variant-select {
3235
+ width: 80%;
3236
+ max-width: 80%;
3237
+ }
2559
3238
  }
2560
3239
 
2561
3240
  /* === Reduced Motion === */
@@ -2573,6 +3252,22 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
2573
3252
  display: flex;
2574
3253
  flex-direction: column;
2575
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;
2576
3271
  }
2577
3272
 
2578
3273
  .lb-volume__tier {
@@ -2677,6 +3372,259 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
2677
3372
  }
2678
3373
 
2679
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
+ }
2680
3628
  `;
2681
3629
  var BUNDLE_SKELETON_CSS = `/* Lime Bundles \u2014 web-component loading skeleton (not mirrored to theme assets) */
2682
3630
 
@@ -3112,7 +4060,8 @@ var LimeBundleElement = class extends HTMLElement {
3112
4060
  BUNDLE_BASE_CSS,
3113
4061
  BUNDLE_FIXED_CSS,
3114
4062
  BUNDLE_MIX_MATCH_CSS,
3115
- BUNDLE_VOLUME_CSS
4063
+ BUNDLE_VOLUME_CSS,
4064
+ BUNDLE_DROPDOWN_CSS
3116
4065
  ].join("\n");
3117
4066
  this.shadow.appendChild(style);
3118
4067
  if (this.shopCustomCss) {
@@ -3129,6 +4078,7 @@ var LimeBundleElement = class extends HTMLElement {
3129
4078
  container.setAttribute("data-bundle-type", bundle.bundleType);
3130
4079
  container.setAttribute("data-bundle-gid", bundle.id);
3131
4080
  applyWidgetConfigVars(container, bundle.widgetConfig);
4081
+ this.renderCleanups.push(trackInputMode(container));
3132
4082
  const dispatch = (lines) => this.handleAddToCart(bundle, lines);
3133
4083
  const registerCleanup = (fn) => this.renderCleanups.push(fn);
3134
4084
  switch (bundle.bundleType) {
@@ -3230,6 +4180,7 @@ if (typeof customElements !== "undefined" && !customElements.get("lime-bundle"))
3230
4180
  customElements.define("lime-bundle", LimeBundleElement);
3231
4181
  }
3232
4182
  export {
3233
- LimeBundleElement
4183
+ LimeBundleElement,
4184
+ trackInputMode
3234
4185
  };
3235
4186
  //# sourceMappingURL=index.js.map