@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.
Files changed (93) hide show
  1. package/dist/components/compute/AppLayout.svelte +37 -7
  2. package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
  3. package/dist/components/compute/ParameterPresetManager.svelte +8 -20
  4. package/dist/components/compute/SolveMessages.svelte +26 -3
  5. package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
  6. package/dist/components/layout/PageFooter.svelte +3 -1
  7. package/dist/components/layout/SideNav.svelte +55 -14
  8. package/dist/components/preview/ChartOutput.svelte +18 -13
  9. package/dist/components/preview/Group.svelte +6 -1
  10. package/dist/components/preview/ImageOutput.svelte +3 -1
  11. package/dist/components/preview/InputControl.svelte +108 -44
  12. package/dist/components/preview/InputControl.svelte.d.ts +5 -1
  13. package/dist/components/preview/TabContent.svelte +61 -29
  14. package/dist/components/preview/TabContent.svelte.d.ts +5 -1
  15. package/dist/components/preview/TabLayout.svelte +25 -3
  16. package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
  17. package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
  18. package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
  19. package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
  20. package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
  21. package/dist/components/preview/inputs/FileInput.svelte +56 -65
  22. package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
  23. package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
  24. package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
  25. package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
  26. package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
  27. package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
  28. package/dist/components/preview/inputs/TileInput.svelte +80 -0
  29. package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
  30. package/dist/components/preview/inputs/index.d.ts +3 -0
  31. package/dist/components/preview/inputs/index.js +3 -0
  32. package/dist/components/primitives/SolvingIndicator.svelte +28 -3
  33. package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
  34. package/dist/components/primitives/button/button.svelte +3 -0
  35. package/dist/components/primitives/button/button.svelte.d.ts +3 -0
  36. package/dist/components/primitives/toggle/toggle.svelte +3 -1
  37. package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
  38. package/dist/components/viewer/Viewer.svelte +11 -11
  39. package/dist/compute/solving.svelte.d.ts +7 -1
  40. package/dist/compute/solving.svelte.js +12 -1
  41. package/dist/contexts/hostContext.svelte.d.ts +39 -0
  42. package/dist/contexts/hostContext.svelte.js +9 -0
  43. package/dist/i18n/messages.d.ts +33 -0
  44. package/dist/i18n/messages.js +58 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.js +3 -0
  47. package/dist/schema/host-support.d.ts +29 -0
  48. package/dist/schema/host-support.js +97 -0
  49. package/dist/schema/option-display.d.ts +12 -0
  50. package/dist/schema/option-display.js +29 -0
  51. package/dist/schema/param-exporter.d.ts +4 -1
  52. package/dist/schema/param-exporter.js +10 -6
  53. package/dist/styles/base.css +6 -3
  54. package/dist/utils/file-download.js +6 -10
  55. package/dist/utils/host-files.d.ts +12 -0
  56. package/dist/utils/host-files.js +46 -0
  57. package/dist/utils/webp-to-png.d.ts +10 -0
  58. package/dist/utils/webp-to-png.js +41 -0
  59. package/package.json +9 -9
  60. package/src/lib/components/compute/AppLayout.svelte +37 -7
  61. package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
  62. package/src/lib/components/compute/SolveMessages.svelte +26 -3
  63. package/src/lib/components/layout/PageFooter.svelte +3 -1
  64. package/src/lib/components/layout/SideNav.svelte +55 -14
  65. package/src/lib/components/preview/ChartOutput.svelte +18 -13
  66. package/src/lib/components/preview/Group.svelte +6 -1
  67. package/src/lib/components/preview/ImageOutput.svelte +3 -1
  68. package/src/lib/components/preview/InputControl.svelte +108 -44
  69. package/src/lib/components/preview/TabContent.svelte +61 -29
  70. package/src/lib/components/preview/TabLayout.svelte +25 -3
  71. package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
  72. package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
  73. package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
  74. package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
  75. package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
  76. package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
  77. package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
  78. package/src/lib/components/preview/inputs/index.ts +3 -0
  79. package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
  80. package/src/lib/components/primitives/button/button.svelte +3 -0
  81. package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
  82. package/src/lib/components/viewer/Viewer.svelte +11 -11
  83. package/src/lib/compute/solving.svelte.ts +16 -1
  84. package/src/lib/contexts/hostContext.svelte.ts +51 -0
  85. package/src/lib/i18n/messages.ts +96 -0
  86. package/src/lib/index.ts +20 -0
  87. package/src/lib/schema/host-support.ts +129 -0
  88. package/src/lib/schema/option-display.ts +44 -0
  89. package/src/lib/schema/param-exporter.ts +10 -7
  90. package/src/lib/styles/base.css +6 -3
  91. package/src/lib/utils/file-download.ts +7 -11
  92. package/src/lib/utils/host-files.ts +61 -0
  93. package/src/lib/utils/webp-to-png.ts +48 -0
