@selvajs/ui 0.9.5 → 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.
Files changed (34) hide show
  1. package/dist/components/layout/AppShell.svelte +10 -1
  2. package/dist/components/layout/AppShell.svelte.d.ts +2 -0
  3. package/dist/components/layout/PageFooter.svelte +3 -2
  4. package/dist/components/layout/PageFooter.svelte.d.ts +1 -0
  5. package/dist/components/layout/PageHeader.svelte +3 -1
  6. package/dist/components/layout/PageHeader.svelte.d.ts +1 -0
  7. package/dist/components/preview/ChartOutput.svelte +5 -3
  8. package/dist/components/preview/ImageOutput.svelte +3 -3
  9. package/dist/components/preview/InputControl.svelte +0 -1
  10. package/dist/components/preview/OutputDisplay.svelte +1 -5
  11. package/dist/components/primitives/FilterableDropdown.svelte +186 -0
  12. package/dist/components/primitives/FilterableDropdown.svelte.d.ts +23 -0
  13. package/dist/components/primitives/index.d.ts +1 -0
  14. package/dist/components/primitives/index.js +1 -0
  15. package/dist/components/primitives/resizable/resizable-handle.svelte +1 -1
  16. package/dist/components/viewer/Viewer.svelte +1 -1
  17. package/dist/constants.d.ts +2 -2
  18. package/dist/constants.js +2 -2
  19. package/dist/utils/debounce.d.ts +1 -1
  20. package/dist/utils/debounce.js +1 -1
  21. package/package.json +3 -3
  22. package/src/lib/components/layout/AppShell.svelte +10 -1
  23. package/src/lib/components/layout/PageFooter.svelte +3 -2
  24. package/src/lib/components/layout/PageHeader.svelte +3 -1
  25. package/src/lib/components/preview/ChartOutput.svelte +5 -3
  26. package/src/lib/components/preview/ImageOutput.svelte +3 -3
  27. package/src/lib/components/preview/InputControl.svelte +0 -1
  28. package/src/lib/components/preview/OutputDisplay.svelte +1 -5
  29. package/src/lib/components/primitives/FilterableDropdown.svelte +186 -0
  30. package/src/lib/components/primitives/index.ts +4 -0
  31. package/src/lib/components/primitives/resizable/resizable-handle.svelte +1 -1
  32. package/src/lib/components/viewer/Viewer.svelte +1 -1
  33. package/src/lib/constants.ts +2 -2
  34. 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}
@@ -4,6 +4,8 @@ interface AppShellProps {
4
4
  homeUrl?: string;
5
5
  title?: string | null;
6
6
  logo?: string;
7
+ brandName?: string;
8
+ copyrightName?: string;
7
9
  showModeToggle?: boolean;
8
10
  navItems?: Snippet;
9
11
  rightContent?: Snippet;
@@ -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 Selva &copy; {_currentYear}</p>
221
+ <p>by {copyrightName} &copy; {_currentYear}</p>
221
222
  </div>
222
223
  </footer>
@@ -2,6 +2,7 @@ import type { Snippet } from 'svelte';
2
2
  interface Props {
3
3
  errors?: string[];
4
4
  warnings?: string[];
5
+ copyrightName?: string;
5
6
  children?: Snippet;
6
7
  }
7
8
  declare const PageFooter: import("svelte").Component<Props, {}, "">;
@@ -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">Selva</span>
38
+ <span class="font-semibold text-sm tracking-tight">{brandName}</span>
37
39
  </a>
38
40
 
39
41
  {#if title !== undefined}
@@ -3,6 +3,7 @@ interface PageHeaderProps {
3
3
  homeUrl?: string;
4
4
  title?: string | null;
5
5
  logo?: string;
6
+ brandName?: string;
6
7
  navItems?: Snippet;
7
8
  rightContent?: Snippet;
8
9
  subnav?: Snippet;
@@ -177,7 +177,7 @@
177
177
 
178
178
  <div
179
179
  bind:this={wrapperEl}
180
- class="group relative overflow-hidden rounded border border-border {isFullscreen
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 class="rounded p-1.5 flex items-center text-muted-foreground border border-border bg-background/80 backdrop-blur-sm">
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
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 rounded border border-border {isFullscreen
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background"
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} />
@@ -37,7 +37,6 @@
37
37
  disabled = false
38
38
  }: Props = $props();
39
39
 
40
- // Stable ID — not reactive, computed once at component creation
41
40
  const inputId = $derived(`input-${item.paramId}`);
42
41
  const label = $derived(displayName || item.displayName || item.paramId);
43
42
 
@@ -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:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
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>
@@ -67,7 +67,7 @@
67
67
  } else {
68
68
  const timer = setTimeout(() => {
69
69
  hideButton = false;
70
- }, APP_DEFAULTS.TIMEOUTS.VIEWER_INIT_DELAY);
70
+ }, APP_DEFAULTS.TIMEOUTS.DRAWER_ANIMATION_MS);
71
71
  return () => clearTimeout(timer);
72
72
  }
73
73
  });
@@ -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
- /** Viewer initialization delay (ms) */
12
- VIEWER_INIT_DELAY: number;
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
- /** Viewer initialization delay (ms) */
14
- VIEWER_INIT_DELAY: 350
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: {
@@ -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. For sliders, use throttle() instead.
6
+ * to finish typing before executing.
7
7
  *
8
8
  * @param func The function to debounce
9
9
  * @param wait The delay in milliseconds
@@ -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. For sliders, use throttle() instead.
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.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -53,7 +53,7 @@
53
53
  "**/*.css"
54
54
  ],
55
55
  "peerDependencies": {
56
- "@selvajs/compute": "1.5.2-beta.7",
56
+ "@selvajs/compute": "1.5.2",
57
57
  "@sveltejs/kit": "^2",
58
58
  "bits-ui": "^2.15.4",
59
59
  "svelte": "^5",
@@ -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.1.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 Selva &copy; {_currentYear}</p>
221
+ <p>by {copyrightName} &copy; {_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">Selva</span>
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 rounded border border-border {isFullscreen
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 class="rounded p-1.5 flex items-center text-muted-foreground border border-border bg-background/80 backdrop-blur-sm">
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
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 rounded border border-border {isFullscreen
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
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 text-foreground border border-border bg-background/80 backdrop-blur-sm transition-colors hover:bg-background"
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} />
@@ -37,7 +37,6 @@
37
37
  disabled = false
38
38
  }: Props = $props();
39
39
 
40
- // Stable ID — not reactive, computed once at component creation
41
40
  const inputId = $derived(`input-${item.paramId}`);
42
41
  const label = $derived(displayName || item.displayName || item.paramId);
43
42
 
@@ -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:bg-foreground/40 group-hover/handle:scale-y-110 group-active/handle:bg-foreground/60"
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>
@@ -67,7 +67,7 @@
67
67
  } else {
68
68
  const timer = setTimeout(() => {
69
69
  hideButton = false;
70
- }, APP_DEFAULTS.TIMEOUTS.VIEWER_INIT_DELAY);
70
+ }, APP_DEFAULTS.TIMEOUTS.DRAWER_ANIMATION_MS);
71
71
  return () => clearTimeout(timer);
72
72
  }
73
73
  });
@@ -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
- /** Viewer initialization delay (ms) */
15
- VIEWER_INIT_DELAY: 350
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. For sliders, use throttle() instead.
6
+ * to finish typing before executing.
7
7
  *
8
8
  * @param func The function to debounce
9
9
  * @param wait The delay in milliseconds