@liquidcommerce/elements-sdk 2.7.25 → 2.7.26

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.
Files changed (81) hide show
  1. package/README.md +20 -4
  2. package/dist/index.checkout.esm.js +8410 -7130
  3. package/dist/index.esm.js +13783 -11655
  4. package/dist/types/core/a11y/announcer.service.d.ts +16 -0
  5. package/dist/types/core/a11y/focus-manager.service.d.ts +21 -0
  6. package/dist/types/core/a11y/focusable.d.ts +5 -0
  7. package/dist/types/core/a11y/glyph-button.d.ts +8 -0
  8. package/dist/types/core/a11y/index.d.ts +5 -0
  9. package/dist/types/core/a11y/single-select.d.ts +16 -0
  10. package/dist/types/core/base-component.service.d.ts +2 -0
  11. package/dist/types/interfaces/configs/global.interface.d.ts +2 -0
  12. package/dist/types/modules/address/address-display.component.d.ts +3 -1
  13. package/dist/types/modules/address/address-input.component.d.ts +10 -1
  14. package/dist/types/modules/cart/components/cart-body.component.d.ts +1 -0
  15. package/dist/types/modules/cart/components/cart-item-quantity-price.component.d.ts +5 -0
  16. package/dist/types/modules/cart/components/cart-item.component.d.ts +6 -0
  17. package/dist/types/modules/checkout/components/checkout-completed.component.d.ts +2 -0
  18. package/dist/types/modules/checkout/components/checkout-item-quantity.component.d.ts +1 -0
  19. package/dist/types/modules/checkout/components/checkout-payment.component.d.ts +1 -0
  20. package/dist/types/modules/checkout/components/checkout-place-order-button.component.d.ts +2 -0
  21. package/dist/types/modules/product/components/components.d.ts +6 -1
  22. package/dist/types/modules/product/components/product-add-to-cart-section.component.d.ts +6 -1
  23. package/dist/types/modules/product/components/product-drawer.component.d.ts +6 -0
  24. package/dist/types/modules/product/components/product-image-carousel.component.d.ts +1 -0
  25. package/dist/types/modules/product/components/product-retailers-carousel.component.d.ts +1 -0
  26. package/dist/types/modules/product/components/product-retailers-popup-list.component.d.ts +5 -0
  27. package/dist/types/modules/product/product.commands.d.ts +2 -2
  28. package/dist/types/modules/product-list/components/card-components/product-price-and-personalization.d.ts +2 -1
  29. package/dist/types/modules/product-list/components/card-components/product-quantity-selector.d.ts +2 -1
  30. package/dist/types/modules/product-list/components/card-components/product-sizes-list.d.ts +1 -0
  31. package/dist/types/modules/product-list/components/filter-components/product-list-fulfillment-filter.d.ts +1 -0
  32. package/dist/types/modules/product-list/components/product-list-filters.component.d.ts +1 -0
  33. package/dist/types/modules/product-list/components/product-list-product-pre-cart.component.d.ts +2 -0
  34. package/dist/types/modules/product-list/product-list.commands.d.ts +1 -1
  35. package/dist/types/modules/product-list/product-list.component.d.ts +3 -0
  36. package/dist/types/modules/theme-provider/services/contrast-guard.service.d.ts +19 -0
  37. package/dist/types/modules/ui-components/alert/alert.component.d.ts +1 -1
  38. package/dist/types/modules/ui-components/buttons/buttons-open-cart.component.d.ts +5 -0
  39. package/dist/types/modules/ui-components/drawer/drawer.component.d.ts +14 -0
  40. package/dist/types/modules/ui-components/engraving/engraving-form.component.d.ts +2 -0
  41. package/dist/types/modules/ui-components/engraving/engraving-view.component.d.ts +1 -0
  42. package/dist/types/modules/ui-components/input/input.component.d.ts +5 -0
  43. package/dist/types/modules/ui-components/lce-element/lce-element.component.d.ts +9 -1
  44. package/dist/types/modules/ui-components/ui.commands.d.ts +3 -1
  45. package/dist/types/static/icon/arrow-right.icon.d.ts +1 -1
  46. package/dist/types/static/icon/bag.icon.d.ts +1 -1
  47. package/dist/types/static/icon/check.icon.d.ts +1 -1
  48. package/dist/types/static/icon/checkbox.icon.d.ts +1 -1
  49. package/dist/types/static/icon/chevron-down.icon.d.ts +1 -1
  50. package/dist/types/static/icon/chevron-left.icon.d.ts +1 -1
  51. package/dist/types/static/icon/chevron-up.icon.d.ts +1 -1
  52. package/dist/types/static/icon/close.icon.d.ts +1 -1
  53. package/dist/types/static/icon/error-info.icon.d.ts +1 -1
  54. package/dist/types/static/icon/filter.icon.d.ts +1 -1
  55. package/dist/types/static/icon/icon.a11y.d.ts +1 -0
  56. package/dist/types/static/icon/icon.types.d.ts +1 -0
  57. package/dist/types/static/icon/index.d.ts +1 -0
  58. package/dist/types/static/icon/info.icon.d.ts +1 -1
  59. package/dist/types/static/icon/loading-spinner.icon.d.ts +1 -1
  60. package/dist/types/static/icon/search.icon.d.ts +1 -1
  61. package/dist/types/static/icon/success.icon.d.ts +1 -1
  62. package/dist/types/static/icon/trash.icon.d.ts +1 -1
  63. package/dist/types/static/icon/warning.icon.d.ts +1 -1
  64. package/dist/types/utils/color-contrast.d.ts +16 -0
  65. package/docs/v1/README.md +3 -0
  66. package/docs/v1/api/actions/address-actions.md +1 -1
  67. package/docs/v1/api/client.md +4 -3
  68. package/docs/v1/api/configuration.md +13 -0
  69. package/docs/v1/api/ui-helpers.md +2 -2
  70. package/docs/v1/getting-started/concepts.md +24 -7
  71. package/docs/v1/getting-started/installation.md +2 -2
  72. package/docs/v1/getting-started/quick-start.md +8 -3
  73. package/docs/v1/guides/accessibility.md +190 -0
  74. package/docs/v1/guides/address-component.md +3 -3
  75. package/docs/v1/guides/best-practices.md +1 -1
  76. package/docs/v1/guides/cart-component.md +3 -3
  77. package/docs/v1/guides/events.md +18 -5
  78. package/docs/v1/guides/theming.md +6 -1
  79. package/docs/v1/integration/vanilla-js.md +1 -1
  80. package/docs/v1/reference/error-handling.md +1 -1
  81. package/package.json +43 -14
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const FilterIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const FilterIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -0,0 +1 @@
1
+ export declare function iconA11yAttributes(label?: string): string;
@@ -3,4 +3,5 @@ export interface IconProps {
3
3
  height?: number;
4
4
  className?: string;
5
5
  color?: string | null;
6
+ label?: string;
6
7
  }