@@ -4,6 +4,7 @@
4
4
  FileInputWidgetConfig,
5
5
  DynamicValueListWidgetConfig,
6
6
  DropdownWidgetConfig,
7
+ UISchema,
7
8
  SupportedTypes
8
9
  } from '@selvajs/schemas';
9
10
  import {
@@ -16,6 +17,8 @@
16
17
  isColorWidget
17
18
  } from '@selvajs/schemas';
18
19
  import * as Field from '$lib/components/primitives/field';
20
+ import { Button } from '$lib/components/primitives/button';
21
+ import { toast } from '$lib/components/primitives/sonner';
19
22
  import HelpButton from './HelpButton.svelte';
20
23
  import {
21
24
  CheckboxInput,
@@ -23,11 +26,21 @@
23
26
  ColorInput,
24
27
  DropdownInput,
25
28
  FileInput,
29
+ GeometryInput,
26
30
  NumberInput,
27
- TextInput
31
+ SegmentedInput,
32
+ TextInput,
33
+ TileInput
28
34
  } from '$lib/components/preview/inputs';
29
35
  import { getClientSlot } from '$lib/contexts/clientSlotContext.svelte';
36
+ import {
37
+ getHostContext,
38
+ type GeometryPickTarget,
39
+ type PickKind
40
+ } from '$lib/contexts/hostContext.svelte';
30
41
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
42
+ import { isGeometryParamType, isTriggerItem } from '$lib/schema/host-support';
43
+ import { resolveOptionDisplay } from '$lib/schema/option-display';
31
44
 
32
45
  interface Props {
33
46
  item: InputLayoutItem;
@@ -42,6 +55,10 @@
42
55
  disabled?: boolean;
43
56
  /** Runtime-computed options for a dynamic value list input (name -> value). */
44
57
  dynamicOptions?: Record<string, string>;
58
+ /** The input's paramType: a geometry input gets its control from this, not from the widget type. */
59
+ paramType?: string;
60
+ /** The schema's icons and thumbnails, which value-list options point into. */
61
+ assets?: UISchema['assets'];
45
62
  }
46
63
 
47
64
  let {
@@ -50,10 +67,25 @@
50
67
  displayName,
51
68
  onChange,
52
69
  disabled = false,
53
- dynamicOptions
70
+ dynamicOptions,
71
+ paramType,
72
+ assets
54
73
  }: Props = $props();
55
74
 
56
75
  const locale = getLocaleContext();
76
+ const hostActions = getHostContext();
77
+ const { pickGeometry, clearGeometry, onGeometrySummary } = hostActions;
78
+ const isGeometry = $derived(isGeometryParamType(paramType));
79
+ const isTrigger = $derived(isTriggerItem(item));
80
+ const pickTarget = $derived(
81
+ isGeometry ? hostActions.describeGeometry?.(item.paramId) : undefined
82
+ );
83
+
84
+ // Get Point/Line/Plane take their own kind; Get Geometry takes what its filter allows.
85
+ function pickKinds(target: GeometryPickTarget | undefined): readonly PickKind[] | undefined {
86
+ if (paramType === 'point' || paramType === 'line' || paramType === 'plane') return [paramType];
87
+ return target?.kinds;
88
+ }
57
89
 
