@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 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
- // 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";
@@ -143,6 +143,7 @@
143
143
  import { Button } from "../Button/index.js";
144
144
  import Thc from "../Thc/Thc.svelte";
145
145
  import { MarkdownEditor } from "../MarkdownEditor/index.js";
146
+ import "./index.css";
146
147
 
147
148
  let {
148
149
  value = $bindable(""),
@@ -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
- @import "./components/CommentInput/index.css";
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
- export * from "./components/CommentInput/index.js";
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";
@@ -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
- ".": Main entry (components, utils, actions, icons, theme types)
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.142.1",
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",