@@ -7,6 +7,7 @@ export * from './chevron-left.icon';
7
7
  export * from './chevron-up.icon';
8
8
  export * from './close.icon';
9
9
  export * from './error-info.icon';
10
+ export * from './icon.a11y';
10
11
  export * from './icon.types';
11
12
  export * from './info.icon';
12
13
  export * from './loading-spinner.icon';
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const InfoIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const InfoIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const LoadingSpinnerIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const LoadingSpinnerIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const SearchIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const SearchIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const SuccessIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const SuccessIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const TrashIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const TrashIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -1,2 +1,2 @@
1
1
  import type { IconProps } from './icon.types';
2
- export declare const WarningIcon: ({ width, height, className, color }: IconProps) => string;
2
+ export declare const WarningIcon: ({ width, height, className, color, label }: IconProps) => string;
@@ -0,0 +1,16 @@
1
+ export interface IRgbColor {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ a: number;
6
+ }
7
+ export declare const TEXT_CONTRAST_MIN = 4.5;
8
+ export declare const NON_TEXT_CONTRAST_MIN = 3;
9
+ export declare function parseColor(value: string | null | undefined): IRgbColor | null;
10
+ export declare function toHexColor({ r, g, b }: IRgbColor): string;
11
+ export declare function compositeOver(foreground: IRgbColor, background: IRgbColor): IRgbColor;
12
+ export declare function relativeLuminance({ r, g, b }: IRgbColor): number;
13
+ export declare function contrastRatio(foreground: IRgbColor, background: IRgbColor): number;
14
+ export declare function meetsContrast(foreground: IRgbColor, background: IRgbColor, minRatio: number): boolean;
15
+ export declare function ensureContrast(foreground: IRgbColor, background: IRgbColor, minRatio: number): IRgbColor;
16
+ export declare function ensureContrastAgainstAll(foreground: IRgbColor, backgrounds: IRgbColor[], minRatio: number): IRgbColor;
package/docs/v1/README.md CHANGED
@@ -32,6 +32,7 @@ In-depth guides for each major component:
32
32
  - **[Address Component](./guides/address-component.md)** - Delivery location management
