@valentinkolb/cloud 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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
|
-
|
|
5
|
-
|
|
5
|
+
name?: string;
|
|
6
|
+
label?: string | JSX.Element;
|
|
7
|
+
description?: string | JSX.Element;
|
|
6
8
|
placeholder?: string;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
35
|
-
const
|
|
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
|
|
39
|
-
const
|
|
132
|
+
const icon = () => props.icon;
|
|
133
|
+
const activeIcon = () => props.activeIcon ?? props.icon;
|
|
40
134
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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={
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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={
|
|
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
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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-
|
|
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-
|
|
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,
|