@marianmeres/stuic 3.122.0 → 3.123.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/API.md CHANGED
@@ -308,6 +308,26 @@ Text input field with label, error, and hint support.
308
308
  <FieldInput bind:value={name} label="Name" required />
309
309
  ```
310
310
 
311
+ #### `FieldMoney`
312
+
313
+ Money input whose canonical bindable `value` is an **integer in minor units** (e.g. cents); the visible input shows/accepts a major-unit decimal (e.g. `"19.99"`). Wraps `FieldInput`. The `name` is applied to a hidden input carrying the integer minor units (the visible input stays name-less so a form never serializes the display string). A built-in numeric guard rejects non-numeric input and enforces the optional major-unit `min` / `max`.
314
+
315
+ | Prop | Type | Default | Description |
316
+ | ---------- | ---------------------------- | ------- | ----------------------------------------------------- |
317
+ | `value` | `number \| string \| null` | — | Bindable amount in integer minor units (e.g. `1999`) |
318
+ | `scale` | `number` | `100` | Minor units per major unit (cents per dollar/euro) |
319
+ | `decimals` | `number` | `2` | Fraction digits shown in the visible input |
320
+ | `name` | `string` | — | Hidden input name for form submission (integer minor) |
321
+ | `min` | `number` | — | Optional minimum, in major units |
322
+ | `max` | `number` | — | Optional maximum, in major units |
323
+ | `validate` | `boolean \| ValidateOptions` | — | `false` disables the built-in guard; object merges it |
324
+
325
+ ```svelte
326
+ <FieldMoney bind:value={priceCents} label="Price" name="price" min={0} />
327
+ ```
328
+
329
+ The `formatMinorUnits`, `parseToMinorUnits`, and `money` helpers are exported for display elsewhere.
330
+
311
331
  #### `FieldTextarea`
312
332
 
313
333
  Multi-line text input.
package/README.md CHANGED
@@ -148,7 +148,7 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
148
148
 
149
149
  ### Forms & Inputs
150
150
 
151
- FieldInput, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
151
+ FieldInput, FieldMoney, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
152
152
 
153
153
  ### Buttons & Controls
154
154
 
@@ -95,7 +95,23 @@
95
95
 
96
96
  let els = $state<Record<number, HTMLButtonElement>>({});
97
97
 
98
+ // An option is non-interactive if the whole group is disabled or the option
99
+ // itself is flagged `disabled`.
100
+ function is_disabled(index: number): boolean {
101
+ return !!disabled || !!coll.items[index]?.option.disabled;
102
+ }
103
+
104
+ // Find the next enabled index in `dir` (+1/-1), skipping disabled options.
105
+ // Does not wrap and clamps at the edges (returns `from` if none found).
106
+ function next_enabled_index(from: number, dir: 1 | -1): number {
107
+ for (let i = from + dir; i >= 0 && i < coll.size; i += dir) {
108
+ if (!is_disabled(i)) return i;
109
+ }
110
+ return from;
111
+ }
112
+
98
113
  async function maybe_activate(index: number, coll: ItemColl) {
114
+ if (is_disabled(index)) return;
99
115
  if ((await onButtonClick?.(index, coll)) !== false) {
100
116
  coll.setActiveIndex(index);
101
117
  els[index].focus();
@@ -121,7 +137,7 @@
121
137
  classButton,
122
138
  $coll.activeIndex === i && classButtonActive
123
139
  )}
124
- {disabled}
140
+ disabled={disabled || item.option.disabled}
125
141
  type="button"
126
142
  role="radio"
127
143
  aria-checked={$coll.activeIndex === i}
@@ -131,10 +147,10 @@
131
147
  bind:el={els[i]}
132
148
  onkeydown={async (e) => {
133
149
  if (["ArrowRight", "ArrowDown"].includes(e.key)) {
134
- await maybe_activate(Math.min(i + 1, coll.size - 1), coll);
150
+ await maybe_activate(next_enabled_index(i, 1), coll);
135
151
  }
136
152
  if (["ArrowLeft", "ArrowUp"].includes(e.key)) {
137
- await maybe_activate(Math.max(0, i - 1), coll);
153
+ await maybe_activate(next_enabled_index(i, -1), coll);
138
154
  }
139
155
  }}
140
156
  id={item.id}
@@ -29,10 +29,28 @@ A radio button group styled as a segmented button toggle. Supports keyboard navi
29
29
  // Or object
30
30
  {
31
31
  label: 'Option A',
32
- value: 'a' // optional, defaults to label
32
+ value: 'a', // optional, defaults to label
33
+ disabled: true // optional, disables just this option
33
34
  }
34
35
  ```
