@marianmeres/stuic 3.121.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 +20 -0
- package/README.md +1 -1
- package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte +19 -3
- package/dist/components/ButtonGroupRadio/README.md +19 -1
- package/dist/components/Float/index.css +6 -3
- package/dist/components/Input/FieldMoney.svelte +193 -0
- package/dist/components/Input/FieldMoney.svelte.d.ts +39 -0
- package/dist/components/Input/FieldRadios.svelte +1 -1
- package/dist/components/Input/README.md +27 -0
- package/dist/components/Input/index.d.ts +1 -0
- package/dist/components/Input/index.js +1 -0
- package/dist/components/Input/types.d.ts +2 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/money-units.d.ts +64 -0
- package/dist/utils/money-units.js +75 -0
- package/dist/utils/round-to-decimals.d.ts +18 -0
- package/dist/utils/round-to-decimals.js +21 -0
- package/docs/domains/components.md +3 -2
- package/package.json +3 -3
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(
|
|
150
|
+
await maybe_activate(next_enabled_index(i, 1), coll);
|
|
135
151
|
}
|
|
136
152
|
if (["ArrowLeft", "ArrowUp"].includes(e.key)) {
|
|
137
|
-
await maybe_activate(
|
|
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'
|
|
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
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
--stuic-color-surface-foreground,
|
|
26
26
|
var(--stuic-color-foreground)
|
|
27
27
|
);
|
|
28
|
-
--stuic-float-header-padding-
|
|
29
|
-
--stuic-float-header-padding-
|
|
28
|
+
--stuic-float-header-padding-t: 0.25rem;
|
|
29
|
+
--stuic-float-header-padding-r: 0.25rem;
|
|
30
|
+
--stuic-float-header-padding-b: 0.25rem;
|
|
31
|
+
--stuic-float-header-padding-l: 0.5rem;
|
|
30
32
|
--stuic-float-header-gap: 0.5rem;
|
|
31
33
|
--stuic-float-header-font-weight: var(--font-weight-medium);
|
|
32
34
|
|
|
@@ -75,7 +77,8 @@
|
|
|
75
77
|
display: flex;
|
|
76
78
|
align-items: center;
|
|
77
79
|
gap: var(--stuic-float-header-gap);
|
|
78
|
-
padding: var(--stuic-float-header-padding-
|
|
80
|
+
padding: var(--stuic-float-header-padding-t) var(--stuic-float-header-padding-r)
|
|
81
|
+
var(--stuic-float-header-padding-b) var(--stuic-float-header-padding-l);
|
|
79
82
|
background: var(--stuic-float-header-bg);
|
|
80
83
|
color: var(--stuic-float-header-text);
|
|
81
84
|
border-bottom: var(--stuic-float-border-width, var(--stuic-border-width)) solid
|
|
@@ -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;
|
|
@@ -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
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/utils/index.js
CHANGED
|
@@ -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`, `
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|