33
33
  - **[Product List Component](./guides/product-list-component.md)** - Filterable product catalogs
34
34
  - **[Theming](./guides/theming.md)** - Customize appearance and branding
35
+ - **[Accessibility](./guides/accessibility.md)** - Keyboard, screen reader, contrast and focus behaviour
35
36
  - **[Events](./guides/events.md)** - Event system and subscriptions
36
37
  - **[Best Practices](./guides/best-practices.md)** - Patterns, tips, and recommendations
37
38
 
@@ -83,6 +84,8 @@ Technical reference and troubleshooting:
83
84
  - **[Troubleshooting](./reference/troubleshooting.md)** - Common issues and solutions
84
85
  - **[Error Handling](./reference/error-handling.md)** - Error types and debugging
85
86
  - **[Performance](./reference/performance.md)** - Optimization tips and best practices
87
+ - **[Analytics](./reference/analytics.md)** - Google Tag Manager / GA4 integration
88
+ - **[Telemetry](./reference/telemetry.md)** - What the SDK reports and how to control it
86
89
 
87
90
  ### Examples
88
91
 
@@ -275,7 +275,7 @@ window.addEventListener('lce:actions.client_ready', () => {
275
275
  // Prompt for address on first visit
276
276
  showAddressModal();
277
277
  }
278
- });
278
+ }, { once: true });
279
279
  ```
280
280
 
281
281
  ## See Also
@@ -256,7 +256,7 @@ window.addEventListener('lce:actions.client_ready', ( event ) => {
256
256
 
257
257
  // Safe to use client
258
258
  window.LiquidCommerce.elements.injectProductElement([...]);
259
- });
259
+ }, { once: true });
260
260
  ```
261
261
 
262
262
  ## Client Interface
@@ -488,7 +488,8 @@ const config: ILiquidCommerceElementsConfig = {
488
488
  env: 'production'
489
489
  };
490
490
 
491
- const client: ILiquidCommerceElementsClient = await Elements('KEY', config);
491
+ // Elements() resolves to `null` on failure, so the type is nullable.
492
+ const client: ILiquidCommerceElementsClient | null = await Elements('KEY', config);
492
493
  ```
493
494
 
494
495
  ### Type Exports
@@ -566,7 +567,7 @@ if (window.LiquidCommerce?.elements) {
566
567
  // Wait for client ready
567
568
  window.addEventListener('lce:actions.client_ready', () => {
568
569
  window.LiquidCommerce.elements.actions.cart.openCart();
569
- });
570
+ }, { once: true });
570
571
  }
571
572
  ```
572
573
 
@@ -74,9 +74,22 @@ interface IGlobalTheme {
74
74
  warningColor: string;
75
75
  successColor: string;
76
76
  drawerBackgroundColor: string;
77
+
78
+ /**
79
+ * Boundary color for form fields. Optional; defaults to a neutral that clears the 3:1
80
+ * contrast required by WCAG 1.4.11.
81
+ */
82
+ inputBorderColor?: string;
83
+
84
+ /** Keyboard focus ring color. Must reach 3:1 against the surface behind the control. */
85
+ focusRingColor?: string;
77
86
  }
78
87
  ```
79
88
 
89
+ > `inputBorderColor` and `focusRingColor` are optional. If you omit them — or supply a value that
90
+ > fails contrast — the runtime contrast guard substitutes a compliant color. Set them explicitly to
91
+ > keep those two details on-brand. See [Accessibility](../guides/accessibility.md).
92
+
80
93
  | Property | Type | Description |
81
94
  |----------|------|-------------|
82
95
  | `buttonCornerRadius` | `string` | CSS border-radius for buttons (e.g., `'8px'`) |
@@ -136,7 +136,7 @@ window.LiquidCommerce.elements.ui.cartItemsCount('items-count', { hideZero: fals
136
136
  <script>
137
137
  window.addEventListener('lce:actions.client_ready', () => {
138
138
  window.LiquidCommerce.elements.ui.cartButton('cart-btn', true);
139
- });
139
+ }, { once: true });
140
140
  </script>
141
141
  ```
142
142
 
@@ -153,7 +153,7 @@ window.addEventListener('lce:actions.client_ready', () => {
153
153
  window.addEventListener('lce:actions.client_ready', () => {
154
154
  window.LiquidCommerce.elements.ui.cartItemsCount('item-count');
155
155
  window.LiquidCommerce.elements.ui.cartSubtotal('cart-total');
156
- });
156
+ }, { once: true });
157
157
  </script>
