@valentinkolb/cloud 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,47 +1,299 @@
1
+ import { createEffect, createSignal, Show, type JSX } from "solid-js";
1
2
  import { InputWrapper, createInputA11y } from "./util";
2
3
 
3
4
  type NumberInputProps = {
4
- label?: string;
5
- description?: string;
5
+ name?: string;
6
+ label?: string | JSX.Element;
7
+ description?: string | JSX.Element;
6
8
  placeholder?: string;
7
- value?: () => number | undefined;
8
- onChange?: (value: number) => void;
9
- onInput?: (value: number) => void;
9
+ /**
10
+ * Reactive value getter. `null` represents "no value" / cleared
11
+ * input; `undefined` is treated the same way for tolerance.
12
+ * Previously this returned `number | undefined` but the component
13
+ * silently coerced to 0, which collapsed the "unset" and "zero"
14
+ * states. Callers that want optional numerics (constraint inputs,
15
+ * filters, default-value editors) need a way to distinguish.
16
+ */
17
+ value?: () => number | null | undefined;
18
+ /**
19
+ * Called on every blur / +/- click with the committed value.
20
+ * Receives `null` when the input was cleared.
21
+ */
22
+ onChange?: (value: number | null) => void;
23
+ /**
24
+ * Called on every keystroke with the current parsed value.
25
+ * Receives `null` when the input is empty. Use this when you
26
+ * need live updates (e.g. URL sync); use `onChange` when you
27
+ * only care about committed values.
28
+ */
29
+ onInput?: (value: number | null) => void;
10
30
  error?: () => string | undefined;
11
31
  max?: number;
12
32
  min?: number;
13
33
  step?: number;
14
34
  required?: boolean;
15
35
  disabled?: boolean;
36
+ /**
37
+ * Number of decimal places the user can type. Default `0`
38
+ * (integer-only) — opt in to decimals explicitly when the caller
39
+ * needs them. The cap is enforced live during typing: characters
40
+ * past the dot are truncated as the user types past the limit, not
41
+ * just on blur.
42
+ *
43
+ * `decimalPlaces: 0` integers only; `.` and `,` are ignored
44
+ * `decimalPlaces: 2` up to 2 decimals; "1.234" truncates to "1.23"
45
+ * `decimalPlaces: 10` effectively unlimited for typical UIs
46
+ *
47
+ * Mirrors Mantine's `decimalScale` semantics.
48
+ */
49
+ decimalPlaces?: number;
50
+ /**
51
+ * Allow a leading minus sign. Default `true`. Set false for inputs
52
+ * that semantically only accept non-negative values (counts, ages,
53
+ * page numbers) — the minus key is silently ignored.
54
+ */
55
+ allowNegative?: boolean;
56
+ /**
57
+ * Show a clear (✕) button when the input has a value. Same UX
58
+ * shape as TextInput's `clearable` — sets value to null on click.
59
+ */
60
+ clearable?: boolean;
61
+ /** Custom clear handler. Default sets value to null via onChange/onInput. */
62
+ onClear?: () => void;
63
+ clearLabel?: string;
64
+ /**
65
+ * Show the +/- stepper buttons. Default true. Set false when the
66
+ * input lives in a dense layout (filters, table cells) where the
67
+ * steppers would be visual noise — the user can still type freely.
68
+ */
69
+ showSteppers?: boolean;
70
+ /**
71
+ * Disable JUST the steppers (typed input still editable). Useful
72
+ * when the +/- semantics don't make sense for the surrounding
73
+ * context (e.g. an aggregated total that's read-only via buttons
74
+ * but editable as a typed-override). When `disabled` is true that
75
+ * trumps this — both inputs and steppers go disabled.
76
+ */
77
+ disableSteppers?: boolean;
78
+ /** Icon shown left, mirrors TextInput's icon API. */
79
+ icon?: string;
80
+ /** Icon shown on focus, blue-tinted; defaults to icon. */
81
+ activeIcon?: string;
82
+ /**
83
+ * Inline label rendered to the LEFT of the typed number, inside
84
+ * the input box. Always visible (focus / blur / empty / typed) —
85
+ * Mantine-style static label. Use short content: "$", "€", "≈",
86
+ * etc. Long content crowds the input.
87
+ */
88
+ prefix?: string | JSX.Element;
89
+ /**
90
+ * Inline label rendered to the RIGHT of the typed number, inside
91
+ * the input box. Always visible. Same shape as `prefix`. Use for
92
+ * unit suffixes ("%", "kg", "/min") or trailing currency labels.
93
+ */
94
+ suffix?: string | JSX.Element;
16
95
  };
