@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.
Files changed (114) hide show
  1. package/dist/components/ErrorScreen.svelte +6 -2
  2. package/dist/components/compute/AppLayout.svelte +163 -74
  3. package/dist/components/compute/AppLayout.svelte.d.ts +11 -0
  4. package/dist/components/compute/ComputeApp.svelte +40 -20
  5. package/dist/components/compute/ComputeApp.svelte.d.ts +19 -2
  6. package/dist/components/compute/ComputeMessagesDialog.svelte +30 -40
  7. package/dist/components/compute/ParameterPresetManager.svelte +7 -44
  8. package/dist/components/compute/ParameterPresetManager.svelte.d.ts +4 -5
  9. package/dist/components/compute/SolveMessages.svelte +30 -37
  10. package/dist/components/compute/SolveMessages.svelte.d.ts +0 -12
  11. package/dist/components/layout/PageFooter.svelte +21 -11
  12. package/dist/components/preview/ChartOutput.svelte +17 -8
  13. package/dist/components/preview/Group.svelte +33 -18
  14. package/dist/components/preview/HelpButton.svelte +4 -2
  15. package/dist/components/preview/ImageOutput.svelte +427 -68
  16. package/dist/components/preview/InputControl.svelte +11 -2
  17. package/dist/components/preview/OutputDisplay.svelte +17 -12
  18. package/dist/components/preview/TabContent.svelte +3 -0
  19. package/dist/components/preview/inputs/ColorInput.svelte +3 -1
  20. package/dist/components/preview/inputs/ColorInput.svelte.d.ts +1 -0
  21. package/dist/components/preview/inputs/DropdownInput.svelte +4 -1
  22. package/dist/components/preview/inputs/FileInput.svelte +50 -34
  23. package/dist/components/preview/inputs/NumberInput.svelte +92 -10
  24. package/dist/components/preview/inputs/NumberInput.svelte.d.ts +2 -0
  25. package/dist/components/preview/inputs/TextInput.svelte +8 -3
  26. package/dist/components/primitives/CalculateButton.svelte +49 -23
  27. package/dist/components/primitives/CalculateButton.svelte.d.ts +3 -0
  28. package/dist/components/primitives/SolvingIndicator.svelte +21 -17
  29. package/dist/components/primitives/badge/badge.svelte +2 -2
  30. package/dist/components/primitives/badge/badge.svelte.d.ts +4 -4
  31. package/dist/components/primitives/button/button.svelte +2 -2
  32. package/dist/components/primitives/button/button.svelte.d.ts +4 -4
  33. package/dist/components/primitives/checkbox/checkbox.svelte +1 -1
  34. package/dist/components/primitives/dialog/dialog-content.svelte +6 -2
  35. package/dist/components/primitives/input/input.svelte +2 -2
  36. package/dist/components/primitives/input-group/input-group.svelte +1 -1
  37. package/dist/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  38. package/dist/components/primitives/scroll-area/scroll-area.svelte +1 -1
  39. package/dist/components/primitives/search/search.svelte +1 -1
  40. package/dist/components/primitives/select/select-trigger.svelte +1 -1
  41. package/dist/components/primitives/slider/slider.svelte +7 -2
  42. package/dist/components/primitives/slider/slider.svelte.d.ts +5 -1
  43. package/dist/components/primitives/switch/switch.svelte +1 -1
  44. package/dist/components/primitives/tabs/tabs-trigger.svelte +1 -1
  45. package/dist/components/primitives/tabs/tabs-trigger.svelte.d.ts +1 -1
  46. package/dist/components/primitives/textarea/textarea.svelte +1 -1
  47. package/dist/components/primitives/toggle/toggle.svelte +1 -1
  48. package/dist/components/primitives/toggle/toggle.svelte.d.ts +1 -1
  49. package/dist/components/viewer/Viewer.svelte +20 -17
  50. package/dist/compute/run-panel-action.d.ts +8 -0
  51. package/dist/compute/run-panel-action.js +12 -0
  52. package/dist/compute/useSolveSession.svelte.js +3 -0
  53. package/dist/i18n/localeContext.svelte.d.ts +10 -3
  54. package/dist/i18n/localeContext.svelte.js +27 -5
  55. package/dist/i18n/messages.d.ts +115 -2
  56. package/dist/i18n/messages.js +199 -11
  57. package/dist/index.d.ts +1 -1
  58. package/dist/public.d.ts +1 -1
  59. package/dist/schema/visibility-rules.js +21 -0
  60. package/dist/styles/base.css +9 -6
  61. package/dist/types/actionButton.d.ts +17 -3
  62. package/dist/types/presetLabels.d.ts +3 -2
  63. package/dist/types/presetLabels.js +9 -8
  64. package/dist/utils/loadScript.d.ts +2 -0
  65. package/dist/utils/loadScript.js +2 -0
  66. package/package.json +8 -8
  67. package/src/lib/components/ErrorScreen.svelte +6 -2
  68. package/src/lib/components/compute/AppLayout.svelte +163 -74
  69. package/src/lib/components/compute/ComputeApp.svelte +40 -20
  70. package/src/lib/components/compute/ComputeMessagesDialog.svelte +30 -40
  71. package/src/lib/components/compute/ParameterPresetManager.svelte +7 -44
  72. package/src/lib/components/compute/SolveMessages.svelte +30 -37
  73. package/src/lib/components/layout/PageFooter.svelte +21 -11
  74. package/src/lib/components/preview/ChartOutput.svelte +17 -8
  75. package/src/lib/components/preview/Group.svelte +33 -18
  76. package/src/lib/components/preview/HelpButton.svelte +4 -2
  77. package/src/lib/components/preview/ImageOutput.svelte +427 -68
  78. package/src/lib/components/preview/InputControl.svelte +11 -2
  79. package/src/lib/components/preview/OutputDisplay.svelte +17 -12
  80. package/src/lib/components/preview/TabContent.svelte +3 -0
  81. package/src/lib/components/preview/inputs/ColorInput.svelte +3 -1
  82. package/src/lib/components/preview/inputs/DropdownInput.svelte +4 -1
  83. package/src/lib/components/preview/inputs/FileInput.svelte +50 -34
  84. package/src/lib/components/preview/inputs/NumberInput.svelte +92 -10
  85. package/src/lib/components/preview/inputs/TextInput.svelte +8 -3
  86. package/src/lib/components/primitives/CalculateButton.svelte +49 -23
  87. package/src/lib/components/primitives/SolvingIndicator.svelte +21 -17
  88. package/src/lib/components/primitives/badge/badge.svelte +2 -2
  89. package/src/lib/components/primitives/button/button.svelte +2 -2
  90. package/src/lib/components/primitives/checkbox/checkbox.svelte +1 -1
  91. package/src/lib/components/primitives/dialog/dialog-content.svelte +6 -2
  92. package/src/lib/components/primitives/input/input.svelte +2 -2
  93. package/src/lib/components/primitives/input-group/input-group.svelte +1 -1
  94. package/src/lib/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  95. package/src/lib/components/primitives/scroll-area/scroll-area.svelte +1 -1
  96. package/src/lib/components/primitives/search/search.svelte +1 -1
  97. package/src/lib/components/primitives/select/select-trigger.svelte +1 -1
  98. package/src/lib/components/primitives/slider/slider.svelte +7 -2
  99. package/src/lib/components/primitives/switch/switch.svelte +1 -1
  100. package/src/lib/components/primitives/tabs/tabs-trigger.svelte +1 -1
  101. package/src/lib/components/primitives/textarea/textarea.svelte +1 -1
  102. package/src/lib/components/primitives/toggle/toggle.svelte +1 -1
  103. package/src/lib/components/viewer/Viewer.svelte +20 -17
  104. package/src/lib/compute/run-panel-action.ts +16 -0
  105. package/src/lib/compute/useSolveSession.svelte.ts +3 -0
  106. package/src/lib/i18n/localeContext.svelte.ts +40 -5
  107. package/src/lib/i18n/messages.ts +351 -13
  108. package/src/lib/index.ts +1 -0
  109. package/src/lib/public.ts +1 -0
  110. package/src/lib/schema/visibility-rules.ts +23 -0
  111. package/src/lib/styles/base.css +9 -6
  112. package/src/lib/types/actionButton.ts +16 -3
  113. package/src/lib/types/presetLabels.ts +13 -11
  114. 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 : 'Failed to download files';
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">Waiting for data...</span>
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
- Waiting for file data...
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
- : `${fileCount} files`}
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">Duplicate file names detected</div>
253
+ <div class="font-medium">{t.duplicateFilesTitle}</div>
249
254
  <div class="mt-1 text-xs">