58
90
  const inputId = $derived(`input-${item.paramId}`);
59
91
  const label = $derived(displayName || item.displayName || item.paramId);
@@ -102,7 +134,8 @@
102
134
  // As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
103
135
  const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
104
136
  options: dynamicListOptions,
105
- displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
137
+ displayAs: dynamicListConfig?.displayAs ?? 'dropdown',
138
+ optionMedia: dynamicListConfig?.optionMedia
106
139
  });
107
140
  const hideDynamicListWhenEmpty = $derived(
108
141
  isDynamicValueListWidget(item) &&
@@ -173,6 +206,48 @@
173
206
  }
174
207
  </script>
175
208
 
209
+ {#snippet valueList(config: DropdownWidgetConfig)}
210
+ {@const display = resolveOptionDisplay(
211
+ config.displayAs,
212
+ Object.keys(config.options ?? {}).length
213
+ )}
214
+ {@const single = typeof value === 'string' ? value : ''}
215
+ {#if display === 'checklist'}
216
+ <ChecklistInput
217
+ {inputId}
218
+ value={Array.isArray(value)
219
+ ? (value as string[])
220
+ : typeof value === 'string' && value
221
+ ? [value]
222
+ : []}
223
+ {config}
224
+ {assets}
225
+ onChange={commit}
226
+ {disabled}
227
+ />
228
+ {:else if display === 'segmented'}
229
+ <SegmentedInput
230
+ labelId="{inputId}-label"
231
+ value={single}
232
+ {config}
233
+ {assets}
234
+ onChange={commit}
235
+ {disabled}
236
+ />
237
+ {:else if display === 'tiles'}
238
+ <TileInput
239
+ labelId="{inputId}-label"
240
+ value={single}
241
+ {config}
242
+ {assets}
243
+ onChange={commit}
244
+ {disabled}
245
+ />
246
+ {:else}
247
+ <DropdownInput value={single} {config} {assets} onChange={commit} {disabled} />
248
+ {/if}
249
+ {/snippet}
250
+
176
251
  {#snippet labelText(forId?: string, tone: 'quiet' | 'strong' = 'quiet')}
177
252
  <Field.Label
178
253
  for={forId}
@@ -218,6 +293,34 @@
218
293
  })}
219
294
  </div>
220
295
  {/if}
296
+ {:else if isGeometry}
297
+ <div role="group" class="group/field gap-1.5 min-w-0 flex flex-col">
298
+ {@render fieldHeader(inputId)}
299
+ <GeometryInput
300
+ {inputId}
301
+ kinds={pickKinds(pickTarget)}
302
+ atMost={pickTarget?.atMost}
303
+ summary={typeof value === 'string' ? value : undefined}
304
+ onPick={pickGeometry && (() => pickGeometry(item.paramId))}
305
+ onClear={clearGeometry && (() => clearGeometry(item.paramId))}
306
+ watchSummary={onGeometrySummary && ((listener) => onGeometrySummary(item.paramId, listener))}
307
+ onError={(message) => toast.error(message)}
308
+ {disabled}
309
+ />
310
+ </div>
311
+ {:else if isTrigger}
312
+ <div role="group" class="group/field gap-1.5 min-w-0 flex items-center" data-control-line>
313
+ <Button
314
+ id={inputId}
315
+ variant="outline"
316
+ class="min-w-0 flex-1"
317
+ onclick={() => hostActions.trigger?.(item.paramId)}
318
+ disabled={disabled || !hostActions.trigger || hostActions.isSolving?.()}
319
+ >
320
+ <span class="truncate">{label}</span>
321
+ </Button>
322
+ {@render helpButton()}
323
+ </div>
221
324
  {:else if !hideDynamicListWhenEmpty}
