@selvajs/ui 6.6.0-beta.8 → 6.6.0-beta.9
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/compute/AppLayout.svelte +37 -7
- package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
- package/dist/components/compute/ParameterPresetManager.svelte +8 -20
- package/dist/components/compute/SolveMessages.svelte +26 -3
- package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
- package/dist/components/layout/PageFooter.svelte +3 -1
- package/dist/components/layout/SideNav.svelte +55 -14
- package/dist/components/preview/ChartOutput.svelte +18 -13
- package/dist/components/preview/Group.svelte +6 -1
- package/dist/components/preview/ImageOutput.svelte +3 -1
- package/dist/components/preview/InputControl.svelte +108 -44
- package/dist/components/preview/InputControl.svelte.d.ts +5 -1
- package/dist/components/preview/TabContent.svelte +61 -29
- package/dist/components/preview/TabContent.svelte.d.ts +5 -1
- package/dist/components/preview/TabLayout.svelte +25 -3
- package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
- package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
- package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
- package/dist/components/preview/inputs/FileInput.svelte +56 -65
- package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
- package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/TileInput.svelte +80 -0
- package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
- package/dist/components/preview/inputs/index.d.ts +3 -0
- package/dist/components/preview/inputs/index.js +3 -0
- package/dist/components/primitives/SolvingIndicator.svelte +28 -3
- package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
- package/dist/components/primitives/button/button.svelte +3 -0
- package/dist/components/primitives/button/button.svelte.d.ts +3 -0
- package/dist/components/primitives/toggle/toggle.svelte +3 -1
- package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
- package/dist/components/viewer/Viewer.svelte +11 -11
- package/dist/compute/solving.svelte.d.ts +7 -1
- package/dist/compute/solving.svelte.js +12 -1
- package/dist/contexts/hostContext.svelte.d.ts +39 -0
- package/dist/contexts/hostContext.svelte.js +9 -0
- package/dist/i18n/messages.d.ts +33 -0
- package/dist/i18n/messages.js +58 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/schema/host-support.d.ts +29 -0
- package/dist/schema/host-support.js +97 -0
- package/dist/schema/option-display.d.ts +12 -0
- package/dist/schema/option-display.js +29 -0
- package/dist/schema/param-exporter.d.ts +4 -1
- package/dist/schema/param-exporter.js +10 -6
- package/dist/styles/base.css +6 -3
- package/dist/utils/file-download.js +6 -10
- package/dist/utils/host-files.d.ts +12 -0
- package/dist/utils/host-files.js +46 -0
- package/dist/utils/webp-to-png.d.ts +10 -0
- package/dist/utils/webp-to-png.js +41 -0
- package/package.json +9 -9
- package/src/lib/components/compute/AppLayout.svelte +37 -7
- package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
- package/src/lib/components/compute/SolveMessages.svelte +26 -3
- package/src/lib/components/layout/PageFooter.svelte +3 -1
- package/src/lib/components/layout/SideNav.svelte +55 -14
- package/src/lib/components/preview/ChartOutput.svelte +18 -13
- package/src/lib/components/preview/Group.svelte +6 -1
- package/src/lib/components/preview/ImageOutput.svelte +3 -1
- package/src/lib/components/preview/InputControl.svelte +108 -44
- package/src/lib/components/preview/TabContent.svelte +61 -29
- package/src/lib/components/preview/TabLayout.svelte +25 -3
- package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
- package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
- package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
- package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
- package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
- package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
- package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
- package/src/lib/components/preview/inputs/index.ts +3 -0
- package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
- package/src/lib/components/primitives/button/button.svelte +3 -0
- package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
- package/src/lib/components/viewer/Viewer.svelte +11 -11
- package/src/lib/compute/solving.svelte.ts +16 -1
- package/src/lib/contexts/hostContext.svelte.ts +51 -0
- package/src/lib/i18n/messages.ts +96 -0
- package/src/lib/index.ts +20 -0
- package/src/lib/schema/host-support.ts +129 -0
- package/src/lib/schema/option-display.ts +44 -0
- package/src/lib/schema/param-exporter.ts +10 -7
- package/src/lib/styles/base.css +6 -3
- package/src/lib/utils/file-download.ts +7 -11
- package/src/lib/utils/host-files.ts +61 -0
- package/src/lib/utils/webp-to-png.ts +48 -0
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { APP_DEFAULTS } from '../../../constants';
|
|
8
8
|
import { getLocaleContext } from '../../../i18n/localeContext.svelte';
|
|
9
9
|
import { formatMessage } from '../../../i18n/messages';
|
|
10
|
+
import { pickFile } from '../../../utils/host-files';
|
|
11
|
+
import { acceptsFileEnding, isWebp, shouldConvertWebp, webpToPng } from '../../../utils/webp-to-png';
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
value?: string;
|
|
@@ -40,7 +42,6 @@
|
|
|
40
42
|
activeMode = effectiveModes.includes(preferred) ? preferred : effectiveModes[0];
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
let fileInput: HTMLInputElement | null = $state(null);
|
|
44
45
|
let uploadedFileName = $state('');
|
|
45
46
|
let urlInput = $state('');
|
|
46
47
|
let isDragging = $state(false);
|
|
@@ -94,9 +95,27 @@
|
|
|
94
95
|
return ext.toLowerCase();
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Runs before the size cap: the PNG is larger than the WebP it came from. A WebP already over
|
|
99
|
+
// the cap is passed through unconverted, so the cap rejects it without a full decode.
|
|
100
|
+
async function prepare(
|
|
101
|
+
blob: Blob,
|
|
102
|
+
fileEnding: string
|
|
103
|
+
): Promise<{ blob: Blob; fileEnding: string }> {
|
|
104
|
+
if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) return { blob, fileEnding };
|
|
105
|
+
if (!shouldConvertWebp(fileEnding, acceptedFormats)) return { blob, fileEnding };
|
|
106
|
+
return { blob: await webpToPng(blob), fileEnding: '.png' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBase64(blob: Blob): Promise<string> {
|
|
110
|
+
return new Promise<string>((resolve, reject) => {
|
|
111
|
+
const reader = new FileReader();
|
|
112
|
+
reader.onload = (e) => {
|
|
113
|
+
const result = e.target?.result as string;
|
|
114
|
+
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
115
|
+
};
|
|
116
|
+
reader.onerror = () => reject(new Error(t.fileReadFailed));
|
|
117
|
+
reader.readAsDataURL(blob);
|
|
118
|
+
});
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
function isCorsError(error: unknown): boolean {
|
|
@@ -138,14 +157,16 @@
|
|
|
138
157
|
return;
|
|
139
158
|
}
|
|
140
159
|
|
|
141
|
-
const
|
|
142
|
-
const
|
|
160
|
+
const fetched = await response.blob();
|
|
161
|
+
const requestedEnding = (await isWebp(fetched)) ? '.webp' : getFileExtension(urlInput);
|
|
143
162
|
|
|
144
|
-
if (!
|
|
145
|
-
urlError = { message: formatNotAccepted(
|
|
163
|
+
if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
|
|
164
|
+
urlError = { message: formatNotAccepted(requestedEnding), isCors: false };
|
|
146
165
|
return;
|
|
147
166
|
}
|
|
148
167
|
|
|
168
|
+
const { blob, fileEnding } = await prepare(fetched, requestedEnding);
|
|
169
|
+
|
|
149
170
|
const maxSize = APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES;
|
|
150
171
|
if (blob.size > maxSize) {
|
|
151
172
|
urlError = {
|
|
@@ -155,15 +176,7 @@
|
|
|
155
176
|
return;
|
|
156
177
|
}
|
|
157
178
|
|
|
158
|
-
const base64Data = await
|
|
159
|
-
const reader = new FileReader();
|
|
160
|
-
reader.onload = (e) => {
|
|
161
|
-
const result = e.target?.result as string;
|
|
162
|
-
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
163
|
-
};
|
|
164
|
-
reader.onerror = () => reject(new Error(t.fileReadFailed));
|
|
165
|
-
reader.readAsDataURL(blob);
|
|
166
|
-
});
|
|
179
|
+
const base64Data = await readBase64(blob);
|
|
167
180
|
const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
|
|
168
181
|
value = data;
|
|
169
182
|
urlSuccess = formatMessage(t.fileLoaded, {
|
|
@@ -206,48 +219,39 @@
|
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
function
|
|
210
|
-
const
|
|
211
|
-
const file = target.files?.[0];
|
|
212
|
-
if (!file) return;
|
|
213
|
-
|
|
214
|
-
const fileEnding = getFileExtension(file.name);
|
|
215
|
-
|
|
216
|
-
if (!isValidFileExtension(fileEnding)) {
|
|
217
|
-
toast.error(formatNotAccepted(fileEnding));
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
222
|
+
async function handleFile(file: File) {
|
|
223
|
+
const requestedEnding = getFileExtension(file.name);
|
|
220
224
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
224
|
-
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
225
|
-
// path applies the same cap.
|
|
226
|
-
if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
227
|
-
toast.error(formatTooLarge(file.size));
|
|
225
|
+
if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
|
|
226
|
+
toast.error(formatNotAccepted(requestedEnding));
|
|
228
227
|
return;
|
|
229
228
|
}
|
|
230
229
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
try {
|
|
231
|
+
const { blob, fileEnding } = await prepare(file, requestedEnding);
|
|
232
|
+
|
|
233
|
+
// The file is base64-embedded into the compute request body, so without a
|
|
234
|
+
// client-side cap an oversize file comes back as an opaque 413. This cap is
|
|
235
|
+
// currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
|
|
236
|
+
// note in constants.ts) — some files pass here and still 413. The URL import
|
|
237
|
+
// path applies the same cap.
|
|
238
|
+
if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
|
|
239
|
+
toast.error(formatTooLarge(blob.size));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
237
242
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
type: 'base64',
|
|
241
|
-
fileEnding
|
|
242
|
-
});
|
|
243
|
+
uploadedFileName = file.name;
|
|
244
|
+
const data = JSON.stringify({ file: await readBase64(blob), type: 'base64', fileEnding });
|
|
243
245
|
value = data;
|
|
244
246
|
onChange(data);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
+
} catch (error) {
|
|
248
|
+
toast.error(error instanceof Error ? error.message : t.fileReadFailed);
|
|
249
|
+
}
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
function openFilePicker() {
|
|
250
|
-
|
|
252
|
+
async function openFilePicker() {
|
|
253
|
+
const file = await pickFile({ accept: acceptAttribute });
|
|
254
|
+
if (file) handleFile(file);
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
function handleDragOver(e: DragEvent) {
|
|
@@ -263,27 +267,14 @@
|
|
|
263
267
|
e.preventDefault();
|
|
264
268
|
isDragging = false;
|
|
265
269
|
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
const fakeEvent = new Event('change');
|
|
269
|
-
const fakeTarget = { files: files } as HTMLInputElement;
|
|
270
|
-
Object.defineProperty(fakeEvent, 'target', { value: fakeTarget });
|
|
271
|
-
handleFileUpload(fakeEvent);
|
|
272
|
-
}
|
|
270
|
+
const file = e.dataTransfer?.files?.[0];
|
|
271
|
+
if (file) handleFile(file);
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
const acceptAttribute = $derived(acceptedFormats.length > 0 ? acceptedFormats.join(',') : '*');
|
|
276
275
|
</script>
|
|
277
276
|
|
|
278
277
|
<div class="gap-2.5 flex w-full flex-col">
|
|
279
|
-
<input
|
|
280
|
-
bind:this={fileInput}
|
|
281
|
-
type="file"
|
|
282
|
-
accept={acceptAttribute}
|
|
283
|
-
onchange={handleFileUpload}
|
|
284
|
-
class="hidden"
|
|
285
|
-
/>
|
|
286
|
-
|
|
287
278
|
{#if showToggle}
|
|
288
279
|
<div class="p-0.5 text-xs flex rounded-md bg-muted">
|
|
289
280
|
{#each effectiveModes as mode (mode)}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from '../../primitives/button';
|
|
3
|
+
import type { GeometryPickResult, PickKind } from '../../../contexts/hostContext.svelte';
|
|
4
|
+
import { getLocaleContext } from '../../../i18n/localeContext.svelte';
|
|
5
|
+
import type { ViewerMessages } from '../../../i18n/messages';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
inputId: string;
|
|
9
|
+
/** The kinds of object the input takes; one kind names the button after it. */
|
|
10
|
+
kinds?: readonly PickKind[];
|
|
11
|
+
/** 1 picks a single object. */
|
|
12
|
+
atMost?: number;
|
|
13
|
+
/** What the host reports the param holds, such as "2 curves"; absent until something is picked. */
|
|
14
|
+
summary?: string;
|
|
15
|
+
/** Absent where this page may not pick, such as a browser tab beside the Rhino panel. */
|
|
16
|
+
onPick?: () => Promise<GeometryPickResult>;
|
|
17
|
+
onClear?: () => void;
|
|
18
|
+
/** Subscribes to what the host holds after any page picks or clears; returns an unsubscribe. */
|
|
19
|
+
watchSummary?: (listener: (summary: string | null) => void) => () => void;
|
|
20
|
+
/** Shown when the pick fails; the host's own message. */
|
|
21
|
+
onError?: (message: string) => void;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
inputId,
|
|
27
|
+
kinds,
|
|
28
|
+
atMost,
|
|
29
|
+
summary,
|
|
30
|
+
onPick,
|
|
31
|
+
onClear,
|
|
32
|
+
watchSummary,
|
|
33
|
+
onError,
|
|
34
|
+
disabled = false
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
const locale = getLocaleContext();
|
|
38
|
+
const t = $derived(locale.messages);
|
|
39
|
+
|
|
40
|
+
type PickLabels = readonly [one: keyof ViewerMessages, other: keyof ViewerMessages];
|
|
41
|
+
|
|
42
|
+
const ANY_LABELS: PickLabels = ['geometryPickAnyOne', 'geometryPickAnyOther'];
|
|
43
|
+
const KIND_LABELS: Record<PickKind, PickLabels> = {
|
|
44
|
+
point: ['geometryPickPointOne', 'geometryPickPointOther'],
|
|
45
|
+
pointCloud: ['geometryPickPointCloudOne', 'geometryPickPointCloudOther'],
|
|
46
|
+
curve: ['geometryPickCurveOne', 'geometryPickCurveOther'],
|
|
47
|
+
line: ['geometryPickLineOne', 'geometryPickLineOther'],
|
|
48
|
+
plane: ['geometryPickPlaneOne', 'geometryPickPlaneOther'],
|
|
49
|
+
surface: ['geometryPickSurfaceOne', 'geometryPickSurfaceOther'],
|
|
50
|
+
polysurface: ['geometryPickPolysurfaceOne', 'geometryPickPolysurfaceOther'],
|
|
51
|
+
extrusion: ['geometryPickExtrusionOne', 'geometryPickExtrusionOther'],
|
|
52
|
+
mesh: ['geometryPickMeshOne', 'geometryPickMeshOther'],
|
|
53
|
+
subd: ['geometryPickSubdOne', 'geometryPickSubdOther'],
|
|
54
|
+
block: ['geometryPickBlockOne', 'geometryPickBlockOther']
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const pickLabel = $derived.by(() => {
|
|
58
|
+
const [one, other] = kinds?.length === 1 ? KIND_LABELS[kinds[0]] : ANY_LABELS;
|
|
59
|
+
return t[atMost === 1 ? one : other];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Rhino's command line prompts during the pick; the page keeps the button from being pressed
|
|
63
|
+
// twice and says where to look.
|
|
64
|
+
let picking = $state(false);
|
|
65
|
+
// The last pick or clear wins over what the host reported when the page loaded.
|
|
66
|
+
let local = $state<string | null | undefined>(undefined);
|
|
67
|
+
const shown = $derived(local !== undefined ? local : (summary ?? null));
|
|
68
|
+
|
|
69
|
+
$effect(() =>
|
|
70
|
+
watchSummary?.((held) => {
|
|
71
|
+
local = held;
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
async function pick(onPick: () => Promise<GeometryPickResult>) {
|
|
76
|
+
picking = true;
|
|
77
|
+
try {
|
|
78
|
+
const result = await onPick();
|
|
79
|
+
if (result.outcome === 'picked') local = result.summary;
|
|
80
|
+
else if (result.outcome === 'failed') onError?.(result.error);
|
|
81
|
+
} finally {
|
|
82
|
+
picking = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The field label points at inputId: the button where there is one, else an <output>, which
|
|
87
|
+
// unlike a <span> can be labelled.
|
|
88
|
+
const readout = $derived(onPick ? 'span' : 'output');
|
|
89
|
+
|
|
90
|
+
function clear(onClear: () => void) {
|
|
91
|
+
local = null;
|
|
92
|
+
onClear();
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<div class="gap-2 min-w-0 flex flex-wrap items-center">
|
|
97
|
+
{#if onPick}
|
|
98
|
+
<Button
|
|
99
|
+
id={inputId}
|
|
100
|
+
variant="outline"
|
|
101
|
+
size="sm"
|
|
102
|
+
onclick={() => pick(onPick)}
|
|
103
|
+
disabled={disabled || picking}
|
|
104
|
+
>
|
|
105
|
+
{shown ? t.geometryPickAgain : pickLabel}
|
|
106
|
+
</Button>
|
|
107
|
+
{/if}
|
|
108
|
+
{#if picking}
|
|
109
|
+
<span class="text-sm min-w-0 text-muted-foreground" role="status">{t.geometryPicking}</span>
|
|
110
|
+
{:else if shown}
|
|
111
|
+
<svelte:element
|
|
112
|
+
this={readout}
|
|
113
|
+
id={onPick ? undefined : inputId}
|
|
114
|
+
class="text-sm min-w-0 truncate text-foreground"
|
|
115
|
+
>
|
|
116
|
+
{shown}
|
|
117
|
+
</svelte:element>
|
|
118
|
+
{#if onClear}
|
|
119
|
+
<Button variant="ghost" size="sm" onclick={() => clear(onClear)} {disabled}>
|
|
120
|
+
{t.geometryClear}
|
|
121
|
+
</Button>
|
|
122
|
+
{/if}
|
|
123
|
+
{:else if !onPick}
|
|
124
|
+
<output id={inputId} class="text-sm min-w-0 text-muted-foreground"
|
|
125
|
+
>{t.geometryPickInPanel}</output
|
|
126
|
+
>
|
|
127
|
+
{/if}
|
|
128
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GeometryPickResult, PickKind } from '../../../contexts/hostContext.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
inputId: string;
|
|
4
|
+
/** The kinds of object the input takes; one kind names the button after it. */
|
|
5
|
+
kinds?: readonly PickKind[];
|
|
6
|
+
/** 1 picks a single object. */
|
|
7
|
+
atMost?: number;
|
|
8
|
+
/** What the host reports the param holds, such as "2 curves"; absent until something is picked. */
|
|
9
|
+
summary?: string;
|
|
10
|
+
/** Absent where this page may not pick, such as a browser tab beside the Rhino panel. */
|
|
11
|
+
onPick?: () => Promise<GeometryPickResult>;
|
|
12
|
+
onClear?: () => void;
|
|
13
|
+
/** Subscribes to what the host holds after any page picks or clears; returns an unsubscribe. */
|
|
14
|
+
watchSummary?: (listener: (summary: string | null) => void) => () => void;
|
|
15
|
+
/** Shown when the pick fails; the host's own message. */
|
|
16
|
+
onError?: (message: string) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
declare const GeometryInput: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type GeometryInput = ReturnType<typeof GeometryInput>;
|
|
21
|
+
export default GeometryInput;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { GlyphSource } from '../../../schema/option-display';
|
|
3
|
+
import { cn } from '../../../utils.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
glyph: GlyphSource;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { glyph, class: className }: Props = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if glyph.kind === 'icon'}
|
|
14
|
+
<!-- Lucide's stroke defaults; the path is bound as an attribute, never parsed as markup. -->
|
|
15
|
+
<svg
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
stroke-width="2"
|
|
20
|
+
stroke-linecap="round"
|
|
21
|
+
stroke-linejoin="round"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
class={cn('size-4 shrink-0', className)}
|
|
24
|
+
>
|
|
25
|
+
<path d={glyph.d} />
|
|
26
|
+
</svg>
|
|
27
|
+
{:else}
|
|
28
|
+
<img src={glyph.src} alt="" class={cn('size-4 shrink-0 rounded-sm object-cover', className)} />
|
|
29
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GlyphSource } from '../../../schema/option-display';
|
|
2
|
+
interface Props {
|
|
3
|
+
glyph: GlyphSource;
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
declare const OptionGlyph: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type OptionGlyph = ReturnType<typeof OptionGlyph>;
|
|
8
|
+
export default OptionGlyph;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
|
+
import * as ToggleGroup from '../../primitives/toggle-group';
|
|
4
|
+
import { optionGlyph } from '../../../schema/option-display';
|
|
5
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
labelId: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
config: DropdownWidgetConfig;
|
|
11
|
+
assets?: UISchema['assets'];
|
|
12
|
+
onChange: (value: SupportedTypes) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const options = $derived(Object.entries(config.options ?? {}));
|
|
19
|
+
const hasIcons = $derived(
|
|
20
|
+
options.some(([name]) => optionGlyph(config.optionMedia?.[name], assets))
|
|
21
|
+
);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<ToggleGroup.Root
|
|
25
|
+
type="single"
|
|
26
|
+
variant="outline"
|
|
27
|
+
size={hasIcons ? 'stacked' : 'default'}
|
|
28
|
+
class="w-full"
|
|
29
|
+
aria-labelledby={labelId}
|
|
30
|
+
value={value ?? ''}
|
|
31
|
+
onValueChange={(next) => {
|
|
32
|
+
// A single-select group emits '' when the active item is clicked again; keep the selection.
|
|
33
|
+
if (next) onChange(next);
|
|
34
|
+
}}
|
|
35
|
+
{disabled}
|
|
36
|
+
>
|
|
37
|
+
{#each options as [name, expr] (name)}
|
|
38
|
+
{@const glyph = optionGlyph(config.optionMedia?.[name], assets)}
|
|
39
|
+
<ToggleGroup.Item value={expr ?? name} class="min-w-0 flex-1" title={name}>
|
|
40
|
+
{#if glyph}<OptionGlyph {glyph} />{/if}
|
|
41
|
+
<span class="max-w-full truncate">{name}</span>
|
|
42
|
+
</ToggleGroup.Item>
|
|
43
|
+
{/each}
|
|
44
|
+
</ToggleGroup.Root>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
2
|
+
interface Props {
|
|
3
|
+
labelId: string;
|
|
4
|
+
value?: string;
|
|
5
|
+
config: DropdownWidgetConfig;
|
|
6
|
+
assets?: UISchema['assets'];
|
|
7
|
+
onChange: (value: SupportedTypes) => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare const SegmentedInput: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type SegmentedInput = ReturnType<typeof SegmentedInput>;
|
|
12
|
+
export default SegmentedInput;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
3
|
+
import { optionGlyph } from '../../../schema/option-display';
|
|
4
|
+
import OptionGlyph from './OptionGlyph.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
labelId: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
config: DropdownWidgetConfig;
|
|
10
|
+
assets?: UISchema['assets'];
|
|
11
|
+
onChange: (value: SupportedTypes) => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
|
|
16
|
+
|
|
17
|
+
const options = $derived(
|
|
18
|
+
Object.entries(config.options ?? {}).map(([name, expr]) => ({ name, value: expr ?? name }))
|
|
19
|
+
);
|
|
20
|
+
// With nothing selected the first tile takes focus, so the group is still reachable by Tab.
|
|
21
|
+
const focusIndex = $derived(
|
|
22
|
+
Math.max(
|
|
23
|
+
0,
|
|
24
|
+
options.findIndex((o) => o.value === value)
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
let tiles: HTMLButtonElement[] = $state([]);
|
|
29
|
+
|
|
30
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
31
|
+
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
|
32
|
+
if (!step) return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const next = (index + step + options.length) % options.length;
|
|
35
|
+
onChange(options[next].value);
|
|
36
|
+
tiles[next]?.focus();
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div role="radiogroup" aria-labelledby={labelId} class="gap-2 tile-grid grid">
|
|
41
|
+
{#each options as option, index (option.name)}
|
|
42
|
+
{@const glyph = optionGlyph(config.optionMedia?.[option.name], assets)}
|
|
43
|
+
{@const selected = option.value === value}
|
|
44
|
+
<button
|
|
45
|
+
bind:this={tiles[index]}
|
|
46
|
+
type="button"
|
|
47
|
+
role="radio"
|
|
48
|
+
aria-checked={selected}
|
|
49
|
+
tabindex={index === focusIndex ? 0 : -1}
|
|
50
|
+
{disabled}
|
|
51
|
+
onclick={() => onChange(option.value)}
|
|
52
|
+
onkeydown={(e) => onKeydown(e, index)}
|
|
53
|
+
class="gap-1.5 p-1.5 min-w-0 flex flex-col rounded-lg border text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 {selected
|
|
54
|
+
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
|
55
|
+
: 'border-input hover:bg-accent/40'}"
|
|
56
|
+
>
|
|
57
|
+
<span
|
|
58
|
+
class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground"
|
|
59
|
+
>
|
|
60
|
+
{#if glyph?.kind === 'image'}
|
|
61
|
+
<OptionGlyph {glyph} class="size-full" />
|
|
62
|
+
{:else if glyph}
|
|
63
|
+
<OptionGlyph {glyph} class="size-7" />
|
|
64
|
+
{:else}
|
|
65
|
+
<span class="text-lg font-medium" aria-hidden="true">{option.name.charAt(0)}</span>
|
|
66
|
+
{/if}
|
|
67
|
+
</span>
|
|
68
|
+
<span class="px-0.5 text-xs leading-tight truncate" class:font-medium={selected}>
|
|
69
|
+
{option.name}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
{/each}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
/* Two or three tiles a row as the panel narrows; a fixed count shrinks them to unreadable. */
|
|
77
|
+
.tile-grid {
|
|
78
|
+
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
|
|
2
|
+
interface Props {
|
|
3
|
+
labelId: string;
|
|
4
|
+
value?: string;
|
|
5
|
+
config: DropdownWidgetConfig;
|
|
6
|
+
assets?: UISchema['assets'];
|
|
7
|
+
onChange: (value: SupportedTypes) => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare const TileInput: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type TileInput = ReturnType<typeof TileInput>;
|
|
12
|
+
export default TileInput;
|
|
@@ -3,5 +3,8 @@ export { default as ChecklistInput } from './ChecklistInput.svelte';
|
|
|
3
3
|
export { default as ColorInput } from './ColorInput.svelte';
|
|
4
4
|
export { default as DropdownInput } from './DropdownInput.svelte';
|
|
5
5
|
export { default as FileInput } from './FileInput.svelte';
|
|
6
|
+
export { default as GeometryInput } from './GeometryInput.svelte';
|
|
6
7
|
export { default as NumberInput } from './NumberInput.svelte';
|
|
8
|
+
export { default as SegmentedInput } from './SegmentedInput.svelte';
|
|
7
9
|
export { default as TextInput } from './TextInput.svelte';
|
|
10
|
+
export { default as TileInput } from './TileInput.svelte';
|
|
@@ -3,5 +3,8 @@ export { default as ChecklistInput } from './ChecklistInput.svelte';
|
|
|
3
3
|
export { default as ColorInput } from './ColorInput.svelte';
|
|
4
4
|
export { default as DropdownInput } from './DropdownInput.svelte';
|
|
5
5
|
export { default as FileInput } from './FileInput.svelte';
|
|
6
|
+
export { default as GeometryInput } from './GeometryInput.svelte';
|
|
6
7
|
export { default as NumberInput } from './NumberInput.svelte';
|
|
8
|
+
export { default as SegmentedInput } from './SegmentedInput.svelte';
|
|
7
9
|
export { default as TextInput } from './TextInput.svelte';
|
|
10
|
+
export { default as TileInput } from './TileInput.svelte';
|
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
show: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `floating` overlays the bottom-left corner. `inline` is a compact chip the size of an
|
|
8
|
+
* icon button, for a narrow host whose corner holds its content and whose action row
|
|
9
|
+
* already reserves the chip's place. That host freezes while it solves, so the chip says
|
|
10
|
+
* Esc aborts: a frozen page can't offer a button.
|
|
11
|
+
*/
|
|
12
|
+
placement?: 'floating' | 'inline';
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
let { show }: Props = $props();
|
|
15
|
+
let { show, placement = 'floating' }: Props = $props();
|
|
9
16
|
|
|
10
17
|
const locale = getLocaleContext();
|
|
11
18
|
const t = $derived(locale.messages);
|
|
@@ -13,8 +20,26 @@
|
|
|
13
20
|
|
|
14
21
|
<!-- The live region stays mounted: a screen reader only announces text that changes inside a
|
|
15
22
|
region it already knows about, so one created together with its text says nothing. -->
|
|
16
|
-
<div
|
|
17
|
-
|
|
23
|
+
<div
|
|
24
|
+
role="status"
|
|
25
|
+
aria-live="polite"
|
|
26
|
+
aria-atomic="true"
|
|
27
|
+
class={placement === 'inline' ? 'h-10 gap-3 min-w-0 flex items-center' : undefined}
|
|
28
|
+
>
|
|
29
|
+
{#if show && placement === 'inline'}
|
|
30
|
+
<div
|
|
31
|
+
class="gap-2 px-3 h-10 text-sm font-semibold flex shrink-0 items-center rounded-md bg-primary text-primary-foreground"
|
|
32
|
+
>
|
|
33
|
+
<div
|
|
34
|
+
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
></div>
|
|
37
|
+
{t.solvingEllipsis}
|
|
38
|
+
</div>
|
|
39
|
+
<span class="text-xs min-w-0 truncate text-muted-foreground" data-testid="solve-esc-hint">
|
|
40
|
+
{t.solveMessagesEscHint}
|
|
41
|
+
</span>
|
|
42
|
+
{:else if show}
|
|
18
43
|
<div
|
|
19
44
|
class="bottom-6 left-6 gap-3 px-5 py-3 shadow-xl animate-in fade-in slide-in-from-left fixed z-50 flex items-center rounded-lg border border-primary/20 bg-primary text-primary-foreground duration-200"
|
|
20
45
|
>
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
interface Props {
|
|
2
2
|
show: boolean;
|
|
3
|
+
/**
|
|
4
|
+
* `floating` overlays the bottom-left corner. `inline` is a compact chip the size of an
|
|
5
|
+
* icon button, for a narrow host whose corner holds its content and whose action row
|
|
6
|
+
* already reserves the chip's place. That host freezes while it solves, so the chip says
|
|
7
|
+
* Esc aborts: a frozen page can't offer a button.
|
|
8
|
+
*/
|
|
9
|
+
placement?: 'floating' | 'inline';
|
|
3
10
|
}
|
|
4
11
|
declare const SolvingIndicator: import("svelte").Component<Props, {}, "">;
|
|
5
12
|
type SolvingIndicator = ReturnType<typeof SolvingIndicator>;
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
'bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground',
|
|
13
13
|
outline:
|
|
14
14
|
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
|
15
|
+
// Outline with an opaque dark fill: the translucent one washes out over the light 3D canvas.
|
|
16
|
+
overlay:
|
|
17
|
+
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border',
|
|
15
18
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
|
16
19
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
17
20
|
dashed:
|
|
@@ -6,6 +6,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
|
|
|
6
6
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
|
|
7
7
|
destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
|
|
8
8
|
outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
|
|
9
|
+
overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
|
|
9
10
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
|
|
10
11
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
|
|
11
12
|
dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
|
|
@@ -25,6 +26,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
|
|
|
25
26
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
|
|
26
27
|
destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
|
|
27
28
|
outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
|
|
29
|
+
overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
|
|
28
30
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
|
|
29
31
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
|
|
30
32
|
dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
|
|
@@ -44,6 +46,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
|
|
|
44
46
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
|
|
45
47
|
destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
|
|
46
48
|
outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
|
|
49
|
+
overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
|
|
47
50
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
|
|
48
51
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
|
|
49
52
|
dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
default:
|
|
13
13
|
'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
14
14
|
sm: "h-7 min-w-7 rounded-md px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
15
|
-
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'
|
|
15
|
+
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
16
|
+
// Icon over a short label, so a row of options still fits a phone-width column.
|
|
17
|
+
stacked: 'min-h-12 min-w-0 flex-col gap-1 px-1 py-1.5 text-xs'
|
|
16
18
|
}
|
|
17
19
|
},
|
|
18
20
|
defaultVariants: {
|