158
158
  ```
159
159
 
@@ -117,7 +117,7 @@ window.addEventListener('lce:actions.client_ready', (event) => {
117
117
 
118
118
  // Safe to use client
119
119
  window.LiquidCommerce.elements.actions.cart.openCart();
120
- });
120
+ }, { once: true });
121
121
  ```
122
122
 
123
123
  ## Declarative vs Programmatic
@@ -197,7 +197,7 @@ window.addEventListener('lce:actions.client_ready', async () => {
197
197
  await window.LiquidCommerce.elements.injectProductElement([
198
198
  { containerId: 'dynamic-product', identifier: selectedProductId }
199
199
  ]);
200
- });
200
+ }, { once: true });
201
201
  </script>
202
202
  ```
203
203
 
@@ -327,20 +327,37 @@ The SDK is designed to fail gracefully and not crash your site.
327
327
 
328
328
  ### Error Isolation
329
329
 
330
- SDK errors are logged and also emitted as a `*_FAILED` event, then re-thrown so your code can catch them:
330
+ Most action failures are **not** thrown. Action methods take one of three paths, so `try/catch`
331
+ alone is not enough to detect failure:
332
+
333
+ | Situation | Behaviour |
334
+ |---|---|
335
+ | Malformed argument (missing `identifier`, invalid `fulfillmentType`, `quantity < 1`) | Throws `SDKError` — `try/catch` catches it |
336
+ | Empty array or no arguments | Logs a warning and **resolves**. No event, no throw. |
337
+ | Valid input that fails at runtime (product not found, unavailable variant, API error) | Publishes `lce:actions.cart_product_add_failed` and **resolves** |
338
+
339
+ So listen for the `*_failed` event **and** wrap the call in `try/catch`:
331
340
 
332
341
  ```javascript
333
- // The SDK emits CART_PRODUCT_ADD_FAILED and re-throws, so this catch runs
342
+ window.addEventListener('lce:actions.cart_product_add_failed', (event) => {
343
+ console.log('Add to cart failed:', event.detail.data);
344
+ });
345
+
334
346
  try {
335
- await window.LiquidCommerce.elements.actions.cart.addProduct([/* invalid data */]);
347
+ // Throws only if the argument itself is malformed.
348
+ await window.LiquidCommerce.elements.actions.cart.addProduct([
349
+ { identifier: '00619947000020', fulfillmentType: 'shipping', quantity: 1 }
350
+ ]);
336
351
  } catch (error) {
337
- console.log('This error is re-thrown by the SDK and caught here');
352
+ console.log('Malformed input rejected by the SDK');
338
353
  }
339
354
 
340
- // Your page keeps working
355
+ // Either way, your page keeps working
341
356
  console.log('Page still functional');
342
357
  ```
343
358
 
359
+ See [Error Handling](../reference/error-handling.md) for the full matrix.
360
+
344
361
  ### Error Types
345
362
 
346
363
  The SDK uses a custom `SDKError` class for all errors:
@@ -37,7 +37,7 @@ The path segment selects which bundle is served:
37
37
  | Path | Bundle |
38
38
  |------|--------|
39
39
  | `/all/elements.js` | Full SDK bundle (product, cart, checkout, and all other elements) |
40
- | `/checkout/elements.js` | Checkout-only bundle (tree-shaken, smaller) |
40
+ | `/checkout/checkout.js` | Checkout-only bundle (tree-shaken, smaller) |
41
41
  | `/all/beta/elements.js` | Beta channel of the full SDK bundle |
42
42
 
43
43
  ### Script Attributes
@@ -87,7 +87,7 @@ If you only need checkout functionality (without product displays or cart), use
87
87
  data-token="YOUR_API_KEY"
88
88
  data-env="production"
89
89
  type="text/javascript"
90
- src="https://elements.reservebar-worker.workers.dev/checkout/elements.js"
90
+ src="https://elements.reservebar-worker.workers.dev/checkout/checkout.js"
91
91
  ></script>
92
92
  ```
93
93
 
@@ -165,7 +165,7 @@ If you prefer JavaScript over HTML attributes:
165
165
  identifier: '00619947000020'
166
166
  }
167
167
  ]);
168
- });
168
+ }, { once: true });
169
169
  </script>
170
170
  </head>
171
171
  <body>
