@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.js CHANGED
@@ -69,6 +69,20 @@ function firstEnabled(opts) {
69
69
  for (let i = 0; i < opts.length; i++) if (!opts[i].disabled) return i;
70
70
  return -1;
71
71
  }
72
+ function findScrollableAncestor(el2) {
73
+ const win = el2.ownerDocument?.defaultView;
74
+ if (!win) return null;
75
+ let cur = el2.parentElement;
76
+ while (cur && cur !== el2.ownerDocument.body) {
77
+ const style = win.getComputedStyle(cur);
78
+ const overflowY = style.overflowY;
79
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "hidden") {
80
+ return cur;
81
+ }
82
+ cur = cur.parentElement;
83
+ }
84
+ return null;
85
+ }
72
86
  var VARIANT_SELECT_CLASSES = [
73
87
  "lb-bundle-variant-select",
74
88
  "lb-mix-match__variant-select"
@@ -174,6 +188,11 @@ function bindDropdown(select) {
174
188
  if (rect.width === 0) return false;
175
189
  const visibleCount = Math.min(optionEls.length || 1, MAX_VISIBLE_ITEMS);
176
190
  const desiredHeight = visibleCount * ITEM_HEIGHT_PX + LIST_PAD_Y;
191
+ const scrollable = listbox.hasAttribute("data-lb-dropdown-portal") ? null : findScrollableAncestor(trigger);
192
+ const clip = scrollable ? (() => {
193
+ const r = scrollable.getBoundingClientRect();
194
+ return { top: r.top, bottom: r.bottom };
195
+ })() : void 0;
177
196
  const result = computePosition({
178
197
  trigger: {
179
198
  top: rect.top,
@@ -182,7 +201,8 @@ function bindDropdown(select) {
182
201
  width: rect.width
183
202
  },
184
203
  viewportHeight: window.innerHeight,
185
- desiredHeight
204
+ desiredHeight,
205
+ clip
186
206
  });
187
207
  listbox.setAttribute("data-placement", result.placement);
188
208
  listbox.style.maxHeight = `${result.maxHeight}px`;
@@ -954,7 +974,12 @@ function computeSale(totalCents, discount, rows) {
954
974
  }
955
975
 
956
976
  // src/renderers/mix-match.ts
957
- import { resolveBundleQty as resolveBundleQty2, isVariantFulfillable as isVariantFulfillable2, shouldShowLowStockBadge as shouldShowLowStockBadge2 } from "@lime-bundles/core";
977
+ import {
978
+ DEFAULT_PRODUCT_RULE,
979
+ isVariantFulfillable as isVariantFulfillable2,
980
+ maxAddableQuantity,
981
+ shouldShowLowStockBadge as shouldShowLowStockBadge2
982
+ } from "@lime-bundles/core";
958
983
  var PLACEHOLDER_THUMB_SVG2 = `
959
984
  <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
960
985
  <rect x="4" y="4" width="20" height="20" rx="3"></rect>
@@ -975,18 +1000,36 @@ var SEARCH_CLEAR_ICON_SVG = `
975
1000
  <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
976
1001
  <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"/>
977
1002
  </svg>`;
1003
+ var STEPPER_MINUS_SVG = `
1004
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1005
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1006
+ </svg>`;
1007
+ var STEPPER_PLUS_SVG = `
1008
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1009
+ <line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1010
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1011
+ </svg>`;
1012
+ function ruleFor(bundle, productId) {
1013
+ return bundle.productRules[productId] ?? DEFAULT_PRODUCT_RULE;
1014
+ }
1015
+ function alreadyInBundleFor(selections, productId, variantId) {
1016
+ let sum = 0;
1017
+ for (const s of selections) {
1018
+ if (s.productId === productId && s.variantId === variantId) sum += s.quantity;
1019
+ }
1020
+ return sum;
1021
+ }
978
1022
  function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
979
1023
  const wc = bundle.widgetConfig;
980
1024
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
981
1025
  const requiredQty = bundle.minQuantity ?? 1;
982
- const maxQty = bundle.maxQuantity ?? requiredQty;
1026
+ const showQtySelector = wc.mixMatchShowQuantitySelector !== false;
983
1027
  const eligible = buildEligibleProducts(bundle, wc.outOfStockBehavior);
984
1028
  const inStockCount = eligible.filter((e) => !e.isOos).length;
985
1029
  if (inStockCount < requiredQty) return;
986
1030
  const selections = [];
987
1031
  const root = el("div", "lb-mix-match", {
988
- "data-required-quantity": String(requiredQty),
989
- "data-max-quantity": String(maxQty)
1032
+ "data-required-quantity": String(requiredQty)
990
1033
  });
991
1034
  const header = renderHeader2(bundle);
992
1035
  root.appendChild(header);
@@ -1020,24 +1063,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1020
1063
  root.appendChild(placeholder);
1021
1064
  const modal = renderModal(bundle, eligible, currency, {
1022
1065
  showSearch: wc.showSearch,
1023
- onAdd: (product, variant) => addSelection(product, variant),
1024
- onRemove: (productId, variantId) => removeSelection(productId, variantId),
1025
- isOverMax: () => selections.length >= maxQty
1066
+ showQtySelector,
1067
+ selections,
1068
+ onAdd: (product, variant, quantity) => addSelection(product, variant, quantity),
1069
+ isComplete: () => selections.length >= requiredQty
1026
1070
  });
1027
1071
  root.appendChild(modal.el);
1028
1072
  const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
1029
1073
  cta.disabled = true;
1030
1074
  cta.addEventListener("click", () => {
1031
1075
  if (cta.disabled) return;
1032
- const lines = selections.map((s) => ({
1033
- merchandiseId: s.variantId,
1034
- quantity: s.quantity,
1035
- attributes: [
1036
- { key: "_lime_bundle_gid", value: bundle.id },
1037
- { key: "_lime_bundle_type", value: bundle.bundleType }
1038
- ]
1039
- }));
1040
- onAddToCart(lines);
1076
+ onAddToCart(buildCartLines(selections, bundle));
1041
1077
  });
1042
1078
  root.appendChild(cta);
1043
1079
  root.appendChild(
@@ -1051,28 +1087,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1051
1087
  );
1052
1088
  container.appendChild(root);
1053
1089
  onCleanup?.(() => unbindAllDropdowns(root));
1054
- const firstEligible = eligible.find((ep) => !ep.isOos);
1055
- const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
1056
- if (firstEligible && firstVariant) {
1057
- selections.push({
1058
- productId: firstEligible.product.id,
1059
- productTitle: firstEligible.product.title,
1060
- variantId: firstVariant.id,
1061
- variantTitle: firstVariant.title,
1062
- imageUrl: firstVariant.image?.url ?? firstEligible.product.featuredImage?.url ?? null,
1063
- priceCents: parseCents(firstVariant.price.amount),
1064
- compareCents: firstVariant.compareAtPrice ? parseCents(firstVariant.compareAtPrice.amount) : null,
1065
- unitPriceLabel: formatUnitPrice(
1066
- firstVariant.unitPrice,
1067
- firstVariant.unitPriceMeasurement,
1068
- currency
1069
- ),
1070
- quantity: resolveBundleQty2(bundle, firstEligible.product.id, firstVariant.id)
1071
- });
1072
- }
1073
1090
  afterMutation();
1074
- function addSelection(product, variant) {
1075
- if (selections.length >= maxQty) return;
1091
+ function addSelection(product, variant, quantity) {
1092
+ if (selections.length >= requiredQty) return;
1093
+ const rule = ruleFor(bundle, product.id);
1094
+ const cap = maxAddableQuantity(
1095
+ variant,
1096
+ rule.max,
1097
+ alreadyInBundleFor(selections, product.id, variant.id)
1098
+ );
1099
+ if (cap < rule.min) return;
1100
+ const clamped = Math.max(rule.min, Math.min(quantity, cap));
1076
1101
  selections.push({
1077
1102
  productId: product.id,
1078
1103
  productTitle: product.title,
@@ -1086,18 +1111,10 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1086
1111
  variant.unitPriceMeasurement,
1087
1112
  currency
1088
1113
  ),
1089
- quantity: resolveBundleQty2(bundle, product.id, variant.id)
1114
+ quantity: clamped
1090
1115
  });
1091
1116
  afterMutation();
1092
1117
  }
1093
- function removeSelection(productId, variantId) {
1094
- const idx = selections.findIndex(
1095
- (s) => s.productId === productId && s.variantId === variantId
1096
- );
1097
- if (idx === -1) return;
1098
- selections.splice(idx, 1);
1099
- afterMutation();
1100
- }
1101
1118
  function removeSlotAt(index) {
1102
1119
  if (index < 0 || index >= selections.length) return;
1103
1120
  selections.splice(index, 1);
@@ -1139,6 +1156,26 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1139
1156
  }
1140
1157
  }
1141
1158
  }
1159
+ function buildCartLines(selections, bundle) {
1160
+ const grouped = /* @__PURE__ */ new Map();
1161
+ for (const s of selections) {
1162
+ const key = `${s.productId}::${s.variantId}`;
1163
+ const existing = grouped.get(key);
1164
+ if (existing) {
1165
+ existing.quantity += s.quantity;
1166
+ } else {
1167
+ grouped.set(key, { variantId: s.variantId, quantity: s.quantity });
1168
+ }
1169
+ }
1170
+ return Array.from(grouped.values()).map((line) => ({
1171
+ merchandiseId: line.variantId,
1172
+ quantity: line.quantity,
1173
+ attributes: [
1174
+ { key: "_lime_bundle_gid", value: bundle.id },
1175
+ { key: "_lime_bundle_type", value: bundle.bundleType }
1176
+ ]
1177
+ }));
1178
+ }
1142
1179
  function renderHeader2(bundle) {
1143
1180
  const wc = bundle.widgetConfig;
1144
1181
  const header = el("div", "lb-bundle-header");
@@ -1254,7 +1291,7 @@ function renderFilledSlot(selection, index, currency, onRemove) {
1254
1291
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1255
1292
  }
1256
1293
  const qtyBadge = el("span", "lb-bundle-qty-badge");
1257
- qtyBadge.textContent = String(selection.quantity);
1294
+ qtyBadge.textContent = `\xD7${selection.quantity}`;
1258
1295
  thumb.appendChild(qtyBadge);
1259
1296
  slot.appendChild(thumb);
1260
1297
  const info = el("div", "lb-mix-match__filled-info");
@@ -1443,10 +1480,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1443
1480
  rowsBuilt = true;
1444
1481
  list.innerHTML = "";
1445
1482
  eligible.forEach((ep) => {
1483
+ const rule = ruleFor(bundle, ep.product.id);
1446
1484
  const availableVariants = ep.variants;
1447
- const firstAvailVariant = ep.variants.find(
1448
- (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))
1449
- ) ?? ep.firstAvailableVariant ?? ep.variants[0];
1485
+ const firstAvailVariant = ep.variants.find((v) => isVariantFulfillable2(v, rule.min)) ?? ep.firstAvailableVariant ?? ep.variants[0];
1450
1486
  if (!firstAvailVariant) return;
1451
1487
  let currentVariant = firstAvailVariant;
1452
1488
  const productEl = el(
@@ -1455,9 +1491,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1455
1491
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
1456
1492
  );
1457
1493
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
1458
- const initialThumbVariant = ep.variants.find(
1459
- (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))
1460
- ) ?? ep.variants[0] ?? null;
1494
+ const initialThumbVariant = ep.variants.find((v) => isVariantFulfillable2(v, rule.min)) ?? ep.variants[0] ?? null;
1461
1495
  const initialThumbImage = initialThumbVariant?.image ?? ep.product.featuredImage ?? null;
1462
1496
  let thumbImg = null;
1463
1497
  if (initialThumbImage) {
@@ -1476,9 +1510,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1476
1510
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1477
1511
  }
1478
1512
  const countBadge = el("span", "lb-bundle-qty-badge");
1479
- countBadge.textContent = String(
1480
- resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1481
- );
1513
+ countBadge.hidden = true;
1482
1514
  thumb.appendChild(countBadge);
1483
1515
  productEl.appendChild(thumb);
1484
1516
  const info = el("div", "lb-mix-match__modal-product-info");
@@ -1486,10 +1518,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1486
1518
  title.textContent = ep.product.title;
1487
1519
  info.appendChild(title);
1488
1520
  const price = el("p", "lb-mix-match__modal-product-price");
1489
- price.textContent = formatCents(
1490
- parseCents(currentVariant.price.amount) * resolveBundleQty2(bundle, ep.product.id, currentVariant.id),
1491
- currency
1492
- );
1521
+ price.textContent = formatCents(parseCents(currentVariant.price.amount), currency);
1493
1522
  info.appendChild(price);
1494
1523
  const unitPrice = el(
1495
1524
  "p",
@@ -1512,10 +1541,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1512
1541
  lowStockBadge.hidden = true;
1513
1542
  info.appendChild(lowStockBadge);
1514
1543
  const refreshLowStockBadge = (variant) => {
1515
- const required = resolveBundleQty2(bundle, ep.product.id, variant.id);
1516
1544
  if (shouldShowLowStockBadge2(
1517
1545
  variant,
1518
- required,
1546
+ rule.min,
1519
1547
  bundle.widgetConfig.lowStockThreshold,
1520
1548
  bundle.widgetConfig.showLowStockBadge
1521
1549
  )) {
@@ -1526,11 +1554,40 @@ function renderModal(bundle, eligible, currency, handlers) {
1526
1554
  }
1527
1555
  };
1528
1556
  refreshLowStockBadge(currentVariant);
1557
+ const computeStepperBounds = () => {
1558
+ const already = alreadyInBundleFor(
1559
+ handlers.selections,
1560
+ ep.product.id,
1561
+ currentVariant.id
1562
+ );
1563
+ const cap = maxAddableQuantity(currentVariant, rule.max, already);
1564
+ const max = Math.max(rule.min, cap);
1565
+ return { min: rule.min, max, cap };
1566
+ };
1567
+ let stepper = null;
1568
+ if (!ep.isOos && handlers.showQtySelector) {
1569
+ const qtyGroup = el(
1570
+ "div",
1571
+ "lb-bundle-variant-option-group lb-mix-match__qty-stepper-group"
1572
+ );
1573
+ const qtyLabel = el("span", "lb-bundle-variant-option-label");
1574
+ qtyLabel.textContent = "Quantity";
1575
+ qtyGroup.appendChild(qtyLabel);
1576
+ stepper = renderQtyStepper({
1577
+ initial: rule.min,
1578
+ getBounds: () => {
1579
+ const { min, max } = computeStepperBounds();
1580
+ return { min, max };
1581
+ }
1582
+ });
1583
+ qtyGroup.appendChild(stepper.el);
1584
+ info.appendChild(qtyGroup);
1585
+ }
1529
1586
  const row = {
1530
1587
  el: productEl,
1531
1588
  product: ep.product,
1532
1589
  variant: firstAvailVariant,
1533
- updateCount: () => {
1590
+ refreshFromSelections: () => {
1534
1591
  }
1535
1592
  };
1536
1593
  if (availableVariants.length > 1) {
@@ -1549,7 +1606,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1549
1606
  });
1550
1607
  };
1551
1608
  const isValueAvailable = (optionIndex, value, selected) => availableVariants.some((v) => {
1552
- if (!isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))) return false;
1609
+ if (!isVariantFulfillable2(v, rule.min)) return false;
1553
1610
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
1554
1611
  return v.selectedOptions.every(
1555
1612
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -1574,15 +1631,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1574
1631
  }
1575
1632
  currentVariant = next;
1576
1633
  row.variant = next;
1577
- const nextQty = resolveBundleQty2(
1578
- bundle,
1579
- ep.product.id,
1580
- currentVariant.id
1581
- );
1582
- price.textContent = formatCents(
1583
- parseCents(currentVariant.price.amount) * nextQty,
1584
- currency
1585
- );
1634
+ price.textContent = formatCents(parseCents(currentVariant.price.amount), currency);
1586
1635
  const nextUnitText = formatUnitPrice(
1587
1636
  currentVariant.unitPrice,
1588
1637
  currentVariant.unitPriceMeasurement,
@@ -1606,7 +1655,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1606
1655
  }
1607
1656
  recomputeDisabled(next.selectedOptions.map((o) => o.value));
1608
1657
  refreshLowStockBadge(currentVariant);
1609
- rowUpdateCount();
1658
+ row.refreshFromSelections();
1610
1659
  };
1611
1660
  const groupsContainer = el("div", "lb-bundle-variant-option-groups");
1612
1661
  optionNames.forEach((name, position) => {
@@ -1653,25 +1702,45 @@ function renderModal(bundle, eligible, currency, handlers) {
1653
1702
  info.appendChild(soldOut);
1654
1703
  }
1655
1704
  productEl.appendChild(info);
1656
- const rowUpdateCount = () => {
1657
- countBadge.textContent = String(
1658
- resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1705
+ let addBtn = null;
1706
+ const refreshAddState = () => {
1707
+ if (!addBtn) return;
1708
+ const { cap } = computeStepperBounds();
1709
+ const already = alreadyInBundleFor(
1710
+ handlers.selections,
1711
+ ep.product.id,
1712
+ currentVariant.id
1659
1713
  );
1714
+ if (already > 0) {
1715
+ countBadge.textContent = String(already);
1716
+ countBadge.hidden = false;
1717
+ } else {
1718
+ countBadge.hidden = true;
1719
+ }
1720
+ addBtn.disabled = handlers.isComplete() || cap < rule.min;
1660
1721
  };
1661
1722
  if (!ep.isOos) {
1662
- const addBtn = document.createElement("button");
1723
+ const actions = el("div", "lb-mix-match__modal-product-actions");
1724
+ addBtn = document.createElement("button");
1663
1725
  addBtn.type = "button";
1664
1726
  addBtn.className = "lb-mix-match__modal-add";
1665
1727
  addBtn.textContent = "Add";
1666
1728
  addBtn.addEventListener("click", () => {
1667
- if (handlers.isOverMax()) return;
1668
- handlers.onAdd(ep.product, currentVariant);
1729
+ if (!addBtn || addBtn.disabled) return;
1730
+ if (handlers.isComplete()) return;
1731
+ const qty = stepper ? stepper.value() : rule.min;
1732
+ handlers.onAdd(ep.product, currentVariant, qty);
1733
+ if (stepper) stepper.reset(rule.min);
1669
1734
  close();
1670
1735
  });
1671
- productEl.appendChild(addBtn);
1736
+ actions.appendChild(addBtn);
1737
+ productEl.appendChild(actions);
1672
1738
  }
1673
- row.updateCount = ep.isOos ? () => {
1674
- } : rowUpdateCount;
1739
+ row.refreshFromSelections = () => {
1740
+ if (stepper) stepper.refresh();
1741
+ refreshAddState();
1742
+ };
1743
+ row.refreshFromSelections();
1675
1744
  productRows.push(row);
1676
1745
  list.appendChild(productEl);
1677
1746
  });
@@ -1706,7 +1775,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1706
1775
  let isOpen = false;
1707
1776
  function open() {
1708
1777
  if (isOpen) return;
1709
- if (handlers.isOverMax()) return;
1778
+ if (handlers.isComplete()) return;
1710
1779
  isOpen = true;
1711
1780
  buildRows();
1712
1781
  lastFocused = overlay.getRootNode().activeElement;
@@ -1731,18 +1800,72 @@ function renderModal(bundle, eligible, currency, handlers) {
1731
1800
  if (e.target === overlay) close();
1732
1801
  }
1733
1802
  function refreshCounts() {
1734
- productRows.forEach((r) => r.updateCount());
1803
+ productRows.forEach((r) => r.refreshFromSelections());
1735
1804
  }
1736
1805
  return { el: overlay, open, close, refreshCounts };
1737
1806
  }
1807
+ function renderQtyStepper(opts) {
1808
+ const wrap = el("div", "lb-mix-match__qty-stepper", {
1809
+ role: "group",
1810
+ "aria-label": "Quantity"
1811
+ });
1812
+ const minus = document.createElement("button");
1813
+ minus.type = "button";
1814
+ minus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--minus";
1815
+ minus.setAttribute("aria-label", "Decrease quantity");
1816
+ minus.innerHTML = STEPPER_MINUS_SVG;
1817
+ wrap.appendChild(minus);
1818
+ const valueEl = el("span", "lb-mix-match__qty-stepper-value", {
1819
+ "aria-live": "polite"
1820
+ });
1821
+ wrap.appendChild(valueEl);
1822
+ const plus = document.createElement("button");
1823
+ plus.type = "button";
1824
+ plus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
1825
+ plus.setAttribute("aria-label", "Increase quantity");
1826
+ plus.innerHTML = STEPPER_PLUS_SVG;
1827
+ wrap.appendChild(plus);
1828
+ let current = clamp2(opts.initial, opts.getBounds());
1829
+ function clamp2(n, b) {
1830
+ return Math.max(b.min, Math.min(b.max, n));
1831
+ }
1832
+ function paint() {
1833
+ const b = opts.getBounds();
1834
+ current = clamp2(current, b);
1835
+ valueEl.textContent = String(current);
1836
+ minus.disabled = current <= b.min;
1837
+ plus.disabled = current >= b.max;
1838
+ }
1839
+ minus.addEventListener("click", () => {
1840
+ const b = opts.getBounds();
1841
+ current = clamp2(current - 1, b);
1842
+ paint();
1843
+ });
1844
+ plus.addEventListener("click", () => {
1845
+ const b = opts.getBounds();
1846
+ current = clamp2(current + 1, b);
1847
+ paint();
1848
+ });
1849
+ paint();
1850
+ return {
1851
+ el: wrap,
1852
+ value: () => current,
1853
+ reset(qty) {
1854
+ current = clamp2(qty, opts.getBounds());
1855
+ paint();
1856
+ },
1857
+ refresh: paint
1858
+ };
1859
+ }
1738
1860
  function buildEligibleProducts(bundle, oosBehavior) {
1739
1861
  const result = [];
1740
1862
  const seen = /* @__PURE__ */ new Set();
1741
1863
  for (const product of bundle.products) {
1742
1864
  if (seen.has(product.id)) continue;
1743
1865
  seen.add(product.id);
1866
+ const rule = ruleFor(bundle, product.id);
1744
1867
  const available = product.variants.nodes.filter(
1745
- (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, product.id, v.id))
1868
+ (v) => isVariantFulfillable2(v, rule.min)
1746
1869
  );
1747
1870
  const isOos = available.length === 0;
1748
1871
  if (isOos && oosBehavior === "hide") continue;
@@ -3164,8 +3287,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3164
3287
 
3165
3288
  .lb-mix-match__modal-product-thumb {
3166
3289
  position: relative;
3167
- width: 48px;
3168
- min-width: 48px;
3290
+ width: 60px;
3291
+ min-width: 60px;
3169
3292
  /* Modal picker thumbs follow the merchant's pickerThumbnailRatio \u2014
3170
3293
  independent from the main widget's thumbnailRatio so a merchant can
3171
3294
  e.g. show tall picker thumbs with square main thumbs. */
@@ -3214,7 +3337,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3214
3337
  font-size: 12px;
3215
3338
  line-height: 20px;
3216
3339
  color: inherit;
3217
- margin: 4px 0 0;
3340
+ margin: 2px 0 0;
3218
3341
  }
3219
3342
 
3220
3343
  .lb-mix-match__modal-product-unit-price {
@@ -3253,6 +3376,86 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3253
3376
  outline-offset: 2px;
3254
3377
  }
3255
3378
 
3379
+ /* Per-pick quantity stepper inside the picker modal product row.
3380
+ Three flex cells (\u2212 / number / +) sharing a single outer border \u2014
3381
+ the same picker-variant border vars the variant select uses, so
3382
+ the two pieces visually agree even when the merchant tweaks
3383
+ --lb-picker-variant-border-width / -color / -radius. The cell
3384
+ dividers come from a 1px border on the centre value rather than
3385
+ per-button borders, so the rounded outer corners stay clean.
3386
+ Mirrors extensions/bundle-theme/assets/bundle-mix-match.css so
3387
+ the headless web component matches the Liquid theme widget. */
3388
+ /* Quantity label + stepper sit together in a .lb-bundle-variant-option-group
3389
+ wrapper so the label-to-stepper gap inherits the same 2px the variant
3390
+ pickers use. The wrapper carries the margin-top that spaces the qty
3391
+ group from the unit-price / low-stock-badge above; the stepper itself
3392
+ has no top margin so the group can be repositioned without coupling. */
3393
+ .lb-mix-match__qty-stepper-group {
3394
+ margin-top: 8px;
3395
+ /* Group is a flex column; without an explicit width it stretches to
3396
+ fill the info column so the \u2212/value/+ cells get pulled apart. Pin
3397
+ to fit-content so the wrapper hugs the stepper's natural width. */
3398
+ width: fit-content;
3399
+ }
3400
+
3401
+ .lb-mix-match__qty-stepper {
3402
+ display: inline-flex;
3403
+ align-items: stretch;
3404
+ flex-shrink: 0;
3405
+ height: 32px;
3406
+ border: var(--lb-picker-variant-border-width) solid var(--lb-picker-variant-border-color);
3407
+ border-radius: var(--lb-picker-variant-radius);
3408
+ background-color: var(--lb-picker-bg);
3409
+ overflow: hidden;
3410
+ box-sizing: border-box;
3411
+ }
3412
+
3413
+ .lb-mix-match__qty-stepper-button {
3414
+ appearance: none;
3415
+ -webkit-appearance: none;
3416
+ background: transparent;
3417
+ border: none;
3418
+ margin: 0;
3419
+ padding: 0;
3420
+ width: 28px;
3421
+ font-family: inherit;
3422
+ font-size: 16px;
3423
+ font-weight: 500;
3424
+ line-height: 1;
3425
+ color: var(--lb-picker-text);
3426
+ cursor: pointer;
3427
+ display: inline-flex;
3428
+ align-items: center;
3429
+ justify-content: center;
3430
+ }
3431
+
3432
+ .lb-mix-match__qty-stepper-button:hover:not(:disabled) {
3433
+ background: color-mix(in srgb, var(--lb-picker-text) 6%, transparent);
3434
+ }
3435
+
3436
+ .lb-mix-match__qty-stepper-button:focus-visible {
3437
+ outline: 2px solid var(--lb-primary-color);
3438
+ outline-offset: -2px;
3439
+ }
3440
+
3441
+ .lb-mix-match__qty-stepper-button:disabled {
3442
+ opacity: 0.4;
3443
+ cursor: not-allowed;
3444
+ }
3445
+
3446
+ .lb-mix-match__qty-stepper-value {
3447
+ display: inline-flex;
3448
+ align-items: center;
3449
+ justify-content: center;
3450
+ min-width: 36px;
3451
+ padding: 0 8px;
3452
+ font-family: inherit;
3453
+ font-size: 13px;
3454
+ font-weight: 500;
3455
+ color: var(--lb-picker-text);
3456
+ box-sizing: border-box;
3457
+ }
3458
+
3256
3459
  .lb-mix-match__modal-add {
3257
3460
  padding: 8px 20px;
3258
3461
  background: var(--lb-picker-add-bg);