@spaethtech/svelte-ui 0.1.10

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 (116) hide show
  1. package/README.md +81 -0
  2. package/dist/components/Badge/Badge.svelte +80 -0
  3. package/dist/components/Badge/Badge.svelte.d.ts +14 -0
  4. package/dist/components/Badge/index.d.ts +1 -0
  5. package/dist/components/Badge/index.js +1 -0
  6. package/dist/components/Button/Button.svelte +172 -0
  7. package/dist/components/Button/Button.svelte.d.ts +32 -0
  8. package/dist/components/Button/index.d.ts +1 -0
  9. package/dist/components/Button/index.js +1 -0
  10. package/dist/components/Dialog/Dialog.svelte +101 -0
  11. package/dist/components/Dialog/Dialog.svelte.d.ts +18 -0
  12. package/dist/components/Dialog/index.d.ts +1 -0
  13. package/dist/components/Dialog/index.js +1 -0
  14. package/dist/components/ElementManager/ElementManager.svelte +397 -0
  15. package/dist/components/ElementManager/ElementManager.svelte.d.ts +43 -0
  16. package/dist/components/ElementManager/index.d.ts +1 -0
  17. package/dist/components/ElementManager/index.js +1 -0
  18. package/dist/components/EmailInput/EmailInput.svelte +47 -0
  19. package/dist/components/EmailInput/EmailInput.svelte.d.ts +14 -0
  20. package/dist/components/EmailInput/index.d.ts +1 -0
  21. package/dist/components/EmailInput/index.js +1 -0
  22. package/dist/components/Icon/Icon.svelte +74 -0
  23. package/dist/components/Icon/Icon.svelte.d.ts +13 -0
  24. package/dist/components/Icon/index.d.ts +1 -0
  25. package/dist/components/Icon/index.js +1 -0
  26. package/dist/components/Input/Input.svelte +268 -0
  27. package/dist/components/Input/Input.svelte.d.ts +18 -0
  28. package/dist/components/Input/index.d.ts +1 -0
  29. package/dist/components/Input/index.js +1 -0
  30. package/dist/components/List/List.svelte +580 -0
  31. package/dist/components/List/List.svelte.d.ts +38 -0
  32. package/dist/components/List/index.d.ts +1 -0
  33. package/dist/components/List/index.js +1 -0
  34. package/dist/components/ListItem/ListItem.svelte +175 -0
  35. package/dist/components/ListItem/ListItem.svelte.d.ts +24 -0
  36. package/dist/components/ListItem/index.d.ts +2 -0
  37. package/dist/components/ListItem/index.js +2 -0
  38. package/dist/components/ListView/ListView.svelte +463 -0
  39. package/dist/components/ListView/ListView.svelte.d.ts +37 -0
  40. package/dist/components/ListView/index.d.ts +2 -0
  41. package/dist/components/ListView/index.js +2 -0
  42. package/dist/components/NodeGraph/BaseNode.d.ts +57 -0
  43. package/dist/components/NodeGraph/BaseNode.js +30 -0
  44. package/dist/components/NodeGraph/Edge.svelte +60 -0
  45. package/dist/components/NodeGraph/Edge.svelte.d.ts +16 -0
  46. package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +82 -0
  47. package/dist/components/NodeGraph/ExpressionEvaluator.js +152 -0
  48. package/dist/components/NodeGraph/Node.svelte +100 -0
  49. package/dist/components/NodeGraph/Node.svelte.d.ts +10 -0
  50. package/dist/components/NodeGraph/NodeGraph.svelte +52 -0
  51. package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +14 -0
  52. package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +80 -0
  53. package/dist/components/NodeGraph/NodeInstance.svelte.js +241 -0
  54. package/dist/components/NodeGraph/NodePort.svelte +75 -0
  55. package/dist/components/NodeGraph/NodePort.svelte.d.ts +11 -0
  56. package/dist/components/NodeGraph/decorators.d.ts +81 -0
  57. package/dist/components/NodeGraph/decorators.js +148 -0
  58. package/dist/components/NodeGraph/index.d.ts +9 -0
  59. package/dist/components/NodeGraph/index.js +9 -0
  60. package/dist/components/NodeGraph/types.d.ts +94 -0
  61. package/dist/components/NodeGraph/types.js +33 -0
  62. package/dist/components/NotesEditor/NotesEditor.svelte +203 -0
  63. package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +9 -0
  64. package/dist/components/NotesEditor/index.d.ts +1 -0
  65. package/dist/components/NotesEditor/index.js +1 -0
  66. package/dist/components/NumberInput/NumberInput.svelte +353 -0
  67. package/dist/components/NumberInput/NumberInput.svelte.d.ts +26 -0
  68. package/dist/components/NumberInput/index.d.ts +1 -0
  69. package/dist/components/NumberInput/index.js +1 -0
  70. package/dist/components/PasswordInput/PasswordInput.svelte +41 -0
  71. package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +13 -0
  72. package/dist/components/PasswordInput/index.d.ts +1 -0
  73. package/dist/components/PasswordInput/index.js +1 -0
  74. package/dist/components/Popup/index.d.ts +1 -0
  75. package/dist/components/Popup/index.js +1 -0
  76. package/dist/components/Rating/Rating.svelte +77 -0
  77. package/dist/components/Rating/Rating.svelte.d.ts +7 -0
  78. package/dist/components/Rating/index.d.ts +1 -0
  79. package/dist/components/Rating/index.js +1 -0
  80. package/dist/components/ScrollView/ScrollView.svelte +285 -0
  81. package/dist/components/ScrollView/ScrollView.svelte.d.ts +19 -0
  82. package/dist/components/ScrollView/index.d.ts +1 -0
  83. package/dist/components/ScrollView/index.js +1 -0
  84. package/dist/components/SearchInput/SearchInput.svelte +50 -0
  85. package/dist/components/SearchInput/SearchInput.svelte.d.ts +15 -0
  86. package/dist/components/SearchInput/index.d.ts +1 -0
  87. package/dist/components/SearchInput/index.js +1 -0
  88. package/dist/components/Select/Select.svelte +278 -0
  89. package/dist/components/Select/Select.svelte.d.ts +35 -0
  90. package/dist/components/Select/index.d.ts +1 -0
  91. package/dist/components/Select/index.js +1 -0
  92. package/dist/components/TextArea/TextArea.svelte +734 -0
  93. package/dist/components/TextArea/TextArea.svelte.d.ts +19 -0
  94. package/dist/components/TextArea/index.d.ts +1 -0
  95. package/dist/components/TextArea/index.js +1 -0
  96. package/dist/components/ThemeSelector/ThemeSelector.svelte +64 -0
  97. package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +3 -0
  98. package/dist/components/ThemeSelector/index.d.ts +1 -0
  99. package/dist/components/ThemeSelector/index.js +1 -0
  100. package/dist/components/TooltipHandler/TooltipHandler.svelte +593 -0
  101. package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +10 -0
  102. package/dist/components/TooltipHandler/index.d.ts +1 -0
  103. package/dist/components/TooltipHandler/index.js +1 -0
  104. package/dist/index.d.ts +24 -0
  105. package/dist/index.js +26 -0
  106. package/dist/styles/sizes.d.ts +78 -0
  107. package/dist/styles/sizes.js +120 -0
  108. package/dist/styles/variants.d.ts +106 -0
  109. package/dist/styles/variants.js +194 -0
  110. package/dist/types/ManagerTypes.d.ts +42 -0
  111. package/dist/types/ManagerTypes.js +1 -0
  112. package/dist/types/sizes.d.ts +5 -0
  113. package/dist/types/sizes.js +1 -0
  114. package/dist/types/variants.d.ts +1 -0
  115. package/dist/types/variants.js +1 -0
  116. package/package.json +65 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Port mode determines the type of connection
