@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.
@@ -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., '$', '€', '£', '¥', etc.
29
- decimalSeparator?: string; // Decimal separator (default: '.')
30
- thousandsChar?: string; // Thousands separator character (default: ',')
31
- roundingMode?: "round" | "floor" | "ceil" | "trunc"; // Rounding behavior for integers
32
- negativeMode?: "minus" | "parentheses" | "both"; // How to display/accept negative numbers
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
- // Convert number to formatted display string
68
- function formatNumber(num: number | null): string {
69
- if (num === null || num === undefined || isNaN(num)) return "";
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
- rounded = Math.floor(absoluteNum);
84
- break;
85
- case "ceil":
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
- // For parentheses, put everything (prefix, number, suffix) inside
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
- // Parse display string back to number
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
- if (finalPrefix) {
137
- const escapedPrefix = finalPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
- // Remove prefix from beginning, or after negative indicators
139
- cleaned = cleaned.replace(new RegExp(`^${escapedPrefix}`), ""); // $123
140
- cleaned = cleaned.replace(new RegExp(`^-${escapedPrefix}`), "-"); // -$123 -> -123
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
- if (suffix) {
145
- const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
- // Replace the decimal separator with standard decimal point
162
- // But only the last occurrence to handle cases like "1.234,56"
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
- return isNegative ? -parsed : parsed;
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
- // Update display value when value changes
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; // Allow null/empty values
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
- // Use custom validator if provided, otherwise use default
198
- const numberValidator = validate || defaultValidator;
199
-
200
- function handleInput(inputValue: string) {
201
- displayValue = inputValue;
202
-
203
- // Only skip parsing for truly incomplete inputs that have no numeric content
204
- const finalPrefix = currency || prefix;
205
-
206
- // Check if input is incomplete (has negative indicators but no actual numbers)
207
- const incompletePatterns = [
208
- "-", // Just minus
209
- "(", // Just opening paren
210
- "()", // Empty parens
211
- ];
212
-
213
- // Add prefix-related incomplete patterns
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
- const parsed = parseNumber(inputValue);
246
- value = parsed;
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
- // Show the full formatted value including prefix/suffix for consistency
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
- // Format the number when focus is lost
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
- // Allow control keys (backspace, delete, arrow keys, tab, etc.)
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 // Allow Ctrl/Cmd shortcuts
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
- // Add currency characters (but validate position)
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(finalPrefix)) {
298
- allowedChars.add(finalPrefix);
299
- } else {
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
- // Add suffix characters (but validate position and prevent duplicates)
306
- if (suffix && event.key === suffix) {
307
- // Only allow suffix at the end and if not already present
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
- // Only allow minus at the very beginning and if not already present
319
- if (cursorPosition === 0 && !currentValue.includes("-")) {
288
+ if (negativeAllowed && cursorPosition === 0 && !currentValue.includes("-"))
320
289
  allowedChars.add("-");
321
- } else {
322
- event.preventDefault();
323
- return;
324
- }
290
+ else return void event.preventDefault();
325
291
  }
326
-
327
292
  if (event.key === "(") {
328
- // Only allow opening parenthesis at the beginning and if not already present
329
- if (cursorPosition === 0 && !currentValue.includes("(")) {
293
+ if (negativeAllowed && cursorPosition === 0 && !currentValue.includes("("))
330
294
  allowedChars.add("(");
331
- } else {
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
- } else {
345
- event.preventDefault();
346
- return;
347
- }
304
+ else return void event.preventDefault();
348
305
  }
349
306
 
350
- // Prevent typing if character is not allowed
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
- // Use native <input type="number"> when the caller isn't using any
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={inputType}
383
- inputmode="numeric"
384
- validate={(val) => {
385
- const parsed = parseNumber(val);
386
- return numberValidator(parsed);
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";
@@ -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
- - **Features**: number formatting and validation
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.7.1-dev.38.b73de30",
3
+ "version": "0.7.1-dev.40.3d22ac0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"