@lime-bundles/widget 3.2.1 → 3.3.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/README.md CHANGED
@@ -61,11 +61,30 @@ document.querySelector("lime-bundle").addEventListener(
61
61
  | `app-url` | | Enables impression + add-to-cart analytics when set. |
62
62
  | `analytics` | | `"false"` suppresses analytics even when `app-url` is set. |
63
63
  | `locale` | | BCP-47 tag forwarded to the Storefront API. |
64
+ | `country` | | ISO-3166 alpha-2 (e.g. `"US"`). Adds `@inContext(country:)` to Storefront queries — Markets pricing + currency. |
65
+ | `language` | | ISO-639-1 (e.g. `"EN"`). Adds `@inContext(language:)`. |
66
+ | `market-id` | | Current visitor's Market GID. Hides market-restricted bundles whose allow-list doesn't include this market. |
64
67
 
65
68
  **Product resolution** (when `bundle-gid` is absent): explicit `product-handle` → `<meta name="shopify:product-handle">` → `/products/<handle>` URL segment.
66
69
 
67
70
  Changing any attribute at runtime re-fetches and re-renders.
68
71
 
72
+ ### B2B buyer identity (programmatic property only)
73
+
74
+ `buyer` is **not** an HTML attribute. Set it on the element after creation so the customer access token never lands in DOM-snapshot tools (Sentry, analytics, browser extensions) or Referer headers:
75
+
76
+ ```js
77
+ const el = document.querySelector("lime-bundle");
78
+ el.buyer = async () => {
79
+ // Resolved per request — no token in static markup
80
+ return { customerAccessToken: await fetchToken(), companyLocationId: "gid://shopify/CompanyLocation/9" };
81
+ };
82
+ ```
83
+
84
+ The Customer Account API PKCE flow is the canonical token source. See https://shopify.dev/docs/api/customer/latest.
85
+
86
+ Buyer-contextual Storefront responses MUST be `Cache-Control: private` — never share across users.
87
+
69
88
  ## Events
70
89
 
71
90
  All events bubble and pierce shadow-DOM boundaries (`composed: true`), so you can listen on any ancestor.
package/dist/index.cjs CHANGED
@@ -150,6 +150,7 @@ function bindDropdown(select) {
150
150
  let optionEls = [];
151
151
  let instance;
152
152
  function syncFromSelect() {
153
+ trigger.disabled = select.disabled;
153
154
  const opts = readOptions(select);
154
155
  const idx = select.selectedIndex;
155
156
  triggerLabel.textContent = idx >= 0 && opts[idx] ? opts[idx].label : "";
@@ -1044,20 +1045,26 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1044
1045
  root.appendChild(pricingSection.el);
1045
1046
  const savingsBar = wc.savingsBar.visible ? renderSavingsBar2() : null;
1046
1047
  if (savingsBar) root.appendChild(savingsBar.el);
1048
+ const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
1049
+ cta.disabled = true;
1050
+ cta.addEventListener("click", () => {
1051
+ if (cta.disabled) return;
1052
+ onAddToCart(buildCartLines(selections, bundle));
1053
+ });
1047
1054
  const modal = renderModal(bundle, eligible, currency, {
1048
1055
  showSearch: wc.showSearch,
1049
1056
  showQtySelector,
1050
1057
  selections,
1051
1058
  onAdd: (product, variant, quantity) => addSelection(product, variant, quantity),
1052
- isComplete: () => selections.length >= requiredQty
1059
+ isComplete: () => selections.length >= requiredQty,
1060
+ getFocusAfterAdd: () => {
1061
+ const empty = slotsContainer.querySelector(
1062
+ ".lb-mix-match__slot--empty"
1063
+ );
1064
+ return empty ?? cta;
1065
+ }
1053
1066
  });
1054
1067
  root.appendChild(modal.el);
1055
- const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
1056
- cta.disabled = true;
1057
- cta.addEventListener("click", () => {
1058
- if (cta.disabled) return;
1059
- onAddToCart(buildCartLines(selections, bundle));
1060
- });
1061
1068
  root.appendChild(cta);
1062
1069
  root.appendChild(
1063
1070
  el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
@@ -1189,12 +1196,14 @@ function renderProgress(requiredQty) {
1189
1196
  const wrap = el("div", "lb-mix-match__progress");
1190
1197
  const labels = el("div", "lb-mix-match__progress-labels");
1191
1198
  const count = el("span", "lb-mix-match__progress-count", {
1192
- "data-progress-count": ""
1199
+ "data-progress-count": "",
1200
+ "aria-live": "polite"
1193
1201
  });
1194
1202
  count.textContent = `0 of ${requiredQty} selected`;
1195
1203
  labels.appendChild(count);
1196
1204
  const remaining = el("span", "lb-mix-match__progress-remaining", {
1197
- "data-progress-remaining": ""
1205
+ "data-progress-remaining": "",
1206
+ "aria-live": "polite"
1198
1207
  });
1199
1208
  remaining.textContent = `${requiredQty} more to go`;
1200
1209
  labels.appendChild(remaining);
@@ -1569,12 +1578,12 @@ function renderModal(bundle, eligible, currency, handlers) {
1569
1578
  refreshFromSelections: () => {
1570
1579
  }
1571
1580
  };
1581
+ const optionSelects = [];
1572
1582
  if (availableVariants.length > 1) {
1573
1583
  const optionNames = availableVariants[0].selectedOptions.map(
1574
1584
  (o) => o.name
1575
1585
  );
1576
1586
  const productIdTail = ep.product.id.replace(/^.*\//, "");
1577
- const optionSelects = [];
1578
1587
  const resolveVariant = (values) => availableVariants.find(
1579
1588
  (v) => v.selectedOptions.length === values.length && v.selectedOptions.every((o, i) => o.value === values[i])
1580
1589
  ) ?? null;
@@ -1683,7 +1692,13 @@ function renderModal(bundle, eligible, currency, handlers) {
1683
1692
  productEl.appendChild(info);
1684
1693
  let addBtn = null;
1685
1694
  const refreshAddState = () => {
1686
- if (!addBtn) return;
1695
+ if (!addBtn) {
1696
+ optionSelects.forEach((sel) => {
1697
+ sel.disabled = true;
1698
+ });
1699
+ if (stepper) stepper.setExternallyDisabled(true);
1700
+ return;
1701
+ }
1687
1702
  const { cap } = computeStepperBounds();
1688
1703
  const already = alreadyInBundleFor(
1689
1704
  handlers.selections,
@@ -1702,12 +1717,19 @@ function renderModal(bundle, eligible, currency, handlers) {
1702
1717
  if (productInBundle) {
1703
1718
  addBtn.disabled = true;
1704
1719
  addBtn.textContent = "Added";
1720
+ addBtn.setAttribute("aria-label", `Added ${ep.product.title}`);
1705
1721
  productEl.classList.add("lb-mix-match__modal-product--in-bundle");
1706
1722
  } else {
1707
1723
  addBtn.textContent = "Add";
1724
+ addBtn.setAttribute("aria-label", `Add ${ep.product.title}`);
1708
1725
  productEl.classList.remove("lb-mix-match__modal-product--in-bundle");
1709
1726
  addBtn.disabled = handlers.isComplete() || cap < rule.min;
1710
1727
  }
1728
+ const rowDisabled = addBtn.disabled;
1729
+ optionSelects.forEach((sel) => {
1730
+ sel.disabled = rowDisabled;
1731
+ });
1732
+ if (stepper) stepper.setExternallyDisabled(rowDisabled);
1711
1733
  };
1712
1734
  if (!ep.isOos) {
1713
1735
  const actions = el("div", "lb-mix-match__modal-product-actions");
@@ -1716,12 +1738,15 @@ function renderModal(bundle, eligible, currency, handlers) {
1716
1738
  addBtn.type = "button";
1717
1739
  addBtn.className = "lb-mix-match__modal-add";
1718
1740
  addBtn.textContent = "Add";
1741
+ addBtn.setAttribute("aria-label", `Add ${ep.product.title}`);
1719
1742
  addBtn.addEventListener("click", () => {
1720
1743
  if (!addBtn || addBtn.disabled) return;
1721
1744
  if (handlers.isComplete()) return;
1722
1745
  const qty = stepper ? stepper.value() : rule.min;
1723
1746
  handlers.onAdd(ep.product, currentVariant, qty);
1724
1747
  if (stepper) stepper.reset(rule.min);
1748
+ const nextFocus = handlers.getFocusAfterAdd();
1749
+ if (nextFocus) lastFocused = nextFocus;
1725
1750
  close();
1726
1751
  });
1727
1752
  actions.appendChild(addBtn);
@@ -1817,6 +1842,7 @@ function renderQtyStepper(opts) {
1817
1842
  plus.innerHTML = STEPPER_PLUS_SVG;
1818
1843
  wrap.appendChild(plus);
1819
1844
  let current = clamp2(opts.initial, opts.getBounds());
1845
+ let externallyDisabled = false;
1820
1846
  function clamp2(n, b) {
1821
1847
  return Math.max(b.min, Math.min(b.max, n));
1822
1848
  }
@@ -1824,8 +1850,8 @@ function renderQtyStepper(opts) {
1824
1850
  const b = opts.getBounds();
1825
1851
  current = clamp2(current, b);
1826
1852
  valueEl.textContent = String(current);
1827
- minus.disabled = current <= b.min;
1828
- plus.disabled = current >= b.max;
1853
+ minus.disabled = externallyDisabled || current <= b.min;
1854
+ plus.disabled = externallyDisabled || current >= b.max;
1829
1855
  }
1830
1856
  minus.addEventListener("click", () => {
1831
1857
  const b = opts.getBounds();
@@ -1845,7 +1871,11 @@ function renderQtyStepper(opts) {
1845
1871
  current = clamp2(qty, opts.getBounds());
1846
1872
  paint();
1847
1873
  },
1848
- refresh: paint
1874
+ refresh: paint,
1875
+ setExternallyDisabled(disabled) {
1876
+ externallyDisabled = disabled;
1877
+ paint();
1878
+ }
1849
1879
  };
1850
1880
  }
1851
1881
  function buildEligibleProducts(bundle, oosBehavior) {
@@ -2492,6 +2522,13 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2492
2522
  text-decoration: underline;
2493
2523
  }
2494
2524
 
2525
+ a.lb-bundle-product-name:focus-visible {
2526
+ outline: 2px solid var(--lb-primary-color);
2527
+ outline-offset: 0;
2528
+ box-shadow: none;
2529
+ border-radius: 2px;
2530
+ }
2531
+
2495
2532
  .lb-bundle-product-price {
2496
2533
  /* --lb-product-price-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
2497
2534
  display: var(--lb-product-price-display, block);
@@ -2940,6 +2977,7 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
2940
2977
  .lb-bundle-variant-select:focus-visible {
2941
2978
  outline: 2px solid var(--lb-primary-color);
2942
2979
  outline-offset: 2px;
2980
+ box-shadow: none;
2943
2981
  }
2944
2982
  `;
2945
2983
  var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
@@ -3017,7 +3055,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3017
3055
 
3018
3056
  .lb-mix-match__slot--empty:focus-visible {
3019
3057
  outline: 2px solid var(--lb-primary-color);
3020
- outline-offset: 2px;
3058
+ outline-offset: -2px;
3059
+ box-shadow: none;
3021
3060
  border-radius: 8px;
3022
3061
  }
3023
3062
 
@@ -3084,6 +3123,13 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3084
3123
  text-decoration: underline;
3085
3124
  }
3086
3125
 
3126
+ .lb-mix-match__slot--filled a.lb-mix-match__filled-title:focus-visible {
3127
+ outline: 2px solid var(--lb-primary-color);
3128
+ outline-offset: 2px;
3129
+ box-shadow: none;
3130
+ border-radius: 2px;
3131
+ }
3132
+
3087
3133
  /* .lb-mix-match__filled-variant \u2014 styled jointly with
3088
3134
  .lb-bundle-variant-badge above to keep the variant text consistent
3089
3135
  across mix-match and fixed bundle widgets. */
@@ -3129,7 +3175,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3129
3175
 
3130
3176
  .lb-mix-match__slot-remove:focus-visible {
3131
3177
  outline: 2px solid var(--lb-primary-color);
3132
- outline-offset: 2px;
3178
+ outline-offset: -2px;
3179
+ box-shadow: none;
3133
3180
  }
3134
3181
 
3135
3182
  /* === Modal Overlay === */
@@ -3209,7 +3256,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3209
3256
 
3210
3257
  .lb-mix-match__modal-close:focus-visible {
3211
3258
  outline: 2px solid var(--lb-primary-color);
3212
- outline-offset: 2px;
3259
+ outline-offset: 0;
3260
+ box-shadow: none;
3213
3261
  }
3214
3262
 
3215
3263
  /* === Modal Search === */
@@ -3237,9 +3285,10 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3237
3285
  color: color-mix(in srgb, var(--lb-picker-text) 50%, transparent);
3238
3286
  }
3239
3287
 
3240
- .lb-mix-match__modal-search-input:focus {
3241
- outline: none;
3242
- box-shadow: 0 0 0 1px var(--lb-primary-color);
3288
+ .lb-mix-match__modal-search-input:focus-visible {
3289
+ outline: 2px solid var(--lb-primary-color);
3290
+ outline-offset: 2px;
3291
+ box-shadow: none;
3243
3292
  }
3244
3293
 
3245
3294
  .lb-mix-match__modal-search-clear {
@@ -3372,6 +3421,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3372
3421
  .lb-mix-match__variant-select:focus-visible {
3373
3422
  outline: 2px solid var(--lb-primary-color);
3374
3423
  outline-offset: 2px;
3424
+ box-shadow: none;
3375
3425
  }
3376
3426
 
3377
3427
  /* Per-pick quantity stepper inside the picker modal product row.
@@ -3426,13 +3476,10 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3426
3476
  justify-content: center;
3427
3477
  }
3428
3478
 
3429
- .lb-mix-match__qty-stepper-button:hover:not(:disabled) {
3430
- background: color-mix(in srgb, var(--lb-picker-text) 6%, transparent);
3431
- }
3432
-
3433
3479
  .lb-mix-match__qty-stepper-button:focus-visible {
3434
3480
  outline: 2px solid var(--lb-primary-color);
3435
3481
  outline-offset: -2px;
3482
+ box-shadow: none;
3436
3483
  }
3437
3484
 
3438
3485
  .lb-mix-match__qty-stepper-button:disabled {
@@ -3488,6 +3535,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3488
3535
  .lb-mix-match__modal-add:focus-visible {
3489
3536
  outline: 2px solid var(--lb-primary-color);
3490
3537
  outline-offset: 2px;
3538
+ box-shadow: none;
3491
3539
  }
3492
3540
 
3493
3541
  .lb-mix-match__modal-add:disabled {
@@ -3605,12 +3653,14 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
3605
3653
  border-color: var(--lb-tier-selected-border-color);
3606
3654
  }
3607
3655
 
3608
- /* Suppress the UA default focus outline so a freshly-clicked selected
3609
- tier doesn't briefly show the 1px focus ring on top of (or in place of)
3610
- the custom selected-state outline below. Keyboard focus is still
3611
- indicated by the :focus-visible rule. */
3656
+ /* Suppress the UA default focus outline + Dawn's focus shadow so a
3657
+ freshly-clicked selected tier doesn't briefly show the 1px focus ring
3658
+ or shadow on top of (or in place of) the custom selected-state
3659
+ outline below. Keyboard focus is still indicated by the
3660
+ :focus-visible rule. */
3612
3661
  .lb-volume__tier:focus {
3613
3662
  outline: none;
3663
+ box-shadow: none;
3614
3664
  }
3615
3665
 
3616
3666
  /* Keyboard focus indicator \u2014 explicitly excludes the selected tier so
@@ -3618,7 +3668,8 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
3618
3668
  outline property when both states apply at once. */
3619
3669
  .lb-volume__tier:focus-visible:not([aria-checked="true"]) {
3620
3670
  outline: 2px solid var(--lb-primary-color);
3621
- outline-offset: 2px;
3671
+ outline-offset: -2px;
3672
+ box-shadow: none;
3622
3673
  }
3623
3674
 
3624
3675
  .lb-volume__tier[aria-checked="true"] {
@@ -3766,6 +3817,7 @@ var BUNDLE_DROPDOWN_CSS = `/**
3766
3817
  .lb-dropdown-trigger:focus-visible {
3767
3818
  outline: 2px solid var(--lb-primary-color);
3768
3819
  outline-offset: 2px;
3820
+ box-shadow: none;
3769
3821
  }
3770
3822
 
3771
3823
  .lb-dropdown-trigger[aria-expanded="true"] {
@@ -4078,8 +4130,19 @@ var LimeBundleElement = class extends HTMLElement {
4078
4130
  "product-handle",
4079
4131
  "app-url",
4080
4132
  "analytics",
4081
- "locale"
4133
+ "locale",
4134
+ "country",
4135
+ "language",
4136
+ "market-id"
4082
4137
  ];
4138
+ /**
4139
+ * B2B buyer identity. Set programmatically — `element.buyer = {...}` or
4140
+ * `element.buyer = () => fetchToken()`. NEVER expose as an HTML attribute
4141
+ * because the customer access token would land in DOM snapshots (Sentry,
4142
+ * analytics, browser extensions) and Referer headers. See the package
4143
+ * README ("Markets & B2B").
4144
+ */
4145
+ buyer = void 0;
4083
4146
  shadow;
4084
4147
  bundles = [];
4085
4148
  abortController = null;
@@ -4142,6 +4205,40 @@ var LimeBundleElement = class extends HTMLElement {
4142
4205
  get analyticsEnabled() {
4143
4206
  return this.getAttribute("analytics") !== "false";
4144
4207
  }
4208
+ get country() {
4209
+ return this.getAttribute("country") ?? void 0;
4210
+ }
4211
+ get language() {
4212
+ return this.getAttribute("language") ?? void 0;
4213
+ }
4214
+ get marketId() {
4215
+ return this.getAttribute("market-id") ?? void 0;
4216
+ }
4217
+ /**
4218
+ * Returns the @inContext-wrapped query when any context field is set;
4219
+ * otherwise the plain query. Keeps responses publicly cacheable when
4220
+ * no buyer/country/language is set.
4221
+ */
4222
+ wrapQueryForContext(query) {
4223
+ return (0, import_core8.hasInContext)({
4224
+ shopDomain: this.shopDomain,
4225
+ accessToken: this.storefrontToken,
4226
+ country: this.country,
4227
+ language: this.language,
4228
+ buyer: this.buyer
4229
+ }) ? (0, import_core8.withInContext)(query) : query;
4230
+ }
4231
+ /**
4232
+ * Returns true if this bundle should be hidden for the current market.
4233
+ * For "all" bundles, always returns false (visible). For "specific"
4234
+ * bundles, returns true when no marketId was set or when the bundle's
4235
+ * marketIds doesn't include the configured market.
4236
+ */
4237
+ isMarketHidden(bundle) {
4238
+ if (bundle.marketVisibility !== "specific") return false;
4239
+ if (!this.marketId) return true;
4240
+ return !bundle.marketIds.includes(this.marketId);
4241
+ }
4145
4242
  async fetchBundle() {
4146
4243
  if (!this.shopDomain || !this.storefrontToken) {
4147
4244
  this.renderError(
@@ -4155,7 +4252,10 @@ var LimeBundleElement = class extends HTMLElement {
4155
4252
  this.renderLoading();
4156
4253
  const client = (0, import_core8.createStorefrontClient)({
4157
4254
  shopDomain: this.shopDomain,
4158
- accessToken: this.storefrontToken
4255
+ accessToken: this.storefrontToken,
4256
+ country: this.country,
4257
+ language: this.language,
4258
+ buyer: this.buyer
4159
4259
  });
4160
4260
  try {
4161
4261
  let bundlePromise;
@@ -4239,7 +4339,7 @@ var LimeBundleElement = class extends HTMLElement {
4239
4339
  }
4240
4340
  async fetchSingleBundle(client, signal) {
4241
4341
  const data = await client.query(
4242
- import_core8.BUNDLE_METAOBJECT_QUERY,
4342
+ this.wrapQueryForContext(import_core8.BUNDLE_METAOBJECT_QUERY),
4243
4343
  { id: this.bundleGid },
4244
4344
  { signal }
4245
4345
  );
@@ -4251,11 +4351,11 @@ var LimeBundleElement = class extends HTMLElement {
4251
4351
  data.metaobject.id,
4252
4352
  data.metaobject.fields
4253
4353
  );
4254
- this.bundles = parsed ? [parsed] : [];
4354
+ this.bundles = parsed && !this.isMarketHidden(parsed) ? [parsed] : [];
4255
4355
  }
4256
4356
  async fetchProductBundles(client, signal, productHandle) {
4257
4357
  const data = await client.query(
4258
- import_core8.BUNDLES_FOR_PRODUCT_QUERY,
4358
+ this.wrapQueryForContext(import_core8.BUNDLES_FOR_PRODUCT_QUERY),
4259
4359
  { handle: productHandle },
4260
4360
  { signal }
4261
4361
  );
@@ -4267,7 +4367,7 @@ var LimeBundleElement = class extends HTMLElement {
4267
4367
  const bundles = [];
4268
4368
  for (const ref of refs) {
4269
4369
  const parsed = (0, import_core8.parseMetaobjectBundle)(ref.id, ref.fields);
4270
- if (parsed) bundles.push(parsed);
4370
+ if (parsed && !this.isMarketHidden(parsed)) bundles.push(parsed);
4271
4371
  }
4272
4372
  this.bundles = bundles;
4273
4373
  }