@marianmeres/stuic 3.122.0 → 3.124.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/Input/FieldMoney.svelte +193 -0
- package/dist/components/Input/FieldMoney.svelte.d.ts +39 -0
- package/dist/components/Input/FieldObject.svelte +129 -9
- package/dist/components/Input/FieldObject.svelte.d.ts +13 -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
|
|
@@ -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;
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
id?: string;
|
|
19
19
|
tabindex?: number;
|
|
20
20
|
renderSize?: "sm" | "md" | "lg" | string;
|
|
21
|
+
/**
|
|
22
|
+
* Max nesting depth rendered in (read-only) preview mode before a node is
|
|
23
|
+
* collapsed to a keys-only / `[n]` summary with a "more…" hint. Prevents
|
|
24
|
+
* deeply nested objects from blowing out horizontally and becoming
|
|
25
|
+
* unreadable. Has no effect on raw edit mode. Defaults to 4.
|
|
26
|
+
*/
|
|
27
|
+
previewMaxDepth?: number;
|
|
28
|
+
/**
|
|
29
|
+
* When true (the default), the edit toggle opens the raw JSON editor in a
|
|
30
|
+
* (near) full-screen modal — comfortable for large / deeply nested objects.
|
|
31
|
+
* Set `false` to edit inline in a textarea below the preview instead.
|
|
32
|
+
*/
|
|
33
|
+
fullscreenEdit?: boolean;
|
|
21
34
|
required?: boolean;
|
|
22
35
|
disabled?: boolean;
|
|
23
36
|
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
@@ -38,6 +51,9 @@
|
|
|
38
51
|
import { validate as validateAction } from "../../actions/validate.svelte.js";
|
|
39
52
|
import { getId } from "../../utils/get-id.js";
|
|
40
53
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
54
|
+
import Button from "../Button/Button.svelte";
|
|
55
|
+
import Modal from "../Modal/Modal.svelte";
|
|
56
|
+
import { Thc, isTHCNotEmpty } from "../Thc/index.js";
|
|
41
57
|
import InputWrap from "./_internal/InputWrap.svelte";
|
|
42
58
|
|
|
43
59
|
let {
|
|
@@ -49,6 +65,8 @@
|
|
|
49
65
|
class: classProp,
|
|
50
66
|
tabindex = 0,
|
|
51
67
|
renderSize = "sm",
|
|
68
|
+
previewMaxDepth = 4,
|
|
69
|
+
fullscreenEdit = true,
|
|
52
70
|
required = false,
|
|
53
71
|
disabled = false,
|
|
54
72
|
// Renamed local binding to avoid collision with `export function validate()` below.
|
|
@@ -113,17 +131,54 @@
|
|
|
113
131
|
}
|
|
114
132
|
} else {
|
|
115
133
|
validation = undefined;
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const obj = JSON.parse(value || "null");
|
|
119
|
-
value = JSON.stringify(obj, null, "\t");
|
|
120
|
-
} catch {
|
|
121
|
-
// leave value as-is if not parseable
|
|
122
|
-
}
|
|
134
|
+
prettyPrint();
|
|
123
135
|
editMode = true;
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
/** Pretty-print `value` in place; leaves it untouched if not parseable. */
|
|
140
|
+
function prettyPrint() {
|
|
141
|
+
try {
|
|
142
|
+
value = JSON.stringify(JSON.parse(value || "null"), null, "\t");
|
|
143
|
+
} catch {
|
|
144
|
+
// not parseable — leave as-is
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Fullscreen modal editor (opt-in via `fullscreenEdit`) ---
|
|
149
|
+
let fsModal: Modal | undefined = $state();
|
|
150
|
+
// Snapshot of `value` captured when the modal opens, so Cancel / Escape can
|
|
151
|
+
// discard edits and restore the original (the inline editor has no cancel).
|
|
152
|
+
let fsSnapshot = "";
|
|
153
|
+
|
|
154
|
+
function openFullscreen(openerOrEvent?: MouseEvent) {
|
|
155
|
+
validation = undefined;
|
|
156
|
+
fsSnapshot = value;
|
|
157
|
+
prettyPrint();
|
|
158
|
+
fsModal?.open(openerOrEvent ?? null);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function applyFullscreen() {
|
|
162
|
+
// Same rule as the inline toggle: invalid JSON keeps the editor open.
|
|
163
|
+
try {
|
|
164
|
+
JSON.parse(value || "null");
|
|
165
|
+
validation = undefined;
|
|
166
|
+
fsModal?.close();
|
|
167
|
+
} catch {
|
|
168
|
+
validation = {
|
|
169
|
+
valid: false,
|
|
170
|
+
message: "This field requires attention. Please review and try again.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cancelFullscreen() {
|
|
176
|
+
// discard edits — restore the value as it was when the modal opened
|
|
177
|
+
value = fsSnapshot;
|
|
178
|
+
validation = undefined;
|
|
179
|
+
fsModal?.close();
|
|
180
|
+
}
|
|
181
|
+
|
|
127
182
|
// Validation
|
|
128
183
|
let validation: ValidationResult | undefined = $state();
|
|
129
184
|
const setValidationResult = (res: ValidationResult) => (validation = res);
|
|
@@ -167,7 +222,7 @@
|
|
|
167
222
|
}
|
|
168
223
|
|
|
169
224
|
const TEXTAREA_CLS =
|
|
170
|
-
"w-full min-h-16 p-2 font-mono text-sm focus:outline-none focus:ring-0";
|
|
225
|
+
"w-full min-h-16 p-2 font-mono text-sm focus:outline-none focus:ring-0 scrollbar-thin";
|
|
171
226
|
|
|
172
227
|
const BTN_CLS = [
|
|
173
228
|
"toggle-btn",
|
|
@@ -198,6 +253,13 @@
|
|
|
198
253
|
{/if}
|
|
199
254
|
{/snippet}
|
|
200
255
|
|
|
256
|
+
{#snippet moreHint()}
|
|
257
|
+
<span
|
|
258
|
+
class="ml-1 align-middle text-xs italic opacity-40"
|
|
259
|
+
title="Open the editor to view the full structure">more…</span
|
|
260
|
+
>
|
|
261
|
+
{/snippet}
|
|
262
|
+
|
|
201
263
|
{#snippet renderValue(val: unknown, depth: number)}
|
|
202
264
|
{#if val === null || val === undefined || typeof val === "boolean" || typeof val === "number" || typeof val === "string"}
|
|
203
265
|
{@render renderPrimitive(val)}
|
|
@@ -210,6 +272,9 @@
|
|
|
210
272
|
<span class="break-words"
|
|
211
273
|
>{val.map((v) => (v === null ? "null" : String(v))).join(", ")}</span
|
|
212
274
|
>
|
|
275
|
+
{:else if depth >= previewMaxDepth}
|
|
276
|
+
<!-- Too deep to render legibly — collapse to count + hint -->
|
|
277
|
+
<span class="opacity-50">[{val.length}]</span>{@render moreHint()}
|
|
213
278
|
{:else}
|
|
214
279
|
<div class="flex flex-col gap-2">
|
|
215
280
|
{#each val as item, i (i)}
|
|
@@ -236,6 +301,11 @@
|
|
|
236
301
|
<span class="opacity-50"
|
|
237
302
|
>{"{"} {entries.map(([k]) => k).join(", ")} {"}"}</span
|
|
238
303
|
>
|
|
304
|
+
{:else if depth >= previewMaxDepth}
|
|
305
|
+
<!-- Too deep to render legibly — collapse to keys + hint -->
|
|
306
|
+
<span class="opacity-50"
|
|
307
|
+
>{"{"} {entries.map(([k]) => k).join(", ")} {"}"}</span
|
|
308
|
+
>{@render moreHint()}
|
|
239
309
|
{:else}
|
|
240
310
|
<div class={twMerge("flex flex-col", depth > 0 && "ml-2")}>
|
|
241
311
|
{#each entries as [key, v], i (key)}
|
|
@@ -310,6 +380,7 @@
|
|
|
310
380
|
class={TEXTAREA_CLS}
|
|
311
381
|
{tabindex}
|
|
312
382
|
{disabled}
|
|
383
|
+
wrap="off"
|
|
313
384
|
use:autogrow={() => ({ enabled: true, value })}
|
|
314
385
|
></textarea>
|
|
315
386
|
{:else}
|
|
@@ -331,7 +402,7 @@
|
|
|
331
402
|
type="button"
|
|
332
403
|
class={BTN_CLS}
|
|
333
404
|
bind:this={toggleBtnEl}
|
|
334
|
-
onclick={toggleMode}
|
|
405
|
+
onclick={(e) => (fullscreenEdit ? openFullscreen(e) : toggleMode())}
|
|
335
406
|
{disabled}
|
|
336
407
|
use:tooltip={() => ({
|
|
337
408
|
enabled: true,
|
|
@@ -374,3 +445,52 @@
|
|
|
374
445
|
};
|
|
375
446
|
}}
|
|
376
447
|
/>
|
|
448
|
+
|
|
449
|
+
{#if fullscreenEdit}
|
|
450
|
+
<!-- Full-screen raw JSON editor (opened by the edit toggle when `fullscreenEdit`) -->
|
|
451
|
+
<Modal
|
|
452
|
+
bind:this={fsModal}
|
|
453
|
+
noClickOutsideClose
|
|
454
|
+
onEscape={() => {
|
|
455
|
+
// Escape behaves like Cancel — discard edits (the dialog closes itself)
|
|
456
|
+
value = fsSnapshot;
|
|
457
|
+
validation = undefined;
|
|
458
|
+
}}
|
|
459
|
+
classDialog="md:size-full"
|
|
460
|
+
classInner="md:h-full md:max-h-full md:min-h-0 md:w-full md:max-w-full"
|
|
461
|
+
classHeader="p-3 flex items-center gap-2 border-b border-(--stuic-color-border)"
|
|
462
|
+
classMain="p-0 flex flex-col overflow-hidden"
|
|
463
|
+
classFooter="p-3 flex items-center border-t border-(--stuic-color-border)"
|
|
464
|
+
>
|
|
465
|
+
{#snippet header()}
|
|
466
|
+
<span class="flex-1 truncate font-semibold">
|
|
467
|
+
{#if typeof label === "function"}
|
|
468
|
+
{@render label({ id })}
|
|
469
|
+
{:else if isTHCNotEmpty(label)}
|
|
470
|
+
<Thc thc={label as THC} />
|
|
471
|
+
{:else}
|
|
472
|
+
Edit JSON
|
|
473
|
+
{/if}
|
|
474
|
+
</span>
|
|
475
|
+
{/snippet}
|
|
476
|
+
|
|
477
|
+
<textarea
|
|
478
|
+
bind:value
|
|
479
|
+
{disabled}
|
|
480
|
+
wrap="off"
|
|
481
|
+
class="min-h-0 w-full flex-1 resize-none p-3 font-mono text-sm scrollbar-thin focus:outline-none focus:ring-0"
|
|
482
|
+
></textarea>
|
|
483
|
+
|
|
484
|
+
{#snippet footer()}
|
|
485
|
+
<div class="flex w-full items-center justify-between gap-3">
|
|
486
|
+
<span class="text-sm text-red-600 dark:text-red-400">
|
|
487
|
+
{#if validation && !validation.valid}{validation.message}{/if}
|
|
488
|
+
</span>
|
|
489
|
+
<div class="flex gap-2">
|
|
490
|
+
<Button type="button" variant="ghost" onclick={cancelFullscreen}>Cancel</Button>
|
|
491
|
+
<Button type="button" onclick={applyFullscreen}>Apply</Button>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
{/snippet}
|
|
495
|
+
</Modal>
|
|
496
|
+
{/if}
|
|
@@ -14,6 +14,19 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
14
14
|
id?: string;
|
|
15
15
|
tabindex?: number;
|
|
16
16
|
renderSize?: "sm" | "md" | "lg" | string;
|
|
17
|
+
/**
|
|
18
|
+
* Max nesting depth rendered in (read-only) preview mode before a node is
|
|
19
|
+
* collapsed to a keys-only / `[n]` summary with a "more…" hint. Prevents
|
|
20
|
+
* deeply nested objects from blowing out horizontally and becoming
|
|
21
|
+
* unreadable. Has no effect on raw edit mode. Defaults to 4.
|
|
22
|
+
*/
|
|
23
|
+
previewMaxDepth?: number;
|
|
24
|
+
/**
|
|
25
|
+
* When true (the default), the edit toggle opens the raw JSON editor in a
|
|
26
|
+
* (near) full-screen modal — comfortable for large / deeply nested objects.
|
|
27
|
+
* Set `false` to edit inline in a textarea below the preview instead.
|
|
28
|
+
*/
|
|
29
|
+
fullscreenEdit?: boolean;
|
|
17
30
|
required?: boolean;
|
|
18
31
|
disabled?: boolean;
|
|
19
32
|
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
@@ -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.124.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"
|