@@ -181,10 +181,15 @@ Or with NPM:
181
181
  import { Elements } from '@liquidcommerce/elements-sdk';
182
182
 
183
183
  async function initProduct() {
184
+ // Elements() resolves to `null` if initialization fails — it never throws.
184
185
  const client = await Elements('YOUR_API_KEY', {
185
186
  env: 'production'
186
187
  });
187
-
188
+
189
+ if (!client) {
190
+ return;
191
+ }
192
+
188
193
  await client.injectProductElement([
189
194
  {
190
195
  containerId: 'product-display',
@@ -256,7 +261,7 @@ window.addEventListener('lce:actions.client_ready', async () => {
256
261
 
257
262
  // You can customize after initialization
258
263
  // See Theming Guide for more options
259
- });
264
+ }, { once: true });
260
265
  </script>
261
266
  ```
262
267
 
@@ -0,0 +1,190 @@
1
+ # Accessibility Guide
2
+
3
+ What the Elements SDK does for accessibility, what it needs from your page, and how to verify it.
4
+
5
+ ## Overview
6
+
7
+ Elements target **WCAG 2.1 Level AA**. Every element ships with keyboard support, screen-reader
8
+ announcements, managed focus and enforced colour contrast — there is nothing to switch on.
9
+
10
+ Accessibility is shared between the SDK and the host page:
11
+
12
+ | The SDK owns | Your page owns |
13
+ |---|---|
14
+ | Roles, names and states of everything inside an element | Page structure: landmarks, heading order, `<html lang>` |
15
+ | Keyboard operation of every control it renders | Contrast of your own content, and the surface you place elements on |
16
+ | Focus movement into and out of its overlays | A theme whose colours are legible (the SDK corrects what it can, and logs when it does) |
17
+ | Announcing its own content changes | Not hiding or `inert`-ing the element container |
18
+ | Reflow down to a 320px viewport | Layout around the element at 400% zoom |
19
+
20
+ ## Keyboard navigation
21
+
22
+ Every control is reachable and operable with a keyboard alone. Related controls are grouped into
23
+ a **single tab stop** with arrow keys inside it, so a long list of retailers or sizes is not a
24
+ tab-through slog.
25
+
26
+ | Widget | Keys |
27
+ |---|---|
28
+ | Size selector (product) | `Tab` to the group, `←` `→` to move, `Enter` / `Space` to choose, `Home` / `End` for the ends |
29
+ | Size dropdown (product list) | `Enter` / `Space` / `↓` opens, `↑` `↓` moves, `Enter` chooses, `Esc` closes |
30
+ | Delivery method tabs (Shipping / Same-Day) | `←` `→` switches method immediately; unavailable methods are skipped |
31
+ | Retailer / delivery-option lists | `↑` `↓` moves, `Enter` / `Space` chooses, `Home` / `End` for the ends |
32
+ | Retailer carousel | `←` `→` moves, `Enter` / `Space` chooses |
33
+ | Product image thumbnails | `←` `→` moves, `Enter` / `Space` shows the image |
34
+ | Quantity steppers | `Tab` to each button, `Enter` / `Space` to step |
35
+ | Address field | Type to search, `↑` `↓` through suggestions, `Enter` selects, `Esc` dismisses |
36
+ | Cart / checkout drawer, modals | `Tab` cycles inside the panel only, `Esc` closes |
37
+
38
+ Arrow keys change the selection immediately only where that is cheap and reversible (the delivery
39
+ method tabs). Where choosing has a side effect the user cannot navigate back out of — picking a
40
+ retailer closes the panel — arrow keys move focus and `Enter` commits.
41
+
42
+ ### Focus management
43
+
44
+ - **Opening an overlay** moves focus into it and remembers the control that opened it.
45
+ - **Closing** returns focus to that control, even when a re-render has replaced it.
46
+ - **While an overlay is open** focus is trapped inside it and the rest of the page is `inert` and
47
+ `aria-hidden`, so a screen reader cannot wander behind the dialog.
48
+ - **In-place content swaps** (Buy Now → address form, Add to Cart → personalize) re-seat focus on
49
+ the new view's heading; nothing is left focused on a destroyed node.
50
+ - **After a re-render** focus returns to the control that caused it — the quantity stepper you
51
+ pressed, the delivery-method tab you switched to.
52
+ - **Nothing hidden is focusable.** Collapsed panels are removed from the tab order and the
53
+ accessibility tree, and closed overlays have their content torn down.
54
+
55
+ ### Focus indicator
56
+
57
+ A 2px ring in `--focus-ring-color`, offset 2px from the control. Controls that span their
58
+ container's full width use an inset ring instead, because an outward ring on those is clipped by
59
+ the scrolling panel around them. On primary-coloured fills the ring switches to
60
+ `--selected-text-color`, which is guaranteed legible against that fill.
61
+
62
+ The ring targets `:focus` as well as `:focus-visible`, so browsers without `:focus-visible`
63
+ support still show it.
64
+
65
+ ## Screen reader support
66
+
67
+ Elements are Web Components inside a shadow root, which is fully visible to assistive technology.
68
+
69
+ ### Announcements
70
+
71
+ Content that appears or changes without navigation is announced through a shared live region
72
+ (`role="status"`, `aria-live="polite"`; `role="alert"` for errors) that lives at the end of
73
+ `document.body` and survives re-renders.
74
+
75
+ | What happened | What is announced |
76
+ |---|---|
77
+ | Buy Now pressed | "Enter delivery address" |
78
+ | Address saved | "Delivery address updated. Now delivering to …" |
79
+ | Delivery option chosen | "Delivery option updated. Shipping from …, $…" |
80
+ | Delivery method switched | "Shipping selected. 5 options available." |
81
+ | Size chosen | "Size 750 ML selected" |
82
+ | Quantity stepped | "Tito's Handmade Vodka, quantity 4" |
83
+ | Personalization saved / removed | "Personalization saved" / "Personalization removed" |
84
+ | Cart or checkout opened / closed | "Shopping cart opened" / "Shopping cart closed" |
85
+ | Panel closed (product) | "Closed. Back to product options." |
86
+ | Item added / removed, promo applied, totals changed | The new state, e.g. "Promo code SPRING applied, $5.00 off" |
87
+ | Product list filtered or searched | "12 products found, showing 12" |
88
+ | Validation failed | The field name and the error, interrupting (assertive) |
89
+ | Carousel image changed | "Image 2 of 4" |
90
+
91
+ Live regions are never written into a component template: components replace their whole subtree
92
+ on render, and a live region recreated with its text already in place is not announced. Use
93
+ `this.announcer.announce(message)` from a component instead.
94
+
95
+ ### Names, roles and states
96
+
97
+ - Dialogs carry `role="dialog"`, an accessible name, and `aria-modal` when they cover the page.
98
+ - Single-select groups are `listbox` / `radiogroup` with `option` / `radio` children carrying
99
+ `aria-selected` / `aria-checked` — not toggle buttons with `aria-pressed`, which would describe
100
+ each choice as an independent on/off switch.
101
+ - Unavailable controls are `aria-disabled` **and** genuinely inert: they cannot be activated.
102
+ - Icon-only buttons take their name from the button's `aria-label`.
103
+ - Visible label text is always contained in the accessible name (2.5.3 Label in Name), so speech
104
+ input works: saying "click See Delivery Options" activates the button that reads that way.
105
+ - Fields whose design has no room for a visible label (promo code, gift card) get an
106
+ `aria-label` instead — no rendered label, no layout change.
107
+
108
+ ### Images and icons
109
+
110
+ - Product photos use the product name as their alt text; a photo painted as a CSS background is
111
+ exposed with `role="img"` and the same name.
112
+ - Decorative images inside a named control use `alt=""` so the control is not announced twice.
113
+ - Every inline SVG icon renders `aria-hidden="true" focusable="false"`. Icons that carry meaning
114
+ on their own opt in to a name: `CloseIcon({ label: 'Close' })`.
115
+
116
+ ## Colour and contrast
117
+
118
+ Element colours come from your theme, and a brand palette chosen to look right often does not
119
+ clear WCAG: white on a mid-tone brand fill is around 3:1, well under the 4.5:1 body text needs.
120
+
121
+ The SDK therefore **measures your theme at runtime and corrects the foreground when it fails**:
122
+
123
+ 1. The surface each element renders on is resolved — a themed token for overlays, otherwise the
124
+ real background behind the element, measured from your page.
125
+ 2. Each foreground token is checked against that surface: text, links, warning/error/success,
126
+ input borders and the focus ring, plus on-primary text against `primaryColor`.
127
+ 3. Failing tokens are shifted in lightness — same hue, same saturation, away from the background
128
+ — until they pass 4.5:1 (text) or 3:1 (borders, focus ring).
129
+ 4. Backgrounds, fills and brand colours are never changed. Compliant themes are untouched.
130
+
131
+ Corrections are applied as inline custom properties on the element host and logged:
132
+
133
+ ```
134
+ Theme colour adjusted for accessibility: defaultTextColor was #FFFFFF (3.02:1 against #E7751E),
135
+ which is below the required 4.5:1. Using #2B2B2B (4.69:1) instead. Set a compliant colour in
136
+ your theme to control this yourself.
137
+ ```
138
+
139
+ **To keep full control of your palette, supply colours that already pass.** Two tokens exist for
140
+ exactly that and are worth setting explicitly:
141
+
142
+ ```javascript
143
+ customTheme: {
144
+ global: {
145
+ theme: {
146
+ inputBorderColor: '#767C85', // form field boundaries, needs 3:1 on your surface
147
+ focusRingColor: '#1D4ED8' // keyboard focus ring, needs 3:1 on your surface
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ The SDK cannot see through a background image: if an element sits on one, surface-relative
154
+ corrections are skipped rather than guessed, and the contrast of that text is yours to get right.
155
+
156
+ ### Zoom, reflow and text spacing
157
+
158
+ Elements reflow down to a 320px CSS viewport (400% zoom on a 1280px screen) without horizontal
159
+ scrolling. Labels wrap instead of truncating, so they survive user text-spacing overrides.
160
+
161
+ ## Verifying it
162
+
163
+ **Keyboard:** unplug the mouse and complete a purchase. Every control must be reachable, every
164
+ focus ring visible, and focus must never land on `<body>`.
165
+
166
+ **Announcements** without a screen reader — the live regions are plain DOM:
167
+
168
+ ```javascript
169
+ for (const region of document.querySelectorAll('[data-lce-live-region]')) {
170
+ new MutationObserver(() => console.log('[SR]', region.textContent)).observe(region, {
171
+ childList: true, subtree: true, characterData: true,
172
+ });
173
+ }
174
+ ```
175
+
176
+ **Contrast corrections** — inspect the element host:
177
+
178
+ ```javascript
179
+ document.querySelector('lce-element[product]').getAttribute('style');
180
+ // "--default-text-color: #2b2b2b; --focus-ring-color: #163ca7; …"
181
+ ```
182
+
183
+ **Screen readers:** verify with VoiceOver (Safari/macOS), NVDA (Firefox/Windows) or TalkBack
184
+ (Chrome/Android) — the pairings that matter most for a storefront.
185
+
186
+ ## See Also
187
+
188
+ - [Theming Guide](./theming.md) — the tokens the contrast guard reads
189
+ - [Best Practices](./best-practices.md)
190
+ - [Browser Support](../reference/browser-support.md)
@@ -214,7 +214,7 @@ Create a dedicated page for address selection:
214
214
  <script>
215
215
  window.addEventListener('lce:actions.client_ready', async () => {
216
216
  await window.LiquidCommerce.elements.injectAddressElement('address');
217
- });
217
+ }, { once: true });
218
218
 
219
219
  window.addEventListener('lce:actions.address_updated', () => {
220
220
  // Redirect to product page after address is set
@@ -260,7 +260,7 @@ window.addEventListener('lce:actions.client_ready', async () => {
260
260
  user.savedCoordinates
261
261
  );
262
262
  }
263
- });
263
+ }, { once: true });
264
264
  ```
265
265
 
266
266
  ### Location-Based Product Filtering
@@ -338,7 +338,7 @@ window.addEventListener('lce:actions.client_ready', () => {
338
338
  // Show address prompt
339
339
  showAddressPrompt();
340
340
  }
341
- });
341
+ }, { once: true });
342
342
  ```
343
343
 
344
344
  ### Provide Context
@@ -41,7 +41,7 @@ if (window.LiquidCommerce.elements) {
41
41
  // Wait for initialization
42
42
  window.addEventListener('lce:actions.client_ready', () => {
43
43
  window.LiquidCommerce.elements.actions.cart.openCart();
44
- });
44
+ }, { once: true });
45
45
  }
