@lime-bundles/widget 2.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -77,6 +77,20 @@ function firstEnabled(opts) {
77
77
  for (let i = 0; i < opts.length; i++) if (!opts[i].disabled) return i;
78
78
  return -1;
79
79
  }
80
+ function findScrollableAncestor(el2) {
81
+ const win = el2.ownerDocument?.defaultView;
82
+ if (!win) return null;
83
+ let cur = el2.parentElement;
84
+ while (cur && cur !== el2.ownerDocument.body) {
85
+ const style = win.getComputedStyle(cur);
86
+ const overflowY = style.overflowY;
87
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "hidden") {
88
+ return cur;
89
+ }
90
+ cur = cur.parentElement;
91
+ }
92
+ return null;
93
+ }
80
94
  var VARIANT_SELECT_CLASSES = [
81
95
  "lb-bundle-variant-select",
82
96
  "lb-mix-match__variant-select"
@@ -182,6 +196,11 @@ function bindDropdown(select) {
182
196
  if (rect.width === 0) return false;
183
197
  const visibleCount = Math.min(optionEls.length || 1, MAX_VISIBLE_ITEMS);
184
198
  const desiredHeight = visibleCount * ITEM_HEIGHT_PX + LIST_PAD_Y;
199
+ const scrollable = listbox.hasAttribute("data-lb-dropdown-portal") ? null : findScrollableAncestor(trigger);
200
+ const clip = scrollable ? (() => {
201
+ const r = scrollable.getBoundingClientRect();
202
+ return { top: r.top, bottom: r.bottom };
203
+ })() : void 0;
185
204
  const result = computePosition({
186
205
  trigger: {
187
206
  top: rect.top,
@@ -190,7 +209,8 @@ function bindDropdown(select) {
190
209
  width: rect.width
191
210
  },
192
211
  viewportHeight: window.innerHeight,
193
- desiredHeight
212
+ desiredHeight,
213
+ clip
194
214
  });
195
215
  listbox.setAttribute("data-placement", result.placement);
196
216
  listbox.style.maxHeight = `${result.maxHeight}px`;
@@ -973,18 +993,36 @@ var SEARCH_CLEAR_ICON_SVG = `
973
993
  <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
974
994
  <path d="M14.348 5.652a.5.5 0 0 0-.707 0L10 9.293 6.36 5.652a.5.5 0 1 0-.708.707L9.293 10l-3.641 3.641a.5.5 0 0 0 .708.707L10 10.707l3.641 3.641a.5.5 0 0 0 .707-.707L10.707 10l3.641-3.641a.5.5 0 0 0 0-.707z"/>
975
995
  </svg>`;
996
+ var STEPPER_MINUS_SVG = `
997
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
998
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
999
+ </svg>`;
1000
+ var STEPPER_PLUS_SVG = `
1001
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1002
+ <line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1003
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1004
+ </svg>`;
1005
+ function ruleFor(bundle, productId) {
1006
+ return bundle.productRules[productId] ?? import_core6.DEFAULT_PRODUCT_RULE;
1007
+ }
1008
+ function alreadyInBundleFor(selections, productId, variantId) {
1009
+ let sum = 0;
1010
+ for (const s of selections) {
1011
+ if (s.productId === productId && s.variantId === variantId) sum += s.quantity;
1012
+ }
1013
+ return sum;
1014
+ }
976
1015
  function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
977
1016
  const wc = bundle.widgetConfig;
978
1017
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
979
1018
  const requiredQty = bundle.minQuantity ?? 1;
980
- const maxQty = bundle.maxQuantity ?? requiredQty;
1019
+ const showQtySelector = wc.mixMatchShowQuantitySelector !== false;
981
1020
  const eligible = buildEligibleProducts(bundle, wc.outOfStockBehavior);
982
1021
  const inStockCount = eligible.filter((e) => !e.isOos).length;
983
1022
  if (inStockCount < requiredQty) return;
984
1023
  const selections = [];
985
1024
  const root = el("div", "lb-mix-match", {
986
- "data-required-quantity": String(requiredQty),
987
- "data-max-quantity": String(maxQty)
1025
+ "data-required-quantity": String(requiredQty)
988
1026
  });
989
1027
  const header = renderHeader2(bundle);
990
1028
  root.appendChild(header);
@@ -1018,24 +1056,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1018
1056
  root.appendChild(placeholder);
1019
1057
  const modal = renderModal(bundle, eligible, currency, {
1020
1058
  showSearch: wc.showSearch,
1021
- onAdd: (product, variant) => addSelection(product, variant),
1022
- onRemove: (productId, variantId) => removeSelection(productId, variantId),
1023
- isOverMax: () => selections.length >= maxQty
1059
+ showQtySelector,
1060
+ selections,
1061
+ onAdd: (product, variant, quantity) => addSelection(product, variant, quantity),
1062
+ isComplete: () => selections.length >= requiredQty
1024
1063
  });
1025
1064
  root.appendChild(modal.el);
1026
1065
  const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
1027
1066
  cta.disabled = true;
1028
1067
  cta.addEventListener("click", () => {
1029
1068
  if (cta.disabled) return;
1030
- const lines = selections.map((s) => ({
1031
- merchandiseId: s.variantId,
1032
- quantity: s.quantity,
1033
- attributes: [
1034
- { key: "_lime_bundle_gid", value: bundle.id },
1035
- { key: "_lime_bundle_type", value: bundle.bundleType }
1036
- ]
1037
- }));
1038
- onAddToCart(lines);
1069
+ onAddToCart(buildCartLines(selections, bundle));
1039
1070
  });
1040
1071
  root.appendChild(cta);
1041
1072
  root.appendChild(
@@ -1049,28 +1080,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1049
1080
  );
1050
1081
  container.appendChild(root);
1051
1082
  onCleanup?.(() => unbindAllDropdowns(root));
1052
- const firstEligible = eligible.find((ep) => !ep.isOos);
1053
- const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
1054
- if (firstEligible && firstVariant) {
1055
- selections.push({
1056
- productId: firstEligible.product.id,
1057
- productTitle: firstEligible.product.title,
1058
- variantId: firstVariant.id,
1059
- variantTitle: firstVariant.title,
1060
- imageUrl: firstVariant.image?.url ?? firstEligible.product.featuredImage?.url ?? null,
1061
- priceCents: (0, import_core2.parseCents)(firstVariant.price.amount),
1062
- compareCents: firstVariant.compareAtPrice ? (0, import_core2.parseCents)(firstVariant.compareAtPrice.amount) : null,
1063
- unitPriceLabel: (0, import_core2.formatUnitPrice)(
1064
- firstVariant.unitPrice,
1065
- firstVariant.unitPriceMeasurement,
1066
- currency
1067
- ),
1068
- quantity: (0, import_core6.resolveBundleQty)(bundle, firstEligible.product.id, firstVariant.id)
1069
- });
1070
- }
1071
1083
  afterMutation();
1072
- function addSelection(product, variant) {
1073
- if (selections.length >= maxQty) return;
1084
+ function addSelection(product, variant, quantity) {
1085
+ if (selections.length >= requiredQty) return;
1086
+ const rule = ruleFor(bundle, product.id);
1087
+ const cap = (0, import_core6.maxAddableQuantity)(
1088
+ variant,
1089
+ rule.max,
1090
+ alreadyInBundleFor(selections, product.id, variant.id)
1091
+ );
1092
+ if (cap < rule.min) return;
1093
+ const clamped = Math.max(rule.min, Math.min(quantity, cap));
1074
1094
  selections.push({
1075
1095
  productId: product.id,
1076
1096
  productTitle: product.title,
@@ -1084,18 +1104,10 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1084
1104
  variant.unitPriceMeasurement,
1085
1105
  currency
1086
1106
  ),
1087
- quantity: (0, import_core6.resolveBundleQty)(bundle, product.id, variant.id)
1107
+ quantity: clamped
1088
1108
  });
1089
1109
  afterMutation();
1090
1110
  }
1091
- function removeSelection(productId, variantId) {
1092
- const idx = selections.findIndex(
1093
- (s) => s.productId === productId && s.variantId === variantId
1094
- );
1095
- if (idx === -1) return;
1096
- selections.splice(idx, 1);
1097
- afterMutation();
1098
- }
1099
1111
  function removeSlotAt(index) {
1100
1112
  if (index < 0 || index >= selections.length) return;
1101
1113
  selections.splice(index, 1);
@@ -1137,6 +1149,26 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1137
1149
  }
1138
1150
  }
1139
1151
  }
1152
+ function buildCartLines(selections, bundle) {
1153
+ const grouped = /* @__PURE__ */ new Map();
1154
+ for (const s of selections) {
1155
+ const key = `${s.productId}::${s.variantId}`;
1156
+ const existing = grouped.get(key);
1157
+ if (existing) {
1158
+ existing.quantity += s.quantity;
1159
+ } else {
1160
+ grouped.set(key, { variantId: s.variantId, quantity: s.quantity });
1161
+ }
1162
+ }
1163
+ return Array.from(grouped.values()).map((line) => ({
1164
+ merchandiseId: line.variantId,
1165
+ quantity: line.quantity,
1166
+ attributes: [
1167
+ { key: "_lime_bundle_gid", value: bundle.id },
1168
+ { key: "_lime_bundle_type", value: bundle.bundleType }
1169
+ ]
1170
+ }));
1171
+ }
1140
1172
  function renderHeader2(bundle) {
1141
1173
  const wc = bundle.widgetConfig;
1142
1174
  const header = el("div", "lb-bundle-header");
@@ -1252,7 +1284,7 @@ function renderFilledSlot(selection, index, currency, onRemove) {
1252
1284
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1253
1285
  }
1254
1286
  const qtyBadge = el("span", "lb-bundle-qty-badge");
1255
- qtyBadge.textContent = String(selection.quantity);
1287
+ qtyBadge.textContent = `\xD7${selection.quantity}`;
1256
1288
  thumb.appendChild(qtyBadge);
1257
1289
  slot.appendChild(thumb);
1258
1290
  const info = el("div", "lb-mix-match__filled-info");
@@ -1441,10 +1473,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1441
1473
  rowsBuilt = true;
1442
1474
  list.innerHTML = "";
1443
1475
  eligible.forEach((ep) => {
1476
+ const rule = ruleFor(bundle, ep.product.id);
1444
1477
  const availableVariants = ep.variants;
1445
- const firstAvailVariant = ep.variants.find(
1446
- (v) => (0, import_core6.isVariantFulfillable)(v, (0, import_core6.resolveBundleQty)(bundle, ep.product.id, v.id))
1447
- ) ?? ep.firstAvailableVariant ?? ep.variants[0];
1478
+ const firstAvailVariant = ep.variants.find((v) => (0, import_core6.isVariantFulfillable)(v, rule.min)) ?? ep.firstAvailableVariant ?? ep.variants[0];
1448
1479
  if (!firstAvailVariant) return;
1449
1480
  let currentVariant = firstAvailVariant;
1450
1481
  const productEl = el(
@@ -1453,9 +1484,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1453
1484
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
1454
1485
  );
1455
1486
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
1456
- const initialThumbVariant = ep.variants.find(
1457
- (v) => (0, import_core6.isVariantFulfillable)(v, (0, import_core6.resolveBundleQty)(bundle, ep.product.id, v.id))
1458
- ) ?? ep.variants[0] ?? null;
1487
+ const initialThumbVariant = ep.variants.find((v) => (0, import_core6.isVariantFulfillable)(v, rule.min)) ?? ep.variants[0] ?? null;
1459
1488
  const initialThumbImage = initialThumbVariant?.image ?? ep.product.featuredImage ?? null;
1460
1489
  let thumbImg = null;
1461
1490
  if (initialThumbImage) {
@@ -1474,9 +1503,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1474
1503
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1475
1504
  }
1476
1505
  const countBadge = el("span", "lb-bundle-qty-badge");
1477
- countBadge.textContent = String(
1478
- (0, import_core6.resolveBundleQty)(bundle, ep.product.id, currentVariant.id)
1479
- );
1506
+ countBadge.hidden = true;
1480
1507
  thumb.appendChild(countBadge);
1481
1508
  productEl.appendChild(thumb);
1482
1509
  const info = el("div", "lb-mix-match__modal-product-info");
@@ -1484,10 +1511,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1484
1511
  title.textContent = ep.product.title;
1485
1512
  info.appendChild(title);
1486
1513
  const price = el("p", "lb-mix-match__modal-product-price");
1487
- price.textContent = (0, import_core2.formatCents)(
1488
- (0, import_core2.parseCents)(currentVariant.price.amount) * (0, import_core6.resolveBundleQty)(bundle, ep.product.id, currentVariant.id),
1489
- currency
1490
- );
1514
+ price.textContent = (0, import_core2.formatCents)((0, import_core2.parseCents)(currentVariant.price.amount), currency);
1491
1515
  info.appendChild(price);
1492
1516
  const unitPrice = el(
1493
1517
  "p",
@@ -1510,10 +1534,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1510
1534
  lowStockBadge.hidden = true;
1511
1535
  info.appendChild(lowStockBadge);
1512
1536
  const refreshLowStockBadge = (variant) => {
1513
- const required = (0, import_core6.resolveBundleQty)(bundle, ep.product.id, variant.id);
1514
1537
  if ((0, import_core6.shouldShowLowStockBadge)(
1515
1538
  variant,
1516
- required,
1539
+ rule.min,
1517
1540
  bundle.widgetConfig.lowStockThreshold,
1518
1541
  bundle.widgetConfig.showLowStockBadge
1519
1542
  )) {
@@ -1524,11 +1547,40 @@ function renderModal(bundle, eligible, currency, handlers) {
1524
1547
  }
1525
1548
  };
1526
1549
  refreshLowStockBadge(currentVariant);
1550
+ const computeStepperBounds = () => {
1551
+ const already = alreadyInBundleFor(
1552
+ handlers.selections,
1553
+ ep.product.id,
1554
+ currentVariant.id
1555
+ );
1556
+ const cap = (0, import_core6.maxAddableQuantity)(currentVariant, rule.max, already);
1557
+ const max = Math.max(rule.min, cap);
1558
+ return { min: rule.min, max, cap };
1559
+ };
1560
+ let stepper = null;
1561
+ if (!ep.isOos && handlers.showQtySelector) {
1562
+ const qtyGroup = el(
1563
+ "div",
1564
+ "lb-bundle-variant-option-group lb-mix-match__qty-stepper-group"
1565
+ );
1566
+ const qtyLabel = el("span", "lb-bundle-variant-option-label");
1567
+ qtyLabel.textContent = "Quantity";
1568
+ qtyGroup.appendChild(qtyLabel);
1569
+ stepper = renderQtyStepper({
1570
+ initial: rule.min,
1571
+ getBounds: () => {
1572
+ const { min, max } = computeStepperBounds();
1573
+ return { min, max };
1574
+ }
1575
+ });
1576
+ qtyGroup.appendChild(stepper.el);
1577
+ info.appendChild(qtyGroup);
1578
+ }
1527
1579
  const row = {
1528
1580
  el: productEl,
1529
1581
  product: ep.product,
1530
1582
  variant: firstAvailVariant,
1531
- updateCount: () => {
1583
+ refreshFromSelections: () => {
1532
1584
  }
1533
1585
  };
1534
1586
  if (availableVariants.length > 1) {
@@ -1547,7 +1599,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1547
1599
  });
1548
1600
  };
1549
1601
  const isValueAvailable = (optionIndex, value, selected) => availableVariants.some((v) => {
1550
- if (!(0, import_core6.isVariantFulfillable)(v, (0, import_core6.resolveBundleQty)(bundle, ep.product.id, v.id))) return false;
1602
+ if (!(0, import_core6.isVariantFulfillable)(v, rule.min)) return false;
1551
1603
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
1552
1604
  return v.selectedOptions.every(
1553
1605
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -1572,15 +1624,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1572
1624
  }
1573
1625
  currentVariant = next;
1574
1626
  row.variant = next;
1575
- const nextQty = (0, import_core6.resolveBundleQty)(
1576
- bundle,
1577
- ep.product.id,
1578
- currentVariant.id
1579
- );
1580
- price.textContent = (0, import_core2.formatCents)(
1581
- (0, import_core2.parseCents)(currentVariant.price.amount) * nextQty,
1582
- currency
1583
- );
1627
+ price.textContent = (0, import_core2.formatCents)((0, import_core2.parseCents)(currentVariant.price.amount), currency);
1584
1628
  const nextUnitText = (0, import_core2.formatUnitPrice)(
1585
1629
  currentVariant.unitPrice,
1586
1630
  currentVariant.unitPriceMeasurement,
@@ -1604,7 +1648,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1604
1648
  }
1605
1649
  recomputeDisabled(next.selectedOptions.map((o) => o.value));
1606
1650
  refreshLowStockBadge(currentVariant);
1607
- rowUpdateCount();
1651
+ row.refreshFromSelections();
1608
1652
  };
1609
1653
  const groupsContainer = el("div", "lb-bundle-variant-option-groups");
1610
1654
  optionNames.forEach((name, position) => {
@@ -1651,25 +1695,45 @@ function renderModal(bundle, eligible, currency, handlers) {
1651
1695
  info.appendChild(soldOut);
1652
1696
  }
1653
1697
  productEl.appendChild(info);
1654
- const rowUpdateCount = () => {
1655
- countBadge.textContent = String(
1656
- (0, import_core6.resolveBundleQty)(bundle, ep.product.id, currentVariant.id)
1698
+ let addBtn = null;
1699
+ const refreshAddState = () => {
1700
+ if (!addBtn) return;
1701
+ const { cap } = computeStepperBounds();
1702
+ const already = alreadyInBundleFor(
1703
+ handlers.selections,
1704
+ ep.product.id,
1705
+ currentVariant.id
1657
1706
  );
1707
+ if (already > 0) {
1708
+ countBadge.textContent = String(already);
1709
+ countBadge.hidden = false;
1710
+ } else {
1711
+ countBadge.hidden = true;
1712
+ }
1713
+ addBtn.disabled = handlers.isComplete() || cap < rule.min;
1658
1714
  };
1659
1715
  if (!ep.isOos) {
1660
- const addBtn = document.createElement("button");
1716
+ const actions = el("div", "lb-mix-match__modal-product-actions");
1717
+ addBtn = document.createElement("button");
1661
1718
  addBtn.type = "button";
1662
1719
  addBtn.className = "lb-mix-match__modal-add";
1663
1720
  addBtn.textContent = "Add";
1664
1721
  addBtn.addEventListener("click", () => {
1665
- if (handlers.isOverMax()) return;
1666
- handlers.onAdd(ep.product, currentVariant);
1722
+ if (!addBtn || addBtn.disabled) return;
1723
+ if (handlers.isComplete()) return;
1724
+ const qty = stepper ? stepper.value() : rule.min;
1725
+ handlers.onAdd(ep.product, currentVariant, qty);
1726
+ if (stepper) stepper.reset(rule.min);
1667
1727
  close();
1668
1728
  });
1669
- productEl.appendChild(addBtn);
1729
+ actions.appendChild(addBtn);
1730
+ productEl.appendChild(actions);
1670
1731
  }
1671
- row.updateCount = ep.isOos ? () => {
1672
- } : rowUpdateCount;
1732
+ row.refreshFromSelections = () => {
1733
+ if (stepper) stepper.refresh();
1734
+ refreshAddState();
1735
+ };
1736
+ row.refreshFromSelections();
1673
1737
  productRows.push(row);
1674
1738
  list.appendChild(productEl);
1675
1739
  });
@@ -1704,7 +1768,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1704
1768
  let isOpen = false;
1705
1769
  function open() {
1706
1770
  if (isOpen) return;
1707
- if (handlers.isOverMax()) return;
1771
+ if (handlers.isComplete()) return;
1708
1772
  isOpen = true;
1709
1773
  buildRows();
1710
1774
  lastFocused = overlay.getRootNode().activeElement;
@@ -1729,18 +1793,72 @@ function renderModal(bundle, eligible, currency, handlers) {
1729
1793
  if (e.target === overlay) close();
1730
1794
  }
1731
1795
  function refreshCounts() {
1732
- productRows.forEach((r) => r.updateCount());
1796
+ productRows.forEach((r) => r.refreshFromSelections());
1733
1797
  }
1734
1798
  return { el: overlay, open, close, refreshCounts };
1735
1799
  }
1800
+ function renderQtyStepper(opts) {
1801
+ const wrap = el("div", "lb-mix-match__qty-stepper", {
1802
+ role: "group",
1803
+ "aria-label": "Quantity"
1804
+ });
1805
+ const minus = document.createElement("button");
1806
+ minus.type = "button";
1807
+ minus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--minus";
1808
+ minus.setAttribute("aria-label", "Decrease quantity");
1809
+ minus.innerHTML = STEPPER_MINUS_SVG;
1810
+ wrap.appendChild(minus);
1811
+ const valueEl = el("span", "lb-mix-match__qty-stepper-value", {
1812
+ "aria-live": "polite"
1813
+ });
1814
+ wrap.appendChild(valueEl);
1815
+ const plus = document.createElement("button");
1816
+ plus.type = "button";
1817
+ plus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
1818
+ plus.setAttribute("aria-label", "Increase quantity");
1819
+ plus.innerHTML = STEPPER_PLUS_SVG;
1820
+ wrap.appendChild(plus);
1821
+ let current = clamp2(opts.initial, opts.getBounds());
1822
+ function clamp2(n, b) {
1823
+ return Math.max(b.min, Math.min(b.max, n));
1824
+ }
1825
+ function paint() {
1826
+ const b = opts.getBounds();
1827
+ current = clamp2(current, b);
1828
+ valueEl.textContent = String(current);
1829
+ minus.disabled = current <= b.min;
1830
+ plus.disabled = current >= b.max;
1831
+ }
1832
+ minus.addEventListener("click", () => {
1833
+ const b = opts.getBounds();
1834
+ current = clamp2(current - 1, b);
1835
+ paint();
1836
+ });
1837
+ plus.addEventListener("click", () => {
1838
+ const b = opts.getBounds();
1839
+ current = clamp2(current + 1, b);
1840
+ paint();
1841
+ });
1842
+ paint();
1843
+ return {
1844
+ el: wrap,
1845
+ value: () => current,
1846
+ reset(qty) {
1847
+ current = clamp2(qty, opts.getBounds());
1848
+ paint();
1849
+ },
1850
+ refresh: paint
1851
+ };
1852
+ }
1736
1853
  function buildEligibleProducts(bundle, oosBehavior) {
1737
1854
  const result = [];
1738
1855
  const seen = /* @__PURE__ */ new Set();
1739
1856
  for (const product of bundle.products) {
1740
1857
  if (seen.has(product.id)) continue;
1741
1858
  seen.add(product.id);
1859
+ const rule = ruleFor(bundle, product.id);
1742
1860
  const available = product.variants.nodes.filter(
1743
- (v) => (0, import_core6.isVariantFulfillable)(v, (0, import_core6.resolveBundleQty)(bundle, product.id, v.id))
1861
+ (v) => (0, import_core6.isVariantFulfillable)(v, rule.min)
1744
1862
  );
1745
1863
  const isOos = available.length === 0;
1746
1864
  if (isOos && oosBehavior === "hide") continue;
@@ -3162,8 +3280,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3162
3280
 
3163
3281
  .lb-mix-match__modal-product-thumb {
3164
3282
  position: relative;
3165
- width: 48px;
3166
- min-width: 48px;
3283
+ width: 60px;
3284
+ min-width: 60px;
3167
3285
  /* Modal picker thumbs follow the merchant's pickerThumbnailRatio \u2014
3168
3286
  independent from the main widget's thumbnailRatio so a merchant can
3169
3287
  e.g. show tall picker thumbs with square main thumbs. */
@@ -3212,7 +3330,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3212
3330
  font-size: 12px;
3213
3331
  line-height: 20px;
3214
3332
  color: inherit;
3215
- margin: 4px 0 0;
3333
+ margin: 2px 0 0;
3216
3334
  }
3217
3335
 
3218
3336
  .lb-mix-match__modal-product-unit-price {
@@ -3251,6 +3369,86 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3251
3369
  outline-offset: 2px;
3252
3370
  }
3253
3371
 
3372
+ /* Per-pick quantity stepper inside the picker modal product row.
3373
+ Three flex cells (\u2212 / number / +) sharing a single outer border \u2014
3374
+ the same picker-variant border vars the variant select uses, so
3375
+ the two pieces visually agree even when the merchant tweaks
3376
+ --lb-picker-variant-border-width / -color / -radius. The cell
3377
+ dividers come from a 1px border on the centre value rather than
3378
+ per-button borders, so the rounded outer corners stay clean.
3379
+ Mirrors extensions/bundle-theme/assets/bundle-mix-match.css so
3380
+ the headless web component matches the Liquid theme widget. */
3381
+ /* Quantity label + stepper sit together in a .lb-bundle-variant-option-group
3382
+ wrapper so the label-to-stepper gap inherits the same 2px the variant
3383
+ pickers use. The wrapper carries the margin-top that spaces the qty
3384
+ group from the unit-price / low-stock-badge above; the stepper itself
3385
+ has no top margin so the group can be repositioned without coupling. */
3386
+ .lb-mix-match__qty-stepper-group {
3387
+ margin-top: 8px;
3388
+ /* Group is a flex column; without an explicit width it stretches to
3389
+ fill the info column so the \u2212/value/+ cells get pulled apart. Pin
3390
+ to fit-content so the wrapper hugs the stepper's natural width. */
3391
+ width: fit-content;
3392
+ }
3393
+
3394
+ .lb-mix-match__qty-stepper {
3395
+ display: inline-flex;
3396
+ align-items: stretch;
3397
+ flex-shrink: 0;
3398
+ height: 32px;
3399
+ border: var(--lb-picker-variant-border-width) solid var(--lb-picker-variant-border-color);
3400
+ border-radius: var(--lb-picker-variant-radius);
3401
+ background-color: var(--lb-picker-bg);
3402
+ overflow: hidden;
3403
+ box-sizing: border-box;
3404
+ }
3405
+
3406
+ .lb-mix-match__qty-stepper-button {
3407
+ appearance: none;
3408
+ -webkit-appearance: none;
3409
+ background: transparent;
3410
+ border: none;
3411
+ margin: 0;
3412
+ padding: 0;
3413
+ width: 28px;
3414
+ font-family: inherit;
3415
+ font-size: 16px;
3416
+ font-weight: 500;
3417
+ line-height: 1;
3418
+ color: var(--lb-picker-text);
3419
+ cursor: pointer;
3420
+ display: inline-flex;
3421
+ align-items: center;
3422
+ justify-content: center;
3423
+ }
3424
+
3425
+ .lb-mix-match__qty-stepper-button:hover:not(:disabled) {
3426
+ background: color-mix(in srgb, var(--lb-picker-text) 6%, transparent);
3427
+ }
3428
+
3429
+ .lb-mix-match__qty-stepper-button:focus-visible {
3430
+ outline: 2px solid var(--lb-primary-color);
3431
+ outline-offset: -2px;
3432
+ }
3433
+
3434
+ .lb-mix-match__qty-stepper-button:disabled {
3435
+ opacity: 0.4;
3436
+ cursor: not-allowed;
3437
+ }
3438
+
3439
+ .lb-mix-match__qty-stepper-value {
3440
+ display: inline-flex;
3441
+ align-items: center;
3442
+ justify-content: center;
3443
+ min-width: 36px;
3444
+ padding: 0 8px;
3445
+ font-family: inherit;
3446
+ font-size: 13px;
3447
+ font-weight: 500;
3448
+ color: var(--lb-picker-text);
3449
+ box-sizing: border-box;
3450
+ }
3451
+
3254
3452
  .lb-mix-match__modal-add {
3255
3453
  padding: 8px 20px;
3256
3454
  background: var(--lb-picker-add-bg);