222
325
  <div role="group" class="group/field gap-1.5 min-w-0 flex flex-col">
223
326
  {#if !isCheckboxWidget(item)}
@@ -254,49 +357,10 @@
254
357
  {disabled}
255
358
  />
256
359
  {:else if isDropdownWidget(item)}
257
- {@const config = item.config}
258
- {#if config.displayAs === 'checklist'}
259
- <ChecklistInput
260
- {inputId}
261
- value={Array.isArray(value)
262
- ? (value as string[])
263
- : typeof value === 'string' && value
264
- ? [value]
265
- : []}
266
- {config}
267
- onChange={commit}
268
- {disabled}
269
- />
270
- {:else}
271
- <DropdownInput
272
- value={typeof value === 'string' ? value : ''}
273
- {config}
274
- onChange={commit}
275
- {disabled}
276
- />
277
- {/if}
360
+ {@render valueList(item.config)}
278
361
  {:else if isDynamicValueListWidget(item)}
279
362
  {#if dynamicListHasOptions}
280
- {#if dynamicListAsDropdownConfig.displayAs === 'checklist'}
281
- <ChecklistInput
282
- {inputId}
283
- value={Array.isArray(value)
284
- ? (value as string[])
285
- : typeof value === 'string' && value
286
- ? [value]
287
- : []}
288
- config={dynamicListAsDropdownConfig}
289
- onChange={commit}
290
- {disabled}
291
- />
292
- {:else}
293
- <DropdownInput
294
- value={typeof value === 'string' ? value : ''}
295
- config={dynamicListAsDropdownConfig}
296
- onChange={commit}
297
- {disabled}
298
- />
299
- {/if}
363
+ {@render valueList(dynamicListAsDropdownConfig)}
300
364
  {:else}
301
365
  <p class="text-sm text-muted-foreground">{locale.messages.noOptionsYet}</p>
302
366
  {/if}
@@ -3,6 +3,7 @@
3
3
  TabConfig,
4
4
  SchemaInput,
5
5
  DiscoveredOutput,
6
+ UISchema,
6
7
  SupportedTypes,
7
8
  InputLayoutItem,
8
9
  OutputLayoutItem
@@ -14,6 +15,8 @@
14
15
  import InputControl from './InputControl.svelte';
15
16
  import OutputDisplay from './OutputDisplay.svelte';
16
17
  import { evaluateGroupVisibility } from '$lib/schema/visibility-rules';
18
+ import { isSupportedOn, layoutItemKind } from '$lib/schema/host-support';
19
+ import { getHostContext } from '$lib/contexts/hostContext.svelte';
17
20
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
18
21
 
19
22
  const locale = getLocaleContext();
@@ -29,6 +32,10 @@
29
32
  outputs: DiscoveredOutput[];
30
33
  /** Computed value list options keyed by the target dynamic-value-list input id. */
31
34
  dynamicOptions?: Record<string, Record<string, string>>;
35
+ /** The schema's icons and thumbnails, which value-list options point into. */
36
+ assets?: UISchema['assets'];
37
+ /** No tinted backdrop and no scroll area of its own: the container scrolls. */
38
+ bare?: boolean;
32
39
  }
33
40
 
34
41
  let {
@@ -39,11 +46,24 @@
39
46
  onValueChange,
40
47
  inputs,
41
48
  outputs,
42
- dynamicOptions = {}
49
+ dynamicOptions = {},
50
+ assets,
51
+ bare = false
43
52
  }: Props = $props();
44
53
 
54
+ const { host } = getHostContext();
55
+
56
+ // An element this host can't render is left out, not shown broken: its input still solves with
57
+ // the definition's own default. A group left with nothing to show goes too.
45
58
  const visibleGroups = $derived(
46
- tab.groups.filter((group) => evaluateGroupVisibility(group, values))
59
+ tab.groups
60
+ .filter((group) => evaluateGroupVisibility(group, values))
61
+ .flatMap((group) => {
62
+ const items = group.items.filter((item) =>
63
+ isSupportedOn(host, layoutItemKind(item, inputs))
64
+ );
65
+ return items.length > 0 || group.items.length === 0 ? [{ ...group, items }] : [];
66
+ })
47
67
  );
48
68
 
49
69
  function getInputById(paramId: string): SchemaInput | undefined {
@@ -68,6 +88,8 @@
68
88
  onChange={onValueChange}
69
89
  disabled={visibility.disabled}
70
90
  dynamicOptions={dynamicOptions[input.id]}
91
+ paramType={input.paramType}
92
+ {assets}
71
93
  />
72
94
  {/if}
73
95
  {/snippet}
@@ -83,34 +105,44 @@
83
105
  {/if}
84
106
  {/snippet}
85
107
 
86
- <Tabs.Content value={tab.id} class="min-h-0">
87
- <div class="h-full bg-muted dark:bg-background">
88
- <ScrollArea class="h-full" orientation="vertical">
89
- <div class="px-3">
90
- <!-- A heading per panel lets a screen reader jump between them; the tab, when shown,
108
+ {#snippet body()}
109
+ <div class={bare ? 'px-1' : 'px-3'}>
110
+ <!-- A heading per panel lets a screen reader jump between them; the tab, when shown,
91
111
  already names it on screen. -->
92
- <h2 class="sr-only">{tab.label}</h2>
93
- {#if visibleGroups.length === 0}
94
- <StateDisplay type="empty" size="medium" message={t.tabNoGroups} />
95
- {:else}
96
- <div class="gap-3 py-3 flex flex-col">
97
- {#each visibleGroups as group (group.id)}
98
- <Group
99
- label={group.label}
100
- description={group.description}
101
- items={group.items}
102
- columns={group.columns ?? 1}
103
- collapsed={collapsedGroups[group.id] ?? false}
104
- {values}
105
- onToggle={() => onToggleGroup(group.id)}
106
- inputSnippet={renderInput}
107
- outputSnippet={renderOutput}
108
- flat={visibleGroups.length === 1}
109
- />
110
- {/each}
111
- </div>
112
- {/if}
112
+ <h2 class="sr-only">{tab.label}</h2>
113
+ {#if visibleGroups.length === 0}
114
+ <StateDisplay type="empty" size="medium" message={t.tabNoGroups} />
115
+ {:else}
116
+ <div class="gap-3 py-3 flex flex-col">
117
+ {#each visibleGroups as group (group.id)}
118
+ <Group
119
+ label={group.label}
120
+ description={group.description}
121
+ items={group.items}
122
+ columns={group.columns ?? 1}
123
+ collapsed={collapsedGroups[group.id] ?? false}
124
+ {values}
125
+ onToggle={() => onToggleGroup(group.id)}
126
+ inputSnippet={renderInput}
127
+ outputSnippet={renderOutput}
128
+ flat={visibleGroups.length === 1}
129
+ />
130
+ {/each}
113
131
  </div>
114
- </ScrollArea>
132
+ {/if}
115
133
  </div>
134
+ {/snippet}
135
+
136
+ <Tabs.Content value={tab.id} class="min-h-0">
137
+ {#if bare}
138
+ {@render body()}
139
+ {:else}
140
+ <div class="h-full bg-muted dark:bg-background">
141
+ <!-- `auto`, not the default `hover`: a hover-only bar comes and goes with the pointer,
142
+ and a host that freezes mid-solve drops the pointer state with it. -->
143
+ <ScrollArea class="h-full" orientation="vertical" type="auto">
144
+ {@render body()}
145
+ </ScrollArea>
146
+ </div>
147
+ {/if}
116
148
  </Tabs.Content>
@@ -15,9 +15,21 @@
15
15
  panelFilter?: 'left' | 'right';
16
16
  /** Externally request a specific tab to be active (e.g. from collapsed strip click) */
17
17
  requestedTabId?: string | null;
18
+ /**
19
+ * Drops the card frame and the tab's own scroll area, for a container that already frames
20
+ * and scrolls it (the phone drawer). Nested, the inner scroll gets only a sliver of height.
21
+ */
22
+ bare?: boolean;
18
23
  }
19
24
 
20
- let { schema, values, onValueChange, panelFilter, requestedTabId = null }: Props = $props();
25
+ let {
26
+ schema,
27
+ values,
28
+ onValueChange,
29
+ panelFilter,
30
+ requestedTabId = null,
31
+ bare = false
32
+ }: Props = $props();
21
33
 
22
34
  let activeTabId = $state('');
23
35
  let collapsedGroups = $state<Record<string, boolean>>({});
@@ -66,7 +78,7 @@
66
78
  }
67
79
  </script>
68
80
 
69
- <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
81
+ {#snippet tabs()}
70
82
  <Tabs.Root value={activeTabId} variant="line" class="min-h-0 flex-1">
71
83
  {#if showTabBar}
72
84
  <TabBar tabs={visibleTabs} {activeTabId} onTabChange={handleTabChange} />
@@ -82,7 +94,17 @@
82
94
  inputs={schema.inputs}
83
95
  outputs={schema.outputs}
84
96
  {dynamicOptions}
97
+ assets={schema.assets}
98
+ {bare}
85
99
  />
86
100
  {/each}
87
101
  </Tabs.Root>
88
- </Card.Root>
102
+ {/snippet}
103
+
104
+ {#if bare}
105
+ {@render tabs()}
106
+ {:else}
107
+ <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
108
+ {@render tabs()}
109
+ </Card.Root>
110
+ {/if}
@@ -1,17 +1,20 @@
1
1
  <script lang="ts">
2
- import type { DropdownWidgetConfig, SupportedTypes } from '@selvajs/schemas';
2
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
3
3
  import { Checkbox } from '$lib/components/primitives/checkbox';
4
+ import { optionGlyph } from '$lib/schema/option-display';
5
+ import OptionGlyph from './OptionGlyph.svelte';
4
6
  import { SvelteSet } from 'svelte/reactivity';
5
7
 
6
8
  interface Props {
7
9
  inputId: string;
8
10
  value?: string[];
9
11
  config?: DropdownWidgetConfig;
12
+ assets?: UISchema['assets'];
10
13
  onChange: (value: SupportedTypes) => void;
11
14
  disabled?: boolean;
12
15
  }
13
16
 
14
- let { inputId, value, config, onChange, disabled = false }: Props = $props();
17
+ let { inputId, value, config, assets, onChange, disabled = false }: Props = $props();
15
18
 
16
19
  const options = $derived(config?.options ?? {});
17
20
  const selected = $derived(new Set(value ?? []));
@@ -32,6 +35,7 @@
32
35
  {@const optionValue = expr ?? name}
33
36
  {@const optionId = `${inputId}-${optionValue}`}
34
37
  {@const isSelected = selected.has(optionValue)}
38
+ {@const glyph = optionGlyph(config?.optionMedia?.[name], assets)}
35
39
  <label
36
40
  for={optionId}
37
41
  class="gap-3 px-3 py-2 text-sm flex items-center transition-colors select-none hover:bg-accent/50 {isSelected
@@ -44,6 +48,7 @@
44
48
  onCheckedChange={(state) => toggle(optionValue, state === true)}
45
49
  {disabled}
46
50
  />
51
+ {#if glyph}<OptionGlyph {glyph} class="text-muted-foreground" />{/if}
47
52
  <span class="flex-1" class:font-medium={isSelected}>{name}</span>
48
53
  </label>
49
54
  {/each}
@@ -1,16 +1,19 @@
1
1
  <script lang="ts">
2
- import type { DropdownWidgetConfig, SupportedTypes } from '@selvajs/schemas';
2
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
3
3
  import * as Select from '$lib/components/primitives/select';
4
+ import { optionGlyph } from '$lib/schema/option-display';
5
+ import OptionGlyph from './OptionGlyph.svelte';
4
6
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
5
7
 
6
8
  interface Props {
7
9
  value?: string;
8
10
  config?: DropdownWidgetConfig;
11
+ assets?: UISchema['assets'];
9
12
  onChange: (value: SupportedTypes) => void;
10
13
  disabled?: boolean;
11
14
  }
12
15
 
13
- let { value, config, onChange, disabled = false }: Props = $props();
16
+ let { value, config, assets, onChange, disabled = false }: Props = $props();
14
17
 
15
18
  const locale = getLocaleContext();
16
19
 
@@ -19,6 +22,8 @@
19
22
  const currentLabel = $derived(
20
23
  Object.entries(options).find(([_, expr]) => expr === currentValue)?.[0] ?? currentValue
21
24
  );
25
+ const glyphOf = (name: string) => optionGlyph(config?.optionMedia?.[name], assets);
26
+ const currentGlyph = $derived(glyphOf(currentLabel));
22
27
  </script>
23
28
 
24
29
  <Select.Root
@@ -33,11 +38,18 @@
33
38
  {disabled}
34
39
  >
35
40
  <Select.Trigger class="w-full" {disabled}>
36
- {currentLabel || locale.messages.selectOption}
41
+ <span class="gap-2 min-w-0 flex items-center">
42
+ {#if currentGlyph}<OptionGlyph glyph={currentGlyph} />{/if}
43
+ <span class="truncate">{currentLabel || locale.messages.selectOption}</span>
44
+ </span>
37
45
  </Select.Trigger>
38
46
  <Select.Content>
39
47
  {#each Object.entries(options) as [name, expr] (name)}
40
- <Select.Item value={expr ?? name} label={name} />
48
+ {@const glyph = glyphOf(name)}
49
+ <Select.Item value={expr ?? name} label={name}>
50
+ {#if glyph}<OptionGlyph {glyph} />{/if}
51
+ {name}
52
+ </Select.Item>
41
53
  {/each}
42
54
  </Select.Content>
43
55
  </Select.Root>
@@ -7,6 +7,8 @@
7
7
  import { APP_DEFAULTS } from '$lib/constants';
8
8
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
9
9
  import { formatMessage } from '$lib/i18n/messages';
10
+ import { pickFile } from '$lib/utils/host-files';
11
+ import { acceptsFileEnding, isWebp, shouldConvertWebp, webpToPng } from '$lib/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
- function isValidFileExtension(fileEnding: string): boolean {
98
- if (acceptedFormats.length === 0) return true;
99
- return acceptedFormats.includes(fileEnding.toLowerCase());
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 blob = await response.blob();
142
- const fileEnding = getFileExtension(urlInput);
160
+ const fetched = await response.blob();
161
+ const requestedEnding = (await isWebp(fetched)) ? '.webp' : getFileExtension(urlInput);
143
162
 
144
- if (!isValidFileExtension(fileEnding)) {
145
- urlError = { message: formatNotAccepted(fileEnding), isCors: false };
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 new Promise<string>((resolve, reject) => {
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 handleFileUpload(event: Event) {
210
- const target = event.target as HTMLInputElement;
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
- // The file is base64-embedded into the compute request body, so without a
222
- // client-side cap an oversize file comes back as an opaque 413. This cap is
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
- uploadedFileName = file.name;
232
-
233
- const reader = new FileReader();
234
- reader.onload = (e) => {
235
- const base64 = e.target?.result as string;
236
- const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
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
- const data = JSON.stringify({
239
- file: base64Data,
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
- reader.readAsDataURL(file);
247
+ } catch (error) {
248
+ toast.error(error instanceof Error ? error.message : t.fileReadFailed);
249
+ }
247
250
  }
248
251
 
249
- function openFilePicker() {
250
- fileInput?.click();
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 files = e.dataTransfer?.files;
267
- if (files?.[0]) {
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)}