@classic-homes/theme-svelte 0.1.4 → 0.1.6
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/lib/components/CardHeader.svelte +22 -2
- package/dist/lib/components/CardHeader.svelte.d.ts +5 -4
- package/dist/lib/components/Combobox.svelte +187 -0
- package/dist/lib/components/Combobox.svelte.d.ts +38 -0
- package/dist/lib/components/DateTimePicker.svelte +415 -0
- package/dist/lib/components/DateTimePicker.svelte.d.ts +31 -0
- package/dist/lib/components/HeaderSearch.svelte +340 -0
- package/dist/lib/components/HeaderSearch.svelte.d.ts +37 -0
- package/dist/lib/components/MultiSelect.svelte +244 -0
- package/dist/lib/components/MultiSelect.svelte.d.ts +40 -0
- package/dist/lib/components/NumberInput.svelte +205 -0
- package/dist/lib/components/NumberInput.svelte.d.ts +33 -0
- package/dist/lib/components/OTPInput.svelte +213 -0
- package/dist/lib/components/OTPInput.svelte.d.ts +23 -0
- package/dist/lib/components/PageHeader.svelte +6 -0
- package/dist/lib/components/PageHeader.svelte.d.ts +1 -1
- package/dist/lib/components/RadioGroup.svelte +124 -0
- package/dist/lib/components/RadioGroup.svelte.d.ts +31 -0
- package/dist/lib/components/Signature.svelte +1070 -0
- package/dist/lib/components/Signature.svelte.d.ts +74 -0
- package/dist/lib/components/Slider.svelte +136 -0
- package/dist/lib/components/Slider.svelte.d.ts +30 -0
- package/dist/lib/components/layout/AuthLayout.svelte +133 -0
- package/dist/lib/components/layout/AuthLayout.svelte.d.ts +48 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +100 -74
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +17 -10
- package/dist/lib/components/layout/ErrorLayout.svelte +206 -0
- package/dist/lib/components/layout/ErrorLayout.svelte.d.ts +52 -0
- package/dist/lib/components/layout/FormPageLayout.svelte +2 -8
- package/dist/lib/components/layout/Header.svelte +232 -41
- package/dist/lib/components/layout/Header.svelte.d.ts +71 -5
- package/dist/lib/components/layout/PublicLayout.svelte +54 -80
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +3 -1
- package/dist/lib/components/layout/QuickLinks.svelte +49 -29
- package/dist/lib/components/layout/QuickLinks.svelte.d.ts +4 -2
- package/dist/lib/components/layout/Sidebar.svelte +345 -86
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +12 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +182 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte.d.ts +18 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +378 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte +121 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte.d.ts +17 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +144 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/index.d.ts +10 -0
- package/dist/lib/components/layout/sidebar/index.js +10 -0
- package/dist/lib/index.d.ts +13 -2
- package/dist/lib/index.js +11 -0
- package/dist/lib/schemas/auth.d.ts +6 -6
- package/dist/lib/stores/sidebar.svelte.d.ts +54 -0
- package/dist/lib/stores/sidebar.svelte.js +171 -1
- package/dist/lib/types/components.d.ts +105 -0
- package/dist/lib/types/layout.d.ts +203 -3
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** Current date/time value */
|
|
3
|
+
value?: Date | null;
|
|
4
|
+
/** Callback when value changes */
|
|
5
|
+
onValueChange?: (date: Date | null) => void;
|
|
6
|
+
/** Placeholder text */
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
/** Whether the picker is disabled */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Whether selection is required */
|
|
11
|
+
required?: boolean;
|
|
12
|
+
/** Minimum selectable date */
|
|
13
|
+
min?: Date;
|
|
14
|
+
/** Maximum selectable date */
|
|
15
|
+
max?: Date;
|
|
16
|
+
/** Time format (12h or 24h) */
|
|
17
|
+
timeFormat?: '12h' | '24h';
|
|
18
|
+
/** Minute step interval (default: 15) */
|
|
19
|
+
minuteStep?: number;
|
|
20
|
+
/** Name attribute for form submission */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Element ID */
|
|
23
|
+
id?: string;
|
|
24
|
+
/** Error message to display */
|
|
25
|
+
error?: string;
|
|
26
|
+
/** Additional class for the trigger */
|
|
27
|
+
class?: string;
|
|
28
|
+
}
|
|
29
|
+
declare const DateTimePicker: import("svelte").Component<Props, {}, "value">;
|
|
30
|
+
type DateTimePicker = ReturnType<typeof DateTimePicker>;
|
|
31
|
+
export default DateTimePicker;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* HeaderSearch - Docusaurus-style search component for the header
|
|
4
|
+
*
|
|
5
|
+
* A flexible, configurable search component that displays a trigger button
|
|
6
|
+
* in the header and opens a modal dialog with search input and results.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Backend-agnostic (callback-based integration)
|
|
10
|
+
* - Keyboard shortcut support (Cmd/Ctrl+K)
|
|
11
|
+
* - Full keyboard navigation in results
|
|
12
|
+
* - Custom result rendering via snippets
|
|
13
|
+
* - Loading and empty states
|
|
14
|
+
*
|
|
15
|
+
* @example Basic usage:
|
|
16
|
+
* ```svelte
|
|
17
|
+
* <HeaderSearch
|
|
18
|
+
* enabled={true}
|
|
19
|
+
* placeholder="Search..."
|
|
20
|
+
* onSearch={(query) => handleSearch(query)}
|
|
21
|
+
* onSelect={(item) => goto(item.href)}
|
|
22
|
+
* results={searchResults}
|
|
23
|
+
* loading={isSearching}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { Dialog as DialogPrimitive } from 'bits-ui';
|
|
28
|
+
import { cn } from '../utils.js';
|
|
29
|
+
import type { Snippet } from 'svelte';
|
|
30
|
+
import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
|
|
31
|
+
import Spinner from './Spinner.svelte';
|
|
32
|
+
|
|
33
|
+
interface Props<T = SearchResultItem> {
|
|
34
|
+
/** Whether search is enabled/visible */
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
/** Placeholder text for search input */
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
/** Called when user types (debounced). App should update results prop. */
|
|
39
|
+
onSearch?: (query: string) => void;
|
|
40
|
+
/** Called when user selects a result */
|
|
41
|
+
onSelect?: (item: T) => void;
|
|
42
|
+
/** Called when search dialog opens/closes */
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
/** Search results to display (flat list or grouped) */
|
|
45
|
+
results?: T[] | SearchResultGroup<T>[];
|
|
46
|
+
/** Whether results are currently loading */
|
|
47
|
+
loading?: boolean;
|
|
48
|
+
/** Message when no results found */
|
|
49
|
+
emptyMessage?: string;
|
|
50
|
+
/** Enable Cmd/Ctrl+K shortcut (default: true) */
|
|
51
|
+
enableShortcut?: boolean;
|
|
52
|
+
/** Custom shortcut key (default: 'k') */
|
|
53
|
+
shortcutKey?: string;
|
|
54
|
+
/** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
|
|
55
|
+
variant?: 'default' | 'icon';
|
|
56
|
+
/** Size variant for trigger button */
|
|
57
|
+
size?: 'sm' | 'md';
|
|
58
|
+
/** Custom result item renderer - receives (item, isSelected) */
|
|
59
|
+
renderResult?: Snippet<[T, boolean]>;
|
|
60
|
+
/** Custom empty state renderer */
|
|
61
|
+
renderEmpty?: Snippet;
|
|
62
|
+
/** Additional classes for trigger button */
|
|
63
|
+
class?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let {
|
|
67
|
+
enabled = true,
|
|
68
|
+
placeholder = 'Search...',
|
|
69
|
+
onSearch,
|
|
70
|
+
onSelect,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
results = [],
|
|
73
|
+
loading = false,
|
|
74
|
+
emptyMessage = 'No results found.',
|
|
75
|
+
enableShortcut = true,
|
|
76
|
+
shortcutKey = 'k',
|
|
77
|
+
variant = 'default',
|
|
78
|
+
size = 'md',
|
|
79
|
+
renderResult,
|
|
80
|
+
renderEmpty,
|
|
81
|
+
class: className,
|
|
82
|
+
}: Props = $props();
|
|
83
|
+
|
|
84
|
+
// State
|
|
85
|
+
let open = $state(false);
|
|
86
|
+
let query = $state('');
|
|
87
|
+
let selectedIndex = $state(0);
|
|
88
|
+
let inputRef: HTMLInputElement | undefined = $state();
|
|
89
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
90
|
+
|
|
91
|
+
// Flatten results for keyboard navigation
|
|
92
|
+
const flatResults = $derived.by(() => {
|
|
93
|
+
if (!results || results.length === 0) return [];
|
|
94
|
+
// Check if grouped results
|
|
95
|
+
if (results.length > 0 && 'items' in results[0]) {
|
|
96
|
+
return (results as SearchResultGroup[]).flatMap((g) => g.items);
|
|
97
|
+
}
|
|
98
|
+
return results as SearchResultItem[];
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Keyboard shortcut handler
|
|
102
|
+
$effect(() => {
|
|
103
|
+
if (!enableShortcut || typeof window === 'undefined') return;
|
|
104
|
+
|
|
105
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
106
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcutKey) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
open = true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
window.addEventListener('keydown', handleKeydown);
|
|
113
|
+
return () => window.removeEventListener('keydown', handleKeydown);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Focus input when dialog opens
|
|
117
|
+
$effect(() => {
|
|
118
|
+
if (open && inputRef) {
|
|
119
|
+
requestAnimationFrame(() => inputRef?.focus());
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Debounced search
|
|
124
|
+
function handleInput(value: string) {
|
|
125
|
+
query = value;
|
|
126
|
+
selectedIndex = 0;
|
|
127
|
+
|
|
128
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
129
|
+
debounceTimer = setTimeout(() => {
|
|
130
|
+
onSearch?.(value);
|
|
131
|
+
}, 200);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Keyboard navigation within results
|
|
135
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
136
|
+
switch (e.key) {
|
|
137
|
+
case 'ArrowDown':
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
|
|
140
|
+
break;
|
|
141
|
+
case 'ArrowUp':
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
144
|
+
break;
|
|
145
|
+
case 'Enter':
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
if (flatResults[selectedIndex]) {
|
|
148
|
+
handleSelect(flatResults[selectedIndex]);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function handleSelect(item: SearchResultItem) {
|
|
155
|
+
onSelect?.(item);
|
|
156
|
+
open = false;
|
|
157
|
+
query = '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleOpenChange(isOpen: boolean) {
|
|
161
|
+
open = isOpen;
|
|
162
|
+
onOpenChange?.(isOpen);
|
|
163
|
+
if (!isOpen) {
|
|
164
|
+
query = '';
|
|
165
|
+
selectedIndex = 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
{#if enabled}
|
|
171
|
+
<!-- Trigger Button -->
|
|
172
|
+
{#if variant === 'icon'}
|
|
173
|
+
<!-- Icon-only variant -->
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onclick={() => (open = true)}
|
|
177
|
+
class={cn(
|
|
178
|
+
'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
179
|
+
size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
|
|
180
|
+
className
|
|
181
|
+
)}
|
|
182
|
+
aria-label="Search"
|
|
183
|
+
title={enableShortcut ? `Search (⌘/${shortcutKey.toUpperCase()})` : 'Search'}
|
|
184
|
+
>
|
|
185
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
186
|
+
<path
|
|
187
|
+
stroke-linecap="round"
|
|
188
|
+
stroke-linejoin="round"
|
|
189
|
+
stroke-width="2"
|
|
190
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
191
|
+
/>
|
|
192
|
+
</svg>
|
|
193
|
+
</button>
|
|
194
|
+
{:else}
|
|
195
|
+
<!-- Default full search box variant -->
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onclick={() => (open = true)}
|
|
199
|
+
class={cn(
|
|
200
|
+
'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
201
|
+
size === 'sm' ? 'h-8' : 'h-9',
|
|
202
|
+
className
|
|
203
|
+
)}
|
|
204
|
+
aria-label="Search"
|
|
205
|
+
>
|
|
206
|
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
207
|
+
<path
|
|
208
|
+
stroke-linecap="round"
|
|
209
|
+
stroke-linejoin="round"
|
|
210
|
+
stroke-width="2"
|
|
211
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
212
|
+
/>
|
|
213
|
+
</svg>
|
|
214
|
+
<span class="hidden sm:inline">{placeholder}</span>
|
|
215
|
+
{#if enableShortcut}
|
|
216
|
+
<kbd
|
|
217
|
+
class="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex"
|
|
218
|
+
>
|
|
219
|
+
<span class="text-xs">⌘</span>{shortcutKey.toUpperCase()}
|
|
220
|
+
</kbd>
|
|
221
|
+
{/if}
|
|
222
|
+
</button>
|
|
223
|
+
{/if}
|
|
224
|
+
|
|
225
|
+
<!-- Search Dialog -->
|
|
226
|
+
<DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
227
|
+
<DialogPrimitive.Portal>
|
|
228
|
+
<DialogPrimitive.Overlay
|
|
229
|
+
class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
230
|
+
/>
|
|
231
|
+
<DialogPrimitive.Content
|
|
232
|
+
class="fixed left-[50%] top-[20%] z-50 w-full max-w-lg translate-x-[-50%] overflow-hidden rounded-lg border bg-background shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:top-[15%]"
|
|
233
|
+
aria-describedby={undefined}
|
|
234
|
+
>
|
|
235
|
+
<!-- Search Input -->
|
|
236
|
+
<div class="flex items-center border-b px-3">
|
|
237
|
+
<svg
|
|
238
|
+
class="h-4 w-4 shrink-0 text-muted-foreground"
|
|
239
|
+
fill="none"
|
|
240
|
+
viewBox="0 0 24 24"
|
|
241
|
+
stroke="currentColor"
|
|
242
|
+
>
|
|
243
|
+
<path
|
|
244
|
+
stroke-linecap="round"
|
|
245
|
+
stroke-linejoin="round"
|
|
246
|
+
stroke-width="2"
|
|
247
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
248
|
+
/>
|
|
249
|
+
</svg>
|
|
250
|
+
<input
|
|
251
|
+
bind:this={inputRef}
|
|
252
|
+
type="search"
|
|
253
|
+
{placeholder}
|
|
254
|
+
value={query}
|
|
255
|
+
oninput={(e) => handleInput(e.currentTarget.value)}
|
|
256
|
+
onkeydown={handleKeydown}
|
|
257
|
+
class="flex h-12 w-full bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
258
|
+
autocomplete="off"
|
|
259
|
+
autocorrect="off"
|
|
260
|
+
spellcheck="false"
|
|
261
|
+
/>
|
|
262
|
+
{#if loading}
|
|
263
|
+
<Spinner size="sm" class="shrink-0" />
|
|
264
|
+
{/if}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- Results Area -->
|
|
268
|
+
<div class="max-h-[300px] overflow-y-auto p-2" role="listbox">
|
|
269
|
+
{#if loading && flatResults.length === 0}
|
|
270
|
+
<div class="flex items-center justify-center py-6">
|
|
271
|
+
<Spinner size="sm" />
|
|
272
|
+
<span class="ml-2 text-sm text-muted-foreground">Searching...</span>
|
|
273
|
+
</div>
|
|
274
|
+
{:else if flatResults.length === 0 && query}
|
|
275
|
+
{#if renderEmpty}
|
|
276
|
+
{@render renderEmpty()}
|
|
277
|
+
{:else}
|
|
278
|
+
<div class="py-6 text-center text-sm text-muted-foreground">
|
|
279
|
+
{emptyMessage}
|
|
280
|
+
</div>
|
|
281
|
+
{/if}
|
|
282
|
+
{:else if flatResults.length === 0}
|
|
283
|
+
<div class="py-6 text-center text-sm text-muted-foreground">Type to search...</div>
|
|
284
|
+
{:else}
|
|
285
|
+
{#each flatResults as item, index (item.id)}
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
onclick={() => handleSelect(item)}
|
|
289
|
+
class={cn(
|
|
290
|
+
'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
|
|
291
|
+
index === selectedIndex
|
|
292
|
+
? 'bg-accent text-accent-foreground'
|
|
293
|
+
: 'hover:bg-accent/50'
|
|
294
|
+
)}
|
|
295
|
+
role="option"
|
|
296
|
+
aria-selected={index === selectedIndex}
|
|
297
|
+
>
|
|
298
|
+
{#if renderResult}
|
|
299
|
+
{@render renderResult(item, index === selectedIndex)}
|
|
300
|
+
{:else}
|
|
301
|
+
<div class="min-w-0 flex-1">
|
|
302
|
+
<div class="truncate font-medium">{item.title}</div>
|
|
303
|
+
{#if item.description}
|
|
304
|
+
<div class="truncate text-xs text-muted-foreground">
|
|
305
|
+
{item.description}
|
|
306
|
+
</div>
|
|
307
|
+
{/if}
|
|
308
|
+
</div>
|
|
309
|
+
{#if item.category}
|
|
310
|
+
<span class="shrink-0 text-xs text-muted-foreground">
|
|
311
|
+
{item.category}
|
|
312
|
+
</span>
|
|
313
|
+
{/if}
|
|
314
|
+
{/if}
|
|
315
|
+
</button>
|
|
316
|
+
{/each}
|
|
317
|
+
{/if}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<!-- Footer with keyboard hints -->
|
|
321
|
+
<div
|
|
322
|
+
class="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground"
|
|
323
|
+
>
|
|
324
|
+
<div class="flex items-center gap-1">
|
|
325
|
+
<kbd class="rounded border bg-muted px-1.5 py-0.5">↑↓</kbd>
|
|
326
|
+
<span>Navigate</span>
|
|
327
|
+
</div>
|
|
328
|
+
<div class="flex items-center gap-1">
|
|
329
|
+
<kbd class="rounded border bg-muted px-1.5 py-0.5">↵</kbd>
|
|
330
|
+
<span>Select</span>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="flex items-center gap-1">
|
|
333
|
+
<kbd class="rounded border bg-muted px-1.5 py-0.5">esc</kbd>
|
|
334
|
+
<span>Close</span>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</DialogPrimitive.Content>
|
|
338
|
+
</DialogPrimitive.Portal>
|
|
339
|
+
</DialogPrimitive.Root>
|
|
340
|
+
{/if}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
|
|
3
|
+
interface Props<T = SearchResultItem> {
|
|
4
|
+
/** Whether search is enabled/visible */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
/** Placeholder text for search input */
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
/** Called when user types (debounced). App should update results prop. */
|
|
9
|
+
onSearch?: (query: string) => void;
|
|
10
|
+
/** Called when user selects a result */
|
|
11
|
+
onSelect?: (item: T) => void;
|
|
12
|
+
/** Called when search dialog opens/closes */
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
/** Search results to display (flat list or grouped) */
|
|
15
|
+
results?: T[] | SearchResultGroup<T>[];
|
|
16
|
+
/** Whether results are currently loading */
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
/** Message when no results found */
|
|
19
|
+
emptyMessage?: string;
|
|
20
|
+
/** Enable Cmd/Ctrl+K shortcut (default: true) */
|
|
21
|
+
enableShortcut?: boolean;
|
|
22
|
+
/** Custom shortcut key (default: 'k') */
|
|
23
|
+
shortcutKey?: string;
|
|
24
|
+
/** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
|
|
25
|
+
variant?: 'default' | 'icon';
|
|
26
|
+
/** Size variant for trigger button */
|
|
27
|
+
size?: 'sm' | 'md';
|
|
28
|
+
/** Custom result item renderer - receives (item, isSelected) */
|
|
29
|
+
renderResult?: Snippet<[T, boolean]>;
|
|
30
|
+
/** Custom empty state renderer */
|
|
31
|
+
renderEmpty?: Snippet;
|
|
32
|
+
/** Additional classes for trigger button */
|
|
33
|
+
class?: string;
|
|
34
|
+
}
|
|
35
|
+
declare const HeaderSearch: import("svelte").Component<Props<SearchResultItem>, {}, "">;
|
|
36
|
+
type HeaderSearch = ReturnType<typeof HeaderSearch>;
|
|
37
|
+
export default HeaderSearch;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Combobox as ComboboxPrimitive } from 'bits-ui';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
import Spinner from './Spinner.svelte';
|
|
5
|
+
|
|
6
|
+
export interface MultiSelectOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Currently selected values */
|
|
14
|
+
value?: string[];
|
|
15
|
+
/** Callback when values change */
|
|
16
|
+
onValueChange?: (values: string[]) => void;
|
|
17
|
+
/** Array of selectable options */
|
|
18
|
+
options: MultiSelectOption[];
|
|
19
|
+
/** Placeholder text for the input */
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Message to display when no results found */
|
|
22
|
+
emptyMessage?: string;
|
|
23
|
+
/** Whether the multiselect is disabled */
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
/** Whether selection is required */
|
|
26
|
+
required?: boolean;
|
|
27
|
+
/** Name attribute for form submission */
|
|
28
|
+
name?: string;
|
|
29
|
+
/** Element ID */
|
|
30
|
+
id?: string;
|
|
31
|
+
/** Error message to display */
|
|
32
|
+
error?: string;
|
|
33
|
+
/** Maximum number of selections allowed */
|
|
34
|
+
max?: number;
|
|
35
|
+
/** Whether async data is loading */
|
|
36
|
+
loading?: boolean;
|
|
37
|
+
/** Callback when search query changes (for async loading) */
|
|
38
|
+
onSearch?: (query: string) => void;
|
|
39
|
+
/** Debounce delay in ms for search callback (default: 300) */
|
|
40
|
+
debounceMs?: number;
|
|
41
|
+
/** Additional class for the trigger */
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
value = $bindable([]),
|
|
47
|
+
onValueChange,
|
|
48
|
+
options,
|
|
49
|
+
placeholder = 'Select options...',
|
|
50
|
+
emptyMessage = 'No results found.',
|
|
51
|
+
disabled = false,
|
|
52
|
+
required = false,
|
|
53
|
+
name,
|
|
54
|
+
id,
|
|
55
|
+
error,
|
|
56
|
+
max,
|
|
57
|
+
loading = false,
|
|
58
|
+
onSearch,
|
|
59
|
+
debounceMs = 300,
|
|
60
|
+
class: className,
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
let searchQuery = $state('');
|
|
64
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
let open = $state(false);
|
|
66
|
+
|
|
67
|
+
// Get labels for selected values
|
|
68
|
+
const selectedLabels = $derived(
|
|
69
|
+
value.map((v) => options.find((opt) => opt.value === v)?.label).filter(Boolean) as string[]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Always filter options locally based on search query
|
|
73
|
+
const filteredOptions = $derived.by(() => {
|
|
74
|
+
if (!searchQuery) return options;
|
|
75
|
+
const query = searchQuery.toLowerCase();
|
|
76
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(query));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Check if max selections reached
|
|
80
|
+
const maxReached = $derived(max !== undefined && value.length >= max);
|
|
81
|
+
|
|
82
|
+
function handleSearchInput(e: Event) {
|
|
83
|
+
const target = e.target as HTMLInputElement;
|
|
84
|
+
searchQuery = target.value;
|
|
85
|
+
|
|
86
|
+
if (onSearch) {
|
|
87
|
+
// Debounce the search callback
|
|
88
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
89
|
+
debounceTimer = setTimeout(() => {
|
|
90
|
+
onSearch(searchQuery);
|
|
91
|
+
}, debounceMs);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleValueChange(newValues: string[] | undefined) {
|
|
96
|
+
if (newValues !== undefined) {
|
|
97
|
+
value = newValues;
|
|
98
|
+
onValueChange?.(newValues);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function removeValue(valueToRemove: string) {
|
|
103
|
+
const newValues = value.filter((v) => v !== valueToRemove);
|
|
104
|
+
value = newValues;
|
|
105
|
+
onValueChange?.(newValues);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleOpenChange(isOpen: boolean) {
|
|
109
|
+
open = isOpen;
|
|
110
|
+
if (!isOpen) {
|
|
111
|
+
searchQuery = '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cleanup debounce timer on unmount
|
|
116
|
+
$effect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<div class={cn('w-full', className)}>
|
|
124
|
+
<!-- Selected tags -->
|
|
125
|
+
{#if selectedLabels.length > 0}
|
|
126
|
+
<div class="flex flex-wrap gap-1 mb-2">
|
|
127
|
+
{#each value as selectedValue}
|
|
128
|
+
{@const label = options.find((o) => o.value === selectedValue)?.label}
|
|
129
|
+
{#if label}
|
|
130
|
+
<span
|
|
131
|
+
class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
|
|
132
|
+
>
|
|
133
|
+
{label}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onclick={() => removeValue(selectedValue)}
|
|
137
|
+
class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
138
|
+
{disabled}
|
|
139
|
+
aria-label={`Remove ${label}`}
|
|
140
|
+
>
|
|
141
|
+
<svg
|
|
142
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
143
|
+
width="14"
|
|
144
|
+
height="14"
|
|
145
|
+
viewBox="0 0 24 24"
|
|
146
|
+
fill="none"
|
|
147
|
+
stroke="currentColor"
|
|
148
|
+
stroke-width="2"
|
|
149
|
+
stroke-linecap="round"
|
|
150
|
+
stroke-linejoin="round"
|
|
151
|
+
>
|
|
152
|
+
<path d="M18 6 6 18" />
|
|
153
|
+
<path d="m6 6 12 12" />
|
|
154
|
+
</svg>
|
|
155
|
+
</button>
|
|
156
|
+
</span>
|
|
157
|
+
{/if}
|
|
158
|
+
{/each}
|
|
159
|
+
</div>
|
|
160
|
+
{/if}
|
|
161
|
+
|
|
162
|
+
<ComboboxPrimitive.Root
|
|
163
|
+
type="multiple"
|
|
164
|
+
{disabled}
|
|
165
|
+
{required}
|
|
166
|
+
{name}
|
|
167
|
+
bind:open
|
|
168
|
+
onOpenChange={handleOpenChange}
|
|
169
|
+
onValueChange={handleValueChange}
|
|
170
|
+
{value}
|
|
171
|
+
>
|
|
172
|
+
<div class="relative">
|
|
173
|
+
<ComboboxPrimitive.Input
|
|
174
|
+
{id}
|
|
175
|
+
placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
|
|
176
|
+
oninput={handleSearchInput}
|
|
177
|
+
onfocus={() => (open = true)}
|
|
178
|
+
onclick={() => (open = true)}
|
|
179
|
+
class={cn(
|
|
180
|
+
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
181
|
+
error && 'border-destructive focus:ring-destructive'
|
|
182
|
+
)}
|
|
183
|
+
aria-invalid={error ? 'true' : undefined}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<ComboboxPrimitive.Content
|
|
188
|
+
class="relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
|
189
|
+
sideOffset={4}
|
|
190
|
+
>
|
|
191
|
+
<div class="p-1 max-h-[300px] overflow-y-auto">
|
|
192
|
+
{#if loading}
|
|
193
|
+
<div class="flex items-center justify-center py-6">
|
|
194
|
+
<Spinner size="sm" />
|
|
195
|
+
<span class="ml-2 text-sm text-muted-foreground">Loading...</span>
|
|
196
|
+
</div>
|
|
197
|
+
{:else if filteredOptions.length === 0}
|
|
198
|
+
<div class="py-6 text-center text-sm text-muted-foreground">
|
|
199
|
+
{emptyMessage}
|
|
200
|
+
</div>
|
|
201
|
+
{:else}
|
|
202
|
+
{#each filteredOptions as option}
|
|
203
|
+
{@const isSelected = value.includes(option.value)}
|
|
204
|
+
{@const isDisabledByMax = maxReached && !isSelected}
|
|
205
|
+
<ComboboxPrimitive.Item
|
|
206
|
+
value={option.value}
|
|
207
|
+
label={option.label}
|
|
208
|
+
disabled={option.disabled || isDisabledByMax}
|
|
209
|
+
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
210
|
+
>
|
|
211
|
+
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
212
|
+
{#if isSelected}
|
|
213
|
+
<svg
|
|
214
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
215
|
+
width="16"
|
|
216
|
+
height="16"
|
|
217
|
+
viewBox="0 0 24 24"
|
|
218
|
+
fill="none"
|
|
219
|
+
stroke="currentColor"
|
|
220
|
+
stroke-width="2"
|
|
221
|
+
stroke-linecap="round"
|
|
222
|
+
stroke-linejoin="round"
|
|
223
|
+
class="h-4 w-4"
|
|
224
|
+
>
|
|
225
|
+
<polyline points="20 6 9 17 4 12" />
|
|
226
|
+
</svg>
|
|
227
|
+
{:else}
|
|
228
|
+
<span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
|
|
229
|
+
{/if}
|
|
230
|
+
</span>
|
|
231
|
+
{option.label}
|
|
232
|
+
</ComboboxPrimitive.Item>
|
|
233
|
+
{/each}
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
236
|
+
</ComboboxPrimitive.Content>
|
|
237
|
+
</ComboboxPrimitive.Root>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{#if error}
|
|
241
|
+
<p class="text-sm text-destructive mt-1.5" role="alert">
|
|
242
|
+
{error}
|
|
243
|
+
</p>
|
|
244
|
+
{/if}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface MultiSelectOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
}
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Currently selected values */
|
|
8
|
+
value?: string[];
|
|
9
|
+
/** Callback when values change */
|
|
10
|
+
onValueChange?: (values: string[]) => void;
|
|
11
|
+
/** Array of selectable options */
|
|
12
|
+
options: MultiSelectOption[];
|
|
13
|
+
/** Placeholder text for the input */
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
/** Message to display when no results found */
|
|
16
|
+
emptyMessage?: string;
|
|
17
|
+
/** Whether the multiselect is disabled */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/** Whether selection is required */
|
|
20
|
+
required?: boolean;
|
|
21
|
+
/** Name attribute for form submission */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** Element ID */
|
|
24
|
+
id?: string;
|
|
25
|
+
/** Error message to display */
|
|
26
|
+
error?: string;
|
|
27
|
+
/** Maximum number of selections allowed */
|
|
28
|
+
max?: number;
|
|
29
|
+
/** Whether async data is loading */
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
/** Callback when search query changes (for async loading) */
|
|
32
|
+
onSearch?: (query: string) => void;
|
|
33
|
+
/** Debounce delay in ms for search callback (default: 300) */
|
|
34
|
+
debounceMs?: number;
|
|
35
|
+
/** Additional class for the trigger */
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
declare const MultiSelect: import("svelte").Component<Props, {}, "value">;
|
|
39
|
+
type MultiSelect = ReturnType<typeof MultiSelect>;
|
|
40
|
+
export default MultiSelect;
|