@marianmeres/stuic 3.66.1 → 3.68.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/actions/autoscroll.d.ts +7 -0
- package/dist/actions/autoscroll.js +7 -0
- package/dist/actions/focus-trap.d.ts +7 -0
- package/dist/actions/focus-trap.js +8 -3
- package/dist/actions/typeahead.svelte.js +40 -4
- package/dist/components/Carousel/Carousel.svelte +9 -2
- package/dist/components/Carousel/README.md +8 -2
- package/dist/components/Cart/Cart.svelte +3 -0
- package/dist/components/Cart/README.md +18 -1
- package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
- package/dist/components/Checkout/README.md +184 -0
- package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
- package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
- package/dist/components/Checkout/index.d.ts +1 -1
- package/dist/components/Checkout/index.js +1 -1
- package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
- package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
- package/dist/components/CronInput/CronInput.svelte +44 -9
- package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
- package/dist/components/CronInput/README.md +145 -0
- package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
- package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
- package/dist/components/CronInput/index.css +0 -8
- package/dist/components/DataTable/DataTable.svelte +276 -83
- package/dist/components/DataTable/DataTable.svelte.d.ts +58 -6
- package/dist/components/DataTable/README.md +155 -25
- package/dist/components/DataTable/index.css +31 -0
- package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
- package/dist/components/DropdownMenu/README.md +37 -9
- package/dist/components/Input/FieldAssets.svelte +9 -7
- package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
- package/dist/components/Input/FieldFile.svelte +13 -7
- package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
- package/dist/components/Input/FieldInput.svelte +10 -8
- package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
- package/dist/components/Input/FieldInputLocalized.svelte +8 -7
- package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
- package/dist/components/Input/FieldKeyValues.svelte +8 -7
- package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
- package/dist/components/Input/FieldLikeButton.svelte +9 -7
- package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
- package/dist/components/Input/FieldObject.svelte +8 -7
- package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
- package/dist/components/Input/FieldOptions.svelte +9 -7
- package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
- package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
- package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
- package/dist/components/Input/FieldSelect.svelte +9 -8
- package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
- package/dist/components/Input/FieldSwitch.svelte +9 -7
- package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
- package/dist/components/Input/FieldTextarea.svelte +7 -8
- package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
- package/dist/components/Input/README.md +20 -0
- package/dist/components/Input/_internal/InputWrap.svelte +2 -10
- package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
- package/dist/components/Input/types.d.ts +28 -0
- package/dist/components/Nav/Nav.svelte +5 -4
- package/dist/components/Nav/Nav.svelte.d.ts +2 -2
- package/dist/components/Nav/README.md +2 -2
- package/dist/components/Nav/index.css +4 -0
- package/dist/components/Tree/README.md +189 -0
- package/dist/components/Tree/Tree.svelte +46 -2
- package/dist/components/Tree/Tree.svelte.d.ts +5 -0
- package/dist/utils/input-history.svelte.d.ts +12 -0
- package/dist/utils/input-history.svelte.js +12 -0
- package/dist/utils/observe-exists.svelte.d.ts +1 -0
- package/dist/utils/observe-exists.svelte.js +11 -3
- package/dist/utils/switch.svelte.d.ts +12 -0
- package/dist/utils/switch.svelte.js +12 -1
- package/docs/architecture.md +0 -1
- package/docs/testing.md +72 -0
- package/docs/upgrading.md +281 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
| `
|
|
21
|
-
| `
|
|
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"`
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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";
|