@marianmeres/stuic 3.66.1 → 3.67.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.
Files changed (75) hide show
  1. package/dist/actions/autoscroll.d.ts +7 -0
  2. package/dist/actions/autoscroll.js +7 -0
  3. package/dist/actions/focus-trap.d.ts +7 -0
  4. package/dist/actions/focus-trap.js +8 -3
  5. package/dist/actions/typeahead.svelte.js +40 -4
  6. package/dist/components/Carousel/Carousel.svelte +9 -2
  7. package/dist/components/Carousel/README.md +8 -2
  8. package/dist/components/Cart/Cart.svelte +3 -0
  9. package/dist/components/Cart/README.md +18 -1
  10. package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
  11. package/dist/components/Checkout/README.md +184 -0
  12. package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
  13. package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
  14. package/dist/components/Checkout/index.d.ts +1 -1
  15. package/dist/components/Checkout/index.js +1 -1
  16. package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
  17. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
  18. package/dist/components/CronInput/CronInput.svelte +44 -9
  19. package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
  20. package/dist/components/CronInput/README.md +145 -0
  21. package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
  22. package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
  23. package/dist/components/CronInput/index.css +0 -8
  24. package/dist/components/DataTable/DataTable.svelte +99 -62
  25. package/dist/components/DataTable/DataTable.svelte.d.ts +13 -3
  26. package/dist/components/DataTable/README.md +79 -25
  27. package/dist/components/DataTable/index.css +7 -0
  28. package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
  29. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
  30. package/dist/components/DropdownMenu/README.md +37 -9
  31. package/dist/components/Input/FieldAssets.svelte +9 -7
  32. package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
  33. package/dist/components/Input/FieldFile.svelte +13 -7
  34. package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
  35. package/dist/components/Input/FieldInput.svelte +10 -8
  36. package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
  37. package/dist/components/Input/FieldInputLocalized.svelte +8 -7
  38. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
  39. package/dist/components/Input/FieldKeyValues.svelte +8 -7
  40. package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
  41. package/dist/components/Input/FieldLikeButton.svelte +9 -7
  42. package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
  43. package/dist/components/Input/FieldObject.svelte +8 -7
  44. package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
  45. package/dist/components/Input/FieldOptions.svelte +9 -7
  46. package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
  47. package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
  48. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
  49. package/dist/components/Input/FieldSelect.svelte +9 -8
  50. package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
  51. package/dist/components/Input/FieldSwitch.svelte +9 -7
  52. package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
  53. package/dist/components/Input/FieldTextarea.svelte +7 -8
  54. package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
  55. package/dist/components/Input/README.md +20 -0
  56. package/dist/components/Input/_internal/InputWrap.svelte +2 -10
  57. package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
  58. package/dist/components/Input/types.d.ts +28 -0
  59. package/dist/components/Nav/Nav.svelte +5 -4
  60. package/dist/components/Nav/Nav.svelte.d.ts +2 -2
  61. package/dist/components/Nav/README.md +2 -2
  62. package/dist/components/Nav/index.css +4 -0
  63. package/dist/components/Tree/README.md +189 -0
  64. package/dist/components/Tree/Tree.svelte +46 -2
  65. package/dist/components/Tree/Tree.svelte.d.ts +5 -0
  66. package/dist/utils/input-history.svelte.d.ts +12 -0
  67. package/dist/utils/input-history.svelte.js +12 -0
  68. package/dist/utils/observe-exists.svelte.d.ts +1 -0
  69. package/dist/utils/observe-exists.svelte.js +11 -3
  70. package/dist/utils/switch.svelte.d.ts +12 -0
  71. package/dist/utils/switch.svelte.js +12 -1
  72. package/docs/architecture.md +0 -1
  73. package/docs/testing.md +72 -0
  74. package/docs/upgrading.md +281 -0
  75. package/package.json +12 -13
