@spaethtech/svelte-ui 0.7.1-dev.38.b73de30 → 0.7.1-dev.40.3d22ac0
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/.claude/skills/svelte-ui/SKILL.md +3 -1
- package/dist/components/NumberInput.svelte +186 -218
- package/dist/components/NumberInput.svelte.d.ts +12 -0
- package/dist/components/PhoneInput.svelte +120 -0
- package/dist/components/PhoneInput.svelte.d.ts +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/components.md +25 -1
- package/package.json +1 -1
|
@@ -154,7 +154,9 @@ examples):
|
|
|
154
154
|
|
|
155
155
|
- **Form:** `Button` `ButtonDropdown` `Input` `Select` `List` `TextArea` `Checkbox` `Toggle` `Radio`
|
|
156
156
|
`Rating` · **`FieldGroup`** (fieldset wrapper for radio/checkbox/toggle sets)
|
|
157
|
-
- **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput`
|
|
157
|
+
- **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
|
|
158
|
+
`percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
|
|
159
|
+
`parse`/`format` for per-country)
|
|
158
160
|
- **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
|
|
159
161
|
- **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
|
|
160
162
|
(query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`).
|
|
@@ -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>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Input from "./Input.svelte";
|
|
3
|
+
import Phone from "~icons/mdi/phone-outline";
|
|
4
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
5
|
+
import type { Size } from "../types/sizes.js";
|
|
6
|
+
import type { Responsive } from "../types/responsive.js";
|
|
7
|
+
import type { Variant } from "../types/variants.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PhoneInput — type a phone number in ANY format; the bound `value` is stored in **E.164**
|
|
11
|
+
* (`+15551234567`): a leading `+`, country calling code, then the national number, digits only.
|
|
12
|
+
* The field shows what you type while editing and snaps to canonical E.164 on blur.
|
|
13
|
+
*
|
|
14
|
+
* **Dependency-free** by design — it normalises the E.164 *shape* only and does NOT do per-country
|
|
15
|
+
* formatting/validation (that needs a phone library + country data, which we don't bundle). To add
|
|
16
|
+
* that, inject `parse` / `format` / `validate` (e.g. backed by `libphonenumber-js`) — the component
|
|
17
|
+
* stays dep-free; you choose the library.
|
|
18
|
+
*/
|
|
19
|
+
interface Props extends Omit<HTMLInputAttributes, "type" | "value" | "size"> {
|
|
20
|
+
/** Bound value in E.164 (`+15551234567`), normalised from whatever the user types. */
|
|
21
|
+
value: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
inputClass?: string;
|
|
24
|
+
/** Validates the normalised E.164 value. Overrides the built-in E.164-shape check. */
|
|
25
|
+
validate?: (value: string) => boolean | string;
|
|
26
|
+
valid?: boolean;
|
|
27
|
+
touched?: boolean;
|
|
28
|
+
element?: HTMLInputElement;
|
|
29
|
+
size?: Responsive<Size>;
|
|
30
|
+
/** Shared axes — forwarded to the underlying Input. */
|
|
31
|
+
variant?: Variant;
|
|
32
|
+
borderless?: boolean;
|
|
33
|
+
required?: boolean;
|
|
34
|
+
/** Calling code (digits, no `+`) prepended when the typed number has no leading `+`. Default `"1"`
|
|
35
|
+
* (North America). Set to the calling code of the numbers your users enter without a `+`. */
|
|
36
|
+
defaultCallingCode?: string;
|
|
37
|
+
/** Normalise typed text → stored E.164. Override to plug a library (e.g. libphonenumber-js). */
|
|
38
|
+
parse?: (input: string, defaultCallingCode: string) => string;
|
|
39
|
+
/** Format the stored E.164 → field display (default: shown as-is). Override for a national /
|
|
40
|
+
* pretty format from your phone library. */
|
|
41
|
+
format?: (value: string) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
value = $bindable(""),
|
|
46
|
+
valid = $bindable(true),
|
|
47
|
+
touched = $bindable(false),
|
|
48
|
+
element = $bindable(),
|
|
49
|
+
validate,
|
|
50
|
+
required = false,
|
|
51
|
+
defaultCallingCode = "1",
|
|
52
|
+
parse,
|
|
53
|
+
format,
|
|
54
|
+
placeholder = "(555) 123-4567",
|
|
55
|
+
...restProps
|
|
56
|
+
}: Props = $props();
|
|
57
|
+
|
|
58
|
+
let displayValue = $state("");
|
|
59
|
+
let focused = $state(false);
|
|
60
|
+
|
|
61
|
+
// Default normalisation: an explicit international `+…` is kept (digits only after the `+`);
|
|
62
|
+
// otherwise the default calling code is prepended. SHAPE only — no per-country length check.
|
|
63
|
+
function defaultParse(raw: string, dcc: string): string {
|
|
64
|
+
const trimmed = (raw ?? "").trim();
|
|
65
|
+
if (!trimmed) return "";
|
|
66
|
+
const digits = trimmed.replace(/\D/g, "");
|
|
67
|
+
if (!digits) return "";
|
|
68
|
+
return trimmed.includes("+") ? `+${digits}` : `+${dcc}${digits}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const toE164 = (raw: string) => (parse ?? defaultParse)(raw, defaultCallingCode);
|
|
72
|
+
const toDisplay = (v: string) => (v ? (format ? format(v) : v) : "");
|
|
73
|
+
|
|
74
|
+
// Reflect external value changes into the field while the user isn't editing.
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (!focused) displayValue = toDisplay(value);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const defaultValidator = (v: string) => {
|
|
80
|
+
if (!v) return required ? "Phone number is required" : true;
|
|
81
|
+
// E.164 shape: `+`, a non-zero country digit, then 6–14 more digits (7–15 total).
|
|
82
|
+
if (!/^\+[1-9]\d{6,14}$/.test(v)) return "Enter a valid phone number";
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
const phoneValidator = $derived(validate ?? defaultValidator);
|
|
86
|
+
|
|
87
|
+
function handleInput(raw: string) {
|
|
88
|
+
displayValue = raw;
|
|
89
|
+
value = toE164(raw);
|
|
90
|
+
}
|
|
91
|
+
function handleFocus() {
|
|
92
|
+
focused = true;
|
|
93
|
+
displayValue = toDisplay(value);
|
|
94
|
+
}
|
|
95
|
+
function handleBlur() {
|
|
96
|
+
focused = false;
|
|
97
|
+
displayValue = toDisplay(value); // snap the field to the canonical (or your `format`) form
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let stringValue = $derived(displayValue);
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<Input
|
|
104
|
+
bind:value={stringValue}
|
|
105
|
+
bind:valid
|
|
106
|
+
bind:touched
|
|
107
|
+
bind:element
|
|
108
|
+
type="tel"
|
|
109
|
+
inputmode="tel"
|
|
110
|
+
autocomplete="tel"
|
|
111
|
+
{required}
|
|
112
|
+
validate={(val) => phoneValidator(toE164(val))}
|
|
113
|
+
oninput={(e) => handleInput((e.target as HTMLInputElement).value)}
|
|
114
|
+
onfocus={handleFocus}
|
|
115
|
+
onblur={handleBlur}
|
|
116
|
+
{placeholder}
|
|
117
|
+
{...restProps}
|
|
118
|
+
>
|
|
119
|
+
{#snippet icon()}<Phone />{/snippet}
|
|
120
|
+
</Input>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
2
|
+
import type { Size } from "../types/sizes.js";
|
|
3
|
+
import type { Responsive } from "../types/responsive.js";
|
|
4
|
+
import type { Variant } from "../types/variants.js";
|
|
5
|
+
/**
|
|
6
|
+
* PhoneInput — type a phone number in ANY format; the bound `value` is stored in **E.164**
|
|
7
|
+
* (`+15551234567`): a leading `+`, country calling code, then the national number, digits only.
|
|
8
|
+
* The field shows what you type while editing and snaps to canonical E.164 on blur.
|
|
9
|
+
*
|
|
10
|
+
* **Dependency-free** by design — it normalises the E.164 *shape* only and does NOT do per-country
|
|
11
|
+
* formatting/validation (that needs a phone library + country data, which we don't bundle). To add
|
|
12
|
+
* that, inject `parse` / `format` / `validate` (e.g. backed by `libphonenumber-js`) — the component
|
|
13
|
+
* stays dep-free; you choose the library.
|
|
14
|
+
*/
|
|
15
|
+
interface Props extends Omit<HTMLInputAttributes, "type" | "value" | "size"> {
|
|
16
|
+
/** Bound value in E.164 (`+15551234567`), normalised from whatever the user types. */
|
|
17
|
+
value: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
inputClass?: string;
|
|
20
|
+
/** Validates the normalised E.164 value. Overrides the built-in E.164-shape check. */
|
|
21
|
+
validate?: (value: string) => boolean | string;
|
|
22
|
+
valid?: boolean;
|
|
23
|
+
touched?: boolean;
|
|
24
|
+
element?: HTMLInputElement;
|
|
25
|
+
size?: Responsive<Size>;
|
|
26
|
+
/** Shared axes — forwarded to the underlying Input. */
|
|
27
|
+
variant?: Variant;
|
|
28
|
+
borderless?: boolean;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
/** Calling code (digits, no `+`) prepended when the typed number has no leading `+`. Default `"1"`
|
|
31
|
+
* (North America). Set to the calling code of the numbers your users enter without a `+`. */
|
|
32
|
+
defaultCallingCode?: string;
|
|
33
|
+
/** Normalise typed text → stored E.164. Override to plug a library (e.g. libphonenumber-js). */
|
|
34
|
+
parse?: (input: string, defaultCallingCode: string) => string;
|
|
35
|
+
/** Format the stored E.164 → field display (default: shown as-is). Override for a national /
|
|
36
|
+
* pretty format from your phone library. */
|
|
37
|
+
format?: (value: string) => string;
|
|
38
|
+
}
|
|
39
|
+
declare const PhoneInput: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched">;
|
|
40
|
+
type PhoneInput = ReturnType<typeof PhoneInput>;
|
|
41
|
+
export default PhoneInput;
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { default as PasswordInput } from "./components/PasswordInput.svelte";
|
|
|
21
21
|
export { default as EmailInput } from "./components/EmailInput.svelte";
|
|
22
22
|
export { default as SearchInput } from "./components/SearchInput.svelte";
|
|
23
23
|
export { default as NumberInput } from "./components/NumberInput.svelte";
|
|
24
|
+
export { default as PhoneInput } from "./components/PhoneInput.svelte";
|
|
24
25
|
export { default as DateTimeInput } from "./components/DateTimeInput.svelte";
|
|
25
26
|
export { default as Calendar } from "./components/Calendar.svelte";
|
|
26
27
|
export { default as DatePicker } from "./components/DatePicker.svelte";
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { default as PasswordInput } from "./components/PasswordInput.svelte";
|
|
|
24
24
|
export { default as EmailInput } from "./components/EmailInput.svelte";
|
|
25
25
|
export { default as SearchInput } from "./components/SearchInput.svelte";
|
|
26
26
|
export { default as NumberInput } from "./components/NumberInput.svelte";
|
|
27
|
+
export { default as PhoneInput } from "./components/PhoneInput.svelte";
|
|
27
28
|
export { default as DateTimeInput } from "./components/DateTimeInput.svelte";
|
|
28
29
|
export { default as Calendar } from "./components/Calendar.svelte";
|
|
29
30
|
export { default as DatePicker } from "./components/DatePicker.svelte";
|
package/docs/components.md
CHANGED
|
@@ -194,8 +194,32 @@ 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`.
|
|
209
|
+
|
|
210
|
+
### PhoneInput
|
|
211
|
+
|
|
212
|
+
Type any format; stores the bound value as **E.164** (`+15551234567`). Field shows what you type,
|
|
213
|
+
snaps to canonical on blur.
|
|
214
|
+
|
|
215
|
+
- **Location**: `src/lib/components/PhoneInput.svelte`
|
|
216
|
+
- **Value**: `string` (E.164)
|
|
217
|
+
- **Props**: `defaultCallingCode` (digits, default `"1"` — prepended when input has no `+`), `required`,
|
|
218
|
+
`validate` (validates the E.164), and the injection hooks **`parse`** (typed → E.164) / **`format`**
|
|
219
|
+
(E.164 → display).
|
|
220
|
+
- **Dep-free** — normalises the E.164 *shape* only (no per-country validation/formatting). For that,
|
|
221
|
+
inject `parse`/`format`/`validate` backed by a phone library (e.g. `libphonenumber-js`); the
|
|
222
|
+
component bundles none.
|
|
199
223
|
|
|
200
224
|
## Navigation
|
|
201
225
|
|