17
96
 
18
97
  /**
19
- * Number input component with increment/decrement buttons
20
- * @param label - Optional label text
21
- * @param description - Optional description text
22
- * @param placeholder - Placeholder text
23
- * @param value - Reactive number value getter
24
- * @param onChange - Called on change event
25
- * @param onInput - Called on input event
26
- * @param error - Reactive error message getter
27
- * @param max - Maximum allowed value (default: Infinity)
28
- * @param min - Minimum allowed value (default: -Infinity)
29
- * @param step - Step increment/decrement amount (default: 1)
30
- * @param required - Show required asterisk after label
31
- * @param disabled - Disable the input
98
+ * Number input with +/- steppers, optional empty state, and inline
99
+ * prefix/suffix slots. Single source of truth for the "user types a
100
+ * number" interaction across the cloud — extended in this rewrite
101
+ * (v2) so it can be used as the backing primitive for the new
102
+ * CurrencyInput, for optional constraint inputs that need a real
103
+ * "cleared" state, and for filter rows where the steppers are noise.
104
+ *
105
+ * Major changes from v1:
106
+ * - `value` is `number | null | undefined`; null = cleared / no
107
+ * value. v1 silently coerced empty to 0.
108
+ * - Empty / non-numeric input emits `null` instead of falling to
109
+ * `min`. v1 flipped the value to -Infinity on clear under default
110
+ * settings, which is almost never what callers wanted.
111
+ * - `clearable` ✕ button matches TextInput's API.
112
+ * - `integer` prop switches parseInt vs parseFloat. Default float —
113
+ * v1 used parseInt unconditionally and silently truncated
114
+ * decimals on currency / percent / decimal fields.
115
+ * - `showSteppers` + `disableSteppers` separate concerns so dense
116
+ * UIs can hide them and read-only contexts can show them muted
117
+ * without disabling the typed input.
118
+ * - `icon` + `prefix` + `suffix` slots for inline currency symbols,
119
+ * units, etc. Mirrors TextInput's icon API.
120
+ * - `step` snaps the typed value on blur so the committed value is
121
+ * always a multiple of the step (when step is set).
32
122
  */
