@selvajs/ui 0.9.6 → 0.10.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 +5 -3
- package/dist/components/preview/ImageOutput.svelte +3 -3
- package/dist/components/preview/InputControl.svelte +0 -1
- package/dist/components/preview/OutputDisplay.svelte +1 -5
- 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/constants.d.ts +2 -2
- package/dist/constants.js +2 -2
- package/dist/utils/debounce.d.ts +1 -1
- package/dist/utils/debounce.js +1 -1
- package/package.json +2 -2
- 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 +5 -3
- 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 +1 -5
- 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/constants.ts +2 -2
- package/src/lib/utils/debounce.ts +1 -1
|
@@ -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}
|
|
@@ -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} />
|
|
@@ -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
|
+
'bg-background flex h-10 w-full items-center gap-2 rounded-md border px-3 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
112
|
+
open ? 'border-ring ring-ring/30 ring-2' : '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="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
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
|
+
'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
|
|
129
|
+
open ? 'rotate-180' : ''
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
{#if open}
|
|
135
|
+
<div
|
|
136
|
+
class="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md"
|
|
137
|
+
>
|
|
138
|
+
{#if showSearch}
|
|
139
|
+
<div class="border-border relative border-b">
|
|
140
|
+
<Search
|
|
141
|
+
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2"
|
|
142
|
+
/>
|
|
143
|
+
<input
|
|
144
|
+
bind:this={inputRef}
|
|
145
|
+
bind:value={query}
|
|
146
|
+
onkeydown={handleKey}
|
|
147
|
+
placeholder={searchPlaceholder}
|
|
148
|
+
class="placeholder:text-muted-foreground h-8 w-full bg-transparent pr-3 pl-9 text-sm outline-none"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
{/if}
|
|
152
|
+
|
|
153
|
+
<div class="max-h-60 overflow-y-auto">
|
|
154
|
+
{#if matches.length === 0}
|
|
155
|
+
<p class="text-muted-foreground px-3 py-3 text-center text-xs">
|
|
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
|
+
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm 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="text-primary h-3.5 w-3.5 shrink-0" />
|
|
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>
|
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/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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"svelte-sonner": "^1.1.1",
|
|
76
76
|
"tailwind-merge": "^3.5.0",
|
|
77
77
|
"tw-animate-css": "^1.4.0",
|
|
78
|
-
"@selvajs/schemas": "1.
|
|
78
|
+
"@selvajs/schemas": "1.2.0"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@internationalized/date": "^3.12.1",
|
|
@@ -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}
|
|
@@ -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} />
|
|
@@ -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
|
+
'bg-background flex h-10 w-full items-center gap-2 rounded-md border px-3 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
112
|
+
open ? 'border-ring ring-ring/30 ring-2' : '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="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
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
|
+
'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
|
|
129
|
+
open ? 'rotate-180' : ''
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
{#if open}
|
|
135
|
+
<div
|
|
136
|
+
class="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md"
|
|
137
|
+
>
|
|
138
|
+
{#if showSearch}
|
|
139
|
+
<div class="border-border relative border-b">
|
|
140
|
+
<Search
|
|
141
|
+
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2"
|
|
142
|
+
/>
|
|
143
|
+
<input
|
|
144
|
+
bind:this={inputRef}
|
|
145
|
+
bind:value={query}
|
|
146
|
+
onkeydown={handleKey}
|
|
147
|
+
placeholder={searchPlaceholder}
|
|
148
|
+
class="placeholder:text-muted-foreground h-8 w-full bg-transparent pr-3 pl-9 text-sm outline-none"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
{/if}
|
|
152
|
+
|
|
153
|
+
<div class="max-h-60 overflow-y-auto">
|
|
154
|
+
{#if matches.length === 0}
|
|
155
|
+
<p class="text-muted-foreground px-3 py-3 text-center text-xs">
|
|
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
|
+
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm 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="text-primary h-3.5 w-3.5 shrink-0" />
|
|
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>
|
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,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
|