@spaethtech/svelte-ui 0.7.1-dev.38.b73de30 → 0.7.1-dev.39.d6b0fa5
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.
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
2
4
|
import Input from "./Input.svelte";
|
|
5
|
+
import Button from "./Button.svelte";
|
|
6
|
+
import Minus from "~icons/mdi/minus";
|
|
7
|
+
import Plus from "~icons/mdi/plus";
|
|
3
8
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
4
9
|
import type { Size } from "../types/sizes.js";
|
|
5
10
|
import type { Responsive } from "../types/responsive.js";
|
|
@@ -25,11 +30,23 @@
|
|
|
25
30
|
thousandsSeparator?: boolean;
|
|
26
31
|
prefix?: string;
|
|
27
32
|
suffix?: string;
|
|
28
|
-
currency?: string; // e.g
|
|
29
|
-
decimalSeparator?: string;
|
|
30
|
-
thousandsChar?: string;
|
|
31
|
-
roundingMode?: "round" | "floor" | "ceil" | "trunc";
|
|
32
|
-
negativeMode?: "minus" | "parentheses" | "both";
|
|
33
|
+
currency?: string; // symbol, e.g. '$', '€' — a prefix (use `suffix` for trailing symbols)
|
|
34
|
+
decimalSeparator?: string;
|
|
35
|
+
thousandsChar?: string;
|
|
36
|
+
roundingMode?: "round" | "floor" | "ceil" | "trunc";
|
|
37
|
+
negativeMode?: "minus" | "parentheses" | "both";
|
|
38
|
+
/** Hard-block the minus key + strip negatives on parse. Default: derived from `min`
|
|
39
|
+
* (`min >= 0` ⇒ `false`, otherwise `true`). */
|
|
40
|
+
allowNegative?: boolean;
|
|
41
|
+
/** On blur, coerce the value into `[min, max]` instead of only surfacing a validation error. */
|
|
42
|
+
clamp?: boolean;
|
|
43
|
+
/** Show ± stepper buttons. Arrow-Up/Down always step by `step` (this just adds the buttons). */
|
|
44
|
+
stepper?: boolean;
|
|
45
|
+
/** Percent mode: store the fraction, display ×100 with a `%` suffix (`0.5` ⇄ `50%`).
|
|
46
|
+
* `value` / `min` / `max` / `step` are all in the fraction units. */
|
|
47
|
+
percent?: boolean;
|
|
48
|
+
/** Group digits AS YOU TYPE (caret preserved) rather than only reformatting on blur. */
|
|
49
|
+
liveFormat?: boolean;
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
let {
|
|
@@ -42,12 +59,6 @@
|
|
|
42
59
|
max,
|
|
43
60
|
step = 1,
|
|
44
61
|
allowDecimals = true,
|
|
45
|
-
// Integer by default. Currency / accounting callers already
|
|
46
|
-
// pass `decimalPlaces={2}` explicitly (see the showcase), so
|
|
47
|
-
// this default only affects bare `<NumberInput bind:value=… />`
|
|
48
|
-
// which reads as an integer field 99% of the time (port numbers,
|
|
49
|
-
// intervals, counts). Prevents the `22 → 22.00` surprise where
|
|
50
|
-
// callers had to opt OUT of decimals to display a whole number.
|
|
51
62
|
decimalPlaces = 0,
|
|
52
63
|
thousandsSeparator: enableThousandsSeparator = false,
|
|
53
64
|
prefix = "",
|
|
@@ -57,6 +68,11 @@
|
|
|
57
68
|
thousandsChar = ",",
|
|
58
69
|
roundingMode = "round",
|
|
59
70
|
negativeMode = "minus",
|
|
71
|
+
allowNegative,
|
|
72
|
+
clamp = false,
|
|
73
|
+
stepper = false,
|
|
74
|
+
percent = false,
|
|
75
|
+
liveFormat = false,
|
|
60
76
|
placeholder = "Enter a number...",
|
|
61
77
|
...restProps
|
|
62
78
|
}: Props = $props();
|
|
@@ -64,204 +80,176 @@
|
|
|
64
80
|
let displayValue = $state("");
|
|
65
81
|
let focused = $state(false);
|
|
66
82
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
// Negatives default to disallowed when `min` fences them off (min >= 0). Percent uses "%" as the
|
|
84
|
+
// effective suffix and drops any prefix; otherwise currency/prefix + suffix apply as before.
|
|
85
|
+
const negativeAllowed = $derived(allowNegative ?? (min === undefined || min < 0));
|
|
86
|
+
const effPrefix = $derived(percent ? "" : currency || prefix);
|
|
87
|
+
const effSuffix = $derived(percent ? "%" : suffix);
|
|
88
|
+
const inputMode = $derived<"decimal" | "numeric">(allowDecimals || percent ? "decimal" : "numeric");
|
|
89
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
90
|
+
|
|
91
|
+
function clampVal(n: number): number {
|
|
92
|
+
if (min !== undefined) n = Math.max(min, n);
|
|
93
|
+
if (max !== undefined) n = Math.min(max, n);
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
70
96
|
|
|
97
|
+
// number -> display string (full formatting: decimals/rounding, grouping, prefix/suffix, negatives)
|
|
98
|
+
function formatNumber(numRaw: number | null): string {
|
|
99
|
+
if (numRaw === null || numRaw === undefined || isNaN(numRaw)) return "";
|
|
100
|
+
const num = percent ? numRaw * 100 : numRaw;
|
|
71
101
|
const isNegative = num < 0;
|
|
72
102
|
const absoluteNum = Math.abs(num);
|
|
73
103
|
let formatted = absoluteNum.toString();
|
|
74
104
|
|
|
75
|
-
// Handle decimal places and rounding
|
|
76
105
|
if (allowDecimals && decimalPlaces > 0) {
|
|
77
106
|
formatted = absoluteNum.toFixed(decimalPlaces);
|
|
78
107
|
} else if (!allowDecimals) {
|
|
79
|
-
// Apply rounding mode for integers
|
|
80
108
|
let rounded: number;
|
|
81
109
|
switch (roundingMode) {
|
|
82
|
-
case "floor":
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
rounded = Math.ceil(absoluteNum);
|
|
87
|
-
break;
|
|
88
|
-
case "trunc":
|
|
89
|
-
rounded = Math.trunc(absoluteNum);
|
|
90
|
-
break;
|
|
91
|
-
default: // 'round'
|
|
92
|
-
rounded = Math.round(absoluteNum);
|
|
110
|
+
case "floor": rounded = Math.floor(absoluteNum); break;
|
|
111
|
+
case "ceil": rounded = Math.ceil(absoluteNum); break;
|
|
112
|
+
case "trunc": rounded = Math.trunc(absoluteNum); break;
|
|
113
|
+
default: rounded = Math.round(absoluteNum);
|
|
93
114
|
}
|
|
94
115
|
formatted = rounded.toString();
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
// Add thousands separator and convert to custom format
|
|
98
118
|
if (enableThousandsSeparator) {
|
|
99
119
|
const parts = formatted.split(".");
|
|
100
120
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandsChar);
|
|
101
121
|
formatted = parts.join(decimalSeparator);
|
|
102
122
|
} else if (decimalSeparator !== ".") {
|
|
103
|
-
// Even without thousands separator, we need to convert decimal separator
|
|
104
123
|
formatted = formatted.replace(".", decimalSeparator);
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
const finalPrefix = currency || prefix;
|
|
108
|
-
|
|
109
|
-
// Handle negative number display
|
|
110
126
|
if (isNegative) {
|
|
111
|
-
if (negativeMode === "parentheses") {
|
|
112
|
-
|
|
113
|
-
return `(${finalPrefix}${formatted}${suffix})`;
|
|
114
|
-
} else {
|
|
115
|
-
// 'minus' or 'both' - put minus in front
|
|
116
|
-
return `-${finalPrefix}${formatted}${suffix}`;
|
|
117
|
-
}
|
|
127
|
+
if (negativeMode === "parentheses") return `(${effPrefix}${formatted}${effSuffix})`;
|
|
128
|
+
return `-${effPrefix}${formatted}${effSuffix}`;
|
|
118
129
|
}
|
|
119
|
-
|
|
120
|
-
return `${finalPrefix}${formatted}${suffix}`;
|
|
130
|
+
return `${effPrefix}${formatted}${effSuffix}`;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
//
|
|
133
|
+
// display string -> number
|
|
124
134
|
function parseNumber(str: string): number | null {
|
|
125
135
|
if (!str || str.trim() === "") return null;
|
|
136
|
+
const isNegative =
|
|
137
|
+
negativeAllowed && ((str.includes("(") && str.includes(")")) || str.includes("-"));
|
|
126
138
|
|
|
127
|
-
// Check for negative indicators
|
|
128
|
-
const hasParentheses = str.includes("(") && str.includes(")");
|
|
129
|
-
const hasMinus = str.includes("-");
|
|
130
|
-
const isNegative = hasParentheses || hasMinus;
|
|
131
|
-
|
|
132
|
-
// Remove prefix, suffix, currency
|
|
133
|
-
const finalPrefix = currency || prefix;
|
|
134
139
|
let cleaned = str;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
cleaned = cleaned.replace(new RegExp(`^\\(${escapedPrefix}`), "("); // ($123 -> (123
|
|
140
|
+
if (effPrefix) {
|
|
141
|
+
const e = esc(effPrefix);
|
|
142
|
+
cleaned = cleaned
|
|
143
|
+
.replace(new RegExp(`^${e}`), "")
|
|
144
|
+
.replace(new RegExp(`^-${e}`), "-")
|
|
145
|
+
.replace(new RegExp(`^\\(${e}`), "(");
|
|
142
146
|
}
|
|
147
|
+
if (effSuffix) cleaned = cleaned.replace(new RegExp(`${esc(effSuffix)}\\)?$`), "");
|
|
143
148
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
cleaned = cleaned.replace(new RegExp(`${escapedSuffix}$`), "");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Remove negative indicators
|
|
150
|
-
cleaned = cleaned.replace(/[()]/g, ""); // Remove parentheses
|
|
151
|
-
cleaned = cleaned.replace(/-/g, ""); // Remove minus signs
|
|
152
|
-
|
|
153
|
-
// Remove thousands separators (both , and . could be thousands separators)
|
|
154
|
-
cleaned = cleaned.replace(
|
|
155
|
-
new RegExp(`\\${thousandsChar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"),
|
|
156
|
-
"",
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
// Convert decimal separator to standard decimal point
|
|
149
|
+
cleaned = cleaned.replace(/[()]/g, "").replace(/-/g, "");
|
|
150
|
+
cleaned = cleaned.replace(new RegExp(esc(thousandsChar), "g"), "");
|
|
160
151
|
if (decimalSeparator !== ".") {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const lastSeparatorIndex = cleaned.lastIndexOf(decimalSeparator);
|
|
164
|
-
if (lastSeparatorIndex !== -1) {
|
|
165
|
-
cleaned =
|
|
166
|
-
cleaned.substring(0, lastSeparatorIndex) +
|
|
167
|
-
"." +
|
|
168
|
-
cleaned.substring(lastSeparatorIndex + 1);
|
|
169
|
-
}
|
|
152
|
+
const last = cleaned.lastIndexOf(decimalSeparator);
|
|
153
|
+
if (last !== -1) cleaned = cleaned.substring(0, last) + "." + cleaned.substring(last + 1);
|
|
170
154
|
}
|
|
171
155
|
|
|
172
156
|
const parsed = parseFloat(cleaned);
|
|
173
157
|
if (isNaN(parsed)) return null;
|
|
158
|
+
const signed = isNegative ? -parsed : parsed;
|
|
159
|
+
return percent ? signed / 100 : signed;
|
|
160
|
+
}
|
|
174
161
|
|
|
175
|
-
|
|
162
|
+
// Live grouping only (thousands) — no toFixed/parentheses while typing, and a trailing decimal
|
|
163
|
+
// separator ("12.") is preserved so decimals can still be entered.
|
|
164
|
+
function liveGroup(raw: string): string {
|
|
165
|
+
const neg = negativeAllowed && raw.includes("-");
|
|
166
|
+
const sep = esc(decimalSeparator);
|
|
167
|
+
const cleaned = raw.replace(new RegExp(`[^0-9${sep}]`, "g"), "");
|
|
168
|
+
const firstSep = cleaned.indexOf(decimalSeparator);
|
|
169
|
+
const intPart = firstSep >= 0 ? cleaned.slice(0, firstSep) : cleaned;
|
|
170
|
+
const decPart =
|
|
171
|
+
firstSep >= 0 ? cleaned.slice(firstSep + 1).replace(new RegExp(sep, "g"), "") : null;
|
|
172
|
+
const grouped = enableThousandsSeparator
|
|
173
|
+
? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsChar)
|
|
174
|
+
: intPart;
|
|
175
|
+
const body = grouped + (decPart !== null ? decimalSeparator + decPart : "");
|
|
176
|
+
if (body === "") return neg ? "-" : "";
|
|
177
|
+
return `${neg ? "-" : ""}${effPrefix}${body}${effSuffix}`;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
//
|
|
180
|
+
// Reflect external value changes into the field when the user isn't editing.
|
|
179
181
|
$effect(() => {
|
|
180
|
-
if (!focused)
|
|
181
|
-
displayValue = formatNumber(value);
|
|
182
|
-
}
|
|
182
|
+
if (!focused) displayValue = formatNumber(value);
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
// Default validation
|
|
186
185
|
const defaultValidator = (val: number | null) => {
|
|
187
|
-
if (val === null) return true;
|
|
188
|
-
|
|
186
|
+
if (val === null) return true;
|
|
189
187
|
if (min !== undefined && val < min) return `Value must be at least ${min}`;
|
|
190
188
|
if (max !== undefined && val > max) return `Value must be at most ${max}`;
|
|
191
|
-
|
|
192
189
|
if (!allowDecimals && val % 1 !== 0) return "Decimal values are not allowed";
|
|
193
|
-
|
|
194
190
|
return true;
|
|
195
191
|
};
|
|
192
|
+
const numberValidator = $derived(validate ?? defaultValidator);
|
|
193
|
+
|
|
194
|
+
function stepBy(dir: 1 | -1) {
|
|
195
|
+
const base = value ?? min ?? 0;
|
|
196
|
+
let next = Number((base + dir * step).toFixed(12));
|
|
197
|
+
if (!allowDecimals) next = Math.round(next);
|
|
198
|
+
next = clampVal(next);
|
|
199
|
+
value = next;
|
|
200
|
+
displayValue = formatNumber(next);
|
|
201
|
+
touched = true;
|
|
202
|
+
}
|
|
196
203
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (finalPrefix) {
|
|
215
|
-
incompletePatterns.push(
|
|
216
|
-
`-${finalPrefix}`, // -$, -€, etc.
|
|
217
|
-
`(${finalPrefix}`, // ($, (€, etc.
|
|
218
|
-
`(${finalPrefix})`, // ($), (€), etc.
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Add suffix-related incomplete patterns
|
|
223
|
-
if (suffix) {
|
|
224
|
-
incompletePatterns.push(
|
|
225
|
-
`-${suffix}`, // -%, etc.
|
|
226
|
-
`(${suffix}`, // (%, etc.
|
|
227
|
-
`(${suffix})`, // (%), etc.
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
// Combined prefix and suffix patterns
|
|
231
|
-
if (finalPrefix) {
|
|
232
|
-
incompletePatterns.push(
|
|
233
|
-
`-${finalPrefix}${suffix}`, // -$%, etc.
|
|
234
|
-
`(${finalPrefix}${suffix}`, // ($%, etc.
|
|
235
|
-
`(${finalPrefix}${suffix})`, // ($%), etc.
|
|
236
|
-
);
|
|
204
|
+
async function handleInput(raw: string) {
|
|
205
|
+
if (liveFormat) {
|
|
206
|
+
const el = element;
|
|
207
|
+
const caret = el?.selectionStart ?? raw.length;
|
|
208
|
+
const digitsLeft = raw.slice(0, caret).replace(/\D/g, "").length;
|
|
209
|
+
value = parseNumber(raw);
|
|
210
|
+
const disp = liveGroup(raw);
|
|
211
|
+
displayValue = disp;
|
|
212
|
+
await tick();
|
|
213
|
+
if (el) {
|
|
214
|
+
let pos = 0;
|
|
215
|
+
let count = 0;
|
|
216
|
+
while (pos < disp.length && count < digitsLeft) {
|
|
217
|
+
if (/\d/.test(disp[pos])) count++;
|
|
218
|
+
pos++;
|
|
219
|
+
}
|
|
220
|
+
el.setSelectionRange(pos, pos);
|
|
237
221
|
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (incompletePatterns.includes(inputValue)) {
|
|
241
|
-
// Keep the current value but update display for incomplete inputs
|
|
242
222
|
return;
|
|
243
223
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
224
|
+
displayValue = raw;
|
|
225
|
+
// Incomplete inputs (just a sign / open paren) keep the current value until a digit arrives.
|
|
226
|
+
if (["-", "(", "()"].includes(raw)) return;
|
|
227
|
+
value = parseNumber(raw);
|
|
247
228
|
}
|
|
248
229
|
|
|
249
230
|
function handleFocus() {
|
|
250
231
|
focused = true;
|
|
251
|
-
|
|
252
|
-
if (value !== null && !isNaN(value)) {
|
|
253
|
-
displayValue = formatNumber(value);
|
|
254
|
-
}
|
|
232
|
+
if (value !== null && !isNaN(value)) displayValue = formatNumber(value);
|
|
255
233
|
}
|
|
256
234
|
|
|
257
235
|
function handleBlur() {
|
|
258
236
|
focused = false;
|
|
259
|
-
|
|
237
|
+
if (clamp && value !== null && !isNaN(value)) value = clampVal(value);
|
|
260
238
|
displayValue = formatNumber(value);
|
|
261
239
|
}
|
|
262
240
|
|
|
263
241
|
function handleKeyDown(event: KeyboardEvent) {
|
|
264
|
-
//
|
|
242
|
+
// Arrow-Up/Down step the value (spinbutton behavior), regardless of the visible stepper buttons.
|
|
243
|
+
if (event.key === "ArrowUp") {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
stepBy(1);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (event.key === "ArrowDown") {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
stepBy(-1);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
265
253
|
if (
|
|
266
254
|
event.key === "Backspace" ||
|
|
267
255
|
event.key === "Delete" ||
|
|
@@ -272,119 +260,99 @@
|
|
|
272
260
|
event.key === "Home" ||
|
|
273
261
|
event.key === "End" ||
|
|
274
262
|
event.ctrlKey ||
|
|
275
|
-
event.metaKey
|
|
263
|
+
event.metaKey
|
|
276
264
|
) {
|
|
277
265
|
return;
|
|
278
266
|
}
|
|
279
267
|
|
|
280
268
|
const currentValue = displayValue;
|
|
281
|
-
const finalPrefix = currency || prefix;
|
|
282
269
|
const input = event.target as HTMLInputElement;
|
|
283
270
|
const cursorPosition = input.selectionStart || 0;
|
|
284
|
-
|
|
285
|
-
// Basic allowed characters
|
|
286
271
|
const allowedChars = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
|
|
287
|
-
|
|
288
|
-
// Always allow both comma and period for international input flexibility
|
|
289
272
|
allowedChars.add(",");
|
|
290
273
|
allowedChars.add(".");
|
|
291
274
|
|
|
292
|
-
|
|
293
|
-
if (finalPrefix && event.key === finalPrefix) {
|
|
294
|
-
// Only allow currency at the beginning (after minus if present)
|
|
275
|
+
if (effPrefix && event.key === effPrefix) {
|
|
295
276
|
const hasMinusAtStart = currentValue.startsWith("-");
|
|
296
277
|
const expectedPosition = hasMinusAtStart ? 1 : 0;
|
|
297
|
-
if (cursorPosition === expectedPosition && !currentValue.includes(
|
|
298
|
-
allowedChars.add(
|
|
299
|
-
|
|
300
|
-
event.preventDefault();
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
278
|
+
if (cursorPosition === expectedPosition && !currentValue.includes(effPrefix))
|
|
279
|
+
allowedChars.add(effPrefix);
|
|
280
|
+
else return void event.preventDefault();
|
|
303
281
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (cursorPosition === currentValue.length && !currentValue.includes(suffix)) {
|
|
309
|
-
allowedChars.add(suffix);
|
|
310
|
-
} else {
|
|
311
|
-
event.preventDefault();
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
282
|
+
if (effSuffix && event.key === effSuffix) {
|
|
283
|
+
if (cursorPosition === currentValue.length && !currentValue.includes(effSuffix))
|
|
284
|
+
allowedChars.add(effSuffix);
|
|
285
|
+
else return void event.preventDefault();
|
|
314
286
|
}
|
|
315
|
-
|
|
316
|
-
// Always allow both minus and parentheses for input (negativeMode only affects display formatting)
|
|
317
287
|
if (event.key === "-") {
|
|
318
|
-
|
|
319
|
-
if (cursorPosition === 0 && !currentValue.includes("-")) {
|
|
288
|
+
if (negativeAllowed && cursorPosition === 0 && !currentValue.includes("-"))
|
|
320
289
|
allowedChars.add("-");
|
|
321
|
-
|
|
322
|
-
event.preventDefault();
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
290
|
+
else return void event.preventDefault();
|
|
325
291
|
}
|
|
326
|
-
|
|
327
292
|
if (event.key === "(") {
|
|
328
|
-
|
|
329
|
-
if (cursorPosition === 0 && !currentValue.includes("(")) {
|
|
293
|
+
if (negativeAllowed && cursorPosition === 0 && !currentValue.includes("("))
|
|
330
294
|
allowedChars.add("(");
|
|
331
|
-
|
|
332
|
-
event.preventDefault();
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
295
|
+
else return void event.preventDefault();
|
|
335
296
|
}
|
|
336
297
|
if (event.key === ")") {
|
|
337
|
-
// Only allow closing parenthesis at the end, if opening exists, and no closing yet
|
|
338
298
|
if (
|
|
339
299
|
cursorPosition === currentValue.length &&
|
|
340
300
|
currentValue.includes("(") &&
|
|
341
301
|
!currentValue.includes(")")
|
|
342
|
-
)
|
|
302
|
+
)
|
|
343
303
|
allowedChars.add(")");
|
|
344
|
-
|
|
345
|
-
event.preventDefault();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
304
|
+
else return void event.preventDefault();
|
|
348
305
|
}
|
|
349
306
|
|
|
350
|
-
|
|
351
|
-
if (!allowedChars.has(event.key)) {
|
|
352
|
-
event.preventDefault();
|
|
353
|
-
}
|
|
307
|
+
if (!allowedChars.has(event.key)) event.preventDefault();
|
|
354
308
|
}
|
|
355
309
|
|
|
356
|
-
// Convert our number value to string for the Input component
|
|
357
310
|
let stringValue = $derived(displayValue);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
// of the custom-formatting features (prefix / suffix / currency /
|
|
361
|
-
// thousands / non-`.` decimal / paren-negative). That gives them
|
|
362
|
-
// the browser spinner + arrow-key increment + guaranteed mobile
|
|
363
|
-
// numeric keypad. When formatting IS used, stay on type="text" so
|
|
364
|
-
// the browser doesn't strip `$`, `%`, `,`, `(`, etc.
|
|
365
|
-
const inputType = $derived(
|
|
366
|
-
prefix ||
|
|
367
|
-
suffix ||
|
|
368
|
-
currency ||
|
|
369
|
-
enableThousandsSeparator ||
|
|
370
|
-
decimalSeparator !== "." ||
|
|
371
|
-
negativeMode === "parentheses"
|
|
372
|
-
? "text"
|
|
373
|
-
: "number",
|
|
374
|
-
);
|
|
311
|
+
const atMin = $derived(value !== null && min !== undefined && value <= min);
|
|
312
|
+
const atMax = $derived(value !== null && max !== undefined && value >= max);
|
|
375
313
|
</script>
|
|
376
314
|
|
|
315
|
+
<!-- Stepper ±. Defined at top level so it can be passed as the `actions` prop CONDITIONALLY (a
|
|
316
|
+
`{#snippet}` wrapped in `{#if}` is not forwarded as a prop). -->
|
|
317
|
+
{#snippet stepperActions()}
|
|
318
|
+
<Button
|
|
319
|
+
variant="ghost"
|
|
320
|
+
size="sm"
|
|
321
|
+
tabindex={-1}
|
|
322
|
+
disabled={atMin}
|
|
323
|
+
onclick={() => stepBy(-1)}
|
|
324
|
+
aria-label="Decrease"
|
|
325
|
+
title="Decrease"
|
|
326
|
+
>
|
|
327
|
+
{#snippet icon()}<Minus />{/snippet}
|
|
328
|
+
</Button>
|
|
329
|
+
<Button
|
|
330
|
+
variant="ghost"
|
|
331
|
+
size="sm"
|
|
332
|
+
tabindex={-1}
|
|
333
|
+
disabled={atMax}
|
|
334
|
+
onclick={() => stepBy(1)}
|
|
335
|
+
aria-label="Increase"
|
|
336
|
+
title="Increase"
|
|
337
|
+
>
|
|
338
|
+
{#snippet icon()}<Plus />{/snippet}
|
|
339
|
+
</Button>
|
|
340
|
+
{/snippet}
|
|
341
|
+
|
|
377
342
|
<Input
|
|
378
343
|
bind:value={stringValue}
|
|
379
344
|
bind:valid
|
|
380
345
|
bind:touched
|
|
381
346
|
bind:element
|
|
382
|
-
type=
|
|
383
|
-
inputmode=
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
347
|
+
type="text"
|
|
348
|
+
inputmode={inputMode}
|
|
349
|
+
role="spinbutton"
|
|
350
|
+
aria-valuenow={value ?? undefined}
|
|
351
|
+
aria-valuemin={min}
|
|
352
|
+
aria-valuemax={max}
|
|
353
|
+
aria-valuetext={displayValue || undefined}
|
|
354
|
+
actions={stepper ? stepperActions : undefined}
|
|
355
|
+
validate={(val) => numberValidator(parseNumber(val))}
|
|
388
356
|
oninput={(e) => handleInput((e.target as HTMLInputElement).value)}
|
|
389
357
|
onfocus={handleFocus}
|
|
390
358
|
onblur={handleBlur}
|
|
@@ -27,6 +27,18 @@ interface Props extends Omit<HTMLInputAttributes, "type" | "value" | "size"> {
|
|
|
27
27
|
thousandsChar?: string;
|
|
28
28
|
roundingMode?: "round" | "floor" | "ceil" | "trunc";
|
|
29
29
|
negativeMode?: "minus" | "parentheses" | "both";
|
|
30
|
+
/** Hard-block the minus key + strip negatives on parse. Default: derived from `min`
|
|
31
|
+
* (`min >= 0` ⇒ `false`, otherwise `true`). */
|
|
32
|
+
allowNegative?: boolean;
|
|
33
|
+
/** On blur, coerce the value into `[min, max]` instead of only surfacing a validation error. */
|
|
34
|
+
clamp?: boolean;
|
|
35
|
+
/** Show ± stepper buttons. Arrow-Up/Down always step by `step` (this just adds the buttons). */
|
|
36
|
+
stepper?: boolean;
|
|
37
|
+
/** Percent mode: store the fraction, display ×100 with a `%` suffix (`0.5` ⇄ `50%`).
|
|
38
|
+
* `value` / `min` / `max` / `step` are all in the fraction units. */
|
|
39
|
+
percent?: boolean;
|
|
40
|
+
/** Group digits AS YOU TYPE (caret preserved) rather than only reformatting on blur. */
|
|
41
|
+
liveFormat?: boolean;
|
|
30
42
|
}
|
|
31
43
|
declare const NumberInput: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched">;
|
|
32
44
|
type NumberInput = ReturnType<typeof NumberInput>;
|
package/docs/components.md
CHANGED
|
@@ -194,8 +194,18 @@ Thin wrappers over `Input`, each preconfigured with an MDI icon (rendered into I
|
|
|
194
194
|
|
|
195
195
|
### NumberInput
|
|
196
196
|
|
|
197
|
+
Formatted numeric field (value-vs-display split; always `type="text"`, so validation is deferred to
|
|
198
|
+
blur/submit).
|
|
199
|
+
|
|
197
200
|
- **Location**: `src/lib/components/NumberInput.svelte`
|
|
198
|
-
- **
|
|
201
|
+
- **Value**: `number | null` (bindable)
|
|
202
|
+
- **Formatting**: `decimalPlaces`, `allowDecimals`, `roundingMode`, `thousandsSeparator`/`thousandsChar`,
|
|
203
|
+
`decimalSeparator`, `prefix`/`suffix`/`currency` (symbol), `negativeMode` (`minus`/`parentheses`/`both`),
|
|
204
|
+
and **`liveFormat`** (group as you type, caret-preserved).
|
|
205
|
+
- **Numeric**: `min`, `max`, `step`, **`clamp`** (coerce into range on blur), **`allowNegative`**
|
|
206
|
+
(default off when `min >= 0`), **`stepper`** (± buttons + Arrow-Up/Down, `role="spinbutton"`),
|
|
207
|
+
and **`percent`** (display ×100 `%`, store the fraction; `min`/`max`/`step` in fraction units).
|
|
208
|
+
- Dep-free by design — for locale/`Intl` or currency codes, format upstream or via `validate`.
|
|
199
209
|
|
|
200
210
|
## Navigation
|
|
201
211
|
|