35
36
 
37
+ ### Disabling individual options
38
+
39
+ Set `disabled: true` on any option to make it non-interactive. Disabled options
40
+ can't be clicked or activated and are skipped by keyboard arrow navigation. To
41
+ disable the whole group instead, use the top-level `disabled` prop.
42
+
43
+ ```svelte
44
+ <ButtonGroupRadio
45
+ options={[
46
+ { label: "Free", value: "free" },
47
+ { label: "Pro", value: "pro" },
48
+ { label: "Enterprise", value: "enterprise", disabled: true },
49
+ ]}
50
+ bind:value={plan}
51
+ />
52
+ ```
53
+
36
54
  ## Usage
37
55
 
38
56
  ### Basic
@@ -0,0 +1,193 @@
1
+ <!--
2
+ FieldMoney — edit a money amount stored as INTEGER minor units.
3
+
4
+ The bound `value` is the amount in minor units (e.g. cents); the visible input
5
+ shows/accepts a major-unit decimal (e.g. "1.00"). Conversion uses `scale` /
6
+ `decimals` (e.g. scale=100, decimals=2 for dollars/cents). Wraps FieldInput so
7
+ you get the full label / description / validation / layout system for free.
8
+
9
+ WHY a hidden input carries `name` (and the visible input stays name-less):
10
+ a <form> serializes the live DOM. If the *visible* input held `name`, the form
11
+ would submit the major-unit display string ("12.34") instead of the integer
12
+ minor units the server expects, and whole values like "20.00" would parse to 20
13
+ (minor units) — storing $0.20 for $20. So the visible input is kept name-less
14
+ and a hidden input carries `name` + the already-converted INTEGER minor units.
15
+ This mirrors FieldPhoneNumber, whose hidden input carries the composed E.164.
16
+ -->
17
+ <script lang="ts" module>
18
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
19
+ import type { Props as FieldInputProps } from "./FieldInput.svelte";
20
+
21
+ export interface Props extends Omit<
22
+ FieldInputProps,
23
+ "value" | "type" | "inputmode" | "min" | "max"
24
+ > {
25
+ /**
26
+ * Bound value in INTEGER minor units (e.g. cents). `null` / `undefined` /
27
+ * `""` mean empty. The visible input shows/accepts a major-unit decimal
28
+ * string (e.g. "1.00"); conversion uses `scale` / `decimals`.
29
+ */
30
+ value?: number | string | null;
31
+ /** Minor units per major unit. Default: 100 (cents per dollar/euro). */
32
+ scale?: number;
33
+ /** Fraction digits shown in the visible input. Default: 2. */
34
+ decimals?: number;
35
+ /**
36
+ * Form field name — applied to the hidden submit input carrying the integer
37
+ * minor units, NOT to the visible input. See the file header for why.
38
+ */
39
+ name?: string;
40
+ /** Optional minimum, in MAJOR units (e.g. `0` to forbid negatives). */
41
+ min?: number;
42
+ /** Optional maximum, in MAJOR units. */
43
+ max?: number;
44
+ /**
45
+ * Validation. `false` disables the built-in numeric guard; an options object
46
+ * is merged with it (your `customValidator` runs after the numeric/min/max
47
+ * checks pass). Defaults to the built-in guard.
48
+ */
49
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
50
+ }
51
+ </script>
52
+
53
+ <script lang="ts">
54
+ import { untrack } from "svelte";
55
+ import type { ValidationResult } from "../../actions/validate.svelte.js";
56
+ import { formatMinorUnits, parseToMinorUnits } from "../../utils/money-units.js";
57
+ import FieldInput from "./FieldInput.svelte";
58
+
59
+ let {
60
+ input = $bindable(),
61
+ value = $bindable(),
62
+ scale = 100,
63
+ decimals = 2,
64
+ name,
65
+ min,
66
+ max,
67
+ validate: validateProp,
68
+ ...rest
69
+ }: Props = $props();
70
+
71
+ // Inner FieldInput instance — forwards the imperative ValidatableField API.
72
+ let field: ReturnType<typeof FieldInput> | undefined = $state();
73
+
74
+ const cfg = $derived({ scale, decimals });
75
+
76
+ // Single source of truth for "is this a valid amount, and what number is it?".
77
+ // A clean decimal is an optional leading "-", then digits with an optional
78
+ // single dot ("12", "12.34", "12.", ".5", "-3.2"). Everything else is rejected
79
+ // → null: hex ("0x10"), trailing garbage ("12.34abc"), thousands separators
80
+ // ("1,234.56"), scientific ("1e3"). Both the value-sync path (toMinor) and the
81
+ // validator below derive from THIS one helper, so what the validator accepts
82
+ // and what the hidden input submits can never disagree (parseFloat alone would:
83
+ // it reads "1,234.56" as 1 and "0x10" as 0, silently storing a wrong amount).
84
+ const DECIMAL_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)$/;
85
+ const parseDecimal = (s: string): number | null => {
86
+ const str = `${s}`.trim();
87
+ return DECIMAL_RE.test(str) ? parseFloat(str) : null;
88
+ };
89
+
90
+ // Major-unit display string → integer minor units. Empty or non-numeric → null
91
+ // (so a half-typed/garbage value is treated as "no amount", not silently 0).
92
+ const toMinor = (s: string): number | null => {
93
+ const n = parseDecimal(s);
94
+ return n === null ? null : parseToMinorUnits(n, cfg);
95
+ };
96
+
97
+ // Integer minor units → major-unit display string ("" when empty).
98
+ const fromValue = (v: number | string | null | undefined): string =>
99
+ v === null || v === undefined || v === "" ? "" : formatMinorUnits(v, cfg);
100
+
101
+ // Input-facing major-unit string (e.g. "1.00").
102
+ let display = $state(fromValue(value));
103
+
104
+ // Typing updates the bound minor-unit value.
105
+ $effect(() => {
106
+ const next = toMinor(display);
107
+ untrack(() => {
108
+ if (next !== (value ?? null)) value = next;
109
+ });
110
+ });
111
+
112
+ // External value changes (model load/switch) resync the display — but only
113
+ // when they don't already match what's typed, so we never clobber user input.
114
+ $effect(() => {
115
+ const incoming = value;
116
+ untrack(() => {
117
+ const incomingMinor =
118
+ incoming === null || incoming === undefined || incoming === ""
119
+ ? null
120
+ : Math.round(Number(incoming));
121
+ if (toMinor(display) !== incomingMinor) display = fromValue(incoming);
122
+ });
123
+ });
124
+
125
+ // What the form submits: INTEGER minor units derived straight from the visible
126
+ // display string (empty/invalid → ""). Derived from `display` (not the bound
127
+ // `value`) so it's correct synchronously at submit time, without depending on
128
+ // the value-sync effect above having flushed first.
129
+ const submitValue = $derived(toMinor(display) ?? "");
130
+
131
+ // Built-in light numeric guard: reject a non-empty-but-non-numeric amount and
132
+ // enforce optional major-unit min/max. `required`-empty is handled natively by
133
+ // the visible input (FieldInput forwards `required`). Consumer opts are honored:
134
+ // `false` disables; an options object is merged (its customValidator runs after
135
+ // the numeric guard passes).
136
+ const validateOpts = $derived.by<
137
+ boolean | Omit<ValidateOptions, "setValidationResult">
138
+ >(() => {
139
+ if (validateProp === false) return false;
140
+ const custom = validateProp && typeof validateProp === "object" ? validateProp : {};
141
+ return {
142
+ ...custom,
143
+ customValidator: (val, ctx, el) => {
144
+ const str = `${val ?? ""}`.trim();
145
+ if (str !== "") {
146
+ const n = parseDecimal(str);
147
+ if (n === null) return "Please enter a valid amount.";
148
+ if (min != null && n < min) return `Amount must be at least ${min}.`;
149
+ if (max != null && n > max) return `Amount must be at most ${max}.`;
150
+ }
151
+ return custom.customValidator?.(val, ctx, el) || "";
152
+ },
153
+ };
154
+ });
155
+
156
+ // ---- Imperative API (satisfies ValidatableField), forwarded to FieldInput ----
157
+
158
+ /** Trigger validation now. Renders the inline message if invalid. */
159
+ export function validate(): ValidationResult | undefined {
160
+ return field?.validate();
161
+ }
162
+
163
+ /** Clear the inline validation message and reset `setCustomValidity`. */
164
+ export function clearValidation(): void {
165
+ field?.clearValidation();
166
+ }
167
+
168
+ /** Current validation state, or undefined if the validator has never run. */
169
+ export function getValidation(): ValidationResult | undefined {
170
+ return field?.getValidation();
171
+ }
172
+
173
+ /** Focus the visible input. */
174
+ export function focus(): void {
175
+ field?.focus();
176
+ }
177
+
178
+ /** Scroll the field into view. Defaults to smooth + center. */
179
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
180
+ field?.scrollIntoView(opts);
181
+ }
182
+ </script>
183
+
184
+ <FieldInput
185
+ bind:this={field}
186
+ bind:input
187
+ type="text"
188
+ inputmode="decimal"
189
+ bind:value={display}
190
+ validate={validateOpts}
191
+ {...rest}
192
+ />
193
+ <input type="hidden" {name} value={submitValue} />
@@ -0,0 +1,39 @@
1
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
2
+ import type { Props as FieldInputProps } from "./FieldInput.svelte";
3
+ export interface Props extends Omit<FieldInputProps, "value" | "type" | "inputmode" | "min" | "max"> {
4
+ /**
5
+ * Bound value in INTEGER minor units (e.g. cents). `null` / `undefined` /
6
+ * `""` mean empty. The visible input shows/accepts a major-unit decimal
7
+ * string (e.g. "1.00"); conversion uses `scale` / `decimals`.
8
+ */
9
+ value?: number | string | null;
10
+ /** Minor units per major unit. Default: 100 (cents per dollar/euro). */
11
+ scale?: number;
12
+ /** Fraction digits shown in the visible input. Default: 2. */
13
+ decimals?: number;
14
+ /**
15
+ * Form field name — applied to the hidden submit input carrying the integer
16
+ * minor units, NOT to the visible input. See the file header for why.
17
+ */
18
+ name?: string;
19
+ /** Optional minimum, in MAJOR units (e.g. `0` to forbid negatives). */
20
+ min?: number;
21
+ /** Optional maximum, in MAJOR units. */
22
+ max?: number;
23
+ /**
24
+ * Validation. `false` disables the built-in numeric guard; an options object
25
+ * is merged with it (your `customValidator` runs after the numeric/min/max
26
+ * checks pass). Defaults to the built-in guard.
27
+ */
28
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
29
+ }
30
+ import type { ValidationResult } from "../../actions/validate.svelte.js";
31
+ declare const FieldMoney: import("svelte").Component<Props, {
32
+ validate: () => ValidationResult | undefined;
33
+ clearValidation: () => void;
34
+ getValidation: () => ValidationResult | undefined;
35
+ focus: () => void;
36
+ scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
37
+ }, "value" | "input">;
38
+ type FieldMoney = ReturnType<typeof FieldMoney>;
39
+ export default FieldMoney;
@@ -115,7 +115,7 @@
115
115
  value={o.value ?? o.label}