@@ -30,6 +30,13 @@ export type AutoscrollOptions = ScrollOptions & {
30
30
  * - Observes child mutations and resize events
31
31
  * - Supports reactive store dependencies for manual trigger
32
32
  *
33
+ * @remarks
34
+ * This action intentionally uses the Svelte-4-style `(node, options)` signature with a
35
+ * `{ destroy }` return shape rather than the newer `.svelte.ts` + `$effect` pattern used
36
+ * by other actions in this library. It's legacy code imported from a pre-Svelte-5 project
37
+ * and is kept as-is for backwards compatibility. Svelte 5 still supports this pattern as a
38
+ * first-class API, so there's no rush to convert.
39
+ *
33
40
  * @param node - The scrollable container element
34
41
  * @param options - Configuration options
35
42
  * @param options.behavior - Scroll behavior: 'smooth' | 'instant' | 'auto' (default: 'smooth')
@@ -14,6 +14,13 @@ const DEFAULTS = {
14
14
  * - Observes child mutations and resize events
15
15
  * - Supports reactive store dependencies for manual trigger
16
16
  *
17
+ * @remarks
18
+ * This action intentionally uses the Svelte-4-style `(node, options)` signature with a
19
+ * `{ destroy }` return shape rather than the newer `.svelte.ts` + `$effect` pattern used
20
+ * by other actions in this library. It's legacy code imported from a pre-Svelte-5 project
21
+ * and is kept as-is for backwards compatibility. Svelte 5 still supports this pattern as a
22
+ * first-class API, so there's no rush to convert.
23
+ *
17
24
  * @param node - The scrollable container element
18
25
  * @param options - Configuration options
19
26
  * @param options.behavior - Scroll behavior: 'smooth' | 'instant' | 'auto' (default: 'smooth')
@@ -17,6 +17,13 @@ export interface FocusTrapOptions {
17
17
  * - Handles dynamically added/removed elements via MutationObserver
18
18
  * - Excludes disabled elements and negative tabindexes
19
19
  *
20
+ * @remarks
21
+ * This action intentionally uses the Svelte-4-style `(node, options)` signature with an
22
+ * `{ update, destroy }` return shape rather than the newer `.svelte.ts` + `$effect` pattern
23
+ * used by other actions in this library. It's legacy code imported from a pre-Svelte-5
24
+ * project and is kept as-is for backwards compatibility. Svelte 5 still supports this
25
+ * pattern as a first-class API, so there's no rush to convert.
26
+ *
20
27
  * @param node - The container element to trap focus within
21
28
  * @param options - Configuration options
22
29
  * @param options.enabled - Whether the trap is active (default: true)
@@ -11,6 +11,13 @@ const defaults = { enabled: true, autoFocusFirst: true };
11
11
  * - Handles dynamically added/removed elements via MutationObserver
12
12
  * - Excludes disabled elements and negative tabindexes
13
13
  *
14
+ * @remarks
15
+ * This action intentionally uses the Svelte-4-style `(node, options)` signature with an
16
+ * `{ update, destroy }` return shape rather than the newer `.svelte.ts` + `$effect` pattern
17
+ * used by other actions in this library. It's legacy code imported from a pre-Svelte-5
18
+ * project and is kept as-is for backwards compatibility. Svelte 5 still supports this
19
+ * pattern as a first-class API, so there's no rush to convert.
20
+ *
14
21
  * @param node - The container element to trap focus within
15
22
  * @param options - Configuration options
16
23
  * @param options.enabled - Whether the trap is active (default: true)
@@ -32,9 +39,7 @@ const defaults = { enabled: true, autoFocusFirst: true };
32
39
  * ```
33
40
  */
34
41
  export function focusTrap(node, options = {}) {
35
- let enabled;
36
- const { enabled: _enabled, autoFocusFirst } = { ...defaults, ...(options || {}) };
37
- enabled = _enabled ?? true;
42
+ let { enabled = true, autoFocusFirst } = { ...defaults, ...(options ?? {}) };
38
43
  const focusableSelectors = [
39
44
  "[contentEditable=true]",
40
45
  //
@@ -32,6 +32,12 @@ export function typeahead(el, fn) {
32
32
  let debounceTimer = null;
33
33
  let previousKey = "";
34
34
  let allowListAll = false;
35
+ // Monotonic token — used to discard stale async results when a newer search has started.
36
+ let searchSeq = 0;
37
+ // Captures parent's inline position value before we mutate it, so cleanup can restore.
38
+ // `null` means we did not mutate the parent (e.g. it wasn't static to begin with).
39
+ let parentOriginalPosition = null;
40
+ let parentEl = null;
35
41
  // Current resolved options
36
42
  let currentOpts = { getOptions: async () => [] };
37
43
  // Helper: render option label
@@ -60,11 +66,12 @@ export function typeahead(el, fn) {
60
66
  const hint = currentOpts.appendHint ?? DEFAULT_APPEND_HINT;
61
67
  return suggestion ? suggestion + hint : "";
62
68
  }
63
- // Update ghost input value
69
+ // Update ghost input value + reflect expanded state for assistive tech
64
70
  function updateGhost() {
65
71
  if (ghostEl) {
66
72
  ghostEl.value = getVisibleSuggestion();
67
73
  }
74
+ el.setAttribute("aria-expanded", options?.active ? "true" : "false");
68
75
  }
69
76
  // Create ghost input element
70
77
  function createGhost() {
@@ -76,11 +83,13 @@ export function typeahead(el, fn) {
76
83
  ghostEl.readOnly = true;
77
84
  ghostEl.setAttribute("aria-hidden", "true");
78
85
  ghostEl.setAttribute("autocomplete", "off");
79
- // Ensure parent has relative positioning
86
+ // Ensure parent has relative positioning; remember original so cleanup can restore it
80
87
  const parent = el.parentElement;
81
88
  if (parent) {
89
+ parentEl = parent;
82
90
  const parentPos = getComputedStyle(parent).position;
83
91
  if (parentPos === "static") {
92
+ parentOriginalPosition = parent.style.position; // typically "" when not previously set inline
84
93
  parent.style.position = "relative";
85
94
  }
86
95
  }
@@ -128,6 +137,8 @@ export function typeahead(el, fn) {
128
137
  async function performSearch(query) {
129
138
  if (!options || !currentOpts.getOptions)
130
139
  return;
140
+ // Tag this call — if a newer one starts before we finish, we'll drop our result.
141
+ const mySeq = ++searchSeq;
131
142
  options.clear();
132
143
  if (!allowListAll && !query) {
133
144
  updateGhost();
@@ -136,6 +147,9 @@ export function typeahead(el, fn) {
136
147
  currentOpts.onFetchingChange?.(true);
137
148
  try {
138
149
  const results = await currentOpts.getOptions(query, []);
150
+ // Stale response — a newer search has started; discard silently.
151
+ if (mySeq !== searchSeq)
152
+ return;
139
153
  if (!results.length) {
140
154
  updateGhost();
141
155
  return;
@@ -160,7 +174,10 @@ export function typeahead(el, fn) {
160
174
  clog.error(err);
161
175
  }
162
176
  finally {
163
- currentOpts.onFetchingChange?.(false);
177
+ // Only the latest in-flight call should toggle the fetching state back off.
178
+ if (mySeq === searchSeq) {
179
+ currentOpts.onFetchingChange?.(false);
180
+ }
164
181
  }
165
182
  }
166
183
  // Submit handler
@@ -225,6 +242,13 @@ export function typeahead(el, fn) {
225
242
  options?.clear();
226
243
  handleSubmit(value);
227
244
  }
245
+ else if (e.key === "Escape" && options?.active) {
246
+ // Dismiss suggestion; stop propagation so we don't e.g. close a surrounding Modal
247
+ options?.clear();
248
+ updateGhost();
249
+ e.preventDefault();
250
+ e.stopPropagation();
251
+ }
228
252
  else if (e.key === "Backspace" && cursorPos === 0) {
229
253
  currentOpts.onDeleteRequest?.();
230
254
  }
@@ -261,6 +285,12 @@ export function typeahead(el, fn) {
261
285
  el.style.position = "";
262
286
  el.style.zIndex = "";
263
287
  el.style.background = "";
288
+ // Restore parent's position if we mutated it
289
+ if (parentEl && parentOriginalPosition !== null) {
290
+ parentEl.style.position = parentOriginalPosition;
291
+ }
292
+ parentEl = null;
293
+ parentOriginalPosition = null;
264
294
  }
265
295
  // Main effect for setup/cleanup
266
296
  $effect(() => {
@@ -313,13 +343,19 @@ export function typeahead(el, fn) {
313
343
  el.addEventListener("keydown", onKeyDown);
314
344
  el.addEventListener("input", onInput);
315
345
  el.addEventListener("blur", onBlur);
316
- // Set autocomplete off
346
+ // Set autocomplete off + combobox semantics for assistive tech
317
347
  el.setAttribute("autocomplete", "off");
348
+ el.setAttribute("role", "combobox");
349
+ el.setAttribute("aria-autocomplete", "inline");
350
+ el.setAttribute("aria-expanded", "false");
318
351
  return () => {
319
352
  // Cleanup
320
353
  el.removeEventListener("keydown", onKeyDown);
321
354
  el.removeEventListener("input", onInput);
322
355
  el.removeEventListener("blur", onBlur);
356
+ el.removeAttribute("role");
357
+ el.removeAttribute("aria-autocomplete");
358
+ el.removeAttribute("aria-expanded");
323
359
  destroyGhost();
324
360
  if (debounceTimer)
325
361
  clearTimeout(debounceTimer);
@@ -98,6 +98,7 @@
98
98
  <script lang="ts">
99
99
  import { ItemCollection } from "@marianmeres/item-collection";
100
100
  import { twMerge } from "../../utils/tw-merge.js";
101
+ import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
101
102
  import Thc from "../Thc/Thc.svelte";
102
103
  import Button from "../Button/Button.svelte";
103
104
  import {
@@ -139,6 +140,12 @@
139
140
  let trackEl: HTMLDivElement | undefined = $state();
140
141
  let itemEls: Record<string | number, HTMLDivElement> = $state({});
141
142
 
143
+ // Respect user's reduced-motion preference — when set, skip smooth scrolling.
144
+ const reducedMotion = prefersReducedMotion();
145
+ const effectiveScrollBehavior = $derived<ScrollBehavior>(
146
+ reducedMotion.current ? "instant" : scrollBehavior
147
+ );
148
+
142
149
  // ItemCollection for managing items and active state
143
150
  const coll: ItemColl = $derived.by(() => {
144
151
  const out = new ItemCollection(
@@ -233,7 +240,7 @@
233
240
  const activeItem = coll.active;
234
241
  if (activeItem && itemEls[activeItem.id]) {
235
242
  itemEls[activeItem.id]?.scrollIntoView({
236
- behavior: scrollBehavior,
243
+ behavior: effectiveScrollBehavior,
237
244
  block: "nearest",
238
245
  inline:
239
246
  snapAlign === "center" ? "center" : snapAlign === "end" ? "end" : "start",
@@ -245,7 +252,7 @@
245
252
  () => {
246
253
  isScrollingProgrammatically = false;
247
254
  },
248
- scrollBehavior === "instant" ? 0 : 300
255
+ effectiveScrollBehavior === "instant" ? 0 : 300
249
256
  );
250
257
  }, 0);
251
258
  }
@@ -11,14 +11,20 @@ keyboard navigation, snap scrolling, and flexible content rendering via THC.
11
11
  | `itemsPerView` | `number` | `1` | Number of items visible per view |
12
12
  | `peekPercent` | `number` | `0` | Percentage of next item to show (0-50) |
13
13
  | `gap` | `number \| string` | - | Gap between items |
14
+ | `minItemWidth` | `number` | `150` | Minimum item width in px (auto-fit floor) |
14
15
  | `trackActive` | `boolean` | `false` | Enable active item tracking |
16
+ | `syncActiveOnScroll` | `boolean` | `false` | Update active item based on scroll position (requires `trackActive`) |
15
17
  | `activeIndex` | `number` | `0` | Active item index (bindable) |
16
18
  | `value` | `string \| number` | - | Active item id (bindable) |
17
19
  | `snap` | `boolean` | `true` | Enable scroll snap |
18
20
  | `snapAlign` | `"start" \| "center" \| "end"` | `"start"` | Snap alignment |
19
21
  | `keyboard` | `boolean` | `true` | Enable keyboard navigation |
20
- | `loop` | `boolean` | `false` | Loop navigation |
21
- | `scrollBehavior` | `ScrollBehavior` | `"smooth"` | Scroll behavior |
22
+ | `wheelScroll` | `boolean` | `true` | Enable horizontal scrolling via mouse wheel |
23
+ | `loop` | `boolean` | `false` | Loop navigation (arrows / keyboard only — wheel never loops) |
24
+ | `scrollBehavior` | `ScrollBehavior` | `"smooth"` | Scroll behavior (overridden to `"instant"` when `prefers-reduced-motion: reduce`) |
25
+ | `scrollbar` | `boolean` | `true` | Show the scrollbar on hover (set `false` when using nav buttons) |
26
+ | `arrows` | `boolean` | `false` | Show prev/next arrow buttons overlaid on left/right edges |
27
+ | `classArrow` | `string` | - | Custom class for arrow buttons |
22
28
  | `class` | `string` | - | Custom class for container |
23
29
  | `classTrack` | `string` | - | Custom class for scroll track |
24
30
  | `classItem` | `string` | - | Custom class for items |
@@ -17,6 +17,7 @@
17
17
  unit_price_each: "{price} each",
18
18
  quantity_label: "Qty: {quantity}",
19
19
  remove_item: "Remove",
20
+ remove_item_aria: "Remove {name}",
20
21
  total_label: "Total",
21
22
  item_count_1: "1 item",
22
23
  item_count_n: "{count} items",
@@ -376,6 +377,7 @@
376
377
  step={item.quantityStep ?? 1}
377
378
  class={!unstyled ? "stuic-cart-quantity-input" : undefined}
378
379
  value={item.quantity}
380
+ aria-label={t("quantity_label", { quantity: item.quantity })}
379
381
  onblur={(e) =>
380
382
  handleQuantityInputCommit(
381
383
  item.id,
@@ -425,6 +427,7 @@
425
427
  type="button"
426
428
  class={!unstyled ? "stuic-cart-remove" : undefined}
427
429
  disabled={isUpdating}
430
+ aria-label={t("remove_item_aria", { name: item.name }) || t("remove_item")}
428
431
  onclick={() => onRemove?.(item.id)}
429
432
  >
430
433
  {t("remove_item")}
@@ -46,12 +46,28 @@ summary) modes, plus a compact variant suitable for popover previews.
46
46
  />
47
47
  ```
48
48
 
49
+ ### Keeping `lineTotal` in sync
50
+
51
+ **`lineTotal` is caller-computed**, not derived from `unitPrice × quantity`. This is intentional — it lets you encode per-line discounts, bulk pricing, or any formula you want. But it also means:
52
+
53
+ > Whenever you respond to `onQuantityChange` or `onRemove`, you must recompute `lineTotal` alongside `quantity` before passing the new `items`. The cart total is summed from `lineTotal` values; if they go stale, the summary is wrong.
54
+
55
+ Simple rule-of-thumb: in your quantity-change handler, if you have no special pricing, set `lineTotal = unitPrice * newQuantity`.
56
+
49
57
  ### Readonly Mode (checkout summary)
50
58
 
51
59
  ```svelte
52
60
  <Cart {items} readonly formatPrice={(v) => `$${(v / 100).toFixed(2)}`} />
53
61
  ```
54
62
 
63
+ ### Summary Variant (receipt-style)
64
+
65
+ Minimal, dense read-only list for order confirmation screens or invoices. Each line is just `name ×qty` plus the line total — no thumbnails, no +/− controls, no footer.
66
+
67
+ ```svelte
68
+ <Cart {items} variant="summary" formatPrice={(v) => `$${(v / 100).toFixed(2)}`} />
69
+ ```
70
+
55
71
  ### Compact Variant (for popovers)
56
72
 
57
73
  ```svelte
@@ -105,7 +121,7 @@ summary) modes, plus a compact variant suitable for popover previews.
105
121
  | Prop | Type | Default | Description |
106
122
  | ------------------ | ----------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
107
123
  | `items` | `CartComponentItem[]` | required | Cart items to display |
108
- | `variant` | `"default" \| "compact"` | `"default"` | Layout variant. Compact is smaller, scrollable, implicitly readonly |
124
+ | `variant` | `"default" \| "compact" \| "summary"` | `"default"` | Layout variant. `compact` = smaller/scrollable (implicit readonly); `summary` = receipt-style list with name ×qty + line total, no thumbnails/controls/footer (implicit readonly) |
109
125
  | `formatPrice` | `(value: number) => string` | `(v) => (v / 100).toFixed(2)` | Format numeric price for display |
110
126
  | `onQuantityChange` | `(id: string, qty: number) => void` | — | Called when quantity changes |
111
127
  | `onRemove` | `(id: string) => void` | — | Called when remove is clicked |
@@ -174,6 +190,7 @@ summary) modes, plus a compact variant suitable for popover previews.
174
190
  | `unit_price_each` | "{price} each" | Unit price label |
175
191
  | `quantity_label` | "Qty: {quantity}" | Readonly quantity display |
176
192
  | `remove_item` | "Remove" | Remove button text |
193
+ | `remove_item_aria` | "Remove {name}" | Accessible label on the remove button (announces item name) |
177
194
  | `total_label` | "Total" | Summary label |
178
195
  | `item_count_1` | "1 item" | Singular item count |
179
196
  | `item_count_n` | "{count} items" | Plural item count |
@@ -93,7 +93,7 @@
93
93
  import Button from "../Button/Button.svelte";
94
94
  import H, { type HLevel } from "../H/H.svelte";
95
95
  import { t_default } from "./_internal/checkout-i18n-defaults.js";
96
- import { defaultFormatPrice } from "./_internal/checkout-utils.js";
96
+ import { defaultFormatPrice, addressesEqual } from "./_internal/checkout-utils.js";
97
97
  import CheckoutSectionHeader from "./CheckoutSectionHeader.svelte";
98
98
 
99
99
  let {
@@ -124,19 +124,9 @@
124
124
  unstyled ? classProp : twMerge("stuic-checkout-review", classProp)
125
125
  );
126
126
 
127
- let isBillingSameAsShipping = $derived.by(() => {
128
- const s = order.shipping_address;
129
- const b = order.billing_address;
130
- if (!b || !s) return true;
131
- return (
132
- s.name === b.name &&
133
- s.street === b.street &&
134
- s.city === b.city &&
135
- s.postal_code === b.postal_code &&
136
- s.country === b.country &&
137
- (s.phone ?? "") === (b.phone ?? "")
138
- );
139
- });
127
+ let isBillingSameAsShipping = $derived(
128
+ addressesEqual(order.shipping_address, order.billing_address)
129
+ );
140
130
  </script>
141
131
 
142
132
  <div bind:this={el} class={_class} {...rest}>
@@ -0,0 +1,184 @@
1
+ # Checkout
2
+
3
+ A **kit of composable checkout components** — not a monolithic wizard. STUIC provides 15 self-contained pieces (step containers, forms, reviews, progress indicator, complete screen) and the consumer owns the composition: which steps run, in what order, and how state flows between them.
4
+
5
+ There is deliberately **no top-level `<Checkout>`** orchestrator. If you want a linear wizard, wire it together in a single page component. If you want a single-page checkout or a novel flow, use the same parts differently.
6
+
7
+ ## Components
8
+
9
+ ### Step containers (one per route/screen)
10
+
11
+ | Component | Purpose |
12
+ | --- | --- |
13
+ | `CheckoutReviewStep` | Guest/login + cart review (entry point) |
14
+ | `CheckoutShippingStep` | Address + delivery option selection |
15
+ | `CheckoutConfirmStep` | Final order review + place-order action |
16
+ | `CheckoutCompleteStep` | Post-purchase confirmation screen |
17
+
18
+ Each step container renders a `CheckoutProgress` indicator (unless `hideProgress`), its own heading, and exposes `onBack` / `onContinue` callbacks. The consumer maps those callbacks to route navigation or state changes.
19
+
20
+ ### Building blocks (composed inside steps, or used standalone)
21
+
22
+ | Component | Purpose |
23
+ | --- | --- |
24
+ | `CheckoutProgress` | Multi-step progress indicator (accessible stepper) |
25
+ | `CheckoutCartReview` | Editable line-item list |
26
+ | `CheckoutOrderSummary` | Totals (subtotal/tax/shipping/discount/total) |
27
+ | `CheckoutOrderReview` | Read-only order dump (items + addresses + delivery) |
28
+ | `CheckoutOrderConfirmation` | Completed-order summary with order number & next steps |
29
+ | `CheckoutGuestOrLoginForm` | Tabbed guest / login |
30
+ | `CheckoutGuestForm` | Guest-checkout fields |
31
+ | `CheckoutLoginForm` | Login (adapts the generic `LoginForm` to checkout i18n) |
32
+ | `CheckoutAddressForm` | Structured address input |
33
+ | `CheckoutDeliveryOptions` | Delivery-method selector |
34
+ | `CheckoutSectionHeader` | Consistent section heading |
35
+
36
+ ## State ownership
37
+
38
+ The consumer owns the entire order shape — typically `CheckoutOrderData` from the exported types:
39
+
40
+ ```ts
41
+ import type {
42
+ CheckoutOrderData,
43
+ CheckoutAddressData,
44
+ CheckoutCustomerFormData,
45
+ CheckoutLoginFormData,
46
+ CheckoutDeliveryOption,
47
+ } from "@marianmeres/stuic";
48
+ ```
49
+
50
+ **Field → owner table:**
51
+
52
+ | Field | Owned by | Passed to |
53
+ | --- | --- | --- |
54
+ | `currentStep` (or equivalent) | Route/page state | `CheckoutProgress`, step visibility logic |
55
+ | `CheckoutCustomerFormData` | Page `$state` | `CheckoutGuestForm` (two-way bind) |
56
+ | `CheckoutLoginFormData` | Page `$state` | `CheckoutLoginForm` (two-way bind) |
57
+ | `shippingAddress`, `billingAddress` | Page `$state` | `CheckoutShippingStep` (two-way bind) — re-used by `CheckoutConfirmStep` for display |
58
+ | `selectedDeliveryId` | Page `$state` | `CheckoutShippingStep` (two-way bind) |
59
+ | `CheckoutOrderData` (assembled) | Page `$state` / server | `CheckoutOrderReview`, `CheckoutConfirmStep`, `CheckoutCompleteStep` (read-only) |
60
+ | Per-field validation errors | Page `$state` (derived from server response) | Forms via `errors` prop; merged with internal errors |
61
+
62
+ Each form exposes a bindable value plus an `errors` prop for server-driven validation messages. Internal client-side validation (via `validateCustomerForm` / `validateAddress` / `validateLoginForm`) fires on submit and populates internal error state, which is merged with the `errors` prop for display.
63
+
64
+ ## Validation flow
65
+
66
+ Client-side validation helpers live in `@marianmeres/stuic`:
67
+
68
+ ```ts
69
+ import {
70
+ validateCustomerForm,
71
+ validateAddress,
72
+ validateLoginForm,
73
+ validateEmail,
74
+ } from "@marianmeres/stuic";
75
+ ```
76
+
77
+ Each returns `CheckoutValidationError[]`:
78
+
79
+ ```ts
80
+ interface CheckoutValidationError {
81
+ field: string; // e.g. "email" or "shipping.street"
82
+ message: string;
83
+ }
84
+ ```
85
+
86
+ ### Pattern for a step
87
+
88
+ ```svelte
89
+ <script lang="ts">
90
+ import {
91
+ CheckoutShippingStep,
92
+ CheckoutAddressForm,
93
+ validateAddress,
94
+ type CheckoutAddressData,
95
+ type CheckoutValidationError,
96
+ } from "@marianmeres/stuic";
97
+
98
+ let shippingAddress = $state<CheckoutAddressData>(/* ... */);
99
+ let errors = $state<CheckoutValidationError[]>([]);
100
+
101
+ async function onContinue() {
102
+ // 1) Client-side gate
103
+ const clientErrors = validateAddress(shippingAddress, "shipping", t);
104
+ if (clientErrors.length) {
105
+ errors = clientErrors;
106
+ return;
107
+ }
108
+ // 2) Server round-trip; merge any server errors
109
+ const res = await submitShipping(shippingAddress);
110
+ if (!res.ok) {
111
+ errors = res.errors;
112
+ return;
113
+ }
114
+ // 3) Advance
115
+ goto("/checkout/confirm");
116
+ }
117
+ </script>
118
+
119
+ <CheckoutShippingStep bind:shippingAddress {errors} {onContinue} onBack={() => history.back()} />
120
+ ```
121
+
122
+ The step component **does not auto-advance**. It calls `onContinue` when the user clicks "Continue"; the consumer decides whether to actually advance, retry, or show errors.
123
+
124
+ ## Price arithmetic
125
+
126
+ **All monetary values are integers in the smallest currency unit (cents).** This applies to `CheckoutOrderLineItem.price`, `CheckoutDeliveryOption.price`, `CheckoutDeliveryOption.free_above`, and every field in `CheckoutOrderTotals`.
127
+
128
+ The built-in `defaultFormatPrice(cents)` returns `"12.99"`. Replace it via each component's `formatPrice` prop for locale-aware formatting:
129
+
130
+ ```ts
131
+ const formatPrice = (cents: number) =>
132
+ new Intl.NumberFormat("sk-SK", { style: "currency", currency: "EUR" })
133
+ .format(cents / 100);
134
+ ```
135
+
136
+ ## i18n
137
+
138
+ Every component accepts an optional `t?: TranslateFn` prop. Sensible English defaults are provided — see `_internal/checkout-i18n-defaults.ts` for the full key set (~140 keys, all prefixed `checkout.*`). Override by passing your own `t` function on each component, or at the step-container level (step containers forward `t` to their children).
139
+
140
+ `CheckoutLoginForm` internally bridges `checkout.login.*` keys to the generic `LoginForm` component's `login_form.*` keys, so you only need one consistent prefix.
141
+
142
+ ## Accessibility
143
+
144
+ - `CheckoutProgress` renders past/current/future steps with `aria-current="step"` on the active step.
145
+ - Form submissions do **not** automatically move focus to the first error field. Consumers wanting this behavior should do it in their `onContinue` handler after receiving validation errors.
146
+ - `CheckoutGuestOrLoginForm` uses the underlying `TabbedMenu` semantics; focus does not auto-move to the tab-panel heading on tab switch.
147
+
148
+ ## Address equality (advanced)
149
+
150
+ ```ts
151
+ import { addressesEqual } from "@marianmeres/stuic";
152
+
153
+ addressesEqual(order.shipping_address, order.billing_address);
154
+ ```
155
+
156
+ Returns `true` when either address is missing or when every `CheckoutAddressData` field matches (nullish values treated as empty strings). Used internally by `CheckoutOrderReview` to decide whether to show a separate billing block.
157
+
158
+ ## Empty-state factories
159
+
160
+ ```ts
161
+ import {
162
+ createEmptyAddress,
163
+ createEmptyCustomerFormData,
164
+ createEmptyLoginFormData,
165
+ } from "@marianmeres/stuic";
166
+ ```
167
+
168
+ Use these to initialize `$state` with the correct shape.
169
+
170
+ ## Conventions
171
+
172
+ Every component in this family:
173
+
174
+ - Exposes `unstyled?: boolean`, `class?: string`, `el?: HTMLElement` (bindable).
175
+ - Accepts `t?: TranslateFn` for i18n.
176
+ - Uses cents-integer price values.
177
+ - Uses `stuic-checkout-*` CSS classes; tokens live in the individual `_*.css` files in this directory.
178
+
179
+ ## Limitations
180
+
181
+ - **No top-level orchestrator.** By design; consumers wire up step navigation.
182
+ - **No generic `<T>` for line items.** `CheckoutOrderLineItem` is a fixed shape; use `itemsSection` / `cell` snippets for custom rendering.
183
+ - **No automatic focus-on-error management.** Left to the consumer.
184
+ - **No example route in `/src/routes`** yet — see the step-pattern snippet above for the shape consumers generally follow.
@@ -7,6 +7,12 @@ import type { TranslateFn } from "../../../types.js";
7
7
  export declare function defaultFormatPrice(cents: number): string;
8
8
  export declare function validateEmail(email: string, t: TranslateFn): string | null;
9
9
  export declare function validateCustomerForm(data: CheckoutCustomerFormData, t: TranslateFn): CheckoutValidationError[];
10
+ /**
11
+ * Structural equality for two addresses. Nullish values are treated as empty strings.
12
+ * If either address is missing, returns true (the UI convention is "don't render a
13
+ * separate billing block").
14
+ */
15
+ export declare function addressesEqual(a: CheckoutAddressData | undefined, b: CheckoutAddressData | undefined): boolean;
10
16
  export declare function validateAddress(address: CheckoutAddressData, prefix: string, t: TranslateFn): CheckoutValidationError[];
11
17
  export declare function validateLoginForm(data: CheckoutLoginFormData, t: TranslateFn): CheckoutValidationError[];
12
18
  export declare function createEmptyAddress(): CheckoutAddressData;
@@ -44,6 +44,30 @@ const REQUIRED_ADDRESS_FIELDS = [
44
44
  "postal_code",
45
45
  "country",
46
46
  ];
47
+ const ALL_ADDRESS_FIELDS = [
48
+ "name",
49
+ "street",
50
+ "city",
51
+ "postal_code",
52
+ "country",
53
+ "phone",
54
+ "label",
55
+ "is_default",
56
+ ];
57
+ /**
58
+ * Structural equality for two addresses. Nullish values are treated as empty strings.
59
+ * If either address is missing, returns true (the UI convention is "don't render a
60
+ * separate billing block").
61
+ */
62
+ export function addressesEqual(a, b) {
63
+ if (!a || !b)
64
+ return true;
65
+ for (const field of ALL_ADDRESS_FIELDS) {
66
+ if ((a[field] ?? "") !== (b[field] ?? ""))
67
+ return false;
68
+ }
69
+ return true;
70
+ }
47
71
  export function validateAddress(address, prefix, t) {
48
72
  const errors = [];
49
73
  for (const field of REQUIRED_ADDRESS_FIELDS) {
@@ -13,4 +13,4 @@ export { default as CheckoutShippingStep, type Props as CheckoutShippingStepProp
13
13
  export { default as CheckoutConfirmStep, type Props as CheckoutConfirmStepProps, } from "./CheckoutConfirmStep.svelte";
14
14
  export { default as CheckoutCompleteStep, type Props as CheckoutCompleteStepProps, } from "./CheckoutCompleteStep.svelte";
15
15
  export type { CheckoutStep, CheckoutAddressData, CheckoutCustomerFormData, CheckoutLoginFormData, CheckoutOrderLineItem, CheckoutOrderTotals, CheckoutDeliveryOption, CheckoutDeliverySnapshot, CheckoutOrderData, CheckoutValidationError, } from "./_internal/checkout-types.js";
16
- export { defaultFormatPrice, validateEmail, validateAddress, validateCustomerForm, validateLoginForm, createEmptyAddress, createEmptyCustomerFormData, createEmptyLoginFormData, } from "./_internal/checkout-utils.js";
16
+ export { defaultFormatPrice, validateEmail, validateAddress, validateCustomerForm, validateLoginForm, createEmptyAddress, createEmptyCustomerFormData, createEmptyLoginFormData, addressesEqual, } from "./_internal/checkout-utils.js";
@@ -14,4 +14,4 @@ export { default as CheckoutShippingStep, } from "./CheckoutShippingStep.svelte"
14
14
  export { default as CheckoutConfirmStep, } from "./CheckoutConfirmStep.svelte";
15
15
  export { default as CheckoutCompleteStep, } from "./CheckoutCompleteStep.svelte";
16
16
  // Utilities
17
- export { defaultFormatPrice, validateEmail, validateAddress, validateCustomerForm, validateLoginForm, createEmptyAddress, createEmptyCustomerFormData, createEmptyLoginFormData, } from "./_internal/checkout-utils.js";
17
+ export { defaultFormatPrice, validateEmail, validateAddress, validateCustomerForm, validateLoginForm, createEmptyAddress, createEmptyCustomerFormData, createEmptyLoginFormData, addressesEqual, } from "./_internal/checkout-utils.js";