3
+ */
4
+ export declare enum PortMode {
5
+ Data = "data",// Pass values between nodes
6
+ Flow = "flow"
7
+ }
8
+ /**
9
+ * Control type for input rendering
10
+ */
11
+ export declare enum ControlType {
12
+ Port = "port",// Port only (default for connected inputs)
13
+ Text = "text",// Text input
14
+ Number = "number",// Number input
15
+ Expression = "expression",// Expression editor with {{ }} support
16
+ Select = "select",// Dropdown
17
+ Checkbox = "checkbox",// Boolean toggle
18
+ Textarea = "textarea",// Multi-line text
19
+ Slider = "slider",// Numeric slider
20
+ Color = "color",// Color picker
21
+ None = "none"
22
+ }
23
+ /**
24
+ * Variable scope hierarchy
25
+ */
26
+ export declare enum VariableScope {
27
+ Global = "global",// Available across all projects
28
+ Project = "project",// Available within a project
29
+ Workflow = "workflow"
30
+ }
31
+ /**
32
+ * Options for @Node decorator
33
+ */
34
+ export interface NodeOptions {
35
+ title: string;
36
+ description?: string;
37
+ color?: string;
38
+ category?: string;
39
+ executeInEditor?: boolean;
40
+ }
41
+ /**
42
+ * Options for @Input decorator
43
+ */
44
+ export interface InputOptions {
45
+ label?: string;
46
+ dataType?: string;
47
+ mode?: PortMode;
48
+ control?: ControlType;
49
+ showPort?: boolean;
50
+ alwaysShowControl?: boolean;
51
+ defaultValue?: any;
52
+ options?: string[];
53
+ }
54
+ /**
55
+ * Options for @Output decorator
56
+ */
57
+ export interface OutputOptions {
58
+ label?: string;
59
+ dataType?: string;
60
+ mode?: PortMode;
61
+ }
62
+ /**
63
+ * Port metadata stored by decorators
64
+ */
65
+ export interface PortMetadata {
66
+ key: string;
67
+ label: string;
68
+ dataType?: string;
69
+ mode: PortMode;
70
+ control?: ControlType;
71
+ showPort?: boolean;
72
+ alwaysShowControl?: boolean;
73
+ defaultValue?: any;
74
+ options?: string[];
75
+ value?: any;
76
+ connected?: boolean;
77
+ sourceNode?: any;
78
+ sourcePort?: string;
79
+ }
80
+ /**
81
+ * Complete node metadata from all decorators
82
+ */
83
+ export interface NodeMetadata {
84
+ node: NodeOptions;
85
+ inputs: PortMetadata[];
86
+ outputs: PortMetadata[];
87
+ }
88
+ /**
89
+ * Position in 2D space
90
+ */
91
+ export interface Position {
92
+ x: number;
93
+ y: number;
94
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Port mode determines the type of connection
3
+ */
4
+ export var PortMode;
5
+ (function (PortMode) {
6
+ PortMode["Data"] = "data";
7
+ PortMode["Flow"] = "flow"; // Control execution order
8
+ })(PortMode || (PortMode = {}));
9
+ /**
10
+ * Control type for input rendering
11
+ */
12
+ export var ControlType;
13
+ (function (ControlType) {
14
+ ControlType["Port"] = "port";
15
+ ControlType["Text"] = "text";
16
+ ControlType["Number"] = "number";
17
+ ControlType["Expression"] = "expression";
18
+ ControlType["Select"] = "select";
19
+ ControlType["Checkbox"] = "checkbox";
20
+ ControlType["Textarea"] = "textarea";
21
+ ControlType["Slider"] = "slider";
22
+ ControlType["Color"] = "color";
23
+ ControlType["None"] = "none"; // No UI control (port only)
24
+ })(ControlType || (ControlType = {}));
25
+ /**
26
+ * Variable scope hierarchy
27
+ */
28
+ export var VariableScope;
29
+ (function (VariableScope) {
30
+ VariableScope["Global"] = "global";
31
+ VariableScope["Project"] = "project";
32
+ VariableScope["Workflow"] = "workflow"; // Available within a specific workflow/graph
33
+ })(VariableScope || (VariableScope = {}));
@@ -0,0 +1,203 @@
1
+ <script lang="ts">
2
+ let {
3
+ notes = $bindable(""),
4
+ placeholder = "Add your notes...",
5
+ onSave,
6
+ className = ""
7
+ }: {
8
+ notes: string | undefined;
9
+ placeholder?: string;
10
+ onSave: (notes: string) => Promise<void>;
11
+ className?: string;
12
+ } = $props();
13
+
14
+ // State management
15
+ let saveState = $state<'idle' | 'typing' | 'saving' | 'saved' | 'error'>('idle');
16
+ let saveTimeout: ReturnType<typeof setTimeout>;
17
+ let lastSavedNotes = $state(notes || '');
18
+
19
+ // Auto-save effect - runs when notes change
20
+ $effect(() => {
21
+ const currentNotes = notes || '';
22
+
23
+ if (currentNotes !== lastSavedNotes && saveState !== 'saving') {
24
+ saveState = 'typing';
25
+
26
+ // Clear existing timeout
27
+ clearTimeout(saveTimeout);
28
+
29
+ // Set new timeout for auto-save
30
+ saveTimeout = setTimeout(async () => {
31
+ await handleSave();
32
+ }, 1000); // Save after 1 second of no typing
33
+ }
34
+ });
35
+
36
+ // Textarea reference for initial sizing
37
+ let textareaElement: HTMLTextAreaElement;
38
+
39
+ // Initial sizing effect - runs after component mounts
40
+ $effect(() => {
41
+ if (textareaElement && notes) {
42
+ // Use setTimeout to ensure DOM is fully rendered
43
+ setTimeout(() => {
44
+ resizeTextarea(textareaElement);
45
+ }, 0);
46
+ }
47
+ });
48
+
49
+ // Ctrl+S listener for manual save
50
+ $effect(() => {
51
+ async function handleKeyDown(event: KeyboardEvent) {
52
+ // Check if Ctrl+S (or Cmd+S on Mac) is pressed and textarea is focused
53
+ if ((event.ctrlKey || event.metaKey) && event.key === 's' &&
54
+ textareaElement && document.activeElement === textareaElement) {
55
+ event.preventDefault();
56
+ event.stopPropagation();
57
+
58
+
59
+ // Clear any pending auto-save timeout and save immediately
60
+ clearTimeout(saveTimeout);
61
+ await handleSave();
62
+ }
63
+ }
64
+
65
+ // Add listener to document
66
+ document.addEventListener('keydown', handleKeyDown);
67
+
68
+ // Cleanup function
69
+ return () => {
70
+ document.removeEventListener('keydown', handleKeyDown);
71
+ };
72
+ });
73
+
74
+ async function handleSave() {
75
+ const currentNotes = notes || '';
76
+
77
+ if (currentNotes === lastSavedNotes) {
78
+ // Show brief "No changes" feedback for manual save attempts
79
+ saveState = 'saved';
80
+ setTimeout(() => {
81
+ if (saveState === 'saved') {
82
+ saveState = 'idle';
83
+ }
84
+ }, 1000);
85
+ return;
86
+ }
87
+
88
+ saveState = 'saving';
89
+
90
+ try {
91
+ await onSave(currentNotes);
92
+ lastSavedNotes = currentNotes;
93
+ saveState = 'saved';
94
+
95
+ // Clear "saved" status after 2 seconds
96
+ setTimeout(() => {
97
+ if (saveState === 'saved') {
98
+ saveState = 'idle';
99
+ }
100
+ }, 2000);
101
+ } catch (error) {
102
+ console.error('Failed to save notes:', error);
103
+ saveState = 'error';
104
+
105
+ // Clear error status after 3 seconds
106
+ setTimeout(() => {
107
+ if (saveState === 'error') {
108
+ saveState = 'idle';
109
+ }
110
+ }, 3000);
111
+ }
112
+ }
113
+
114
+ // Shared function to resize textarea
115
+ function resizeTextarea(target: HTMLTextAreaElement) {
116
+ // Get computed styles to calculate exact line height
117
+ const computedStyle = window.getComputedStyle(target);
118
+ const lineHeight = parseInt(computedStyle.lineHeight);
119
+ const paddingTop = parseInt(computedStyle.paddingTop);
120
+ const paddingBottom = parseInt(computedStyle.paddingBottom);
121
+ const borderTop = parseInt(computedStyle.borderTopWidth);
122
+ const borderBottom = parseInt(computedStyle.borderBottomWidth);
123
+
124
+ // Calculate heights based on actual lines
125
+ const minLines = 3;
126
+ const maxLines = 15;
127
+ const minHeight = (lineHeight * minLines) + paddingTop + paddingBottom + borderTop + borderBottom;
128
+ const maxHeight = (lineHeight * maxLines) + paddingTop + paddingBottom + borderTop + borderBottom;
129
+
130
+ // Reset height to measure content
131
+ target.style.height = 'auto';
132
+
133
+ // Set height based on content, capped at max
134
+ const contentHeight = target.scrollHeight;
135
+ const newHeight = Math.min(Math.max(contentHeight, minHeight), maxHeight);
136
+ target.style.height = newHeight + 'px';
137
+
138
+ // Only show scrollbar when content actually exceeds max height
139
+ if (contentHeight > maxHeight) {
140
+ target.style.overflowY = 'auto';
141
+ } else {
142
+ target.style.overflowY = 'hidden';
143
+ }
144
+ }
145
+
146
+ function handleInput(event: Event) {
147
+ const target = event.target as HTMLTextAreaElement;
148
+ resizeTextarea(target);
149
+ }
150
+
151
+
152
+ // Get status text and styling - simplified approach
153
+ let statusText = $state('');
154
+ let statusClass = $state('');
155
+
156
+ $effect(() => {
157
+ switch (saveState) {
158
+ case 'typing':
159
+ statusText = 'Typing...';
160
+ statusClass = 'text-gray-500';
161
+ break;
162
+ case 'saving':
163
+ statusText = 'Saving...';
164
+ statusClass = 'text-blue-500';
165
+ break;
166
+ case 'saved':
167
+ statusText = 'Saved';
168
+ statusClass = 'text-green-600';
169
+ break;
170
+ case 'error':
171
+ statusText = 'Save failed';
172
+ statusClass = 'text-red-500';
173
+ break;
174
+ default:
175
+ statusText = '';
176
+ statusClass = '';
177
+ }
178
+ });
179
+ </script>
180
+
181
+ <div class={className}>
182
+ <!-- Header with Notes label and status -->
183
+ <div class="flex items-center justify-between mb-2">
184
+ <label for="notes-textarea" class="block text-sm font-medium [color:var(--color-text)]">
185
+ Notes
186
+ </label>
187
+ <span class="text-xs {statusClass} min-h-[16px]">
188
+ {statusText}
189
+ </span>
190
+ </div>
191
+
192
+ <!-- Textarea -->
193
+ <textarea
194
+ id="notes-textarea"
195
+ bind:this={textareaElement}
196
+ bind:value={notes}
197
+ {placeholder}
198
+ class="w-full px-3 py-2 bg-transparent [color:var(--color-text)] [border-color:var(--color-secondary)] border-2 rounded-md focus:outline-none hover:[border-color:color-mix(in_srgb,var(--color-secondary)_80%,black)] focus:[border-color:color-mix(in_srgb,var(--color-secondary)_90%,black)] focus:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus:[outline-offset:0px] resize-none min-h-[80px] overflow-hidden"
199
+ rows="3"
200
+ oninput={handleInput}
201
+ ></textarea>
202
+
203
+ </div>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ notes: string | undefined;
3
+ placeholder?: string;
4
+ onSave: (notes: string) => Promise<void>;
5
+ className?: string;
6
+ };
7
+ declare const NotesEditor: import("svelte").Component<$$ComponentProps, {}, "notes">;
8
+ type NotesEditor = ReturnType<typeof NotesEditor>;
9
+ export default NotesEditor;
@@ -0,0 +1 @@
1
+ export { default } from './NotesEditor.svelte';
@@ -0,0 +1 @@
1
+ export { default } from './NotesEditor.svelte';
@@ -0,0 +1,353 @@
1
+ <script lang="ts">
2
+ import Input from '../Input/Input.svelte';
3
+ import type { HTMLInputAttributes } from 'svelte/elements';
4
+
5
+ interface Props extends Omit<HTMLInputAttributes, 'type' | 'value'> {
6
+ value: number | null;
7
+ class?: string;
8
+ inputClass?: string;
9
+ validate?: (value: number | null) => boolean | string;
10
+ valid?: boolean;
11
+ touched?: boolean;
12
+ element?: HTMLInputElement;
13
+ min?: number;
14
+ max?: number;
15
+ step?: number;
16
+ allowDecimals?: boolean;
17
+ decimalPlaces?: number;
18
+ thousandsSeparator?: boolean;
19
+ prefix?: string;
20
+ suffix?: string;
21
+ currency?: string; // e.g., '$', '€', '£', '¥', etc.
22
+ decimalSeparator?: string; // Decimal separator (default: '.')
23
+ thousandsChar?: string; // Thousands separator character (default: ',')
24
+ roundingMode?: 'round' | 'floor' | 'ceil' | 'trunc'; // Rounding behavior for integers
25
+ negativeMode?: 'minus' | 'parentheses' | 'both'; // How to display/accept negative numbers
26
+ }
27
+
28
+ let {
29
+ value = $bindable(),
30
+ valid = $bindable(true),
31
+ touched = $bindable(false),
32
+ element = $bindable(),
33
+ validate,
34
+ min,
35
+ max,
36
+ step = 1,
37
+ allowDecimals = true,
38
+ decimalPlaces = 2,
39
+ thousandsSeparator: enableThousandsSeparator = false,
40
+ prefix = '',
41
+ suffix = '',
42
+ currency,
43
+ decimalSeparator = '.',
44
+ thousandsChar = ',',
45
+ roundingMode = 'round',
46
+ negativeMode = 'minus',
47
+ placeholder = 'Enter a number...',
48
+ ...restProps
49
+ }: Props = $props();
50
+
51
+ let displayValue = $state('');
52
+ let focused = $state(false);
53
+
54
+ // Convert number to formatted display string
55
+ function formatNumber(num: number | null): string {
56
+ if (num === null || num === undefined || isNaN(num)) return '';
57
+
58
+ const isNegative = num < 0;
59
+ const absoluteNum = Math.abs(num);
60
+ let formatted = absoluteNum.toString();
61
+
62
+ // Handle decimal places and rounding
63
+ if (allowDecimals && decimalPlaces > 0) {
64
+ formatted = absoluteNum.toFixed(decimalPlaces);
65
+ } else if (!allowDecimals) {
66
+ // Apply rounding mode for integers
67
+ let rounded: number;
68
+ switch (roundingMode) {
69
+ case 'floor':
70
+ rounded = Math.floor(absoluteNum);
71
+ break;
72
+ case 'ceil':
73
+ rounded = Math.ceil(absoluteNum);
74
+ break;
75
+ case 'trunc':
76
+ rounded = Math.trunc(absoluteNum);
77
+ break;
78
+ default: // 'round'
79
+ rounded = Math.round(absoluteNum);
80
+ }
81
+ formatted = rounded.toString();
82
+ }
83
+
84
+ // Add thousands separator and convert to custom format
85
+ if (enableThousandsSeparator) {
86
+ const parts = formatted.split('.');
87
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandsChar);
88
+ formatted = parts.join(decimalSeparator);
89
+ } else if (decimalSeparator !== '.') {
90
+ // Even without thousands separator, we need to convert decimal separator
91
+ formatted = formatted.replace('.', decimalSeparator);
92
+ }
93
+
94
+ const finalPrefix = currency || prefix;
95
+
96
+ // Handle negative number display
97
+ if (isNegative) {
98
+ if (negativeMode === 'parentheses') {
99
+ // For parentheses, put everything (prefix, number, suffix) inside
100
+ return `(${finalPrefix}${formatted}${suffix})`;
101
+ } else {
102
+ // 'minus' or 'both' - put minus in front
103
+ return `-${finalPrefix}${formatted}${suffix}`;
104
+ }
105
+ }
106
+
107
+ return `${finalPrefix}${formatted}${suffix}`;
108
+ }
109
+
110
+ // Parse display string back to number
111
+ function parseNumber(str: string): number | null {
112
+ if (!str || str.trim() === '') return null;
113
+
114
+ // Check for negative indicators
115
+ const hasParentheses = str.includes('(') && str.includes(')');
116
+ const hasMinus = str.includes('-');
117
+ const isNegative = hasParentheses || hasMinus;
118
+
119
+ // Remove prefix, suffix, currency
120
+ const finalPrefix = currency || prefix;
121
+ let cleaned = str;
122
+
123
+ if (finalPrefix) {
124
+ const escapedPrefix = finalPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ // Remove prefix from beginning, or after negative indicators
126
+ cleaned = cleaned.replace(new RegExp(`^${escapedPrefix}`), ''); // $123
127
+ cleaned = cleaned.replace(new RegExp(`^-${escapedPrefix}`), '-'); // -$123 -> -123
128
+ cleaned = cleaned.replace(new RegExp(`^\\(${escapedPrefix}`), '('); // ($123 -> (123
129
+ }
130
+
131
+ if (suffix) {
132
+ const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
133
+ cleaned = cleaned.replace(new RegExp(`${escapedSuffix}$`), '');
134
+ }
135
+
136
+ // Remove negative indicators
137
+ cleaned = cleaned.replace(/[()]/g, ''); // Remove parentheses
138
+ cleaned = cleaned.replace(/-/g, ''); // Remove minus signs
139
+
140
+ // Remove thousands separators (both , and . could be thousands separators)
141
+ cleaned = cleaned.replace(new RegExp(`\\${thousandsChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'), '');
142
+
143
+ // Convert decimal separator to standard decimal point
144
+ if (decimalSeparator !== '.') {
145
+ // Replace the decimal separator with standard decimal point
146
+ // But only the last occurrence to handle cases like "1.234,56"
147
+ const lastSeparatorIndex = cleaned.lastIndexOf(decimalSeparator);
148
+ if (lastSeparatorIndex !== -1) {
149
+ cleaned = cleaned.substring(0, lastSeparatorIndex) + '.' + cleaned.substring(lastSeparatorIndex + 1);
150
+ }
151
+ }
152
+
153
+ const parsed = parseFloat(cleaned);
154
+ if (isNaN(parsed)) return null;
155
+
156
+ return isNegative ? -parsed : parsed;
157
+ }
158
+
159
+ // Update display value when value changes
160
+ $effect(() => {
161
+ if (!focused) {
162
+ displayValue = formatNumber(value);
163
+ }
164
+ });
165
+
166
+ // Default validation
167
+ const defaultValidator = (val: number | null) => {
168
+ if (val === null) return true; // Allow null/empty values
169
+
170
+ if (min !== undefined && val < min) return `Value must be at least ${min}`;
171
+ if (max !== undefined && val > max) return `Value must be at most ${max}`;
172
+
173
+ if (!allowDecimals && val % 1 !== 0) return 'Decimal values are not allowed';
174
+
175
+ return true;
176
+ };
177
+
178
+ // Use custom validator if provided, otherwise use default
179
+ const numberValidator = validate || defaultValidator;
180
+
181
+ function handleInput(inputValue: string) {
182
+ displayValue = inputValue;
183
+
184
+ // Only skip parsing for truly incomplete inputs that have no numeric content
185
+ const finalPrefix = currency || prefix;
186
+
187
+ // Check if input is incomplete (has negative indicators but no actual numbers)
188
+ const incompletePatterns = [
189
+ '-', // Just minus
190
+ '(', // Just opening paren
191
+ '()', // Empty parens
192
+ ];
193
+
194
+ // Add prefix-related incomplete patterns
195
+ if (finalPrefix) {
196
+ incompletePatterns.push(
197
+ `-${finalPrefix}`, // -$, -€, etc.
198
+ `(${finalPrefix}`, // ($, (€, etc.
199
+ `(${finalPrefix})` // ($), (€), etc.
200
+ );
201
+ }
202
+
203
+ // Add suffix-related incomplete patterns
204
+ if (suffix) {
205
+ incompletePatterns.push(
206
+ `-${suffix}`, // -%, etc.
207
+ `(${suffix}`, // (%, etc.
208
+ `(${suffix})` // (%), etc.
209
+ );
210
+
211
+ // Combined prefix and suffix patterns
212
+ if (finalPrefix) {
213
+ incompletePatterns.push(
214
+ `-${finalPrefix}${suffix}`, // -$%, etc.
215
+ `(${finalPrefix}${suffix}`, // ($%, etc.
216
+ `(${finalPrefix}${suffix})` // ($%), etc.
217
+ );
218
+ }
219
+ }
220
+
221
+ if (incompletePatterns.includes(inputValue)) {
222
+ // Keep the current value but update display for incomplete inputs
223
+ return;
224
+ }
225
+
226
+ const parsed = parseNumber(inputValue);
227
+ value = parsed;
228
+ }
229
+
230
+ function handleFocus() {
231
+ focused = true;
232
+ // Show the full formatted value including prefix/suffix for consistency
233
+ if (value !== null && !isNaN(value)) {
234
+ displayValue = formatNumber(value);
235
+ }
236
+ }
237
+
238
+ function handleBlur() {
239
+ focused = false;
240
+ // Format the number when focus is lost
241
+ displayValue = formatNumber(value);
242
+ }
243
+
244
+ function handleKeyDown(event: KeyboardEvent) {
245
+ // Allow control keys (backspace, delete, arrow keys, tab, etc.)
246
+ if (
247
+ event.key === 'Backspace' ||
248
+ event.key === 'Delete' ||
249
+ event.key === 'Tab' ||
250
+ event.key === 'Escape' ||
251
+ event.key === 'Enter' ||
252
+ event.key.startsWith('Arrow') ||
253
+ event.key === 'Home' ||
254
+ event.key === 'End' ||
255
+ (event.ctrlKey || event.metaKey) // Allow Ctrl/Cmd shortcuts
256
+ ) {
257
+ return;
258
+ }
259
+
260
+ const currentValue = displayValue;
261
+ const finalPrefix = currency || prefix;
262
+ const input = event.target as HTMLInputElement;
263
+ const cursorPosition = input.selectionStart || 0;
264
+
265
+ // Basic allowed characters
266
+ const allowedChars = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
267
+
268
+ // Always allow both comma and period for international input flexibility
269
+ allowedChars.add(',');
270
+ allowedChars.add('.');
271
+
272
+ // Add currency characters (but validate position)
273
+ if (finalPrefix && event.key === finalPrefix) {
274
+ // Only allow currency at the beginning (after minus if present)
275
+ const hasMinusAtStart = currentValue.startsWith('-');
276
+ const expectedPosition = hasMinusAtStart ? 1 : 0;
277
+ if (cursorPosition === expectedPosition && !currentValue.includes(finalPrefix)) {
278
+ allowedChars.add(finalPrefix);
279
+ } else {
280
+ event.preventDefault();
281
+ return;
282
+ }
283
+ }
284
+
285
+ // Add suffix characters (but validate position and prevent duplicates)
286
+ if (suffix && event.key === suffix) {
287
+ // Only allow suffix at the end and if not already present
288
+ if (cursorPosition === currentValue.length && !currentValue.includes(suffix)) {
289
+ allowedChars.add(suffix);
290
+ } else {
291
+ event.preventDefault();
292
+ return;
293
+ }
294
+ }
295
+
296
+ // Always allow both minus and parentheses for input (negativeMode only affects display formatting)
297
+ if (event.key === '-') {
298
+ // Only allow minus at the very beginning and if not already present
299
+ if (cursorPosition === 0 && !currentValue.includes('-')) {
300
+ allowedChars.add('-');
301
+ } else {
302
+ event.preventDefault();
303
+ return;
304
+ }
305
+ }
306
+
307
+ if (event.key === '(') {
308
+ // Only allow opening parenthesis at the beginning and if not already present
309
+ if (cursorPosition === 0 && !currentValue.includes('(')) {
310
+ allowedChars.add('(');
311
+ } else {
312
+ event.preventDefault();
313
+ return;
314
+ }
315
+ }
316
+ if (event.key === ')') {
317
+ // Only allow closing parenthesis at the end, if opening exists, and no closing yet
318
+ if (cursorPosition === currentValue.length && currentValue.includes('(') && !currentValue.includes(')')) {
319
+ allowedChars.add(')');
320
+ } else {
321
+ event.preventDefault();
322
+ return;
323
+ }
324
+ }
325
+
326
+ // Prevent typing if character is not allowed
327
+ if (!allowedChars.has(event.key)) {
328
+ event.preventDefault();
329
+ }
330
+ }
331
+
332
+ // Convert our number value to string for the Input component
333
+ let stringValue = $derived(displayValue);
334
+ </script>
335
+
336
+ <Input
337
+ bind:value={stringValue}
338
+ bind:valid
339
+ bind:touched
340
+ bind:element
341
+ type="text"
342
+ inputmode="numeric"
343
+ validate={(val) => {
344
+ const parsed = parseNumber(val);
345
+ return numberValidator(parsed);
346
+ }}
347
+ oninput={(e) => handleInput((e.target as HTMLInputElement).value)}
348
+ onfocus={handleFocus}
349
+ onblur={handleBlur}
350
+ onkeydown={handleKeyDown}
351
+ {placeholder}
352
+ {...restProps}
353
+ />