116
116
  description={o.description}
117
117
  {renderSize}
118
- {disabled}
118
+ disabled={disabled || o.disabled}
119
119
  {tabindex}
120
120
  {required}
121
121
  validate={validateProp}
@@ -7,6 +7,7 @@ A comprehensive form input system with multiple field components, validation sup
7
7
  | Component | Description |
8
8
  | ----------------- | ---------------------------------------------------- |
9
9
  | `FieldInput` | Text, email, password, number, and other input types |
10
+ | `FieldMoney` | Money amount stored as integer minor units (cents) |
10
11
  | `FieldTextarea` | Multi-line text input with auto-grow |
11
12
  | `FieldSelect` | Dropdown select with option groups |
12
13
  | `FieldCheckbox` | Single checkbox with label |
@@ -158,6 +159,32 @@ Component-specific targets (e.g. `classInput` for the inner `<input>`/`<select>`
158
159
  </FieldInput>
159
160
  ```
160
161
 
162
+ ### Money Input
163
+
164
+ `FieldMoney` edits a money amount whose canonical value is an **integer in minor
165
+ units** (e.g. cents), while the user sees and types a major-unit decimal
166
+ ("12.34"). It wraps `FieldInput`, so all the common props (label, validation,
167
+ sizing, class props, the imperative API) work as usual.
168
+
169
+ ```svelte
170
+ <script lang="ts">
171
+ import { FieldMoney } from "stuic";
172
+
173
+ // bound value is the integer amount of minor units (e.g. 1999 = $19.99)
174
+ let priceCents = $state(1999);
175
+ </script>
176
+
177
+ <FieldMoney label="Price" name="price" bind:value={priceCents} min={0} />
178
+ ```
179
+
180
+ The `name` is applied to a hidden input carrying the integer minor units — the
181
+ visible input stays name-less so a `<form>` never serializes the display string.
182
+ A built-in numeric guard rejects non-numeric input and enforces the optional
183
+ major-unit `min` / `max`. Use `scale` / `decimals` for non-cents currencies
184
+ (e.g. `scale={1000} decimals={3}`). The matching `formatMinorUnits`,
185
+ `parseToMinorUnits`, and `money` helpers are exported from `@marianmeres/stuic`
186
+ for display elsewhere.
187
+
161
188
  ### Left-aligned Label
162
189
 
163
190
  ```svelte
@@ -3,6 +3,7 @@ export { default as FieldAssets, type Props as FieldAssetsProps, type FieldAsset
3
3
  export { default as FieldCheckbox, type Props as FieldCheckboxProps, } from "./FieldCheckbox.svelte";
4
4
  export { default as FieldFile, type Props as FieldFileProps } from "./FieldFile.svelte";
5
5
  export { default as FieldInput, type Props as FieldInputProps, } from "./FieldInput.svelte";
6
+ export { default as FieldMoney, type Props as FieldMoneyProps, } from "./FieldMoney.svelte";
6
7
  export { default as FieldLikeButton, type Props as FieldLikeButtonProps, } from "./FieldLikeButton.svelte";
7
8
  export { default as FieldOptions, type Props as FieldOptionsProps, type Option as FieldOption, } from "./FieldOptions.svelte";
8
9
  export { default as FieldRadios, type Props as FieldRadiosProps, } from "./FieldRadios.svelte";
@@ -3,6 +3,7 @@ export { default as FieldAssets, } from "./FieldAssets.svelte";
3
3
  export { default as FieldCheckbox, } from "./FieldCheckbox.svelte";
4
4
  export { default as FieldFile } from "./FieldFile.svelte";
5
5
  export { default as FieldInput, } from "./FieldInput.svelte";
6
+ export { default as FieldMoney, } from "./FieldMoney.svelte";
6
7
  export { default as FieldLikeButton, } from "./FieldLikeButton.svelte";
7
8
  export { default as FieldOptions, } from "./FieldOptions.svelte";
8
9
  export { default as FieldRadios, } from "./FieldRadios.svelte";
@@ -8,6 +8,8 @@ export interface FieldRadiosOption {
8
8
  label: string;
9
9
  value?: string;
10
10
  description?: THC;
11
+ /** Disable this individual option (non-interactive, skipped by keyboard nav). */
12
+ disabled?: boolean;
11
13
  }
12
14
  /**
13
15
  * Class props forwarded to the shared `InputWrap` scaffolding used by every
@@ -19,6 +19,7 @@ export * from "./is-mac.js";
19
19
  export * from "./is-nullish.js";
20
20
  export * from "./maybe-json-parse.js";
21
21
  export * from "./maybe-json-stringify.js";
22
+ export * from "./money-units.js";
22
23
  export * from "./nl2br.js";
23
24
  export * from "./observe-exists.svelte.js";
24
25
  export * from "./omit-pick.js";
@@ -30,6 +31,7 @@ export * from "./preload-img.js";
30
31
  export * from "./qsa.js";
31
32
  export * from "./replace-map.js";
32
33
  export * from "./resolve-url.js";
34
+ export * from "./round-to-decimals.js";
33
35
  export * from "./seconds.js";
34
36
  export * from "./sleep.js";
35
37
  export * from "./storage-abstraction.js";
@@ -19,6 +19,7 @@ export * from "./is-mac.js";
19
19
  export * from "./is-nullish.js";
20
20
  export * from "./maybe-json-parse.js";
21
21
  export * from "./maybe-json-stringify.js";
22
+ export * from "./money-units.js";
22
23
  export * from "./nl2br.js";
23
24
  export * from "./observe-exists.svelte.js";
24
25
  export * from "./omit-pick.js";
@@ -30,6 +31,7 @@ export * from "./preload-img.js";
30
31
  export * from "./qsa.js";
31
32
  export * from "./replace-map.js";
32
33
  export * from "./resolve-url.js";
34
+ export * from "./round-to-decimals.js";
33
35
  export * from "./seconds.js";
34
36
  export * from "./sleep.js";
35
37
  export * from "./storage-abstraction.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Configuration for converting between an integer "minor units" amount (the
3
+ * smallest indivisible unit a currency is stored in, e.g. cents) and its
4
+ * human-facing "major units" decimal (e.g. dollars/euros).
5
+ *
6
+ * `scale` is how many minor units make one major unit (100 cents = 1 dollar);
7
+ * `decimals` is how many fraction digits the major-unit string shows.
8
+ */
9
+ export interface MinorUnitsConfig {
10
+ /** Minor units per major unit. Default: 100. */
11
+ scale?: number;
12
+ /** Fraction digits in the major-unit string. Default: 2. */
13
+ decimals?: number;
14
+ }
15
+ /**
16
+ * Stored minor units (e.g. cents) → major-unit decimal string (e.g. `"1.00"`).
17
+ * No currency symbol or grouping separators — the result round-trips cleanly
18
+ * back through {@link parseToMinorUnits}, so it's safe for editable inputs.
19
+ *
20
+ * Non-numeric input is passed through stringified (so a half-typed value isn't
21
+ * silently zeroed); callers that need a guarantee should validate first.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * formatMinorUnits(100); // "1.00"
26
+ * formatMinorUnits(12345); // "123.45"
27
+ * formatMinorUnits(5, { scale: 1000, decimals: 3 }); // "0.005"
28
+ * ```
29
+ */
30
+ export declare function formatMinorUnits(v: string | number | null | undefined, cfg?: MinorUnitsConfig): string;
31
+ /**
32
+ * Major-unit decimal input (e.g. `"1.00"`) → integer minor units (e.g. `100`).
33
+ *
34
+ * NaN-safe: non-numeric input yields `0`. This is a low-level helper — UI that
35
+ * must distinguish "empty/invalid" from "zero" should guard for finiteness
36
+ * before calling (see how `FieldMoney` does it).
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * parseToMinorUnits("1.00"); // 100
41
+ * parseToMinorUnits("123.45"); // 12345
42
+ * parseToMinorUnits("0.005", { scale: 1000, decimals: 3 }); // 5
43
+ * parseToMinorUnits("abc"); // 0
44
+ * ```
45
+ */
46
+ export declare function parseToMinorUnits(v: string | number | null | undefined, cfg?: MinorUnitsConfig): number;
47
+ /**
48
+ * Format an integer amount of minor units (cents) as a localized currency
49
+ * string, e.g. `money(12345, "USD")` → `"$123.45"`.
50
+ *
51
+ * Uses `Intl.NumberFormat` with `currencyDisplay: "narrowSymbol"` so you get
52
+ * `"$"` / `"€"` rather than the locale-disambiguated `"US$"`. Falls back to a
53
+ * plain `"123.45 XXX"` for unknown/invalid currency codes.
54
+ *
55
+ * Unlike {@link formatMinorUnits} this is for DISPLAY only (it adds symbols and
56
+ * grouping) — do not feed its output back into an editable money input.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * money(12345, "USD"); // "$123.45"
61
+ * money(null, "EUR"); // "€0.00"
62
+ * ```
63
+ */
64
+ export declare function money(cents: number | undefined | null, currency: string): string;
@@ -0,0 +1,75 @@
1
+ import { roundToDecimals } from "./round-to-decimals.js";
2
+ /**
3
+ * Stored minor units (e.g. cents) → major-unit decimal string (e.g. `"1.00"`).
4
+ * No currency symbol or grouping separators — the result round-trips cleanly
5
+ * back through {@link parseToMinorUnits}, so it's safe for editable inputs.
6
+ *
7
+ * Non-numeric input is passed through stringified (so a half-typed value isn't
8
+ * silently zeroed); callers that need a guarantee should validate first.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * formatMinorUnits(100); // "1.00"
13
+ * formatMinorUnits(12345); // "123.45"
14
+ * formatMinorUnits(5, { scale: 1000, decimals: 3 }); // "0.005"
15
+ * ```
16
+ */
17
+ export function formatMinorUnits(v, cfg) {
18
+ const scale = cfg?.scale ?? 100;
19
+ const decimals = cfg?.decimals ?? 2;
20
+ const n = parseFloat(`${v}`);
21
+ if (!Number.isFinite(n))
22
+ return `${v ?? ""}`;
23
+ return roundToDecimals(n / scale, decimals).toFixed(decimals);
24
+ }
25
+ /**
26
+ * Major-unit decimal input (e.g. `"1.00"`) → integer minor units (e.g. `100`).
27
+ *
28
+ * NaN-safe: non-numeric input yields `0`. This is a low-level helper — UI that
29
+ * must distinguish "empty/invalid" from "zero" should guard for finiteness
30
+ * before calling (see how `FieldMoney` does it).
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * parseToMinorUnits("1.00"); // 100
35
+ * parseToMinorUnits("123.45"); // 12345
36
+ * parseToMinorUnits("0.005", { scale: 1000, decimals: 3 }); // 5
37
+ * parseToMinorUnits("abc"); // 0
38
+ * ```
39
+ */
40
+ export function parseToMinorUnits(v, cfg) {
41
+ const scale = cfg?.scale ?? 100;
42
+ const n = parseFloat(`${v}`);
43
+ return Number.isFinite(n) ? Math.round(n * scale) : 0;
44
+ }
45
+ /**
46
+ * Format an integer amount of minor units (cents) as a localized currency
47
+ * string, e.g. `money(12345, "USD")` → `"$123.45"`.
48
+ *
49
+ * Uses `Intl.NumberFormat` with `currencyDisplay: "narrowSymbol"` so you get
50
+ * `"$"` / `"€"` rather than the locale-disambiguated `"US$"`. Falls back to a
51
+ * plain `"123.45 XXX"` for unknown/invalid currency codes.
52
+ *
53
+ * Unlike {@link formatMinorUnits} this is for DISPLAY only (it adds symbols and
54
+ * grouping) — do not feed its output back into an editable money input.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * money(12345, "USD"); // "$123.45"
59
+ * money(null, "EUR"); // "€0.00"
60
+ * ```
61
+ */
62
+ export function money(cents, currency) {
63
+ const value = (cents ?? 0) / 100;
64
+ try {
65
+ return new Intl.NumberFormat(undefined, {
66
+ style: "currency",
67
+ currency,
68
+ currencyDisplay: "narrowSymbol",
69
+ }).format(value);
70
+ }
71
+ catch {
72
+ // Unknown/invalid currency code — degrade to a plain "12.00 XXX".
73
+ return `${value.toFixed(2)} ${currency}`;
74
+ }
75
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Round a number to a fixed number of decimal places.
3
+ *
4
+ * Avoids the trailing float noise of naive `toFixed`-then-`parseFloat` by
5
+ * scaling to integers, rounding, then scaling back.
6
+ *
7
+ * @param value - The number to round
8
+ * @param decimals - Number of decimal places to keep (default: 5)
9
+ * @returns The rounded number
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * roundToDecimals(1.005, 2); // 1.01
14
+ * roundToDecimals(2.5 / 100, 2); // 0.03
15
+ * roundToDecimals(3.14159); // 3.14159 (default 5 decimals)
16
+ * ```
17
+ */
18
+ export declare function roundToDecimals(value: number, decimals?: number): number;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Round a number to a fixed number of decimal places.
3
+ *
4
+ * Avoids the trailing float noise of naive `toFixed`-then-`parseFloat` by
5
+ * scaling to integers, rounding, then scaling back.
6
+ *
7
+ * @param value - The number to round
8
+ * @param decimals - Number of decimal places to keep (default: 5)
9
+ * @returns The rounded number
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * roundToDecimals(1.005, 2); // 1.01
14
+ * roundToDecimals(2.5 / 100, 2); // 0.03
15
+ * roundToDecimals(3.14159); // 3.14159 (default 5 decimals)
16
+ * ```
17
+ */
18
+ export function roundToDecimals(value, decimals = 5) {
19
+ const factor = Math.pow(10, decimals);
20
+ return Math.round(value * factor) / factor;
21
+ }
@@ -55,6 +55,7 @@
55
55
  | Component | Purpose |
56
56
  | --------------------------------------------- | --------------------------------------------------------------------------- |
57
57
  | Input (FieldInput, FieldSelect, etc.) | Form fields |
58
+ | FieldMoney | Money input storing integer minor units (e.g. cents) |
58
59
  | FieldPhoneNumber | International phone input with country picker |
59
60
  | FieldObject | Dual-mode JSON object editor (pretty-print/raw) |
60
61
  | CronInput | Cron expression editor with presets and validation |
@@ -146,8 +147,8 @@ Use `validate={false}` to bypass stuic's validation entirely.
146
147
 
147
148
  ### Per-field methods
148
149
 
149
- Available on `FieldInput`, `FieldTextarea`, `FieldCheckbox`, `FieldSelect`,
150
- `FieldFile`, `FieldObject`, `FieldAssets`, `FieldInputLocalized`,
150
+ Available on `FieldInput`, `FieldMoney`, `FieldTextarea`, `FieldCheckbox`,
151
+ `FieldSelect`, `FieldFile`, `FieldObject`, `FieldAssets`, `FieldInputLocalized`,
151
152
  `FieldKeyValues`, `FieldPhoneNumber`, `FieldCountry`, `FieldLikeButton`,
152
153
  `FieldRadios`, `FieldSwitch`, `FieldOptions`, and `Switch`:
153
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.122.0",
3
+ "version": "3.123.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -152,7 +152,7 @@
152
152
  "dotenv": "^16.6.1",
153
153
  "eslint": "^9.39.4",
154
154
  "globals": "^16.5.0",
155
- "playwright": "^1.60.0",
155
+ "playwright": "^1.61.0",
156
156
  "prettier": "^3.8.4",
157
157
  "prettier-plugin-svelte": "^3.5.2",
158
158
  "publint": "^0.3.21",
@@ -161,7 +161,7 @@
161
161
  "tailwindcss": "^4.3.1",
162
162
  "tsx": "^4.22.4",
163
163
  "typescript": "^5.9.3",
164
- "typescript-eslint": "^8.61.0",
164
+ "typescript-eslint": "^8.61.1",
165
165
  "vite": "^7.3.5",
166
166
  "vitest": "^4.1.9",
167
167
  "vitest-browser-svelte": "^2.1.1"