250
- The following {duplicatePaths.length === 1 ? 'path appears' : 'paths appear'} more than once:
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
- ? 'Downloading...'
272
- : `Download ${fileCount === 1 ? 'File' : `${fileCount} Files`}`}
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 ? 'Copied!' : 'Copy'}
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 || 'Select an option...'}
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 = `Uploaded file (${parsed.fileEnding}, ${sizeMB}MB)`;
60
+ uploadedFileName = formatMessage(t.uploadedFileSize, {
61
+ ext: parsed.fileEnding,
62
+ size: sizeMB
63
+ });
56
64
  } else {
57
- uploadedFileName = `Uploaded file (${parsed.fileEnding})`;
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: 'Invalid URL — make sure it starts with https://', isCors: false };
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: 'Only http:// and https:// URLs are supported.', isCors: false };
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: `Server returned ${response.status} ${response.statusText}. Check the URL is correct and publicly accessible.`,
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: `File too large: ${(blob.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB). Download it and use Upload instead.`,
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('Failed to read the downloaded file.'));
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 = `Loaded ${fileEnding} (${(blob.size / 1024).toFixed(0)} KB)`;
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: `Could not reach "${host}" — the file may not exist, or the server does not allow browser access.`,
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 : 'Unknown error',
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(`File format not accepted: ${fileEnding}`);
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' ? 'Upload' : 'URL'}
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
- File URL
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 ? 'Fetching...' : 'Fetch'}
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 || 'Choose File or Drag & Drop'}
372
+ {uploadedFileName || t.chooseFile}
357
373
  </Button>
