@selvajs/ui 0.9.6 → 2.0.0
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/layout/AppShell.svelte +10 -1
- package/dist/components/layout/AppShell.svelte.d.ts +2 -0
- package/dist/components/layout/PageFooter.svelte +3 -2
- package/dist/components/layout/PageFooter.svelte.d.ts +1 -0
- package/dist/components/layout/PageHeader.svelte +3 -1
- package/dist/components/layout/PageHeader.svelte.d.ts +1 -0
- package/dist/components/preview/ChartOutput.svelte +6 -4
- package/dist/components/preview/ImageOutput.svelte +3 -3
- package/dist/components/preview/InputControl.svelte +0 -1
- package/dist/components/preview/OutputDisplay.svelte +4 -8
- package/dist/components/primitives/FilterableDropdown.svelte +186 -0
- package/dist/components/primitives/FilterableDropdown.svelte.d.ts +23 -0
- package/dist/components/primitives/index.d.ts +1 -0
- package/dist/components/primitives/index.js +1 -0
- package/dist/components/primitives/resizable/resizable-handle.svelte +1 -1
- package/dist/components/viewer/Viewer.svelte +1 -1
- package/dist/compute/computeThrottle.svelte.js +2 -2
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +2 -2
- package/dist/external/storage.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schema/param-exporter.js +2 -1
- package/dist/utils/debounce.d.ts +1 -1
- package/dist/utils/debounce.js +1 -1
- package/dist/utils/randomId.d.ts +1 -0
- package/dist/utils/randomId.js +51 -0
- package/package.json +6 -5
- package/src/lib/components/layout/AppShell.svelte +10 -1
- package/src/lib/components/layout/PageFooter.svelte +3 -2
- package/src/lib/components/layout/PageHeader.svelte +3 -1
- package/src/lib/components/preview/ChartOutput.svelte +6 -4
- package/src/lib/components/preview/ImageOutput.svelte +3 -3
- package/src/lib/components/preview/InputControl.svelte +0 -1
- package/src/lib/components/preview/OutputDisplay.svelte +4 -8
- package/src/lib/components/primitives/FilterableDropdown.svelte +186 -0
- package/src/lib/components/primitives/index.ts +4 -0
- package/src/lib/components/primitives/resizable/resizable-handle.svelte +1 -1
- package/src/lib/components/viewer/Viewer.svelte +1 -1
- package/src/lib/compute/computeThrottle.svelte.ts +2 -2
- package/src/lib/constants.ts +2 -2
- package/src/lib/external/storage.ts +2 -2
- package/src/lib/index.ts +1 -0
- package/src/lib/schema/param-exporter.ts +2 -1
- package/src/lib/utils/debounce.ts +1 -1
- package/src/lib/utils/randomId.ts +52 -0
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
homeUrl?: string;
|
|
10
10
|
title?: string | null;
|
|
11
11
|
logo?: string;
|
|
12
|
+
brandName?: string;
|
|
13
|
+
copyrightName?: string;
|
|
12
14
|
showModeToggle?: boolean;
|
|
13
15
|
navItems?: Snippet;
|
|
14
16
|
rightContent?: Snippet;
|
|
@@ -40,6 +42,8 @@
|
|
|
40
42
|
homeUrl = '/',
|
|
41
43
|
title = undefined,
|
|
42
44
|
logo = '/favicon/favicon.svg',
|
|
45
|
+
brandName = 'Selva',
|
|
46
|
+
copyrightName = undefined,
|
|
43
47
|
showModeToggle = true,
|
|
44
48
|
navItems,
|
|
45
49
|
rightContent,
|
|
@@ -56,6 +60,10 @@
|
|
|
56
60
|
children
|
|
57
61
|
}: AppShellProps = $props();
|
|
58
62
|
|
|
63
|
+
// Default the footer's copyright owner to the header's brand name when the
|
|
64
|
+
// caller didn't specify one.
|
|
65
|
+
const _copyright = $derived(copyrightName ?? brandName);
|
|
66
|
+
|
|
59
67
|
// fixed: viewport-locked, no page scroll — body owns its own scroll internally.
|
|
60
68
|
// scroll: normal page flow, footer sticks to bottom via sticky positioning.
|
|
61
69
|
const rootClass = $derived(
|
|
@@ -79,6 +87,7 @@
|
|
|
79
87
|
{homeUrl}
|
|
80
88
|
{title}
|
|
81
89
|
{logo}
|
|
90
|
+
{brandName}
|
|
82
91
|
{showModeToggle}
|
|
83
92
|
{navItems}
|
|
84
93
|
{rightContent}
|
|
@@ -100,7 +109,7 @@
|
|
|
100
109
|
|
|
101
110
|
{#if showFooter}
|
|
102
111
|
<div class="bottom-0 sticky z-10 shrink-0">
|
|
103
|
-
<PageFooter {errors} {warnings}>
|
|
112
|
+
<PageFooter {errors} {warnings} copyrightName={_copyright}>
|
|
104
113
|
{#if footerChildren}
|
|
105
114
|
{@render footerChildren()}
|
|
106
115
|
{/if}
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
interface Props {
|
|
11
11
|
errors?: string[];
|
|
12
12
|
warnings?: string[];
|
|
13
|
+
copyrightName?: string;
|
|
13
14
|
children?: Snippet;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
let { errors = [], warnings = [], children }: Props = $props();
|
|
17
|
+
let { errors = [], warnings = [], copyrightName = 'Selva', children }: Props = $props();
|
|
17
18
|
|
|
18
19
|
let footerStore = (() => {
|
|
19
20
|
try {
|
|
@@ -217,6 +218,6 @@
|
|
|
217
218
|
<FooterItemRenderer {item} />
|
|
218
219
|
{/each}
|
|
219
220
|
|
|
220
|
-
<p>by
|
|
221
|
+
<p>by {copyrightName} © {_currentYear}</p>
|
|
221
222
|
</div>
|
|
222
223
|
</footer>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
homeUrl?: string;
|
|
7
7
|
title?: string | null;
|
|
8
8
|
logo?: string;
|
|
9
|
+
brandName?: string;
|
|
9
10
|
navItems?: Snippet;
|
|
10
11
|
rightContent?: Snippet;
|
|
11
12
|
subnav?: Snippet;
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
homeUrl = '/app',
|
|
18
19
|
title = undefined,
|
|
19
20
|
logo = '/favicon/favicon.svg',
|
|
21
|
+
brandName = 'Selva',
|
|
20
22
|
navItems,
|
|
21
23
|
rightContent,
|
|
22
24
|
subnav,
|
|
@@ -33,7 +35,7 @@
|
|
|
33
35
|
<!-- Logo -->
|
|
34
36
|
<a href={homeUrl} class="gap-2 flex shrink-0 items-center">
|
|
35
37
|
<img src={logo} alt="" aria-hidden="true" class="h-5 w-5" />
|
|
36
|
-
<span class="font-semibold text-sm tracking-tight">
|
|
38
|
+
<span class="font-semibold text-sm tracking-tight">{brandName}</span>
|
|
37
39
|
</a>
|
|
38
40
|
|
|
39
41
|
{#if title !== undefined}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
value: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
let { item, value }: Props = $props();
|
|
12
|
+
let { item: _item, value }: Props = $props();
|
|
13
13
|
|
|
14
14
|
const MAP_TYPE_SET = new Set(['scattermap', 'scattermapbox', 'choropleth', 'scattergeo']);
|
|
15
15
|
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
|
|
178
178
|
<div
|
|
179
179
|
bind:this={wrapperEl}
|
|
180
|
-
class="group relative overflow-hidden
|
|
180
|
+
class="group rounded relative overflow-hidden border border-border {isFullscreen
|
|
181
181
|
? 'bg-background'
|
|
182
182
|
: ''}"
|
|
183
183
|
>
|
|
@@ -200,7 +200,9 @@
|
|
|
200
200
|
: ''}"
|
|
201
201
|
>
|
|
202
202
|
{#if loading}
|
|
203
|
-
<div
|
|
203
|
+
<div
|
|
204
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-muted-foreground"
|
|
205
|
+
>
|
|
204
206
|
<Loader size={14} class="animate-spin" />
|
|
205
207
|
</div>
|
|
206
208
|
{/if}
|
|
@@ -209,7 +211,7 @@
|
|
|
209
211
|
onclick={toggleFullscreen}
|
|
210
212
|
disabled={loading}
|
|
211
213
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
212
|
-
class="rounded p-1.5 flex items-center
|
|
214
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
213
215
|
>
|
|
214
216
|
{#if isFullscreen}
|
|
215
217
|
<Minimize size={14} />
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
|
|
94
94
|
<div
|
|
95
95
|
bind:this={wrapperEl}
|
|
96
|
-
class="group relative overflow-hidden
|
|
96
|
+
class="group rounded relative overflow-hidden border border-border {isFullscreen
|
|
97
97
|
? 'bg-background'
|
|
98
98
|
: ''}"
|
|
99
99
|
>
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
onclick={handleDownload}
|
|
132
132
|
disabled={downloading}
|
|
133
133
|
title="Download image"
|
|
134
|
-
class="rounded p-1.5 flex items-center
|
|
134
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
135
135
|
>
|
|
136
136
|
<Download size={14} />
|
|
137
137
|
</button>
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<button
|
|
142
142
|
onclick={toggleFullscreen}
|
|
143
143
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
144
|
-
class="rounded p-1.5 flex items-center
|
|
144
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background"
|
|
145
145
|
>
|
|
146
146
|
{#if isFullscreen}
|
|
147
147
|
<Minimize size={14} />
|
|
@@ -103,12 +103,12 @@
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const duplicatePaths = $derived.by(() => {
|
|
106
|
-
const seen
|
|
106
|
+
const seen: Record<string, number> = {};
|
|
107
107
|
for (const f of filesArray) {
|
|
108
108
|
const key = fullPath(f);
|
|
109
|
-
seen
|
|
109
|
+
seen[key] = (seen[key] ?? 0) + 1;
|
|
110
110
|
}
|
|
111
|
-
return
|
|
111
|
+
return Object.entries(seen)
|
|
112
112
|
.filter(([, n]) => n > 1)
|
|
113
113
|
.map(([path]) => path);
|
|
114
114
|
});
|
|
@@ -279,11 +279,7 @@
|
|
|
279
279
|
{downloadError}
|
|
280
280
|
</div>
|
|
281
281
|
{/if}
|
|
282
|
-
<Button
|
|
283
|
-
onclick={handleDownload}
|
|
284
|
-
disabled={downloading || hasDuplicates}
|
|
285
|
-
class="w-full"
|
|
286
|
-
>
|
|
282
|
+
<Button onclick={handleDownload} disabled={downloading || hasDuplicates} class="w-full">
|
|
287
283
|
{downloading
|
|
288
284
|
? 'Downloading...'
|
|
289
285
|
: `Download ${fileCount === 1 ? 'File' : `${fileCount} Files`}`}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { Search, ChevronDown, Check } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../../utils.js';
|
|
5
|
+
|
|
6
|
+
export interface FilterableDropdownItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: Component;
|
|
10
|
+
iconClass?: string;
|
|
11
|
+
iconStyle?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
items: FilterableDropdownItem[];
|
|
16
|
+
value: string | null | undefined;
|
|
17
|
+
onChange: (id: string) => void;
|
|
18
|
+
id?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
triggerIcon?: Component;
|
|
21
|
+
searchPlaceholder?: string;
|
|
22
|
+
searchThreshold?: number;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
items,
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
id,
|
|
32
|
+
placeholder = 'Select...',
|
|
33
|
+
triggerIcon: TriggerIcon,
|
|
34
|
+
searchPlaceholder = 'Filter...',
|
|
35
|
+
searchThreshold = 4,
|
|
36
|
+
disabled = false,
|
|
37
|
+
class: className
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let open = $state(false);
|
|
41
|
+
let query = $state('');
|
|
42
|
+
let highlighted = $state(0);
|
|
43
|
+
let inputRef = $state<HTMLInputElement>();
|
|
44
|
+
let containerRef = $state<HTMLDivElement>();
|
|
45
|
+
|
|
46
|
+
const selected = $derived(items.find((item) => item.id === value) ?? null);
|
|
47
|
+
const showSearch = $derived(items.length > searchThreshold);
|
|
48
|
+
|
|
49
|
+
const matches = $derived.by(() => {
|
|
50
|
+
const q = query.trim().toLowerCase();
|
|
51
|
+
if (!q) return items;
|
|
52
|
+
return items.filter((item) => item.label.toLowerCase().includes(q));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
$effect(() => {
|
|
56
|
+
void matches;
|
|
57
|
+
highlighted = 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (open) {
|
|
62
|
+
queueMicrotask(() => inputRef?.focus());
|
|
63
|
+
} else {
|
|
64
|
+
query = '';
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function pick(item: FilterableDropdownItem) {
|
|
69
|
+
onChange(item.id);
|
|
70
|
+
open = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleKey(e: KeyboardEvent) {
|
|
74
|
+
if (e.key === 'ArrowDown') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
highlighted = Math.min(highlighted + 1, matches.length - 1);
|
|
77
|
+
} else if (e.key === 'ArrowUp') {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
highlighted = Math.max(highlighted - 1, 0);
|
|
80
|
+
} else if (e.key === 'Enter') {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
const m = matches[highlighted];
|
|
83
|
+
if (m) pick(m);
|
|
84
|
+
} else if (e.key === 'Escape') {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
open = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleDocClick(e: MouseEvent) {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
if (containerRef && !containerRef.contains(e.target as Node)) open = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (!open) return;
|
|
97
|
+
document.addEventListener('mousedown', handleDocClick);
|
|
98
|
+
return () => document.removeEventListener('mousedown', handleDocClick);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const SelectedIcon = $derived(selected?.icon);
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<div bind:this={containerRef} class={cn('relative', className)}>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
{id}
|
|
108
|
+
{disabled}
|
|
109
|
+
onclick={() => (open = !open)}
|
|
110
|
+
class={cn(
|
|
111
|
+
'h-10 gap-2 px-3 text-sm flex w-full items-center rounded-md border bg-background text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
112
|
+
open ? 'border-ring ring-2 ring-ring/30' : 'border-input hover:bg-muted/40'
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{#if SelectedIcon}
|
|
116
|
+
<SelectedIcon
|
|
117
|
+
class={cn('h-3.5 w-3.5 shrink-0', selected?.iconClass ?? 'text-muted-foreground')}
|
|
118
|
+
style={selected?.iconStyle}
|
|
119
|
+
/>
|
|
120
|
+
{:else if TriggerIcon}
|
|
121
|
+
<TriggerIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
122
|
+
{/if}
|
|
123
|
+
<span class={cn('min-w-0 flex-1 truncate', selected ? '' : 'text-muted-foreground')}>
|
|
124
|
+
{selected ? selected.label : placeholder}
|
|
125
|
+
</span>
|
|
126
|
+
<ChevronDown
|
|
127
|
+
class={cn(
|
|
128
|
+
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
|
|
129
|
+
open ? 'rotate-180' : ''
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
{#if open}
|
|
135
|
+
<div
|
|
136
|
+
class="right-0 left-0 mt-1 shadow-md absolute top-full z-50 overflow-hidden rounded-md border border-border bg-popover"
|
|
137
|
+
>
|
|
138
|
+
{#if showSearch}
|
|
139
|
+
<div class="relative border-b border-border">
|
|
140
|
+
<Search
|
|
141
|
+
class="left-3 h-3.5 w-3.5 pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
142
|
+
/>
|
|
143
|
+
<input
|
|
144
|
+
bind:this={inputRef}
|
|
145
|
+
bind:value={query}
|
|
146
|
+
onkeydown={handleKey}
|
|
147
|
+
placeholder={searchPlaceholder}
|
|
148
|
+
class="h-8 pr-3 pl-9 text-sm w-full bg-transparent outline-none placeholder:text-muted-foreground"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
{/if}
|
|
152
|
+
|
|
153
|
+
<div class="max-h-60 overflow-y-auto">
|
|
154
|
+
{#if matches.length === 0}
|
|
155
|
+
<p class="px-3 py-3 text-xs text-center text-muted-foreground">
|
|
156
|
+
No matches for "{query}"
|
|
157
|
+
</p>
|
|
158
|
+
{:else}
|
|
159
|
+
{#each matches as item, i (item.id)}
|
|
160
|
+
{@const ItemIcon = item.icon}
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onclick={() => pick(item)}
|
|
164
|
+
onmouseenter={() => (highlighted = i)}
|
|
165
|
+
class={cn(
|
|
166
|
+
'gap-2 px-3 py-1.5 text-sm flex w-full items-center text-left transition-colors',
|
|
167
|
+
highlighted === i ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{#if ItemIcon}
|
|
171
|
+
<ItemIcon
|
|
172
|
+
class={cn('h-3.5 w-3.5 shrink-0', item.iconClass ?? 'text-muted-foreground')}
|
|
173
|
+
style={item.iconStyle}
|
|
174
|
+
/>
|
|
175
|
+
{/if}
|
|
176
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
177
|
+
{#if item.id === value}
|
|
178
|
+
<Check class="h-3.5 w-3.5 shrink-0 text-primary" />
|
|
179
|
+
{/if}
|
|
180
|
+
</button>
|
|
181
|
+
{/each}
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
export interface FilterableDropdownItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
icon?: Component;
|
|
6
|
+
iconClass?: string;
|
|
7
|
+
iconStyle?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Props {
|
|
10
|
+
items: FilterableDropdownItem[];
|
|
11
|
+
value: string | null | undefined;
|
|
12
|
+
onChange: (id: string) => void;
|
|
13
|
+
id?: string;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
triggerIcon?: Component;
|
|
16
|
+
searchPlaceholder?: string;
|
|
17
|
+
searchThreshold?: number;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
declare const FilterableDropdown: Component<Props, {}, "">;
|
|
22
|
+
type FilterableDropdown = ReturnType<typeof FilterableDropdown>;
|
|
23
|
+
export default FilterableDropdown;
|
|
@@ -27,3 +27,4 @@ export { ModeToggle } from './mode-toggle';
|
|
|
27
27
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
28
28
|
export { default as ImageUploadField } from './ImageUploadField.svelte';
|
|
29
29
|
export { default as DataTable, type DataTableColumn } from './DataTable.svelte';
|
|
30
|
+
export { default as FilterableDropdown, type FilterableDropdownItem } from './FilterableDropdown.svelte';
|
|
@@ -30,3 +30,4 @@ export { ModeToggle } from './mode-toggle';
|
|
|
30
30
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
31
31
|
export { default as ImageUploadField } from './ImageUploadField.svelte';
|
|
32
32
|
export { default as DataTable } from './DataTable.svelte';
|
|
33
|
+
export { default as FilterableDropdown } from './FilterableDropdown.svelte';
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
>
|
|
24
24
|
{#if withHandle}
|
|
25
25
|
<div
|
|
26
|
-
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:
|
|
26
|
+
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:scale-y-110 group-hover/handle:bg-foreground/40 group-active/handle:bg-foreground/60"
|
|
27
27
|
></div>
|
|
28
28
|
{/if}
|
|
29
29
|
</ResizablePrimitive.PaneResizer>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
|
|
3
|
-
* Used only by callers without a deployment-specific limit (e.g.
|
|
4
|
-
* over WebSocket);
|
|
3
|
+
* Used only by callers without a deployment-specific limit (e.g. plugin-ui
|
|
4
|
+
* over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
|
|
5
5
|
* server config via `ComputeApp`'s `solveTimeoutMs` prop.
|
|
6
6
|
*/
|
|
7
7
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
package/dist/constants.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export declare const APP_DEFAULTS: {
|
|
|
8
8
|
SOLVE_STATE_DURATION: number;
|
|
9
9
|
/** Parameter export callback delay (ms) */
|
|
10
10
|
PARAM_EXPORT_DELAY: number;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/** Drawer open/close animation duration (ms) — matches the CSS transition. */
|
|
12
|
+
DRAWER_ANIMATION_MS: number;
|
|
13
13
|
};
|
|
14
14
|
SOLVING_INDICATOR: {
|
|
15
15
|
/** If solve is faster than this, don't show indicator (ms) */
|
package/dist/constants.js
CHANGED
|
@@ -10,8 +10,8 @@ export const APP_DEFAULTS = {
|
|
|
10
10
|
SOLVE_STATE_DURATION: 3500,
|
|
11
11
|
/** Parameter export callback delay (ms) */
|
|
12
12
|
PARAM_EXPORT_DELAY: 100,
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/** Drawer open/close animation duration (ms) — matches the CSS transition. */
|
|
14
|
+
DRAWER_ANIMATION_MS: 350
|
|
15
15
|
},
|
|
16
16
|
// Solving indicator adaptive thresholds
|
|
17
17
|
SOLVING_INDICATOR: {
|
package/dist/external/storage.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// When an input has source.kind === 'external', a producer route writes the produced
|
|
4
4
|
// value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
|
|
5
5
|
// values for one solver/input don't bleed into another. The scope key is whatever
|
|
6
|
-
// uniquely identifies the solver context — sessionId in
|
|
7
|
-
// definition guid in
|
|
6
|
+
// uniquely identifies the solver context — sessionId in plugin-ui/preview,
|
|
7
|
+
// definition guid in selva/library, etc.
|
|
8
8
|
//
|
|
9
9
|
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
10
|
const STORAGE_PREFIX = 'external';
|
package/dist/index.d.ts
CHANGED
|
@@ -11,5 +11,6 @@ export * from './external/storage';
|
|
|
11
11
|
export * from './contexts/footerContext.svelte';
|
|
12
12
|
export * from './composables/useFooterItem.svelte';
|
|
13
13
|
export * from './utils';
|
|
14
|
+
export { randomId } from './utils/randomId';
|
|
14
15
|
export type { ActionButton } from './types/actionButton';
|
|
15
16
|
export type { SolveFn } from './types/solveFn';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { APP_DEFAULTS } from '../constants';
|
|
2
|
+
import { randomId } from '../utils/randomId';
|
|
2
3
|
function getGroups(schema) {
|
|
3
4
|
if (!schema.layout)
|
|
4
5
|
return [];
|
|
@@ -29,7 +30,7 @@ export function createSavedState(schema, currentValues, metadata) {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
return {
|
|
32
|
-
id:
|
|
33
|
+
id: randomId(),
|
|
33
34
|
name: metadata.name,
|
|
34
35
|
description: metadata.description,
|
|
35
36
|
timestamp: new Date().toISOString(),
|
package/dist/utils/debounce.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* has elapsed since the last time it was invoked.
|
|
4
4
|
*
|
|
5
5
|
* Use for text inputs, search boxes, etc. where you want to wait for the user
|
|
6
|
-
* to finish typing before executing.
|
|
6
|
+
* to finish typing before executing.
|
|
7
7
|
*
|
|
8
8
|
* @param func The function to debounce
|
|
9
9
|
* @param wait The delay in milliseconds
|
package/dist/utils/debounce.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* has elapsed since the last time it was invoked.
|
|
4
4
|
*
|
|
5
5
|
* Use for text inputs, search boxes, etc. where you want to wait for the user
|
|
6
|
-
* to finish typing before executing.
|
|
6
|
+
* to finish typing before executing.
|
|
7
7
|
*
|
|
8
8
|
* @param func The function to debounce
|
|
9
9
|
* @param wait The delay in milliseconds
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function randomId(): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Generate a UUIDv4-shaped string in any browser context.
|
|
2
|
+
//
|
|
3
|
+
// `crypto.randomUUID()` requires a secure context (HTTPS or localhost).
|
|
4
|
+
// Plain-HTTP deployments — common for first-time self-hosters trying things
|
|
5
|
+
// out — get `crypto.randomUUID is not a function`, which is a confusing
|
|
6
|
+
// browser-level error nobody can diagnose without devtools open.
|
|
7
|
+
//
|
|
8
|
+
// We try in order:
|
|
9
|
+
// 1. crypto.randomUUID() — fastest path, available in secure contexts
|
|
10
|
+
// 2. crypto.getRandomValues() — Web Crypto's lower-level entropy, works
|
|
11
|
+
// in any context that ships Web Crypto
|
|
12
|
+
// 3. Math.random() fallback — last resort; non-cryptographic, but the
|
|
13
|
+
// IDs we generate here are only used as
|
|
14
|
+
// collision-resistant keys for client-side
|
|
15
|
+
// form rows, not as security tokens.
|
|
16
|
+
//
|
|
17
|
+
// Callers that need cryptographic randomness (tokens, secrets) must NOT use
|
|
18
|
+
// this — they should fail loudly in insecure contexts instead of silently
|
|
19
|
+
// downgrading.
|
|
20
|
+
export function randomId() {
|
|
21
|
+
const c = typeof crypto !== 'undefined' ? crypto : undefined;
|
|
22
|
+
if (c?.randomUUID)
|
|
23
|
+
return c.randomUUID();
|
|
24
|
+
if (c?.getRandomValues)
|
|
25
|
+
return uuidFromBytes(c.getRandomValues(new Uint8Array(16)));
|
|
26
|
+
return uuidFromBytes(mathRandomBytes());
|
|
27
|
+
}
|
|
28
|
+
function mathRandomBytes() {
|
|
29
|
+
const out = new Uint8Array(16);
|
|
30
|
+
for (let i = 0; i < 16; i++)
|
|
31
|
+
out[i] = Math.floor(Math.random() * 256);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// Format 16 bytes as a UUIDv4 string with the version + variant bits set per
|
|
35
|
+
// RFC 4122 §4.4. Same shape as crypto.randomUUID() returns.
|
|
36
|
+
function uuidFromBytes(bytes) {
|
|
37
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
38
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
|
|
39
|
+
const hex = [];
|
|
40
|
+
for (let i = 0; i < 16; i++)
|
|
41
|
+
hex.push(bytes[i].toString(16).padStart(2, '0'));
|
|
42
|
+
return (hex.slice(0, 4).join('') +
|
|
43
|
+
'-' +
|
|
44
|
+
hex.slice(4, 6).join('') +
|
|
45
|
+
'-' +
|
|
46
|
+
hex.slice(6, 8).join('') +
|
|
47
|
+
'-' +
|
|
48
|
+
hex.slice(8, 10).join('') +
|
|
49
|
+
'-' +
|
|
50
|
+
hex.slice(10, 16).join(''));
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"bits-ui": "^2.15.4",
|
|
59
59
|
"svelte": "^5",
|
|
60
60
|
"tailwind-variants": "^3.2.2",
|
|
61
|
-
"three": "^0.184.0"
|
|
61
|
+
"three": "^0.184.0",
|
|
62
|
+
"@selvajs/schemas": "2.0.0"
|
|
62
63
|
},
|
|
63
64
|
"peerDependenciesMeta": {
|
|
64
65
|
"three": {
|
|
@@ -74,8 +75,7 @@
|
|
|
74
75
|
"paneforge": "^1.0.2",
|
|
75
76
|
"svelte-sonner": "^1.1.1",
|
|
76
77
|
"tailwind-merge": "^3.5.0",
|
|
77
|
-
"tw-animate-css": "^1.4.0"
|
|
78
|
-
"@selvajs/schemas": "1.1.1"
|
|
78
|
+
"tw-animate-css": "^1.4.0"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@internationalized/date": "^3.12.1",
|
|
@@ -84,11 +84,12 @@
|
|
|
84
84
|
"bits-ui": "^2.18.0",
|
|
85
85
|
"svelte": "5.55.5",
|
|
86
86
|
"tailwind-variants": "^3.2.2",
|
|
87
|
+
"@selvajs/schemas": "2.0.0",
|
|
87
88
|
"@selvajs/config": "0.0.0"
|
|
88
89
|
},
|
|
89
90
|
"scripts": {
|
|
90
91
|
"dev": "vite dev",
|
|
91
|
-
"build": "rm -rf dist &&
|
|
92
|
+
"build": "rm -rf dist && pnpm prepack",
|
|
92
93
|
"build:fast": "svelte-kit sync && svelte-package",
|
|
93
94
|
"build:watch": "svelte-kit sync && svelte-package --watch",
|
|
94
95
|
"preview": "vite preview",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
homeUrl?: string;
|
|
10
10
|
title?: string | null;
|
|
11
11
|
logo?: string;
|
|
12
|
+
brandName?: string;
|
|
13
|
+
copyrightName?: string;
|
|
12
14
|
showModeToggle?: boolean;
|
|
13
15
|
navItems?: Snippet;
|
|
14
16
|
rightContent?: Snippet;
|
|
@@ -40,6 +42,8 @@
|
|
|
40
42
|
homeUrl = '/',
|
|
41
43
|
title = undefined,
|
|
42
44
|
logo = '/favicon/favicon.svg',
|
|
45
|
+
brandName = 'Selva',
|
|
46
|
+
copyrightName = undefined,
|
|
43
47
|
showModeToggle = true,
|
|
44
48
|
navItems,
|
|
45
49
|
rightContent,
|
|
@@ -56,6 +60,10 @@
|
|
|
56
60
|
children
|
|
57
61
|
}: AppShellProps = $props();
|
|
58
62
|
|
|
63
|
+
// Default the footer's copyright owner to the header's brand name when the
|
|
64
|
+
// caller didn't specify one.
|
|
65
|
+
const _copyright = $derived(copyrightName ?? brandName);
|
|
66
|
+
|
|
59
67
|
// fixed: viewport-locked, no page scroll — body owns its own scroll internally.
|
|
60
68
|
// scroll: normal page flow, footer sticks to bottom via sticky positioning.
|
|
61
69
|
const rootClass = $derived(
|
|
@@ -79,6 +87,7 @@
|
|
|
79
87
|
{homeUrl}
|
|
80
88
|
{title}
|
|
81
89
|
{logo}
|
|
90
|
+
{brandName}
|
|
82
91
|
{showModeToggle}
|
|
83
92
|
{navItems}
|
|
84
93
|
{rightContent}
|
|
@@ -100,7 +109,7 @@
|
|
|
100
109
|
|
|
101
110
|
{#if showFooter}
|
|
102
111
|
<div class="bottom-0 sticky z-10 shrink-0">
|
|
103
|
-
<PageFooter {errors} {warnings}>
|
|
112
|
+
<PageFooter {errors} {warnings} copyrightName={_copyright}>
|
|
104
113
|
{#if footerChildren}
|
|
105
114
|
{@render footerChildren()}
|
|
106
115
|
{/if}
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
interface Props {
|
|
11
11
|
errors?: string[];
|
|
12
12
|
warnings?: string[];
|
|
13
|
+
copyrightName?: string;
|
|
13
14
|
children?: Snippet;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
let { errors = [], warnings = [], children }: Props = $props();
|
|
17
|
+
let { errors = [], warnings = [], copyrightName = 'Selva', children }: Props = $props();
|
|
17
18
|
|
|
18
19
|
let footerStore = (() => {
|
|
19
20
|
try {
|
|
@@ -217,6 +218,6 @@
|
|
|
217
218
|
<FooterItemRenderer {item} />
|
|
218
219
|
{/each}
|
|
219
220
|
|
|
220
|
-
<p>by
|
|
221
|
+
<p>by {copyrightName} © {_currentYear}</p>
|
|
221
222
|
</div>
|
|
222
223
|
</footer>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
homeUrl?: string;
|
|
7
7
|
title?: string | null;
|
|
8
8
|
logo?: string;
|
|
9
|
+
brandName?: string;
|
|
9
10
|
navItems?: Snippet;
|
|
10
11
|
rightContent?: Snippet;
|
|
11
12
|
subnav?: Snippet;
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
homeUrl = '/app',
|
|
18
19
|
title = undefined,
|
|
19
20
|
logo = '/favicon/favicon.svg',
|
|
21
|
+
brandName = 'Selva',
|
|
20
22
|
navItems,
|
|
21
23
|
rightContent,
|
|
22
24
|
subnav,
|
|
@@ -33,7 +35,7 @@
|
|
|
33
35
|
<!-- Logo -->
|
|
34
36
|
<a href={homeUrl} class="gap-2 flex shrink-0 items-center">
|
|
35
37
|
<img src={logo} alt="" aria-hidden="true" class="h-5 w-5" />
|
|
36
|
-
<span class="font-semibold text-sm tracking-tight">
|
|
38
|
+
<span class="font-semibold text-sm tracking-tight">{brandName}</span>
|
|
37
39
|
</a>
|
|
38
40
|
|
|
39
41
|
{#if title !== undefined}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
value: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
let { item, value }: Props = $props();
|
|
12
|
+
let { item: _item, value }: Props = $props();
|
|
13
13
|
|
|
14
14
|
const MAP_TYPE_SET = new Set(['scattermap', 'scattermapbox', 'choropleth', 'scattergeo']);
|
|
15
15
|
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
|
|
178
178
|
<div
|
|
179
179
|
bind:this={wrapperEl}
|
|
180
|
-
class="group relative overflow-hidden
|
|
180
|
+
class="group rounded relative overflow-hidden border border-border {isFullscreen
|
|
181
181
|
? 'bg-background'
|
|
182
182
|
: ''}"
|
|
183
183
|
>
|
|
@@ -200,7 +200,9 @@
|
|
|
200
200
|
: ''}"
|
|
201
201
|
>
|
|
202
202
|
{#if loading}
|
|
203
|
-
<div
|
|
203
|
+
<div
|
|
204
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-muted-foreground"
|
|
205
|
+
>
|
|
204
206
|
<Loader size={14} class="animate-spin" />
|
|
205
207
|
</div>
|
|
206
208
|
{/if}
|
|
@@ -209,7 +211,7 @@
|
|
|
209
211
|
onclick={toggleFullscreen}
|
|
210
212
|
disabled={loading}
|
|
211
213
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
212
|
-
class="rounded p-1.5 flex items-center
|
|
214
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
213
215
|
>
|
|
214
216
|
{#if isFullscreen}
|
|
215
217
|
<Minimize size={14} />
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
|
|
94
94
|
<div
|
|
95
95
|
bind:this={wrapperEl}
|
|
96
|
-
class="group relative overflow-hidden
|
|
96
|
+
class="group rounded relative overflow-hidden border border-border {isFullscreen
|
|
97
97
|
? 'bg-background'
|
|
98
98
|
: ''}"
|
|
99
99
|
>
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
onclick={handleDownload}
|
|
132
132
|
disabled={downloading}
|
|
133
133
|
title="Download image"
|
|
134
|
-
class="rounded p-1.5 flex items-center
|
|
134
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
135
135
|
>
|
|
136
136
|
<Download size={14} />
|
|
137
137
|
</button>
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<button
|
|
142
142
|
onclick={toggleFullscreen}
|
|
143
143
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
144
|
-
class="rounded p-1.5 flex items-center
|
|
144
|
+
class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background"
|
|
145
145
|
>
|
|
146
146
|
{#if isFullscreen}
|
|
147
147
|
<Minimize size={14} />
|
|
@@ -103,12 +103,12 @@
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const duplicatePaths = $derived.by(() => {
|
|
106
|
-
const seen
|
|
106
|
+
const seen: Record<string, number> = {};
|
|
107
107
|
for (const f of filesArray) {
|
|
108
108
|
const key = fullPath(f);
|
|
109
|
-
seen
|
|
109
|
+
seen[key] = (seen[key] ?? 0) + 1;
|
|
110
110
|
}
|
|
111
|
-
return
|
|
111
|
+
return Object.entries(seen)
|
|
112
112
|
.filter(([, n]) => n > 1)
|
|
113
113
|
.map(([path]) => path);
|
|
114
114
|
});
|
|
@@ -279,11 +279,7 @@
|
|
|
279
279
|
{downloadError}
|
|
280
280
|
</div>
|
|
281
281
|
{/if}
|
|
282
|
-
<Button
|
|
283
|
-
onclick={handleDownload}
|
|
284
|
-
disabled={downloading || hasDuplicates}
|
|
285
|
-
class="w-full"
|
|
286
|
-
>
|
|
282
|
+
<Button onclick={handleDownload} disabled={downloading || hasDuplicates} class="w-full">
|
|
287
283
|
{downloading
|
|
288
284
|
? 'Downloading...'
|
|
289
285
|
: `Download ${fileCount === 1 ? 'File' : `${fileCount} Files`}`}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { Search, ChevronDown, Check } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '$lib/utils.js';
|
|
5
|
+
|
|
6
|
+
export interface FilterableDropdownItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: Component;
|
|
10
|
+
iconClass?: string;
|
|
11
|
+
iconStyle?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
items: FilterableDropdownItem[];
|
|
16
|
+
value: string | null | undefined;
|
|
17
|
+
onChange: (id: string) => void;
|
|
18
|
+
id?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
triggerIcon?: Component;
|
|
21
|
+
searchPlaceholder?: string;
|
|
22
|
+
searchThreshold?: number;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
items,
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
id,
|
|
32
|
+
placeholder = 'Select...',
|
|
33
|
+
triggerIcon: TriggerIcon,
|
|
34
|
+
searchPlaceholder = 'Filter...',
|
|
35
|
+
searchThreshold = 4,
|
|
36
|
+
disabled = false,
|
|
37
|
+
class: className
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let open = $state(false);
|
|
41
|
+
let query = $state('');
|
|
42
|
+
let highlighted = $state(0);
|
|
43
|
+
let inputRef = $state<HTMLInputElement>();
|
|
44
|
+
let containerRef = $state<HTMLDivElement>();
|
|
45
|
+
|
|
46
|
+
const selected = $derived(items.find((item) => item.id === value) ?? null);
|
|
47
|
+
const showSearch = $derived(items.length > searchThreshold);
|
|
48
|
+
|
|
49
|
+
const matches = $derived.by(() => {
|
|
50
|
+
const q = query.trim().toLowerCase();
|
|
51
|
+
if (!q) return items;
|
|
52
|
+
return items.filter((item) => item.label.toLowerCase().includes(q));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
$effect(() => {
|
|
56
|
+
void matches;
|
|
57
|
+
highlighted = 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (open) {
|
|
62
|
+
queueMicrotask(() => inputRef?.focus());
|
|
63
|
+
} else {
|
|
64
|
+
query = '';
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function pick(item: FilterableDropdownItem) {
|
|
69
|
+
onChange(item.id);
|
|
70
|
+
open = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleKey(e: KeyboardEvent) {
|
|
74
|
+
if (e.key === 'ArrowDown') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
highlighted = Math.min(highlighted + 1, matches.length - 1);
|
|
77
|
+
} else if (e.key === 'ArrowUp') {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
highlighted = Math.max(highlighted - 1, 0);
|
|
80
|
+
} else if (e.key === 'Enter') {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
const m = matches[highlighted];
|
|
83
|
+
if (m) pick(m);
|
|
84
|
+
} else if (e.key === 'Escape') {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
open = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleDocClick(e: MouseEvent) {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
if (containerRef && !containerRef.contains(e.target as Node)) open = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (!open) return;
|
|
97
|
+
document.addEventListener('mousedown', handleDocClick);
|
|
98
|
+
return () => document.removeEventListener('mousedown', handleDocClick);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const SelectedIcon = $derived(selected?.icon);
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<div bind:this={containerRef} class={cn('relative', className)}>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
{id}
|
|
108
|
+
{disabled}
|
|
109
|
+
onclick={() => (open = !open)}
|
|
110
|
+
class={cn(
|
|
111
|
+
'h-10 gap-2 px-3 text-sm flex w-full items-center rounded-md border bg-background text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
112
|
+
open ? 'border-ring ring-2 ring-ring/30' : 'border-input hover:bg-muted/40'
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{#if SelectedIcon}
|
|
116
|
+
<SelectedIcon
|
|
117
|
+
class={cn('h-3.5 w-3.5 shrink-0', selected?.iconClass ?? 'text-muted-foreground')}
|
|
118
|
+
style={selected?.iconStyle}
|
|
119
|
+
/>
|
|
120
|
+
{:else if TriggerIcon}
|
|
121
|
+
<TriggerIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
122
|
+
{/if}
|
|
123
|
+
<span class={cn('min-w-0 flex-1 truncate', selected ? '' : 'text-muted-foreground')}>
|
|
124
|
+
{selected ? selected.label : placeholder}
|
|
125
|
+
</span>
|
|
126
|
+
<ChevronDown
|
|
127
|
+
class={cn(
|
|
128
|
+
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
|
|
129
|
+
open ? 'rotate-180' : ''
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
{#if open}
|
|
135
|
+
<div
|
|
136
|
+
class="right-0 left-0 mt-1 shadow-md absolute top-full z-50 overflow-hidden rounded-md border border-border bg-popover"
|
|
137
|
+
>
|
|
138
|
+
{#if showSearch}
|
|
139
|
+
<div class="relative border-b border-border">
|
|
140
|
+
<Search
|
|
141
|
+
class="left-3 h-3.5 w-3.5 pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
142
|
+
/>
|
|
143
|
+
<input
|
|
144
|
+
bind:this={inputRef}
|
|
145
|
+
bind:value={query}
|
|
146
|
+
onkeydown={handleKey}
|
|
147
|
+
placeholder={searchPlaceholder}
|
|
148
|
+
class="h-8 pr-3 pl-9 text-sm w-full bg-transparent outline-none placeholder:text-muted-foreground"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
{/if}
|
|
152
|
+
|
|
153
|
+
<div class="max-h-60 overflow-y-auto">
|
|
154
|
+
{#if matches.length === 0}
|
|
155
|
+
<p class="px-3 py-3 text-xs text-center text-muted-foreground">
|
|
156
|
+
No matches for "{query}"
|
|
157
|
+
</p>
|
|
158
|
+
{:else}
|
|
159
|
+
{#each matches as item, i (item.id)}
|
|
160
|
+
{@const ItemIcon = item.icon}
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onclick={() => pick(item)}
|
|
164
|
+
onmouseenter={() => (highlighted = i)}
|
|
165
|
+
class={cn(
|
|
166
|
+
'gap-2 px-3 py-1.5 text-sm flex w-full items-center text-left transition-colors',
|
|
167
|
+
highlighted === i ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{#if ItemIcon}
|
|
171
|
+
<ItemIcon
|
|
172
|
+
class={cn('h-3.5 w-3.5 shrink-0', item.iconClass ?? 'text-muted-foreground')}
|
|
173
|
+
style={item.iconStyle}
|
|
174
|
+
/>
|
|
175
|
+
{/if}
|
|
176
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
177
|
+
{#if item.id === value}
|
|
178
|
+
<Check class="h-3.5 w-3.5 shrink-0 text-primary" />
|
|
179
|
+
{/if}
|
|
180
|
+
</button>
|
|
181
|
+
{/each}
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
@@ -39,3 +39,7 @@ export { ModeToggle } from './mode-toggle';
|
|
|
39
39
|
export { default as ViewToggle } from './ViewToggle.svelte';
|
|
40
40
|
export { default as ImageUploadField } from './ImageUploadField.svelte';
|
|
41
41
|
export { default as DataTable, type DataTableColumn } from './DataTable.svelte';
|
|
42
|
+
export {
|
|
43
|
+
default as FilterableDropdown,
|
|
44
|
+
type FilterableDropdownItem
|
|
45
|
+
} from './FilterableDropdown.svelte';
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
>
|
|
24
24
|
{#if withHandle}
|
|
25
25
|
<div
|
|
26
|
-
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:
|
|
26
|
+
class="h-10 w-1 z-10 flex shrink-0 rounded-lg bg-border transition-[background-color,transform] duration-150 group-hover/handle:scale-y-110 group-hover/handle:bg-foreground/40 group-active/handle:bg-foreground/60"
|
|
27
27
|
></div>
|
|
28
28
|
{/if}
|
|
29
29
|
</ResizablePrimitive.PaneResizer>
|
|
@@ -12,8 +12,8 @@ interface ComputeThrottleOptions {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
|
|
15
|
-
* Used only by callers without a deployment-specific limit (e.g.
|
|
16
|
-
* over WebSocket);
|
|
15
|
+
* Used only by callers without a deployment-specific limit (e.g. plugin-ui
|
|
16
|
+
* over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
|
|
17
17
|
* server config via `ComputeApp`'s `solveTimeoutMs` prop.
|
|
18
18
|
*/
|
|
19
19
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
package/src/lib/constants.ts
CHANGED
|
@@ -11,8 +11,8 @@ export const APP_DEFAULTS = {
|
|
|
11
11
|
SOLVE_STATE_DURATION: 3500,
|
|
12
12
|
/** Parameter export callback delay (ms) */
|
|
13
13
|
PARAM_EXPORT_DELAY: 100,
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/** Drawer open/close animation duration (ms) — matches the CSS transition. */
|
|
15
|
+
DRAWER_ANIMATION_MS: 350
|
|
16
16
|
},
|
|
17
17
|
|
|
18
18
|
// Solving indicator adaptive thresholds
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// When an input has source.kind === 'external', a producer route writes the produced
|
|
4
4
|
// value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
|
|
5
5
|
// values for one solver/input don't bleed into another. The scope key is whatever
|
|
6
|
-
// uniquely identifies the solver context — sessionId in
|
|
7
|
-
// definition guid in
|
|
6
|
+
// uniquely identifies the solver context — sessionId in plugin-ui/preview,
|
|
7
|
+
// definition guid in selva/library, etc.
|
|
8
8
|
//
|
|
9
9
|
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
10
|
|
package/src/lib/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from './composables/useFooterItem.svelte';
|
|
|
28
28
|
|
|
29
29
|
// Utils (cn function)
|
|
30
30
|
export * from './utils';
|
|
31
|
+
export { randomId } from './utils/randomId';
|
|
31
32
|
|
|
32
33
|
// UI-specific runtime types (not from schema)
|
|
33
34
|
export type { ActionButton } from './types/actionButton';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { APP_DEFAULTS } from '../constants';
|
|
2
|
+
import { randomId } from '../utils/randomId';
|
|
2
3
|
import type {
|
|
3
4
|
UISchema,
|
|
4
5
|
ParameterPreset,
|
|
@@ -44,7 +45,7 @@ export function createSavedState(
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
return {
|
|
47
|
-
id:
|
|
48
|
+
id: randomId(),
|
|
48
49
|
name: metadata.name,
|
|
49
50
|
description: metadata.description,
|
|
50
51
|
timestamp: new Date().toISOString(),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* has elapsed since the last time it was invoked.
|
|
4
4
|
*
|
|
5
5
|
* Use for text inputs, search boxes, etc. where you want to wait for the user
|
|
6
|
-
* to finish typing before executing.
|
|
6
|
+
* to finish typing before executing.
|
|
7
7
|
*
|
|
8
8
|
* @param func The function to debounce
|
|
9
9
|
* @param wait The delay in milliseconds
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generate a UUIDv4-shaped string in any browser context.
|
|
2
|
+
//
|
|
3
|
+
// `crypto.randomUUID()` requires a secure context (HTTPS or localhost).
|
|
4
|
+
// Plain-HTTP deployments — common for first-time self-hosters trying things
|
|
5
|
+
// out — get `crypto.randomUUID is not a function`, which is a confusing
|
|
6
|
+
// browser-level error nobody can diagnose without devtools open.
|
|
7
|
+
//
|
|
8
|
+
// We try in order:
|
|
9
|
+
// 1. crypto.randomUUID() — fastest path, available in secure contexts
|
|
10
|
+
// 2. crypto.getRandomValues() — Web Crypto's lower-level entropy, works
|
|
11
|
+
// in any context that ships Web Crypto
|
|
12
|
+
// 3. Math.random() fallback — last resort; non-cryptographic, but the
|
|
13
|
+
// IDs we generate here are only used as
|
|
14
|
+
// collision-resistant keys for client-side
|
|
15
|
+
// form rows, not as security tokens.
|
|
16
|
+
//
|
|
17
|
+
// Callers that need cryptographic randomness (tokens, secrets) must NOT use
|
|
18
|
+
// this — they should fail loudly in insecure contexts instead of silently
|
|
19
|
+
// downgrading.
|
|
20
|
+
export function randomId(): string {
|
|
21
|
+
const c = typeof crypto !== 'undefined' ? crypto : undefined;
|
|
22
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
23
|
+
if (c?.getRandomValues) return uuidFromBytes(c.getRandomValues(new Uint8Array(16)));
|
|
24
|
+
return uuidFromBytes(mathRandomBytes());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mathRandomBytes(): Uint8Array {
|
|
28
|
+
const out = new Uint8Array(16);
|
|
29
|
+
for (let i = 0; i < 16; i++) out[i] = Math.floor(Math.random() * 256);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Format 16 bytes as a UUIDv4 string with the version + variant bits set per
|
|
34
|
+
// RFC 4122 §4.4. Same shape as crypto.randomUUID() returns.
|
|
35
|
+
function uuidFromBytes(bytes: Uint8Array): string {
|
|
36
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
37
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
|
|
38
|
+
|
|
39
|
+
const hex: string[] = [];
|
|
40
|
+
for (let i = 0; i < 16; i++) hex.push(bytes[i].toString(16).padStart(2, '0'));
|
|
41
|
+
return (
|
|
42
|
+
hex.slice(0, 4).join('') +
|
|
43
|
+
'-' +
|
|
44
|
+
hex.slice(4, 6).join('') +
|
|
45
|
+
'-' +
|
|
46
|
+
hex.slice(6, 8).join('') +
|
|
47
|
+
'-' +
|
|
48
|
+
hex.slice(8, 10).join('') +
|
|
49
|
+
'-' +
|
|
50
|
+
hex.slice(10, 16).join('')
|
|
51
|
+
);
|
|
52
|
+
}
|