@marianmeres/stuic 3.142.0 → 3.143.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.
@@ -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
- // snippet — `validateAllFields` skips nullish entries.
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 stateField = $state<FieldInput>();
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
- stateField,
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
- <!-- svelte-ignore binding_property_non_reactive -->
280
- <FieldInput
281
- bind:this={stateField}
282
- bind:value={address.state_or_region}
283
- label={t("checkout.address.state_or_region_label")}
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: address.country,
322
- onchange: (v) => {
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={address.country}
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 { CheckoutAddressData, CheckoutValidationError } from "./_internal/checkout-types.js";
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";
@@ -203,7 +203,10 @@
203
203
  // routing would be ambiguous, so focus (a click on the field) must decide.
204
204
  if (paste_targets.size === 1 && !is_text_entry(active)) {
205
205
  const [t] = paste_targets;
206
- if (is_visible(t.el) && (is_unclaimed_focus(active) || shares_modal(t.el, active))) {
206
+ if (
207
+ is_visible(t.el) &&
208
+ (is_unclaimed_focus(active) || shares_modal(t.el, active))
209
+ ) {
207
210
  t.handle(e);
208
211
  }
209
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.142.0",
3
+ "version": "3.143.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",