358
374
  {#if acceptedFormats.length > 0}
359
375
  <p class="text-xs text-muted-foreground">
360
- Accepted formats: {acceptedFormats.join(', ')}
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 { inputId, value = $bindable(), config, onChange, disabled = false }: Props = $props();
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 `Minimum value is ${config.minimum}`;
46
+ return formatMessage(t.minValue, { min: config.minimum });
33
47
  if (config?.maximum !== undefined && val > config.maximum)
34
- return `Maximum value is ${config.maximum}`;
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
- function handleSliderChange(newValue: number) {
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
- {min}
92
- {max}
93
- {step}
94
- onValueChange={handleSliderChange}
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 `Maximum ${config.maxLength} characters allowed`;
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 || 'Invalid format';
33
+ return config.customErrorMessage || t.invalidFormat;
29
34
  } catch {
30
- return 'Invalid validation pattern configured';
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 { hasPendingChanges, hasNeverSolved = false, isSolving, oncalculate }: Props = $props();
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
- <Button
21
- variant={hasPendingChanges ? 'default' : 'ghost'}
22
- size="lg"
23
- onclick={() => !isSolving && oncalculate()}
24
- disabled={!hasPendingChanges && !isSolving}
25
- aria-busy={isSolving}
26
- class="gap-2 font-medium w-full transition-all duration-200
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
- {#if isSolving}
31
- <Loader class="h-4 w-4 animate-spin" />
32
- {t.solvingEllipsis}
33
- {:else if hasNeverSolved}
34
- <Play class="h-4 w-4" />
35
- {t.pressToCalculate}
36
- {:else if hasPendingChanges}
37
- <Zap class="h-4 w-4" />
38
- {t.calculate}
39
- {:else}
40
- <Check class="h-4 w-4" />
41
- {t.upToDate}
42
- {/if}
43
- </Button>
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>