@selvajs/ui 6.6.0-beta.4 → 6.6.0-beta.6
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/dist/components/ErrorScreen.svelte +6 -2
- package/dist/components/compute/AppLayout.svelte +163 -74
- package/dist/components/compute/AppLayout.svelte.d.ts +11 -0
- package/dist/components/compute/ComputeApp.svelte +40 -20
- package/dist/components/compute/ComputeApp.svelte.d.ts +19 -2
- package/dist/components/compute/ComputeMessagesDialog.svelte +30 -40
- package/dist/components/compute/ParameterPresetManager.svelte +7 -44
- package/dist/components/compute/ParameterPresetManager.svelte.d.ts +4 -5
- package/dist/components/compute/SolveMessages.svelte +30 -37
- package/dist/components/compute/SolveMessages.svelte.d.ts +0 -12
- package/dist/components/layout/PageFooter.svelte +21 -11
- package/dist/components/preview/ChartOutput.svelte +17 -8
- package/dist/components/preview/Group.svelte +33 -18
- package/dist/components/preview/HelpButton.svelte +4 -2
- package/dist/components/preview/ImageOutput.svelte +427 -68
- package/dist/components/preview/InputControl.svelte +11 -2
- package/dist/components/preview/OutputDisplay.svelte +17 -12
- package/dist/components/preview/TabContent.svelte +3 -0
- package/dist/components/preview/inputs/ColorInput.svelte +3 -1
- package/dist/components/preview/inputs/ColorInput.svelte.d.ts +1 -0
- package/dist/components/preview/inputs/DropdownInput.svelte +4 -1
- package/dist/components/preview/inputs/FileInput.svelte +50 -34
- package/dist/components/preview/inputs/NumberInput.svelte +92 -10
- package/dist/components/preview/inputs/NumberInput.svelte.d.ts +2 -0
- package/dist/components/preview/inputs/TextInput.svelte +8 -3
- package/dist/components/primitives/CalculateButton.svelte +49 -23
- package/dist/components/primitives/CalculateButton.svelte.d.ts +3 -0
- package/dist/components/primitives/SolvingIndicator.svelte +21 -17
- package/dist/components/primitives/badge/badge.svelte +2 -2
- package/dist/components/primitives/badge/badge.svelte.d.ts +4 -4
- package/dist/components/primitives/button/button.svelte +2 -2
- package/dist/components/primitives/button/button.svelte.d.ts +4 -4
- package/dist/components/primitives/checkbox/checkbox.svelte +1 -1
- package/dist/components/primitives/dialog/dialog-content.svelte +6 -2
- package/dist/components/primitives/input/input.svelte +2 -2
- package/dist/components/primitives/input-group/input-group.svelte +1 -1
- package/dist/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
- package/dist/components/primitives/scroll-area/scroll-area.svelte +1 -1
- package/dist/components/primitives/search/search.svelte +1 -1
- package/dist/components/primitives/select/select-trigger.svelte +1 -1
- package/dist/components/primitives/slider/slider.svelte +7 -2
- package/dist/components/primitives/slider/slider.svelte.d.ts +5 -1
- package/dist/components/primitives/switch/switch.svelte +1 -1
- package/dist/components/primitives/tabs/tabs-trigger.svelte +1 -1
- package/dist/components/primitives/tabs/tabs-trigger.svelte.d.ts +1 -1
- package/dist/components/primitives/textarea/textarea.svelte +1 -1
- package/dist/components/primitives/toggle/toggle.svelte +1 -1
- package/dist/components/primitives/toggle/toggle.svelte.d.ts +1 -1
- package/dist/components/viewer/Viewer.svelte +20 -17
- package/dist/compute/run-panel-action.d.ts +8 -0
- package/dist/compute/run-panel-action.js +12 -0
- package/dist/compute/useSolveSession.svelte.js +3 -0
- package/dist/i18n/localeContext.svelte.d.ts +10 -3
- package/dist/i18n/localeContext.svelte.js +27 -5
- package/dist/i18n/messages.d.ts +115 -2
- package/dist/i18n/messages.js +199 -11
- package/dist/index.d.ts +1 -1
- package/dist/public.d.ts +1 -1
- package/dist/schema/visibility-rules.js +21 -0
- package/dist/styles/base.css +9 -6
- package/dist/types/actionButton.d.ts +17 -3
- package/dist/types/presetLabels.d.ts +3 -2
- package/dist/types/presetLabels.js +9 -8
- package/dist/utils/loadScript.d.ts +2 -0
- package/dist/utils/loadScript.js +2 -0
- package/package.json +8 -8
- package/src/lib/components/ErrorScreen.svelte +6 -2
- package/src/lib/components/compute/AppLayout.svelte +163 -74
- package/src/lib/components/compute/ComputeApp.svelte +40 -20
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +30 -40
- package/src/lib/components/compute/ParameterPresetManager.svelte +7 -44
- package/src/lib/components/compute/SolveMessages.svelte +30 -37
- package/src/lib/components/layout/PageFooter.svelte +21 -11
- package/src/lib/components/preview/ChartOutput.svelte +17 -8
- package/src/lib/components/preview/Group.svelte +33 -18
- package/src/lib/components/preview/HelpButton.svelte +4 -2
- package/src/lib/components/preview/ImageOutput.svelte +427 -68
- package/src/lib/components/preview/InputControl.svelte +11 -2
- package/src/lib/components/preview/OutputDisplay.svelte +17 -12
- package/src/lib/components/preview/TabContent.svelte +3 -0
- package/src/lib/components/preview/inputs/ColorInput.svelte +3 -1
- package/src/lib/components/preview/inputs/DropdownInput.svelte +4 -1
- package/src/lib/components/preview/inputs/FileInput.svelte +50 -34
- package/src/lib/components/preview/inputs/NumberInput.svelte +92 -10
- package/src/lib/components/preview/inputs/TextInput.svelte +8 -3
- package/src/lib/components/primitives/CalculateButton.svelte +49 -23
- package/src/lib/components/primitives/SolvingIndicator.svelte +21 -17
- package/src/lib/components/primitives/badge/badge.svelte +2 -2
- package/src/lib/components/primitives/button/button.svelte +2 -2
- package/src/lib/components/primitives/checkbox/checkbox.svelte +1 -1
- package/src/lib/components/primitives/dialog/dialog-content.svelte +6 -2
- package/src/lib/components/primitives/input/input.svelte +2 -2
- package/src/lib/components/primitives/input-group/input-group.svelte +1 -1
- package/src/lib/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
- package/src/lib/components/primitives/scroll-area/scroll-area.svelte +1 -1
- package/src/lib/components/primitives/search/search.svelte +1 -1
- package/src/lib/components/primitives/select/select-trigger.svelte +1 -1
- package/src/lib/components/primitives/slider/slider.svelte +7 -2
- package/src/lib/components/primitives/switch/switch.svelte +1 -1
- package/src/lib/components/primitives/tabs/tabs-trigger.svelte +1 -1
- package/src/lib/components/primitives/textarea/textarea.svelte +1 -1
- package/src/lib/components/primitives/toggle/toggle.svelte +1 -1
- package/src/lib/components/viewer/Viewer.svelte +20 -17
- package/src/lib/compute/run-panel-action.ts +16 -0
- package/src/lib/compute/useSolveSession.svelte.ts +3 -0
- package/src/lib/i18n/localeContext.svelte.ts +40 -5
- package/src/lib/i18n/messages.ts +351 -13
- package/src/lib/index.ts +1 -0
- package/src/lib/public.ts +1 -0
- package/src/lib/schema/visibility-rules.ts +23 -0
- package/src/lib/styles/base.css +9 -6
- package/src/lib/types/actionButton.ts +16 -3
- package/src/lib/types/presetLabels.ts +13 -11
- package/src/lib/utils/loadScript.ts +3 -0
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import HelpButton from './HelpButton.svelte';
|
|
15
15
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
16
16
|
import { Label } from '$lib/components/primitives/label';
|
|
17
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
18
|
+
import { formatCount, formatMessage } from '$lib/i18n/messages';
|
|
17
19
|
|
|
18
20
|
interface Props {
|
|
19
21
|
item: OutputLayoutItem;
|
|
@@ -23,6 +25,9 @@
|
|
|
23
25
|
|
|
24
26
|
let { item, value, displayName }: Props = $props();
|
|
25
27
|
|
|
28
|
+
const locale = getLocaleContext();
|
|
29
|
+
const t = $derived(locale.messages);
|
|
30
|
+
|
|
26
31
|
let copied = $state(false);
|
|
27
32
|
let copyTimeout: ReturnType<typeof setTimeout>;
|
|
28
33
|
|
|
@@ -142,7 +147,7 @@
|
|
|
142
147
|
await downloadFiles(filesArray, fileName);
|
|
143
148
|
}
|
|
144
149
|
} catch (err) {
|
|
145
|
-
downloadError = err instanceof Error ? err.message :
|
|
150
|
+
downloadError = err instanceof Error ? err.message : t.downloadFailed;
|
|
146
151
|
console.error('[OutputDisplayWidget] Download error:', err);
|
|
147
152
|
} finally {
|
|
148
153
|
downloading = false;
|
|
@@ -160,7 +165,7 @@
|
|
|
160
165
|
{/snippet}
|
|
161
166
|
|
|
162
167
|
{#snippet placeholder()}
|
|
163
|
-
<span class="text-muted-foreground not-italic">
|
|
168
|
+
<span class="text-muted-foreground not-italic">{t.waitingForData}</span>
|
|
164
169
|
{/snippet}
|
|
165
170
|
|
|
166
171
|
{#snippet treeNodes(nodes: Map<string, TreeNode>, path: string)}
|
|
@@ -209,7 +214,7 @@
|
|
|
209
214
|
{#snippet fileDisplay()}
|
|
210
215
|
{#if fileCount === 0}
|
|
211
216
|
<div class="rounded px-3 py-3 text-sm border border-border bg-muted text-muted-foreground">
|
|
212
|
-
|
|
217
|
+
{t.waitingForFiles}
|
|
213
218
|
</div>
|
|
214
219
|
{:else}
|
|
215
220
|
<div class="gap-2 flex flex-col">
|
|
@@ -218,7 +223,7 @@
|
|
|
218
223
|
<span class="min-w-0 text-sm font-medium truncate text-foreground">
|
|
219
224
|
{fileCount === 1
|
|
220
225
|
? `${filesArray[0].fileName}${filesArray[0].fileType}`
|
|
221
|
-
:
|
|
226
|
+
: formatMessage(t.fileCount, { count: fileCount })}
|
|
222
227
|
</span>
|
|
223
228
|
<span class="text-xs shrink-0 text-muted-foreground">{formatFileSize(totalSize)}</span>
|
|
224
229
|
</div>
|
|
@@ -245,18 +250,16 @@
|
|
|
245
250
|
<div
|
|
246
251
|
class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
|
|
247
252
|
>
|
|
248
|
-
<div class="font-medium">
|
|
253
|
+
<div class="font-medium">{t.duplicateFilesTitle}</div>
|
|
249
254
|
<div class="mt-1 text-xs">
|
|
250
|
-
|
|
255
|
+
{formatCount(duplicatePaths.length, t.duplicatePathsOne, t.duplicatePathsOther)}
|
|
251
256
|
</div>
|
|
252
257
|
<ul class="mt-1 ml-4 text-xs list-disc">
|
|
253
258
|
{#each duplicatePaths as path (path)}
|
|
254
259
|
<li class="truncate"><span class="font-mono">{path}</span></li>
|
|
255
260
|
{/each}
|
|
256
261
|
</ul>
|
|
257
|
-
<div class="mt-1 text-xs">
|
|
258
|
-
Rename outputs in your Grasshopper definition so each file has a unique name.
|
|
259
|
-
</div>
|
|
262
|
+
<div class="mt-1 text-xs">{t.duplicateFilesHint}</div>
|
|
260
263
|
</div>
|
|
261
264
|
{/if}
|
|
262
265
|
{#if downloadError}
|
|
@@ -268,8 +271,10 @@
|
|
|
268
271
|
{/if}
|
|
269
272
|
<Button onclick={handleDownload} disabled={downloading || hasDuplicates} class="w-full">
|
|
270
273
|
{downloading
|
|
271
|
-
?
|
|
272
|
-
:
|
|
274
|
+
? t.downloading
|
|
275
|
+
: fileCount === 1
|
|
276
|
+
? t.downloadFile
|
|
277
|
+
: formatMessage(t.downloadFiles, { count: fileCount })}
|
|
273
278
|
</Button>
|
|
274
279
|
</div>
|
|
275
280
|
{/if}
|
|
@@ -316,7 +321,7 @@
|
|
|
316
321
|
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}"
|
|
317
322
|
>
|
|
318
323
|
<Button onclick={copyToClipboard} size="sm">
|
|
319
|
-
{copied ?
|
|
324
|
+
{copied ? t.copied : t.copy}
|
|
320
325
|
</Button>
|
|
321
326
|
</div>
|
|
322
327
|
{/if}
|
|
@@ -87,6 +87,9 @@
|
|
|
87
87
|
<div class="h-full bg-muted dark:bg-background">
|
|
88
88
|
<ScrollArea class="h-full" orientation="vertical">
|
|
89
89
|
<div class="px-3">
|
|
90
|
+
<!-- A heading per panel lets a screen reader jump between them; the tab, when shown,
|
|
91
|
+
already names it on screen. -->
|
|
92
|
+
<h2 class="sr-only">{tab.label}</h2>
|
|
90
93
|
{#if visibleGroups.length === 0}
|
|
91
94
|
<StateDisplay type="empty" size="medium" message={t.tabNoGroups} />
|
|
92
95
|
{:else}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
interface Props {
|
|
3
|
+
inputId: string;
|
|
3
4
|
value?: string;
|
|
4
5
|
onChange: (value: string) => void;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
|
-
let { value = '#000000', onChange }: Props = $props();
|
|
8
|
+
let { inputId, value = '#000000', onChange }: Props = $props();
|
|
8
9
|
</script>
|
|
9
10
|
|
|
10
11
|
<div class="gap-2 flex items-center">
|
|
11
12
|
<input
|
|
13
|
+
id={inputId}
|
|
12
14
|
type="color"
|
|
13
15
|
value={value || '#000000'}
|
|
14
16
|
class="h-9 w-12 rounded p-1 cursor-pointer border border-input bg-background dark:bg-input/30"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { DropdownWidgetConfig, SupportedTypes } from '@selvajs/schemas';
|
|
3
3
|
import * as Select from '$lib/components/primitives/select';
|
|
4
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
value?: string;
|
|
@@ -11,6 +12,8 @@
|
|
|
11
12
|
|
|
12
13
|
let { value, config, onChange, disabled = false }: Props = $props();
|
|
13
14
|
|
|
15
|
+
const locale = getLocaleContext();
|
|
16
|
+
|
|
14
17
|
const options = $derived(config?.options || {});
|
|
15
18
|
const currentValue = $derived(value || '');
|
|
16
19
|
const currentLabel = $derived(
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
{disabled}
|
|
31
34
|
>
|
|
32
35
|
<Select.Trigger class="w-full" {disabled}>
|
|
33
|
-
{currentLabel ||
|
|
36
|
+
{currentLabel || locale.messages.selectOption}
|
|
34
37
|
</Select.Trigger>
|
|
35
38
|
<Select.Content>
|
|
36
39
|
{#each Object.entries(options) as [name, expr] (name)}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { toast } from '$lib/components/primitives/sonner';
|
|
6
6
|
import { FileUp, Link, CircleAlert, CircleCheck } from '@lucide/svelte';
|
|
7
7
|
import { APP_DEFAULTS } from '$lib/constants';
|
|
8
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
9
|
+
import { formatMessage } from '$lib/i18n/messages';
|
|
8
10
|
|
|
9
11
|
interface Props {
|
|
10
12
|
value?: string;
|
|
@@ -22,6 +24,9 @@
|
|
|
22
24
|
onChange
|
|
23
25
|
}: Props = $props();
|
|
24
26
|
|
|
27
|
+
const locale = getLocaleContext();
|
|
28
|
+
const t = $derived(locale.messages);
|
|
29
|
+
|
|
25
30
|
let effectiveModes = $derived(
|
|
26
31
|
allowedInputModes && allowedInputModes.length > 0
|
|
27
32
|
? allowedInputModes
|
|
@@ -52,9 +57,12 @@
|
|
|
52
57
|
if (parsed.type === 'base64') {
|
|
53
58
|
if (parsed._isMetadata && parsed._fileSize) {
|
|
54
59
|
const sizeMB = (parsed._fileSize / 1024 / 1024).toFixed(2);
|
|
55
|
-
uploadedFileName =
|
|
60
|
+
uploadedFileName = formatMessage(t.uploadedFileSize, {
|
|
61
|
+
ext: parsed.fileEnding,
|
|
62
|
+
size: sizeMB
|
|
63
|
+
});
|
|
56
64
|
} else {
|
|
57
|
-
uploadedFileName =
|
|
65
|
+
uploadedFileName = formatMessage(t.uploadedFile, { ext: parsed.fileEnding });
|
|
58
66
|
}
|
|
59
67
|
} else if (parsed.type === 'url') {
|
|
60
68
|
urlInput = parsed.file;
|
|
@@ -65,6 +73,20 @@
|
|
|
65
73
|
}
|
|
66
74
|
});
|
|
67
75
|
|
|
76
|
+
function formatNotAccepted(fileEnding: string): string {
|
|
77
|
+
return formatMessage(t.fileFormatNotAccepted, {
|
|
78
|
+
ext: fileEnding,
|
|
79
|
+
formats: acceptedFormats.join(', ')
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatTooLarge(bytes: number): string {
|
|
84
|
+
return formatMessage(t.fileTooLarge, {
|
|
85
|
+
size: (bytes / 1024 / 1024).toFixed(2),
|
|
86
|
+
max: APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function getFileExtension(filename: string): string {
|
|
69
91
|
// Strip query strings before extracting extension
|
|
70
92
|
const cleanPath = filename.split('?')[0];
|
|
@@ -96,19 +118,21 @@
|
|
|
96
118
|
try {
|
|
97
119
|
parsedUrl = new URL(urlInput);
|
|
98
120
|
} catch {
|
|
99
|
-
urlError = { message:
|
|
121
|
+
urlError = { message: t.urlInvalid, isCors: false };
|
|
100
122
|
return;
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
|
104
|
-
urlError = { message:
|
|
126
|
+
urlError = { message: t.urlProtocolUnsupported, isCors: false };
|
|
105
127
|
return;
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
const response = await fetch(urlInput);
|
|
109
131
|
if (!response.ok) {
|
|
110
132
|
urlError = {
|
|
111
|
-
message:
|
|
133
|
+
message: formatMessage(t.urlServerError, {
|
|
134
|
+
status: `${response.status} ${response.statusText}`.trim()
|
|
135
|
+
}),
|
|
112
136
|
isCors: false
|
|
113
137
|
};
|
|
114
138
|
return;
|
|
@@ -118,17 +142,14 @@
|
|
|
118
142
|
const fileEnding = getFileExtension(urlInput);
|
|
119
143
|
|
|
120
144
|
if (!isValidFileExtension(fileEnding)) {
|
|
121
|
-
urlError = {
|
|
122
|
-
message: `File format "${fileEnding}" is not accepted. Allowed: ${acceptedFormats.join(', ')}`,
|
|
123
|
-
isCors: false
|
|
124
|
-
};
|
|
145
|
+
urlError = { message: formatNotAccepted(fileEnding), isCors: false };
|
|
125
146
|
return;
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
const maxSize = APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES;
|
|
129
150
|
if (blob.size > maxSize) {
|
|
130
151
|
urlError = {
|
|
131
|
-
message:
|
|
152
|
+
message: `${formatTooLarge(blob.size)} ${t.fileTooLargeUseUpload}`,
|
|
132
153
|
isCors: false
|
|
133
154
|
};
|
|
134
155
|
return;
|
|
@@ -140,12 +161,15 @@
|
|
|
140
161
|
const result = e.target?.result as string;
|
|
141
162
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
142
163
|
};
|
|
143
|
-
reader.onerror = () => reject(new Error(
|
|
164
|
+
reader.onerror = () => reject(new Error(t.fileReadFailed));
|
|
144
165
|
reader.readAsDataURL(blob);
|
|
145
166
|
});
|
|
146
167
|
const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
|
|
147
168
|
value = data;
|
|
148
|
-
urlSuccess =
|
|
169
|
+
urlSuccess = formatMessage(t.fileLoaded, {
|
|
170
|
+
ext: fileEnding,
|
|
171
|
+
size: (blob.size / 1024).toFixed(0)
|
|
172
|
+
});
|
|
149
173
|
onChange(data);
|
|
150
174
|
} catch (error) {
|
|
151
175
|
if (isCorsError(error)) {
|
|
@@ -158,26 +182,22 @@
|
|
|
158
182
|
})();
|
|
159
183
|
// The opaque TypeError hides CORS blocks and plain network failures (404, DNS)
|
|
160
184
|
// alike, so the message has to cover both.
|
|
161
|
-
let hint =
|
|
162
|
-
'Check that the URL is correct and the file is publicly accessible. If the server requires login, download the file and use the Upload option instead.';
|
|
185
|
+
let hint = t.urlUnreachableHint;
|
|
163
186
|
if (host.includes('sharepoint.com') || host.includes('onedrive.com')) {
|
|
164
|
-
hint =
|
|
165
|
-
'SharePoint/OneDrive blocks browser access. Open the file in SharePoint, use "Download" to save it locally, then upload it here.';
|
|
187
|
+
hint = t.urlHintSharePoint;
|
|
166
188
|
} else if (host.includes('drive.google.com')) {
|
|
167
|
-
hint =
|
|
168
|
-
'Google Drive blocks browser access. Use "Download" in Google Drive to save the file locally, then upload it here.';
|
|
189
|
+
hint = t.urlHintGoogleDrive;
|
|
169
190
|
} else if (host.includes('dropbox.com')) {
|
|
170
|
-
hint =
|
|
171
|
-
'Dropbox links may block browser access. Try changing "?dl=0" to "?dl=1" in the URL, or download and upload instead.';
|
|
191
|
+
hint = t.urlHintDropbox;
|
|
172
192
|
}
|
|
173
193
|
urlError = {
|
|
174
|
-
message:
|
|
194
|
+
message: formatMessage(t.urlUnreachable, { host }),
|
|
175
195
|
isCors: true,
|
|
176
196
|
hint
|
|
177
197
|
};
|
|
178
198
|
} else {
|
|
179
199
|
urlError = {
|
|
180
|
-
message: error instanceof Error ? error.message :
|
|
200
|
+
message: error instanceof Error ? error.message : t.unknownError,
|
|
181
201
|
isCors: false
|
|
182
202
|
};
|
|
183
203
|
}
|
|
@@ -194,7 +214,7 @@
|
|
|
194
214
|
const fileEnding = getFileExtension(file.name);
|
|
195
215
|
|
|
196
216
|
if (!isValidFileExtension(fileEnding)) {
|
|
197
|
-
toast.error(
|
|
217
|
+
toast.error(formatNotAccepted(fileEnding));
|
|
198
218
|
return;
|
|
199
219
|
}
|
|
200
220
|
|
|
@@ -204,9 +224,7 @@
|
|
|
204
224
|
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
205
225
|
// path applies the same cap.
|
|
206
226
|
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
207
|
-
toast.error(
|
|
208
|
-
`File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
|
|
209
|
-
);
|
|
227
|
+
toast.error(formatTooLarge(file.size));
|
|
210
228
|
return;
|
|
211
229
|
}
|
|
212
230
|
|
|
@@ -279,7 +297,7 @@
|
|
|
279
297
|
: 'text-muted-foreground hover:text-foreground'
|
|
280
298
|
}`}
|
|
281
299
|
>
|
|
282
|
-
{mode === 'upload' ?
|
|
300
|
+
{mode === 'upload' ? t.fileModeUpload : t.fileModeUrl}
|
|
283
301
|
</button>
|
|
284
302
|
{/each}
|
|
285
303
|
</div>
|
|
@@ -289,7 +307,7 @@
|
|
|
289
307
|
<div class="gap-2 flex flex-col">
|
|
290
308
|
<Label for="url-input">
|
|
291
309
|
<Link size={16} />
|
|
292
|
-
|
|
310
|
+
{t.fileUrlLabel}
|
|
293
311
|
</Label>
|
|
294
312
|
<div class="gap-2 flex">
|
|
295
313
|
<Input
|
|
@@ -310,7 +328,7 @@
|
|
|
310
328
|
disabled={isLoading || !urlInput.trim()}
|
|
311
329
|
onclick={handleUrlChange}
|
|
312
330
|
>
|
|
313
|
-
{isLoading ?
|
|
331
|
+
{isLoading ? t.fetchingFile : t.fetchFile}
|
|
314
332
|
</Button>
|
|
315
333
|
</div>
|
|
316
334
|
|
|
@@ -336,9 +354,7 @@
|
|
|
336
354
|
{/if}
|
|
337
355
|
|
|
338
356
|
{#if !urlError && !urlSuccess}
|
|
339
|
-
<p class="text-xs text-muted-foreground">
|
|
340
|
-
URL must be publicly accessible (no login required).
|
|
341
|
-
</p>
|
|
357
|
+
<p class="text-xs text-muted-foreground">{t.fileUrlPublic}</p>
|
|
342
358
|
{/if}
|
|
343
359
|
</div>
|
|
344
360
|
{:else}
|
|
@@ -353,11 +369,11 @@
|
|
|
353
369
|
ondrop={handleDrop}
|
|
354
370
|
>
|
|
355
371
|
<FileUp size={16} />
|
|
356
|
-
{uploadedFileName ||
|
|
372
|
+
{uploadedFileName || t.chooseFile}
|
|
357
373
|
</Button>
|
|
358
374
|
{#if acceptedFormats.length > 0}
|
|
359
375
|
<p class="text-xs text-muted-foreground">
|
|
360
|
-
|
|
376
|
+
{formatMessage(t.acceptedFormats, { formats: acceptedFormats.join(', ') })}
|
|
361
377
|
</p>
|
|
362
378
|
{/if}
|
|
363
379
|
</div>
|
|
@@ -6,16 +6,30 @@
|
|
|
6
6
|
import * as InputGroup from '$lib/components/primitives/input-group';
|
|
7
7
|
import { Separator } from '$lib/components/primitives/separator';
|
|
8
8
|
import * as Field from '$lib/components/primitives/field';
|
|
9
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
10
|
+
import { formatMessage } from '$lib/i18n/messages';
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
inputId: string;
|
|
14
|
+
/** Id of the field's label, which also names the slider thumb. */
|
|
15
|
+
labelId?: string;
|
|
12
16
|
value?: number;
|
|
13
17
|
config?: NumberWidgetConfig;
|
|
14
18
|
onChange: (value: SupportedTypes) => void;
|
|
15
19
|
disabled?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
let {
|
|
22
|
+
let {
|
|
23
|
+
inputId,
|
|
24
|
+
labelId,
|
|
25
|
+
value = $bindable(),
|
|
26
|
+
config,
|
|
27
|
+
onChange,
|
|
28
|
+
disabled = false
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
|
|
31
|
+
const locale = getLocaleContext();
|
|
32
|
+
const t = $derived(locale.messages);
|
|
19
33
|
|
|
20
34
|
let validationError = $state<string | null>(null);
|
|
21
35
|
|
|
@@ -29,9 +43,9 @@
|
|
|
29
43
|
|
|
30
44
|
function validateNumber(val: number): string | null {
|
|
31
45
|
if (config?.minimum !== undefined && val < config.minimum)
|
|
32
|
-
return
|
|
46
|
+
return formatMessage(t.minValue, { min: config.minimum });
|
|
33
47
|
if (config?.maximum !== undefined && val > config.maximum)
|
|
34
|
-
return
|
|
48
|
+
return formatMessage(t.maxValue, { max: config.maximum });
|
|
35
49
|
return null;
|
|
36
50
|
}
|
|
37
51
|
|
|
@@ -44,11 +58,69 @@
|
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
|
|
61
|
+
// bits-ui builds an array of every step and rebuilds a tick object per step on each value
|
|
62
|
+
// change, so a fine-grained range freezes the drag. Past this many steps the track drives a
|
|
63
|
+
// coarse proxy scale instead; the number box next to it still sets any exact value.
|
|
64
|
+
const MAX_TRACK_STEPS = 1000;
|
|
65
|
+
const FINE_DRAG_FACTOR = 10;
|
|
66
|
+
const stepCount = $derived((max - min) / step);
|
|
67
|
+
const useProxyTrack = $derived(
|
|
68
|
+
step > 0 && Number.isFinite(stepCount) && stepCount > MAX_TRACK_STEPS
|
|
69
|
+
);
|
|
70
|
+
const trackMin = $derived(useProxyTrack ? 0 : min);
|
|
71
|
+
const trackMax = $derived(useProxyTrack ? MAX_TRACK_STEPS : max);
|
|
72
|
+
const stepPrecision = $derived((String(step).split('.')[1] ?? '').length);
|
|
73
|
+
|
|
74
|
+
let dragging = false;
|
|
75
|
+
let shiftHeld = false;
|
|
76
|
+
let lastTrack: number | null = null;
|
|
77
|
+
let fineAnchor: { track: number; value: number } | null = null;
|
|
78
|
+
|
|
79
|
+
function snapToStep(v: number): number {
|
|
80
|
+
const clamped = Math.min(max, Math.max(min, v));
|
|
81
|
+
if (!(step > 0)) return clamped;
|
|
82
|
+
const snapped = min + Math.round((clamped - min) / step) * step;
|
|
83
|
+
return Math.min(max, Number(snapped.toFixed(stepPrecision)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toTrack(v: number): number {
|
|
87
|
+
return useProxyTrack ? Math.round(((v - min) / (max - min)) * MAX_TRACK_STEPS) : v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function trackSpanToValue(trackDelta: number): number {
|
|
91
|
+
return (trackDelta / (trackMax - trackMin)) * (max - min);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// bits-ui maps the pointer to an absolute track position, so fine drag works in deltas: the
|
|
95
|
+
// value moves FINE_DRAG_FACTOR times slower than the pointer from where Shift took effect.
|
|
96
|
+
function setFromTrack(track: number) {
|
|
97
|
+
if (max <= min) return;
|
|
98
|
+
let newValue: number;
|
|
99
|
+
if (dragging && shiftHeld) {
|
|
100
|
+
fineAnchor ??= { track: lastTrack ?? track, value: numValue };
|
|
101
|
+
newValue = snapToStep(
|
|
102
|
+
fineAnchor.value + trackSpanToValue(track - fineAnchor.track) / FINE_DRAG_FACTOR
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
fineAnchor = null;
|
|
106
|
+
newValue = snapToStep(min + trackSpanToValue(track - trackMin));
|
|
107
|
+
}
|
|
108
|
+
lastTrack = track;
|
|
48
109
|
value = newValue;
|
|
49
110
|
commitSliderDebounced(newValue);
|
|
50
111
|
}
|
|
51
112
|
|
|
113
|
+
function startDrag(e: PointerEvent) {
|
|
114
|
+
dragging = true;
|
|
115
|
+
shiftHeld = e.shiftKey;
|
|
116
|
+
lastTrack = null;
|
|
117
|
+
fineAnchor = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setShift(e: KeyboardEvent) {
|
|
121
|
+
if (e.key === 'Shift') shiftHeld = e.type === 'keydown';
|
|
122
|
+
}
|
|
123
|
+
|
|
52
124
|
const decimals = $derived(Math.max(0, -Math.floor(Math.log10(step))));
|
|
53
125
|
|
|
54
126
|
function commitTypedValue(input: HTMLInputElement) {
|
|
@@ -65,6 +137,13 @@
|
|
|
65
137
|
}
|
|
66
138
|
</script>
|
|
67
139
|
|
|
140
|
+
<svelte:window
|
|
141
|
+
onkeydown={setShift}
|
|
142
|
+
onkeyup={setShift}
|
|
143
|
+
onpointerup={() => (dragging = false)}
|
|
144
|
+
onblur={() => (shiftHeld = dragging = false)}
|
|
145
|
+
/>
|
|
146
|
+
|
|
68
147
|
{#if config?.renderAsSlider}
|
|
69
148
|
<!-- Value and track share one box, the height of a number input, so mixed rows line up and
|
|
70
149
|
a value can't be read as belonging to the neighbouring slider. -->
|
|
@@ -84,14 +163,17 @@
|
|
|
84
163
|
}}
|
|
85
164
|
/>
|
|
86
165
|
<Separator orientation="vertical" />
|
|
87
|
-
<div class="px-3.5 min-w-0 flex flex-1 items-center">
|
|
166
|
+
<div class="px-3.5 min-w-0 flex flex-1 items-center" onpointerdowncapture={startDrag}>
|
|
167
|
+
<!-- A function binding, not value + onValueChange: when a fine drag leaves the value
|
|
168
|
+
unchanged, the thumb must stay put instead of following the pointer. -->
|
|
88
169
|
<Slider
|
|
89
170
|
type="single"
|
|
90
|
-
value={numValue}
|
|
91
|
-
{
|
|
92
|
-
{
|
|
93
|
-
{step}
|
|
94
|
-
|
|
171
|
+
bind:value={() => toTrack(numValue), setFromTrack}
|
|
172
|
+
min={trackMin}
|
|
173
|
+
max={trackMax}
|
|
174
|
+
step={useProxyTrack ? 1 : step}
|
|
175
|
+
title={t.fineDragHint}
|
|
176
|
+
thumbLabelledby={labelId}
|
|
95
177
|
{disabled}
|
|
96
178
|
/>
|
|
97
179
|
</div>
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { debounce } from '$lib/utils/debounce';
|
|
4
4
|
import { Input } from '$lib/components/primitives/input';
|
|
5
5
|
import * as Field from '$lib/components/primitives/field';
|
|
6
|
+
import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
|
|
7
|
+
import { formatMessage } from '$lib/i18n/messages';
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
inputId: string;
|
|
@@ -14,6 +16,9 @@
|
|
|
14
16
|
|
|
15
17
|
let { inputId, value = $bindable(), config, onChange, disabled = false }: Props = $props();
|
|
16
18
|
|
|
19
|
+
const locale = getLocaleContext();
|
|
20
|
+
const t = $derived(locale.messages);
|
|
21
|
+
|
|
17
22
|
let validationError = $state<string | null>(null);
|
|
18
23
|
let currentValue = $state(value || '');
|
|
19
24
|
|
|
@@ -21,13 +26,13 @@
|
|
|
21
26
|
|
|
22
27
|
function validateText(val: string): string | null {
|
|
23
28
|
if (config?.maxLength && val.length > config.maxLength)
|
|
24
|
-
return
|
|
29
|
+
return formatMessage(t.maxLength, { max: config.maxLength });
|
|
25
30
|
if (config?.pattern) {
|
|
26
31
|
try {
|
|
27
32
|
if (!new RegExp(config.pattern).test(val))
|
|
28
|
-
return config.customErrorMessage ||
|
|
33
|
+
return config.customErrorMessage || t.invalidFormat;
|
|
29
34
|
} catch {
|
|
30
|
-
return
|
|
35
|
+
return t.invalidPattern;
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack, type Snippet } from 'svelte';
|
|
2
3
|
import { Button } from './button';
|
|
3
4
|
import { Zap, Check, Loader, Play } from '@lucide/svelte';
|
|
4
5
|
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
@@ -11,34 +12,59 @@
|
|
|
11
12
|
hasNeverSolved?: boolean;
|
|
12
13
|
isSolving: boolean;
|
|
13
14
|
oncalculate: () => void;
|
|
15
|
+
/** Beside the button, sharing its row: the panel footer's actions menu. */
|
|
16
|
+
trailing?: Snippet;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
let {
|
|
19
|
+
let {
|
|
20
|
+
hasPendingChanges,
|
|
21
|
+
hasNeverSolved = false,
|
|
22
|
+
isSolving,
|
|
23
|
+
oncalculate,
|
|
24
|
+
trailing
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
// The label below changes too, but a screen reader doesn't read a label change on a button
|
|
28
|
+
// that has lost focus, so start and end of a solve go through a live region as well.
|
|
29
|
+
let announcement = $state('');
|
|
30
|
+
let wasSolving = false;
|
|
31
|
+
$effect(() => {
|
|
32
|
+
const solving = isSolving;
|
|
33
|
+
untrack(() => {
|
|
34
|
+
if (solving) announcement = t.solvingEllipsis;
|
|
35
|
+
else if (wasSolving) announcement = hasPendingChanges ? '' : t.upToDate;
|
|
36
|
+
wasSolving = solving;
|
|
37
|
+
});
|
|
38
|
+
});
|
|
17
39
|
</script>
|
|
18
40
|
|
|
19
41
|
<div class="bottom-0 mt-3 pb-3 pt-2 backdrop-blur-sm px-2 sticky border-t border-border/50">
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
|
43
|
+
<div class="gap-2 flex">
|
|
44
|
+
<Button
|
|
45
|
+
variant={hasPendingChanges ? 'default' : 'ghost'}
|
|
46
|
+
size="lg"
|
|
47
|
+
onclick={() => !isSolving && oncalculate()}
|
|
48
|
+
disabled={!hasPendingChanges && !isSolving}
|
|
49
|
+
aria-busy={isSolving}
|
|
50
|
+
class="gap-2 font-medium min-w-0 flex-1 transition-all duration-200
|
|
27
51
|
{hasPendingChanges && !isSolving ? 'shadow-md' : ''}
|
|
28
52
|
{!hasPendingChanges && !isSolving ? 'text-muted-foreground' : ''}"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
>
|
|
54
|
+
{#if isSolving}
|
|
55
|
+
<Loader class="h-4 w-4 animate-spin" />
|
|
56
|
+
{t.solvingEllipsis}
|
|
57
|
+
{:else if hasNeverSolved}
|
|
58
|
+
<Play class="h-4 w-4" />
|
|
59
|
+
{t.pressToCalculate}
|
|
60
|
+
{:else if hasPendingChanges}
|
|
61
|
+
<Zap class="h-4 w-4" />
|
|
62
|
+
{t.calculate}
|
|
63
|
+
{:else}
|
|
64
|
+
<Check class="h-4 w-4" />
|
|
65
|
+
{t.upToDate}
|
|
66
|
+
{/if}
|
|
67
|
+
</Button>
|
|
68
|
+
{@render trailing?.()}
|
|
69
|
+
</div>
|
|
44
70
|
</div>
|