@spaethtech/svelte-ui 0.7.1-dev.37.ab40d6b → 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., '$', '€', '£', '¥', 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>;
@@ -268,22 +268,42 @@
268
268
  <div>
269
269
  <h4 class="mb-1 font-semibold">Dynamic dates</h4>
270
270
  <p class="mb-1 text-xs [color:color-mix(in_srgb,var(--ui-color-text)_70%,transparent)]">
271
- <code class="font-mono">now()</code> is the current time; add a signed offset
272
- <code class="font-mono">now()±&lt;n&gt;&lt;unit&gt;</code> — units <code>s m h d w</code>
273
- (e.g. <code>now()-7d</code>, <code>now()+1h</code>). Resolved when the query runs.
271
+ <code class="font-mono">now()</code> is the current time, resolved when the query runs. Add a
272
+ signed offset <code class="font-mono">now()±&lt;n&gt;&lt;unit&gt;</code> — units
273
+ <code>s</code> <code>m</code> <code>h</code> <code>d</code> <code>w</code>. Great with a date
274
+ column and <code>&gt;=</code>/<code>&lt;</code> or a range.
274
275
  </p>
276
+ <table class="w-full text-left">
277
+ <tbody
278
+ class="[&_td]:py-0.5 [&_td:first-child]:whitespace-nowrap [&_td:first-child]:pr-4 [&_td:first-child]:font-mono [&_td:last-child]:text-xs [&_td:last-child]:[color:color-mix(in_srgb,var(--ui-color-text)_65%,transparent)]"
279
+ >
280
+ <tr><td>$created &gt;= now()-7d</td><td>in the last 7 days</td></tr>
281
+ <tr><td>$expires &lt; now()</td><td>already expired</td></tr>
282
+ <tr><td>$due |= [now()..now()+30d]</td><td>due within 30 days</td></tr>
283
+ </tbody>
284
+ </table>
275
285
  </div>
276
286
  <div>
277
287
  <h4 class="mb-1 font-semibold">Examples</h4>
278
- <pre
279
- class="overflow-x-auto rounded p-2 font-mono text-xs [background-color:color-mix(in_srgb,var(--ui-color-text)_6%,transparent)]"><code
280
- >$status == active
281
- $email ~= /@gmail\.com$/i
282
- $price |= [20..50) && $role |= [admin, ops]
283
- $created >= now()-7d && $status == open
284
- $ip|inet |= [10.0.0.0|inet..10.255.255.255|inet]
285
- "lemon balm" || $featured == true</code
286
- ></pre>
288
+ <table class="w-full text-left">
289
+ <tbody
290
+ class="[&_td]:py-0.5 [&_td:first-child]:whitespace-nowrap [&_td:first-child]:pr-4 [&_td:first-child]:font-mono [&_td:last-child]:text-xs [&_td:last-child]:[color:color-mix(in_srgb,var(--ui-color-text)_65%,transparent)]"
291
+ >
292
+ <tr><td>$status == active</td><td>exact match</td></tr>
293
+ <tr><td>$name ^= Ada</td><td>starts with “Ada”</td></tr>
294
+ <tr><td>$email ~= @gmail.com</td><td>contains substring</td></tr>
295
+ <tr><td>$email ~= /@gmail\.com$/i</td><td>regex, case-insensitive</td></tr>
296
+ <tr><td>$age &gt;= 30</td><td>numeric compare</td></tr>
297
+ <tr><td>$age |= [21..40)</td><td>range — 21 ≤ age &lt; 40</td></tr>
298
+ <tr><td>$role |= [admin, ops]</td><td>any of a set</td></tr>
299
+ <tr><td>$tags &amp;= [urgent, vip]</td><td>all of (multi-value column)</td></tr>
300
+ <tr><td>$created &gt;= now()-7d</td><td>dynamic date (last 7 days)</td></tr>
301
+ <tr><td>$ip|inet &gt;= 10.0.0.0|inet</td><td>typed IP compare</td></tr>
302
+ <tr><td>!($role == admin)</td><td>negate a group</td></tr>
303
+ <tr><td>$status == open &amp;&amp; $priority &gt;= 3</td><td>combine with &amp;&amp; / ||</td></tr>
304
+ <tr><td>"lemon balm"</td><td>free-text search (no $)</td></tr>
305
+ </tbody>
306
+ </table>
287
307
  </div>
288
308
  <p class="text-xs [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]">
289
309
  Tip: type <kbd class="rounded border px-1 {bc}">$</kbd> for columns; enum columns suggest
@@ -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
- - **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`.
199
209
 
200
210
  ## Navigation
201
211
 
@@ -232,7 +242,8 @@ Config-driven, responsive 12-column data table over a `DataGrid`.
232
242
  ### Query
233
243
 
234
244
  Filter bar over a `DataSet` — monospace input with `$column` + enum-value autocomplete and a help
235
- dialog.
245
+ dialog (operators, casts, dynamic `now()` dates, and a worked examples table). The DSL supports
246
+ `now()` / `now()±<n><unit>` dynamic dates — see the help dialog + the data spec.
236
247
 
237
248
  - **Location**: `src/lib/components/Query.svelte`
238
249
  - **Props**: `dataset` (`DataSet`), `placeholder`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.7.1-dev.37.ab40d6b",
3
+ "version": "0.7.1-dev.39.d6b0fa5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"