@marianmeres/stuic 3.142.1 → 3.144.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/AGENTS.md +3 -0
- package/README.md +23 -0
- package/dist/components/Checkout/CheckoutAddressForm.svelte +185 -25
- package/dist/components/Checkout/CheckoutAddressForm.svelte.d.ts +61 -1
- package/dist/components/Checkout/CheckoutShippingStep.svelte +1 -0
- package/dist/components/Checkout/CheckoutShippingStep.svelte.d.ts +1 -0
- package/dist/components/Checkout/README.md +51 -0
- package/dist/components/Checkout/_internal/checkout-i18n-defaults.js +1 -0
- package/dist/components/Checkout/_internal/checkout-types.d.ts +11 -0
- package/dist/components/Checkout/index.d.ts +1 -1
- package/dist/components/CommentInput/CommentInput.svelte +1 -0
- package/dist/components/CommentInput/CommentInput.svelte.d.ts +1 -0
- package/dist/components/CommentInput/README.md +11 -1
- package/dist/components/CommentInput/index.css +13 -0
- package/dist/index.css +3 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +5 -1
- package/docs/architecture.md +23 -1
- package/package.json +6 -11
package/AGENTS.md
CHANGED
|
@@ -53,6 +53,9 @@ Theme CSS files are not bundled in this package — they're provided by `@marian
|
|
|
53
53
|
5. Create components without `unstyled`, `class`, `el` props
|
|
54
54
|
6. Use `dark:` Tailwind prefix when CSS vars handle dark mode
|
|
55
55
|
7. Import CSS inside components — centralize in `src/lib/index.css`
|
|
56
|
+
(**exception:** subpath-export components with optional peer deps — `MarkdownEditor`,
|
|
57
|
+
`CommentInput` — import their own `index.css` locally so their styles don't ship to
|
|
58
|
+
barrel-only consumers. Enforced by `src/lib/barrel-optional-peers.test.ts`.)
|
|
56
59
|
8. Declare component tokens at `:root` that reference shared structural tokens (use fallback pattern instead)
|
|
57
60
|
|
|
58
61
|
### CSS Variable Pattern
|
package/README.md
CHANGED
|
@@ -27,6 +27,29 @@ npm install @marianmeres/stuic
|
|
|
27
27
|
</Modal>
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Subpath exports
|
|
31
|
+
|
|
32
|
+
Most of the library is on the main entry. A few things live behind subpaths:
|
|
33
|
+
|
|
34
|
+
| Subpath | Contents |
|
|
35
|
+
| ------------------------------------- | ------------------------------------------------------------- |
|
|
36
|
+
| `@marianmeres/stuic` | Components, actions, icons, utils — everything below excepted |
|
|
37
|
+
| `@marianmeres/stuic/utils` | Utilities only, without pulling in components |
|
|
38
|
+
| `@marianmeres/stuic/phone-validation` | Phone validation helpers |
|
|
39
|
+
| `@marianmeres/stuic/markdown-editor` | `MarkdownEditor` — requires optional peer deps |
|
|
40
|
+
| `@marianmeres/stuic/comment-input` | `CommentInput` — requires optional peer deps |
|
|
41
|
+
|
|
42
|
+
The last two are **not** on the main entry by design: they depend on Milkdown and
|
|
43
|
+
CodeMirror, which are declared as _optional_ peer dependencies. Keeping them off the
|
|
44
|
+
barrel means consumers who don't use them never have to install that stack — and,
|
|
45
|
+
more importantly, their builds don't fail for want of it.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { CommentInput } from "@marianmeres/stuic/comment-input";
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See each component's README for the peer set to install.
|
|
52
|
+
|
|
30
53
|
## Theming System
|
|
31
54
|
|
|
32
55
|
STUIC uses a 4-layer CSS variable token system:
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
import type { TranslateFn } from "../../types.js";
|
|
6
6
|
import type { Props as FieldCountryProps } from "../Input/FieldCountry.svelte";
|
|
7
7
|
import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
|
|
8
|
+
import type { Props as FieldSelectProps } from "../Input/FieldSelect.svelte";
|
|
8
9
|
import type {
|
|
9
10
|
CheckoutAddressData,
|
|
11
|
+
CheckoutSubdivisionOption,
|
|
10
12
|
CheckoutValidationError,
|
|
11
13
|
} from "./_internal/checkout-types.js";
|
|
12
14
|
|
|
@@ -87,6 +89,71 @@
|
|
|
87
89
|
*/
|
|
88
90
|
countryNames?: Record<string, string>;
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Subdivision (state/province/region) lists keyed by UPPERCASE ISO alpha-2
|
|
94
|
+
* country code. When the currently selected `address.country` has a
|
|
95
|
+
* non-empty entry, the `state_or_region` field renders as a fixed select
|
|
96
|
+
* over these options (storing the option `code`); for every other country
|
|
97
|
+
* it stays the default free-text input, whose value is never touched.
|
|
98
|
+
* stuic ships no subdivision data — pass exactly the countries your
|
|
99
|
+
* checkout logic keys on (tax, shipping). Default: undefined (free text
|
|
100
|
+
* everywhere, current behavior).
|
|
101
|
+
*
|
|
102
|
+
* When the built-in select is active, a stored value is reconciled
|
|
103
|
+
* against the list: an exact `code` match (case-insensitive) is
|
|
104
|
+
* normalized to the canonical code, an exact `name` match ("Michigan")
|
|
105
|
+
* is rewritten to its `code` ("MI"), and anything else is left
|
|
106
|
+
* untouched and renders as unselected — surfaced by required-validation
|
|
107
|
+
* rather than destroyed. Reconciliation is skipped when the `stateField`
|
|
108
|
+
* snippet replaces the field — a custom control owns its value.
|
|
109
|
+
*
|
|
110
|
+
* Country edits made through this form flip the mode live even for a
|
|
111
|
+
* plain (non-`$state`) `address` object; external mutations of a plain
|
|
112
|
+
* object are not observable — pass a `$state` object for those.
|
|
113
|
+
*/
|
|
114
|
+
subdivisions?: Record<string, CheckoutSubdivisionOption[]>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether `state_or_region` is required while the subdivision select is
|
|
118
|
+
* active (i.e. the current country has a `subdivisions` entry). Also
|
|
119
|
+
* accepts a per-country predicate receiving the UPPERCASE ISO code.
|
|
120
|
+
* Countries without a list keep the plain `requiredFields` behavior
|
|
121
|
+
* (as does a `requiredFields` entry for "state_or_region", which wins
|
|
122
|
+
* regardless). Default: true.
|
|
123
|
+
*/
|
|
124
|
+
subdivisionRequired?: boolean | ((countryIso: string) => boolean);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Override the state/region field with a custom control (parity with
|
|
128
|
+
* `countryField`). When provided, replaces the built-in field in both
|
|
129
|
+
* modes; `options` is the active country's subdivision list, or null
|
|
130
|
+
* when the free-text input would apply.
|
|
131
|
+
*/
|
|
132
|
+
stateField?: Snippet<
|
|
133
|
+
[
|
|
134
|
+
{
|
|
135
|
+
/** Current state/region value */
|
|
136
|
+
value: string;
|
|
137
|
+
/** Called when the value changes */
|
|
138
|
+
onchange: (value: string) => void;
|
|
139
|
+
/** Error message for this field (if any) */
|
|
140
|
+
error?: string;
|
|
141
|
+
/** Field label text */
|
|
142
|
+
label: string;
|
|
143
|
+
/** HTML id attribute for the input */
|
|
144
|
+
id: string;
|
|
145
|
+
/** Active country's subdivision list, or null (free-text mode) */
|
|
146
|
+
options: CheckoutSubdivisionOption[] | null;
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extra props forwarded to the internal FieldSelect when the subdivision
|
|
153
|
+
* select is active (parity with `countryFieldProps`).
|
|
154
|
+
*/
|
|
155
|
+
stateFieldProps?: Partial<FieldSelectProps>;
|
|
156
|
+
|
|
90
157
|
/** Extra props forwarded to the internal FieldPhoneNumber component. */
|
|
91
158
|
phoneFieldProps?: Partial<FieldPhoneNumberProps>;
|
|
92
159
|
|
|
@@ -109,6 +176,7 @@
|
|
|
109
176
|
import FieldCountry from "../Input/FieldCountry.svelte";
|
|
110
177
|
import FieldInput from "../Input/FieldInput.svelte";
|
|
111
178
|
import FieldPhoneNumber from "../Input/FieldPhoneNumber.svelte";
|
|
179
|
+
import FieldSelect from "../Input/FieldSelect.svelte";
|
|
112
180
|
import { validatePhoneNumber } from "../Input/phone-validation.js";
|
|
113
181
|
import { t_default } from "./_internal/checkout-i18n-defaults.js";
|
|
114
182
|
import { createEmptyAddress } from "./_internal/checkout-utils.js";
|
|
@@ -125,6 +193,10 @@
|
|
|
125
193
|
countryList,
|
|
126
194
|
preferredCountries,
|
|
127
195
|
countryNames,
|
|
196
|
+
subdivisions,
|
|
197
|
+
subdivisionRequired = true,
|
|
198
|
+
stateField,
|
|
199
|
+
stateFieldProps,
|
|
128
200
|
phoneFieldProps,
|
|
129
201
|
countryFieldProps,
|
|
130
202
|
t: tProp,
|
|
@@ -147,6 +219,56 @@
|
|
|
147
219
|
let containerWidth = $state(0);
|
|
148
220
|
let isSmall = $derived(containerWidth > 0 && containerWidth < 480);
|
|
149
221
|
|
|
222
|
+
// Subdivision select mode (see the `subdivisions` prop) --------------------
|
|
223
|
+
// Local mirror of `address.country` (writable derived). The select/input
|
|
224
|
+
// mode must react to country edits even when the consumer passed a plain
|
|
225
|
+
// (non-$state) address object, whose deep mutations Svelte cannot observe —
|
|
226
|
+
// so every country write that goes through this form also overrides the
|
|
227
|
+
// mirror directly, while reactive addresses and prop replacement resync it
|
|
228
|
+
// through the derived expression. External mutations of a plain object stay
|
|
229
|
+
// unobservable (true for every field).
|
|
230
|
+
let _country = $derived(address.country ?? "");
|
|
231
|
+
|
|
232
|
+
function setCountry(v: string) {
|
|
233
|
+
address.country = v;
|
|
234
|
+
_country = v;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let _countryCC = $derived(_country.trim().toUpperCase());
|
|
238
|
+
|
|
239
|
+
let _subdivisionList = $derived.by(() => {
|
|
240
|
+
const list = _countryCC ? subdivisions?.[_countryCC] : undefined;
|
|
241
|
+
return list?.length ? list : null;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
let _subdivisionIsRequired = $derived(
|
|
245
|
+
isRequired("state_or_region") ||
|
|
246
|
+
(typeof subdivisionRequired === "function"
|
|
247
|
+
? subdivisionRequired(_countryCC)
|
|
248
|
+
: subdivisionRequired)
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Reconcile a pre-existing value whenever the built-in select becomes
|
|
252
|
+
// active: legacy free-text rows ("Michigan", "mi") self-heal to the
|
|
253
|
+
// canonical code ("MI"); anything unrecognized is left untouched (renders
|
|
254
|
+
// unselected, caught by required-validation) — never destroyed. Converges:
|
|
255
|
+
// the write triggers one re-run which then matches the code branch with
|
|
256
|
+
// nothing to change. Skipped entirely under the `stateField` snippet — a
|
|
257
|
+
// custom control owns its value (a controlled combobox writing in-progress
|
|
258
|
+
// text through `onchange` must not have it rewritten mid-typing).
|
|
259
|
+
$effect(() => {
|
|
260
|
+
if (stateField) return;
|
|
261
|
+
const list = _subdivisionList;
|
|
262
|
+
if (!list) return;
|
|
263
|
+
const raw = address.state_or_region ?? "";
|
|
264
|
+
const needle = raw.trim().toLowerCase();
|
|
265
|
+
if (!needle) return;
|
|
266
|
+
const hit =
|
|
267
|
+
list.find((o) => o.code.toLowerCase() === needle) ??
|
|
268
|
+
list.find((o) => o.name.trim().toLowerCase() === needle);
|
|
269
|
+
if (hit && raw !== hit.code) address.state_or_region = hit.code;
|
|
270
|
+
});
|
|
271
|
+
|
|
150
272
|
let _class = $derived(
|
|
151
273
|
unstyled ? classProp : twMerge("stuic-checkout-address", classProp)
|
|
152
274
|
);
|
|
@@ -154,12 +276,12 @@
|
|
|
154
276
|
// Imperative API ----------------------------------------------------------
|
|
155
277
|
// Field refs collected during render so consumers can trigger validation
|
|
156
278
|
// without waiting for native form submission. Refs stay undefined for
|
|
157
|
-
// fields hidden via the `fields` prop or replaced by the `countryField
|
|
158
|
-
//
|
|
279
|
+
// fields hidden via the `fields` prop or replaced by the `countryField`/
|
|
280
|
+
// `stateField` snippets — `validateAllFields` skips nullish entries.
|
|
159
281
|
let nameField = $state<FieldInput>();
|
|
160
282
|
let streetField = $state<FieldInput>();
|
|
161
283
|
let cityField = $state<FieldInput>();
|
|
162
|
-
let
|
|
284
|
+
let stateFieldRef = $state<FieldInput | FieldSelect>();
|
|
163
285
|
let postalCodeField = $state<FieldInput>();
|
|
164
286
|
let countryFieldRef = $state<FieldCountry>();
|
|
165
287
|
let phoneField = $state<FieldPhoneNumber>();
|
|
@@ -170,7 +292,7 @@
|
|
|
170
292
|
nameField,
|
|
171
293
|
streetField,
|
|
172
294
|
cityField,
|
|
173
|
-
|
|
295
|
+
stateFieldRef,
|
|
174
296
|
postalCodeField,
|
|
175
297
|
countryFieldRef,
|
|
176
298
|
phoneField,
|
|
@@ -276,22 +398,63 @@
|
|
|
276
398
|
/>
|
|
277
399
|
{/if}
|
|
278
400
|
{#if fields?.state_or_region !== false}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
labelLeftBreakpoint={0}
|
|
285
|
-
placeholder={t("checkout.address.state_or_region_placeholder")}
|
|
286
|
-
required={isRequired("state_or_region")}
|
|
287
|
-
name="{label}-state_or_region"
|
|
288
|
-
id="{label}-state_or_region"
|
|
289
|
-
validate={{
|
|
290
|
-
customValidator(val) {
|
|
291
|
-
return fieldError("state_or_region") || "";
|
|
401
|
+
{#if stateField}
|
|
402
|
+
{@render stateField({
|
|
403
|
+
value: address.state_or_region ?? "",
|
|
404
|
+
onchange: (v) => {
|
|
405
|
+
address.state_or_region = v;
|
|
292
406
|
},
|
|
293
|
-
|
|
294
|
-
|
|
407
|
+
error: fieldError("state_or_region"),
|
|
408
|
+
label: t("checkout.address.state_or_region_label"),
|
|
409
|
+
id: `${label}-state_or_region`,
|
|
410
|
+
options: _subdivisionList,
|
|
411
|
+
})}
|
|
412
|
+
{:else if _subdivisionList}
|
|
413
|
+
<FieldSelect
|
|
414
|
+
bind:this={stateFieldRef}
|
|
415
|
+
bind:value={
|
|
416
|
+
() => address.state_or_region ?? "",
|
|
417
|
+
(v) => {
|
|
418
|
+
address.state_or_region = String(v ?? "");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
options={[
|
|
422
|
+
{
|
|
423
|
+
label: t("checkout.address.state_or_region_select_placeholder"),
|
|
424
|
+
value: "",
|
|
425
|
+
},
|
|
426
|
+
..._subdivisionList.map((o) => ({ label: o.name, value: o.code })),
|
|
427
|
+
]}
|
|
428
|
+
label={t("checkout.address.state_or_region_label")}
|
|
429
|
+
labelLeftBreakpoint={0}
|
|
430
|
+
required={_subdivisionIsRequired}
|
|
431
|
+
name="{label}-state_or_region"
|
|
432
|
+
id="{label}-state_or_region"
|
|
433
|
+
validate={{
|
|
434
|
+
customValidator(val) {
|
|
435
|
+
return fieldError("state_or_region") || "";
|
|
436
|
+
},
|
|
437
|
+
}}
|
|
438
|
+
{...stateFieldProps}
|
|
439
|
+
/>
|
|
440
|
+
{:else}
|
|
441
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
442
|
+
<FieldInput
|
|
443
|
+
bind:this={stateFieldRef}
|
|
444
|
+
bind:value={address.state_or_region}
|
|
445
|
+
label={t("checkout.address.state_or_region_label")}
|
|
446
|
+
labelLeftBreakpoint={0}
|
|
447
|
+
placeholder={t("checkout.address.state_or_region_placeholder")}
|
|
448
|
+
required={isRequired("state_or_region")}
|
|
449
|
+
name="{label}-state_or_region"
|
|
450
|
+
id="{label}-state_or_region"
|
|
451
|
+
validate={{
|
|
452
|
+
customValidator(val) {
|
|
453
|
+
return fieldError("state_or_region") || "";
|
|
454
|
+
},
|
|
455
|
+
}}
|
|
456
|
+
/>
|
|
457
|
+
{/if}
|
|
295
458
|
{/if}
|
|
296
459
|
{#if fields?.postal_code !== false}
|
|
297
460
|
<!-- svelte-ignore binding_property_non_reactive -->
|
|
@@ -318,19 +481,16 @@
|
|
|
318
481
|
{#if fields?.country !== false}
|
|
319
482
|
{#if countryField}
|
|
320
483
|
{@render countryField({
|
|
321
|
-
value:
|
|
322
|
-
onchange:
|
|
323
|
-
address.country = v;
|
|
324
|
-
},
|
|
484
|
+
value: _country,
|
|
485
|
+
onchange: setCountry,
|
|
325
486
|
error: fieldError("country"),
|
|
326
487
|
label: t("checkout.address.country_label"),
|
|
327
488
|
id: `${label}-country`,
|
|
328
489
|
})}
|
|
329
490
|
{:else}
|
|
330
|
-
<!-- svelte-ignore binding_property_non_reactive -->
|
|
331
491
|
<FieldCountry
|
|
332
492
|
bind:this={countryFieldRef}
|
|
333
|
-
bind:value={
|
|
493
|
+
bind:value={() => _country, setCountry}
|
|
334
494
|
label={t("checkout.address.country_label")}
|
|
335
495
|
placeholder={t("checkout.address.country_placeholder")}
|
|
336
496
|
required={isRequired("country")}
|
|
@@ -4,7 +4,8 @@ import type { HTMLAttributes } from "svelte/elements";
|
|
|
4
4
|
import type { TranslateFn } from "../../types.js";
|
|
5
5
|
import type { Props as FieldCountryProps } from "../Input/FieldCountry.svelte";
|
|
6
6
|
import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
|
|
7
|
-
import type {
|
|
7
|
+
import type { Props as FieldSelectProps } from "../Input/FieldSelect.svelte";
|
|
8
|
+
import type { CheckoutAddressData, CheckoutSubdivisionOption, CheckoutValidationError } from "./_internal/checkout-types.js";
|
|
8
9
|
export interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, "children"> {
|
|
9
10
|
/**
|
|
10
11
|
* Bindable address data. Default: createEmptyAddress().
|
|
@@ -71,6 +72,65 @@ export interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, "childr
|
|
|
71
72
|
* values are the localized name. Missing keys fall back to English.
|
|
72
73
|
*/
|
|
73
74
|
countryNames?: Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* Subdivision (state/province/region) lists keyed by UPPERCASE ISO alpha-2
|
|
77
|
+
* country code. When the currently selected `address.country` has a
|
|
78
|
+
* non-empty entry, the `state_or_region` field renders as a fixed select
|
|
79
|
+
* over these options (storing the option `code`); for every other country
|
|
80
|
+
* it stays the default free-text input, whose value is never touched.
|
|
81
|
+
* stuic ships no subdivision data — pass exactly the countries your
|
|
82
|
+
* checkout logic keys on (tax, shipping). Default: undefined (free text
|
|
83
|
+
* everywhere, current behavior).
|
|
84
|
+
*
|
|
85
|
+
* When the built-in select is active, a stored value is reconciled
|
|
86
|
+
* against the list: an exact `code` match (case-insensitive) is
|
|
87
|
+
* normalized to the canonical code, an exact `name` match ("Michigan")
|
|
88
|
+
* is rewritten to its `code` ("MI"), and anything else is left
|
|
89
|
+
* untouched and renders as unselected — surfaced by required-validation
|
|
90
|
+
* rather than destroyed. Reconciliation is skipped when the `stateField`
|
|
91
|
+
* snippet replaces the field — a custom control owns its value.
|
|
92
|
+
*
|
|
93
|
+
* Country edits made through this form flip the mode live even for a
|
|
94
|
+
* plain (non-`$state`) `address` object; external mutations of a plain
|
|
95
|
+
* object are not observable — pass a `$state` object for those.
|
|
96
|
+
*/
|
|
97
|
+
subdivisions?: Record<string, CheckoutSubdivisionOption[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Whether `state_or_region` is required while the subdivision select is
|
|
100
|
+
* active (i.e. the current country has a `subdivisions` entry). Also
|
|
101
|
+
* accepts a per-country predicate receiving the UPPERCASE ISO code.
|
|
102
|
+
* Countries without a list keep the plain `requiredFields` behavior
|
|
103
|
+
* (as does a `requiredFields` entry for "state_or_region", which wins
|
|
104
|
+
* regardless). Default: true.
|
|
105
|
+
*/
|
|
106
|
+
subdivisionRequired?: boolean | ((countryIso: string) => boolean);
|
|
107
|
+
/**
|
|
108
|
+
* Override the state/region field with a custom control (parity with
|
|
109
|
+
* `countryField`). When provided, replaces the built-in field in both
|
|
110
|
+
* modes; `options` is the active country's subdivision list, or null
|
|
111
|
+
* when the free-text input would apply.
|
|
112
|
+
*/
|
|
113
|
+
stateField?: Snippet<[
|
|
114
|
+
{
|
|
115
|
+
/** Current state/region value */
|
|
116
|
+
value: string;
|
|
117
|
+
/** Called when the value changes */
|
|
118
|
+
onchange: (value: string) => void;
|
|
119
|
+
/** Error message for this field (if any) */
|
|
120
|
+
error?: string;
|
|
121
|
+
/** Field label text */
|
|
122
|
+
label: string;
|
|
123
|
+
/** HTML id attribute for the input */
|
|
124
|
+
id: string;
|
|
125
|
+
/** Active country's subdivision list, or null (free-text mode) */
|
|
126
|
+
options: CheckoutSubdivisionOption[] | null;
|
|
127
|
+
}
|
|
128
|
+
]>;
|
|
129
|
+
/**
|
|
130
|
+
* Extra props forwarded to the internal FieldSelect when the subdivision
|
|
131
|
+
* select is active (parity with `countryFieldProps`).
|
|
132
|
+
*/
|
|
133
|
+
stateFieldProps?: Partial<FieldSelectProps>;
|
|
74
134
|
/** Extra props forwarded to the internal FieldPhoneNumber component. */
|
|
75
135
|
phoneFieldProps?: Partial<FieldPhoneNumberProps>;
|
|
76
136
|
/** Extra props forwarded to the internal FieldCountry component. */
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
* Props forwarded to both inner `CheckoutAddressForm` instances (shipping
|
|
100
100
|
* and, when visible, billing). Use this to configure `phoneFieldProps`,
|
|
101
101
|
* `countryFieldProps`, `preferredCountries`, `countryList`, `countryNames`,
|
|
102
|
+
* `subdivisions`, `subdivisionRequired`, `stateFieldProps`,
|
|
102
103
|
* `requiredFields`, or `fields` without replacing the entire left column
|
|
103
104
|
* via the `leftColumn` snippet.
|
|
104
105
|
*
|
|
@@ -55,6 +55,7 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
|
|
|
55
55
|
* Props forwarded to both inner `CheckoutAddressForm` instances (shipping
|
|
56
56
|
* and, when visible, billing). Use this to configure `phoneFieldProps`,
|
|
57
57
|
* `countryFieldProps`, `preferredCountries`, `countryList`, `countryNames`,
|
|
58
|
+
* `subdivisions`, `subdivisionRequired`, `stateFieldProps`,
|
|
58
59
|
* `requiredFields`, or `fields` without replacing the entire left column
|
|
59
60
|
* via the `leftColumn` snippet.
|
|
60
61
|
*
|
|
@@ -126,6 +126,57 @@ interface CheckoutValidationError {
|
|
|
126
126
|
|
|
127
127
|
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.
|
|
128
128
|
|
|
129
|
+
## Country-aware state/region select (`subdivisions`)
|
|
130
|
+
|
|
131
|
+
By default `CheckoutAddressForm` renders `state_or_region` as free text. For
|
|
132
|
+
countries where downstream logic keys on an exact subdivision code (US
|
|
133
|
+
sales-tax tables, shipping zones), pass `subdivisions` — lists keyed by
|
|
134
|
+
UPPERCASE ISO alpha-2 country code. When the selected country has an entry,
|
|
135
|
+
the field swaps to a fixed select storing the canonical `code`; every other
|
|
136
|
+
country keeps the free-text input, verbatim. stuic ships **no** subdivision
|
|
137
|
+
data — pass exactly what your app needs:
|
|
138
|
+
|
|
139
|
+
```svelte
|
|
140
|
+
<CheckoutAddressForm
|
|
141
|
+
bind:address
|
|
142
|
+
subdivisions={{
|
|
143
|
+
US: [
|
|
144
|
+
{ code: "AL", name: "Alabama" },
|
|
145
|
+
{ code: "AK", name: "Alaska" },
|
|
146
|
+
// ... full USPS list
|
|
147
|
+
],
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Behavior details:
|
|
153
|
+
|
|
154
|
+
- **Stored value is always the `code`** ("MI") — same wire shape as free text,
|
|
155
|
+
no server contract change.
|
|
156
|
+
- **Prefill reconciliation:** entering select mode with a legacy value
|
|
157
|
+
self-heals it — `"mi"` → `"MI"`, `"Michigan"` → `"MI"` (written back into
|
|
158
|
+
the bound `address`). Unrecognized values are left untouched and render as
|
|
159
|
+
unselected, surfaced by validation rather than destroyed. Applies only to
|
|
160
|
+
the built-in select — the `stateField` snippet owns its value entirely.
|
|
161
|
+
- **Country switching never clears the field** — a US → CA → US round-trip
|
|
162
|
+
restores "MI".
|
|
163
|
+
- **Required:** while the select is active the field is required by default
|
|
164
|
+
(`subdivisionRequired`, also accepts a per-country predicate
|
|
165
|
+
`(countryIso) => boolean`). Countries without a list keep the plain
|
|
166
|
+
`requiredFields` behavior.
|
|
167
|
+
- `stateFieldProps` forwards extras to the internal `FieldSelect`; the
|
|
168
|
+
`stateField` snippet replaces the field entirely (receives the active
|
|
169
|
+
`options`, or `null` in free-text mode) — parity with
|
|
170
|
+
`countryField`/`countryFieldProps`.
|
|
171
|
+
- i18n: the select's empty option uses
|
|
172
|
+
`checkout.address.state_or_region_select_placeholder` (default `"Select…"`).
|
|
173
|
+
- Forward through composite steps via
|
|
174
|
+
`CheckoutShippingStep.addressFormProps`.
|
|
175
|
+
|
|
176
|
+
Note: the `validateAddress()` utility has no access to the `subdivisions`
|
|
177
|
+
config, so it does not enforce subdivision-required — use the component's
|
|
178
|
+
imperative `validate()` (step containers already do).
|
|
179
|
+
|
|
129
180
|
## Price arithmetic
|
|
130
181
|
|
|
131
182
|
**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`.
|
|
@@ -67,6 +67,7 @@ const DEFAULTS = {
|
|
|
67
67
|
"checkout.address.city_placeholder": "",
|
|
68
68
|
"checkout.address.state_or_region_label": "State / Region",
|
|
69
69
|
"checkout.address.state_or_region_placeholder": "",
|
|
70
|
+
"checkout.address.state_or_region_select_placeholder": "Select…",
|
|
70
71
|
"checkout.address.postal_code_label": "Postal Code",
|
|
71
72
|
"checkout.address.postal_code_placeholder": "",
|
|
72
73
|
"checkout.address.country_label": "Country",
|
|
@@ -10,6 +10,17 @@ export interface CheckoutStep {
|
|
|
10
10
|
/** Whether this step can be navigated to (clicked). Default: true for past steps */
|
|
11
11
|
navigable?: boolean;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* One selectable subdivision (state/province/region) for the
|
|
15
|
+
* `CheckoutAddressForm` `subdivisions` prop. stuic ships no subdivision
|
|
16
|
+
* data — consumers pass the lists for the countries they care about.
|
|
17
|
+
*/
|
|
18
|
+
export interface CheckoutSubdivisionOption {
|
|
19
|
+
/** Canonical stored value — ISO 3166-2 subdivision suffix / USPS code (e.g. "MI"). */
|
|
20
|
+
code: string;
|
|
21
|
+
/** Display name (e.g. "Michigan"). */
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
13
24
|
export interface CheckoutAddressData {
|
|
14
25
|
name: string;
|
|
15
26
|
street: string;
|
|
@@ -12,5 +12,5 @@ export { default as CheckoutReviewStep, type Props as CheckoutReviewStepProps, }
|
|
|
12
12
|
export { default as CheckoutShippingStep, type Props as CheckoutShippingStepProps, } from "./CheckoutShippingStep.svelte";
|
|
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
|
-
export type { CheckoutStep, CheckoutAddressData, CheckoutCustomerFormData, CheckoutLoginFormData, CheckoutOrderLineItem, CheckoutOrderTotals, CheckoutDeliveryOption, CheckoutDeliverySnapshot, CheckoutOrderData, CheckoutValidationError, } from "./_internal/checkout-types.js";
|
|
15
|
+
export type { CheckoutStep, CheckoutAddressData, CheckoutSubdivisionOption, CheckoutCustomerFormData, CheckoutLoginFormData, CheckoutOrderLineItem, CheckoutOrderTotals, CheckoutDeliveryOption, CheckoutDeliverySnapshot, CheckoutOrderData, CheckoutValidationError, } from "./_internal/checkout-types.js";
|
|
16
16
|
export { defaultFormatPrice, validateEmail, validateAddress, validateCustomerForm, validateLoginForm, createEmptyAddress, createEmptyCustomerFormData, createEmptyLoginFormData, addressesEqual, } from "./_internal/checkout-utils.js";
|
|
@@ -110,6 +110,7 @@ export interface Props extends InputWrapClassProps {
|
|
|
110
110
|
classFooter?: string;
|
|
111
111
|
style?: string;
|
|
112
112
|
}
|
|
113
|
+
import "./index.css";
|
|
113
114
|
declare const CommentInput: import("svelte").Component<Props, {
|
|
114
115
|
validate: () => ValidationResult | undefined;
|
|
115
116
|
clearValidation: () => void;
|
|
@@ -12,9 +12,14 @@ lightweight, opinionated wrapper for the common "leave a comment / reply" use
|
|
|
12
12
|
case: a clean minimal toolbar, an avatar, a submit button, and
|
|
13
13
|
⌘/Ctrl+Enter-to-send — without you having to wire any of it up.
|
|
14
14
|
|
|
15
|
+
Because it embeds `MarkdownEditor`, it is shipped as an **optional subpath export**
|
|
16
|
+
(`@marianmeres/stuic/comment-input`), separate from the main barrel — so the heavy
|
|
17
|
+
editor dependencies never reach consumers who don't use it. Importing it from
|
|
18
|
+
`@marianmeres/stuic` will not work.
|
|
19
|
+
|
|
15
20
|
```svelte
|
|
16
21
|
<script>
|
|
17
|
-
import { CommentInput } from "@marianmeres/stuic";
|
|
22
|
+
import { CommentInput } from "@marianmeres/stuic/comment-input";
|
|
18
23
|
|
|
19
24
|
let value = $state("");
|
|
20
25
|
|
|
@@ -49,6 +54,11 @@ This is the same peer set as `MarkdownEditor` — see its README for the
|
|
|
49
54
|
authoritative list. If they aren't installed, the surface stays empty and a
|
|
50
55
|
console error explains why.
|
|
51
56
|
|
|
57
|
+
> **CSS is imported locally** by this component, not via the central
|
|
58
|
+
> `src/lib/index.css`. Deliberate deviation from the usual STUIC convention,
|
|
59
|
+
> required so these styles ship only to subpath users. Same as `MarkdownEditor`;
|
|
60
|
+
> see `index.css` for the rationale.
|
|
61
|
+
|
|
52
62
|
## Toolbar
|
|
53
63
|
|
|
54
64
|
`toolbar` accepts `true` (the default), `false` (hidden), or an ordered
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
/******************************************************************************
|
|
2
2
|
CommentInput
|
|
3
3
|
|
|
4
|
+
NOTE — intentional deviation from the usual STUIC CSS convention.
|
|
5
|
+
Every other component's `index.css` is `@import`-ed into the central
|
|
6
|
+
`src/lib/index.css`. This component is NOT: because it embeds `MarkdownEditor`
|
|
7
|
+
it is an OPTIONAL subpath export (`@marianmeres/stuic/comment-input`) kept off
|
|
8
|
+
the main barrel, so the heavy Milkdown/CodeMirror peers never reach consumers
|
|
9
|
+
who don't use it. Centralizing its CSS would ship these styles to every
|
|
10
|
+
consumer anyway. Instead `CommentInput.svelte` imports THIS file locally; the
|
|
11
|
+
package's `sideEffects` glob (all css files) keeps the import for real users.
|
|
12
|
+
|
|
13
|
+
`MarkdownEditor/index.css` arrives on its own — `MarkdownEditor.svelte`
|
|
14
|
+
imports it locally too — so do NOT `@import` it here (it would double-inject
|
|
15
|
+
for anyone using both components).
|
|
16
|
+
|
|
4
17
|
A GitHub-style comment composer built on top of `MarkdownEditor` (a rich
|
|
5
18
|
WYSIWYG / source surface) with an avatar gutter and a submit/cancel footer.
|
|
6
19
|
|
package/dist/index.css
CHANGED
|
@@ -71,7 +71,9 @@ In practice:
|
|
|
71
71
|
@import "./components/LoginOrRegisterForm/index.css";
|
|
72
72
|
@import "./components/Checkout/index.css";
|
|
73
73
|
@import "./components/CommandMenu/index.css";
|
|
74
|
-
|
|
74
|
+
/* NOTE: CommentInput/index.css is deliberately NOT imported here — same reason as
|
|
75
|
+
MarkdownEditor's. It is a subpath-export component and imports its own CSS
|
|
76
|
+
locally (see components/CommentInput/index.css). Do not re-add. */
|
|
75
77
|
@import "./components/ContactUsForm/index.css";
|
|
76
78
|
@import "./components/CronInput/index.css";
|
|
77
79
|
@import "./components/DataTable/index.css";
|
package/dist/index.d.ts
CHANGED
|
@@ -38,7 +38,6 @@ export * from "./components/Checkout/index.js";
|
|
|
38
38
|
export * from "./components/Collapsible/index.js";
|
|
39
39
|
export * from "./components/ColorScheme/index.js";
|
|
40
40
|
export * from "./components/CommandMenu/index.js";
|
|
41
|
-
export * from "./components/CommentInput/index.js";
|
|
42
41
|
export * from "./components/ContactUsForm/index.js";
|
|
43
42
|
export * from "./components/CronInput/index.js";
|
|
44
43
|
export * from "./components/DataTable/index.js";
|
package/dist/index.js
CHANGED
|
@@ -39,7 +39,11 @@ export * from "./components/Checkout/index.js";
|
|
|
39
39
|
export * from "./components/Collapsible/index.js";
|
|
40
40
|
export * from "./components/ColorScheme/index.js";
|
|
41
41
|
export * from "./components/CommandMenu/index.js";
|
|
42
|
-
|
|
42
|
+
// NOTE: CommentInput is deliberately NOT exported here. It embeds MarkdownEditor,
|
|
43
|
+
// whose optional @milkdown/* + @codemirror/* peers would then be pulled into the
|
|
44
|
+
// root entry graph of every consumer. It ships as the `@marianmeres/stuic/comment-input`
|
|
45
|
+
// subpath export instead, mirroring `@marianmeres/stuic/markdown-editor`.
|
|
46
|
+
// Guarded by src/lib/barrel-optional-peers.test.ts — do not re-add.
|
|
43
47
|
export * from "./components/ContactUsForm/index.js";
|
|
44
48
|
export * from "./components/CronInput/index.js";
|
|
45
49
|
export * from "./components/DataTable/index.js";
|
package/docs/architecture.md
CHANGED
|
@@ -80,6 +80,17 @@ src/lib/
|
|
|
80
80
|
|
|
81
81
|
**DO NOT** use `import './index.css'` inside component `.svelte` files.
|
|
82
82
|
|
|
83
|
+
**Exception — subpath-export components.** A component whose peer dependencies are
|
|
84
|
+
declared _optional_ (`MarkdownEditor`, `CommentInput`) is kept off the root barrel and
|
|
85
|
+
shipped behind its own subpath export, so those peers never enter the entry graph of
|
|
86
|
+
consumers who don't use it. Such a component imports its own `index.css` locally and is
|
|
87
|
+
**not** `@import`-ed into `src/lib/index.css` — centralizing it would ship the styles to
|
|
88
|
+
every consumer, defeating the isolation. `sideEffects: ["**/*.css"]` in `package.json`
|
|
89
|
+
keeps the local import alive for real users.
|
|
90
|
+
|
|
91
|
+
Both halves are enforced by `src/lib/barrel-optional-peers.test.ts` — adding such a
|
|
92
|
+
component to the barrel (or its CSS to the central stylesheet) fails the suite.
|
|
93
|
+
|
|
83
94
|
---
|
|
84
95
|
|
|
85
96
|
## Data Flow
|
|
@@ -151,9 +162,20 @@ Theme CSS files are provided by the `@marianmeres/design-tokens` package (a regu
|
|
|
151
162
|
### Package Exports
|
|
152
163
|
|
|
153
164
|
```json
|
|
154
|
-
".":
|
|
165
|
+
".": Main entry (components, utils, actions, icons, theme types)
|
|
166
|
+
"./utils": Utilities only
|
|
167
|
+
"./phone-validation": Phone validation helpers
|
|
168
|
+
"./markdown-editor": MarkdownEditor — optional peer deps, local CSS
|
|
169
|
+
"./comment-input": CommentInput — optional peer deps, local CSS
|
|
155
170
|
```
|
|
156
171
|
|
|
172
|
+
The last two are deliberately **off** the main entry: they reach `@milkdown/*` and
|
|
173
|
+
`@codemirror/*`, which are optional peers. A consumer that has not installed those
|
|
174
|
+
peers gets a hard bundler error (rolldown reports every named import from the
|
|
175
|
+
unresolved stub as `MISSING_EXPORT`) if the root barrel can reach them — even though
|
|
176
|
+
the editor backends sit behind `await import()`, because the specifiers are static
|
|
177
|
+
literals that a bundler must still resolve at build time.
|
|
178
|
+
|
|
157
179
|
---
|
|
158
180
|
|
|
159
181
|
## Security Boundaries
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/stuic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.144.0",
|
|
4
4
|
"packageManager": "pnpm@11.5.0",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite dev",
|
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"types": "./dist/components/MarkdownEditor/index.d.ts",
|
|
55
55
|
"svelte": "./dist/components/MarkdownEditor/index.js",
|
|
56
56
|
"default": "./dist/components/MarkdownEditor/index.js"
|
|
57
|
+
},
|
|
58
|
+
"./comment-input": {
|
|
59
|
+
"types": "./dist/components/CommentInput/index.d.ts",
|
|
60
|
+
"svelte": "./dist/components/CommentInput/index.js",
|
|
61
|
+
"default": "./dist/components/CommentInput/index.js"
|
|
57
62
|
}
|
|
58
63
|
},
|
|
59
64
|
"peerDependencies": {
|
|
@@ -72,8 +77,6 @@
|
|
|
72
77
|
"@milkdown/prose": "^7.0.0",
|
|
73
78
|
"@milkdown/transformer": "^7.0.0",
|
|
74
79
|
"@milkdown/utils": "^7.0.0",
|
|
75
|
-
"dompurify": "^3.0.0",
|
|
76
|
-
"marked": "^18.0.0",
|
|
77
80
|
"svelte": "^5.0.0"
|
|
78
81
|
},
|
|
79
82
|
"peerDependenciesMeta": {
|
|
@@ -121,12 +124,6 @@
|
|
|
121
124
|
},
|
|
122
125
|
"@milkdown/utils": {
|
|
123
126
|
"optional": true
|
|
124
|
-
},
|
|
125
|
-
"dompurify": {
|
|
126
|
-
"optional": true
|
|
127
|
-
},
|
|
128
|
-
"marked": {
|
|
129
|
-
"optional": true
|
|
130
127
|
}
|
|
131
128
|
},
|
|
132
129
|
"devDependencies": {
|
|
@@ -157,11 +154,9 @@
|
|
|
157
154
|
"@tailwindcss/vite": "^4.3.1",
|
|
158
155
|
"@types/node": "^25.9.4",
|
|
159
156
|
"@vitest/browser-playwright": "^4.1.9",
|
|
160
|
-
"dompurify": "^3.4.11",
|
|
161
157
|
"dotenv": "^16.6.1",
|
|
162
158
|
"eslint": "^9.39.4",
|
|
163
159
|
"globals": "^16.5.0",
|
|
164
|
-
"marked": "^18.0.5",
|
|
165
160
|
"playwright": "^1.61.1",
|
|
166
161
|
"prettier": "^3.9.0",
|
|
167
162
|
"prettier-plugin-svelte": "^3.5.2",
|