46
46
  ```
47
47
 
@@ -241,7 +241,7 @@ Show cart subtotal anywhere on your page:
241
241
  <script>
242
242
  window.addEventListener('lce:actions.client_ready', () => {
243
243
  window.LiquidCommerce.elements.ui.cartSubtotal('cart-total');
244
- });
244
+ }, { once: true });
245
245
  </script>
246
246
  ```
247
247
 
@@ -261,7 +261,7 @@ window.addEventListener('lce:actions.client_ready', () => {
261
261
  window.LiquidCommerce.elements.ui.cartItemsCount('items-count', {
262
262
  hideZero: true // Hide when cart is empty
263
263
  });
264
- });
264
+ }, { once: true });
265
265
  </script>
266
266
  ```
267
267
 
@@ -679,7 +679,7 @@ Pre-load cart on page load for faster access:
679
679
  window.addEventListener('lce:actions.client_ready', () => {
680
680
  // Cart loads automatically, but you can pre-fetch if needed
681
681
  window.LiquidCommerce.elements.actions.cart.getDetails();
682
- });
682
+ }, { once: true });
683
683
  ```
684
684
 
685
685
  ### Provide Visual Feedback
@@ -16,12 +16,18 @@ window.addEventListener('lce:actions.client_ready', (event) => {
16
16
  originalEvent,
17
17
  actionNamespace,
18
18
  eventId,
19
- timestamp
19
+ timestamp,
20
+ sdkVersion,
21
+ env,
22
+ tenantName,
23
+ tenantCode
20
24
  } = metadata;
21
- });
25
+ }, { once: true });
22
26
  ```
23
27
 
24
- `data` is event-specific. `metadata` describes the event (name, namespace, timestamp, etc).
28
+ `data` is event-specific. `metadata` carries the same ten fields on **every** event — including
29
+ `sdkVersion`, `env`, `tenantName` and `tenantCode`, so partner/tenant identification is already
30
+ present and does not need to be re-derived in your analytics layer.
25
31
 
26
32
  ## Event Namespaces
27
33
 
@@ -43,7 +49,14 @@ window.addEventListener('lce:actions', (event) => {
43
49
 
44
50
  ### `lce:actions.client_ready`
45
51
 
46
- Fires once when the SDK finishes initializing and is ready for actions.
52
+ Fires when the SDK finishes initializing and is ready for actions.
53
+
54
+ > **Always register this listener with `{ once: true }`.** The specific topic
55
+ > `lce:actions.client_ready` is dispatched **twice** — once synchronously, and once again on a
56
+ > microtask, so that listeners attached immediately after `await Elements(...)` resolves still catch
57
+ > it. Without `{ once: true }`, a handler registered before initialization runs **twice**, which
58
+ > double-injects components and double-counts analytics. (The namespace-level `lce:actions` listener
59
+ > receives it only once.)
47
60
 
48
61
  **Data includes:** `isReady`, `message`, `timestamp`, `version`.
49
62
 
@@ -55,7 +68,7 @@ window.addEventListener('lce:actions.client_ready', (event) => {
55
68
  if (data.isReady) {
56
69
  console.log('Elements SDK ready', data.version);
57
70
  }
58
- });
71
+ }, { once: true });
59
72
  ```