33
123
  const NumberInput = (props: NumberInputProps) => {
34
- const value = () => props.value?.() ?? 0;
35
- const max = () => props.max ?? Infinity;
124
+ const disabled = () => props.disabled ?? false;
125
+ const decimalPlaces = () => Math.max(0, props.decimalPlaces ?? 0);
126
+ const allowNegative = () => props.allowNegative ?? true;
127
+ const showSteppers = () => props.showSteppers ?? true;
128
+ const steppersDisabled = () => disabled() || (props.disableSteppers ?? false);
36
129
  const min = () => props.min ?? -Infinity;
130
+ const max = () => props.max ?? Infinity;
37
131
  const step = () => props.step ?? 1;
38
- const disabled = () => props.disabled ?? false;
39
- const a11y = createInputA11y({ description: props.description, error: props.error });
132
+ const icon = () => props.icon;
133
+ const activeIcon = () => props.activeIcon ?? props.icon;
40
134
 
41
- const parse = (val: string, applyConstraints: boolean = true) => {
42
- const parsed = parseInt(val);
43
- if (isNaN(parsed)) return min();
44
- return applyConstraints ? Math.max(min(), Math.min(max(), parsed)) : parsed;
135
+ const a11y = createInputA11y({
136
+ description: props.description,
137
+ error: props.error,
138
+ inputId: props.name,
139
+ });
140
+
141
+ /** Current value, normalising both null and undefined to null. */
142
+ const currentValue = (): number | null => {
143
+ const v = props.value?.();
144
+ return v === null || v === undefined ? null : v;
145
+ };
146
+
147
+ const hasValue = () => currentValue() !== null;
148
+
149
+ // Internal raw-text buffer. Holds exactly what the user typed, NOT
150
+ // a stringified version of the parsed numeric value. Without this,
151
+ // typing "12." emits 12 on input, the parent re-renders with value
152
+ // 12, the controlled `value` binding writes "12" back into the
153
+ // <input>, and the user's "." disappears mid-keystroke. With the
154
+ // buffer, the input stays in sync with what the user typed and
155
+ // re-syncs to the parsed value only on blur or external resets
156
+ // (clear button, step click, parent state change while unfocused).
157
+ const [rawText, setRawText] = createSignal<string>(
158
+ currentValue() === null ? "" : String(currentValue()),
159
+ );
160
+ const [focused, setFocused] = createSignal(false);
161
+
162
+ // When the input is NOT being typed into, mirror the parent's value
163
+ // into the buffer. Comparing parsed values (not strings) avoids
164
+ // overwriting "12.0" with "12" — both parse to 12, leave the user's
165
+ // formatting alone. Runs on every signal change so external resets
166
+ // (clear button → null, step click → +1, parent setValue) propagate.
167
+ createEffect(() => {
168
+ if (focused()) return;
169
+ const v = currentValue();
170
+ const parsedRaw = parse(rawText(), false);
171
+ if (v !== parsedRaw) {
172
+ setRawText(v === null ? "" : String(v));
173
+ }
174
+ });
175
+
176
+ const canClear = () =>
177
+ (props.clearable ?? false) && !disabled() && hasValue();
178
+
179
+ /**
180
+ * Filter a raw keystroke / paste string to the allowed character set
181
+ * for the current configuration. Drops letters, multiple dots,
182
+ * trailing non-digit junk; replaces comma with dot (German keyboards);
183
+ * truncates decimal-place overflow. The result is what we put back
184
+ * into the input box AND what we hand to `parse` — keeps the visible
185
+ * input and the emitted value consistent.
186
+ *
187
+ * Mantine-style — invalid characters are silently swallowed rather
188
+ * than clearing the whole input (the v1 behaviour, which produced
189
+ * "type a letter → field empties" surprises).
190
+ */
191
+ const filterInput = (raw: string): string => {
192
+ // Normalise European decimal comma → dot. Users on German /
193
+ // French keyboards type "12,34" and expect it to be the same as
194
+ // "12.34". Doing this before character filtering means the dot
195
+ // is treated as a real decimal separator below.
196
+ let s = raw.replace(/,/g, ".");
197
+ let out = "";
198
+ let dotSeen = false;
199
+ let dotIdx = -1;
200
+ for (let i = 0; i < s.length; i++) {
201
+ const c = s[i]!;
202
+ if (c >= "0" && c <= "9") {
203
+ out += c;
204
+ continue;
205
+ }
206
+ // Leading minus, only when allowed and only at position 0.
207
+ if (c === "-" && out.length === 0 && allowNegative()) {
208
+ out += c;
209
+ continue;
210
+ }
211
+ // Single dot, only when decimals are allowed.
212
+ if (c === "." && !dotSeen && decimalPlaces() > 0) {
213
+ dotIdx = out.length;
214
+ out += c;
215
+ dotSeen = true;
216
+ continue;
217
+ }
218
+ // Everything else — letters, second dots, spaces, junk —
219
+ // is silently dropped.
220
+ }
221
+ // Enforce the decimal-place cap. Cuts any digits past the cap;
222
+ // user-pasted "1121.121212" with cap=2 becomes "1121.12".
223
+ if (dotSeen) {
224
+ const decimals = out.length - dotIdx - 1;
225
+ const cap = decimalPlaces();
226
+ if (decimals > cap) {
227
+ // cap=0 shouldn't actually reach here (dot rejected above)
228
+ // but guard anyway.
229
+ out = cap === 0 ? out.slice(0, dotIdx) : out.slice(0, dotIdx + 1 + cap);
230
+ }
231
+ }
232
+ return out;
233
+ };
234
+
235
+ /**
236
+ * Parse a raw input string. Empty / non-numeric → null. Decimal-
237
+ * mode uses parseFloat / Number; integer-mode (decimalPlaces===0)
238
+ * uses parseInt for explicit truncation semantics. The
239
+ * `applyConstraints` flag clamps to min/max after parsing — we
240
+ * apply on commit (blur / +-) but not on every keystroke (so the
241
+ * user can type "1" while building "100" without it bouncing to
242
+ * min between digits).
243
+ */
244
+ const parse = (raw: string, applyConstraints: boolean): number | null => {
245
+ const t = raw.trim();
246
+ if (t === "" || t === "-" || t === ".") return null;
247
+ const parsed = decimalPlaces() === 0 ? parseInt(t, 10) : Number(t);
248
+ if (!Number.isFinite(parsed)) return null;
249
+ if (!applyConstraints) return parsed;
250
+ return Math.max(min(), Math.min(max(), parsed));
251
+ };
252
+
253
+ /**
254
+ * Snap a number to the nearest multiple of step, anchored at min
255
+ * (when min is finite, else at 0). Only invoked when step is set
256
+ * AND finite. Keeps the committed value on the step grid so
257
+ * downstream consumers can rely on it without re-snapping.
258
+ */
259
+ const snapToStep = (n: number): number => {
260
+ const s = step();
261
+ if (!Number.isFinite(s) || s <= 0) return n;
262
+ const anchor = Number.isFinite(min()) ? min() : 0;
263
+ const snapped = Math.round((n - anchor) / s) * s + anchor;
264
+ // parseFloat round-trip kills accumulated FP error from the
265
+ // multiply / divide so "0.1 + 0.2"-style sums don't surface.
266
+ return decimalPlaces() === 0 ? Math.round(snapped) : parseFloat(snapped.toFixed(10));
267
+ };
268
+
269
+ const commit = (n: number | null) => {
270
+ if (n === null) {
271
+ props.onChange?.(null);
272
+ props.onInput?.(null);
273
+ return;
274
+ }
275
+ const clamped = Math.max(min(), Math.min(max(), n));
276
+ const snapped = snapToStep(clamped);
277
+ props.onChange?.(snapped);
278
+ props.onInput?.(snapped);
279
+ };
280
+
281
+ const handleClear = () => {
282
+ if (props.onClear) {
283
+ props.onClear();
284
+ return;
285
+ }
286
+ commit(null);
287
+ };
288
+
289
+ const stepBy = (delta: number) => {
290
+ if (steppersDisabled()) return;
291
+ const current = currentValue();
292
+ // Empty + step click → start at the closer of min / 0. Picks the
293
+ // boundary the user is implicitly anchored at: bounded inputs get
294
+ // their min as the seed, unbounded inputs get 0.
295
+ const seed = current ?? (Number.isFinite(min()) ? min() : 0);
296
+ commit(seed + delta * step());
45
297
  };
46
298
 
47
299
  return (
@@ -54,33 +306,105 @@ const NumberInput = (props: NumberInputProps) => {
54
306
  descriptionId={a11y.descriptionId}
55
307
  errorId={a11y.errorId}
56
308
  >
57
- <div class={`flex flex-row flex-nowrap gap-3 text-nowrap ${disabled() ? "opacity-50" : ""}`}>
58
- <button
59
- type="button"
60
- class={`input ti ti-minus px-3 cursor-pointer hover:text-primary ${value() <= min() && "opacity-40"}`}
61
- aria-label="Decrease value"
62
- onClick={() => {
63
- const v = Math.max(min(), value() - step());
64
- props.onChange?.(v);
65
- props.onInput?.(v);
66
- }}
67
- disabled={disabled() || value() <= min()}
68
- />
69
- <div class="group relative flex-1">
309
+ <div
310
+ class={`flex flex-row flex-nowrap gap-3 text-nowrap ${
311
+ disabled() ? "opacity-50" : ""
312
+ }`}
313
+ >
314
+ <Show when={showSteppers()}>
315
+ <button
316
+ type="button"
317
+ class={`input ti ti-minus px-3 cursor-pointer hover:text-primary ${
318
+ hasValue() && currentValue()! <= min() ? "opacity-40" : ""
319
+ }`}
320
+ aria-label="Decrease value"
321
+ onClick={() => stepBy(-1)}
322
+ disabled={
323
+ steppersDisabled() ||
324
+ (hasValue() && currentValue()! <= min())
325
+ }
326
+ />
327
+ </Show>
328
+
329
+ {/* Single .input box with flex children. Previous design used
330
+ absolute-positioned prefix / suffix / clear overlays inside
331
+ a relative wrapper, which broke for long suffix strings
332
+ (e.g. a configurable currency label like "€ (Test)" —
333
+ ~5rem wide overflows the fixed `pr-9` padding and crashes
334
+ into the typed value). Flex layout puts every part in its
335
+ own column; the native input takes flex-1 and is the only
336
+ thing that shrinks. No overlap possible regardless of
337
+ suffix length. */}
338
+ <div
339
+ class={`input flex flex-1 items-center gap-2 ${
340
+ disabled() ? "cursor-not-allowed" : ""
341
+ }`}
342
+ >
343
+ {/* Left icon, mirrors TextInput's pattern. Active icon swaps
344
+ on focus-within. */}
345
+ <Show when={icon()}>
346
+ <span class="shrink-0 flex items-center pointer-events-none text-zinc-400 dark:text-zinc-500">
347
+ <i class={`${icon()} group-focus-within:hidden`} />
348
+ <i
349
+ class={`${activeIcon()} hidden text-blue-500 group-focus-within:block`}
350
+ />
351
+ </span>
352
+ </Show>
353
+
354
+ {/* Prefix label — short inline text like "€" / "$". Sits
355
+ flush before the typed value. */}
356
+ <Show when={props.prefix}>
357
+ <span class="shrink-0 flex items-center pointer-events-none text-sm text-zinc-500 dark:text-zinc-400">
358
+ {props.prefix}
359
+ </span>
360
+ </Show>
361
+
70
362
  <input
71
363
  id={a11y.inputId}
72
- type="number"
73
- class={`input w-full text-center font-mono font-semibold ${disabled() ? "cursor-not-allowed" : ""}`}
364
+ name={props.name}
365
+ type="text"
366
+ role="spinbutton"
367
+ inputMode={decimalPlaces() === 0 ? "numeric" : "decimal"}
368
+ // Transparent / borderless / zero-padding: the wrapping
369
+ // <div class="input"> owns the visual box, the native
370
+ // input is just a typing surface inside it. Without
371
+ // zeroing the browser default padding, the input would
372
+ // double-pad and the row would grow taller than other
373
+ // inputs in the same form.
374
+ class={`flex-1 min-w-0 bg-transparent border-0 outline-none p-0 text-right font-mono font-semibold ${
375
+ disabled() ? "cursor-not-allowed" : ""
376
+ }`}
74
377
  placeholder={props.placeholder}
75
- value={value()}
76
- onChange={(e) => {
378
+ value={rawText()}
379
+ onFocus={() => setFocused(true)}
380
+ onBlur={(e) => {
381
+ // Always clear the focused flag — onChange only fires
382
+ // when the value changed, so leaving the input untouched
383
+ // wouldn't reset focused via the onChange handler alone.
384
+ setFocused(false);
385
+ // Commit on blur: clamp + snap + emit, then sync the
386
+ // buffer to the canonical string so the user sees the
387
+ // normalised number (e.g. "12.30" → "12.3"). Safe to
388
+ // run unconditionally — when nothing changed,
389
+ // setRawText to the same value is a no-op.
77
390
  const v = parse(e.currentTarget.value, true);
78
- props.onChange?.(v);
79
- e.currentTarget.value = `${v}`;
391
+ const final = v === null ? null : snapToStep(v);
392
+ props.onChange?.(final);
393
+ setRawText(final === null ? "" : String(final));
80
394
  }}
81
395
  onInput={(e) => {
82
- const v = parse(e.currentTarget.value, false);
83
- props.onInput?.(v);
396
+ // Filter the raw input to the allowed character set
397
+ // (digits + optional leading minus + optional single
398
+ // dot, capped at decimalPlaces). Junk characters
399
+ // (letters, second dots, etc.) are silently dropped
400
+ // instead of clearing the field. Comma → dot for German
401
+ // keyboards.
402
+ const next = filterInput(e.currentTarget.value);
403
+ if (e.currentTarget.value !== next) {
404
+ e.currentTarget.value = next;
405
+ }
406
+ setRawText(next);
407
+ props.onInput?.(parse(next, false));
84
408
  }}
85
409
  disabled={disabled()}
86
410
  aria-label={!props.label ? props.placeholder || "Enter number" : undefined}
@@ -88,23 +412,55 @@ const NumberInput = (props: NumberInputProps) => {
88
412
  aria-invalid={!!props.error?.()}
89
413
  aria-required={props.required}
90
414
  aria-disabled={disabled()}
91
- aria-valuemin={min()}
92
- aria-valuemax={max()}
93
- aria-valuenow={value()}
415
+ aria-valuemin={Number.isFinite(min()) ? min() : undefined}
416
+ aria-valuemax={Number.isFinite(max()) ? max() : undefined}
417
+ aria-valuenow={currentValue() ?? undefined}
94
418
  />
419
+
420
+ {/* Suffix label — short inline text like "%" / "€" / "(Test)".
421
+ Shown only when the input has a value; sits flush after
422
+ the typed number. Hidden in the empty state because the
423
+ caller's placeholder typically already conveys the unit
424
+ (e.g. placeholder="12.34 €" with suffix="€"); otherwise
425
+ both render side-by-side and the user sees "12.34 € €"
426
+ ghost-and-real. The same reactive `hasValue()` that
427
+ gates the clear button also gates the suffix. */}
428
+ <Show when={props.suffix && hasValue()}>
429
+ <span class="shrink-0 flex items-center pointer-events-none text-sm text-zinc-500 dark:text-zinc-400">
430
+ {props.suffix}
431
+ </span>
432
+ </Show>
433
+
434
+ {/* Clear button — same shape as TextInput's ✕ button.
435
+ Coexists peacefully with suffix because both are flex
436
+ siblings; no `right-3` race. */}
437
+ <Show when={canClear()}>
438
+ <button
439
+ type="button"
440
+ class="shrink-0 flex items-center text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
441
+ onClick={handleClear}
442
+ aria-label={props.clearLabel ?? "Clear input"}
443
+ tabIndex={-1}
444
+ >
445
+ <i class="ti ti-x" />
446
+ </button>
447
+ </Show>
95
448
  </div>
96
449
 
97
- <button
98
- type="button"
99
- class={`input ti ti-plus px-3 cursor-pointer hover:text-primary ${value() >= max() && "opacity-40"}`}
100
- aria-label="Increase value"
101
- onClick={() => {
102
- const v = Math.min(max(), value() + step());
103
- props.onChange?.(v);
104
- props.onInput?.(v);
105
- }}
106
- disabled={disabled() || value() >= max()}
107
- />
450
+ <Show when={showSteppers()}>
451
+ <button
452
+ type="button"
453
+ class={`input ti ti-plus px-3 cursor-pointer hover:text-primary ${
454
+ hasValue() && currentValue()! >= max() ? "opacity-40" : ""
455
+ }`}
456
+ aria-label="Increase value"
457
+ onClick={() => stepBy(1)}
458
+ disabled={
459
+ steppersDisabled() ||
460
+ (hasValue() && currentValue()! >= max())
461
+ }
462
+ />
463
+ </Show>
108
464
  </div>
109
465
  </InputWrapper>
110
466
  );
@@ -64,7 +64,7 @@ function SegmentedControl<T extends string>({
64
64
  <div
65
65
  role="radiogroup"
66
66
  aria-label={ariaLabel}
67
- class="inline-flex w-full items-stretch rounded-xl border border-zinc-300/50 bg-zinc-100/55 p-0.5 dark:border-zinc-700/50 dark:bg-zinc-900/50"
67
+ class="inline-flex w-full items-stretch rounded-xl border border-zinc-300/50 bg-zinc-200/60 p-0.5 dark:border-zinc-700/50 dark:bg-zinc-900/50 [box-shadow:var(--theme-recess)]"
68
68
  classList={{ "opacity-50 pointer-events-none": disabled }}
69
69
  >
70
70
  <For each={options}>
@@ -76,7 +76,7 @@ function SegmentedControl<T extends string>({
76
76
  tabIndex={value() === option.value ? 0 : -1}
77
77
  class="relative z-0 flex-1 min-w-0 rounded-lg px-2 py-1 text-xs leading-4 flex items-center justify-center gap-1 transition-[background-color,color,box-shadow] duration-150 outline-none"
78
78
  classList={{
79
- "z-10 rounded-[0.95rem] bg-zinc-200/80 dark:bg-zinc-800/95 text-zinc-900 dark:text-zinc-100":
79
+ "z-10 rounded-[0.95rem] bg-white dark:bg-zinc-800/95 text-zinc-900 dark:text-zinc-100 [box-shadow:var(--theme-bevel-top),0_1px_3px_-1px_rgb(0_0_0/0.2)]":
80
80
  value() === option.value,
81
81
  "text-zinc-700 dark:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300 hover:bg-zinc-50/65 dark:hover:bg-zinc-800/35":
82
82
  value() !== option.value,