60
73
 
61
74
  ## Product Events
@@ -27,7 +27,12 @@ const client = await Elements('YOUR_API_KEY', {
27
27
  errorColor: '#dc3545',
28
28
  warningColor: '#ffc107',
29
29
  successColor: '#28a745',
30
- drawerBackgroundColor: '#ffffff'
30
+ drawerBackgroundColor: '#ffffff',
31
+
32
+ // Optional. Omit either one and the runtime contrast guard picks a
33
+ // WCAG-compliant value for you; set them to keep these details on-brand.
34
+ inputBorderColor: '#d4d4d8',
35
+ focusRingColor: '#007bff'
31
36
  }
32
37
  }
33
38
  }
@@ -55,7 +55,7 @@ window.addEventListener('lce:actions.client_ready', async () => {
55
55
  await client.injectProductElement([
56
56
  { containerId: 'product', identifier: '00619947000020' }
57
57
  ]);
58
- });
58
+ }, { once: true });
59
59
  </script>
60
60
 
61
61
  <div id="product"></div>
@@ -29,7 +29,7 @@ try {
29
29
  }
30
30
  ```
31
31
 
32
- > A well-formed entry with an invalid product identifier does **not** throw — it renders an error view inside the component and sets an error in the store. `try/catch` here only catches structural input errors (a non-array argument or an empty array).
32
+ > A well-formed entry with an invalid product identifier does **not** throw — it renders an error view inside the component and sets an error in the store. `try/catch` here only catches malformed input: a missing or non-string `identifier`, a `fulfillmentType` other than `shipping`/`onDemand`, or a `quantity` below 1. An **empty array logs a warning and resolves** — it does not throw and emits no event.
33
33
 
34
34
  ## Error Isolation
35
35