@classic-homes/theme-svelte 0.1.5 → 0.1.7
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/CardFooter.svelte +20 -2
- package/dist/lib/components/CardFooter.svelte.d.ts +20 -2
- package/dist/lib/components/CardHeader.svelte +22 -2
- package/dist/lib/components/CardHeader.svelte.d.ts +5 -4
- package/dist/lib/components/HeaderSearch.svelte +340 -0
- package/dist/lib/components/HeaderSearch.svelte.d.ts +37 -0
- package/dist/lib/components/PageHeader.svelte +6 -0
- package/dist/lib/components/PageHeader.svelte.d.ts +1 -1
- 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 +39 -60
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +6 -1
- 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/Footer.svelte +58 -53
- 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/sidebar/SidebarNavItem.svelte +16 -7
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +4 -4
- package/dist/lib/index.d.ts +4 -1
- package/dist/lib/index.js +3 -0
- package/dist/lib/schemas/common.d.ts +2 -2
- package/dist/lib/types/layout.d.ts +187 -1
- package/package.json +1 -1
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* CardFooter - Footer section for Card component
|
|
4
|
+
*
|
|
5
|
+
* Provides consistent padding and flex layout for card action buttons
|
|
6
|
+
* or summary content.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Card>
|
|
10
|
+
* <CardHeader>...</CardHeader>
|
|
11
|
+
* <CardContent>...</CardContent>
|
|
12
|
+
* <CardFooter>
|
|
13
|
+
* <Button>Save</Button>
|
|
14
|
+
* <Button variant="outline">Cancel</Button>
|
|
15
|
+
* </CardFooter>
|
|
16
|
+
* </Card>
|
|
17
|
+
*/
|
|
2
18
|
import type { Snippet } from 'svelte';
|
|
19
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
20
|
import { cn } from '../utils.js';
|
|
4
21
|
|
|
5
|
-
interface Props {
|
|
22
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
/** Additional CSS classes */
|
|
6
24
|
class?: string;
|
|
25
|
+
/** Footer content */
|
|
7
26
|
children: Snippet;
|
|
8
|
-
[key: string]: unknown;
|
|
9
27
|
}
|
|
10
28
|
|
|
11
29
|
let { class: className, children, ...restProps }: Props = $props();
|
|
@@ -1,8 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardFooter - Footer section for Card component
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent padding and flex layout for card action buttons
|
|
5
|
+
* or summary content.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <Card>
|
|
9
|
+
* <CardHeader>...</CardHeader>
|
|
10
|
+
* <CardContent>...</CardContent>
|
|
11
|
+
* <CardFooter>
|
|
12
|
+
* <Button>Save</Button>
|
|
13
|
+
* <Button variant="outline">Cancel</Button>
|
|
14
|
+
* </CardFooter>
|
|
15
|
+
* </Card>
|
|
16
|
+
*/
|
|
1
17
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
18
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
19
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
20
|
+
/** Additional CSS classes */
|
|
3
21
|
class?: string;
|
|
22
|
+
/** Footer content */
|
|
4
23
|
children: Snippet;
|
|
5
|
-
[key: string]: unknown;
|
|
6
24
|
}
|
|
7
25
|
declare const CardFooter: import("svelte").Component<Props, {}, "">;
|
|
8
26
|
type CardFooter = ReturnType<typeof CardFooter>;
|
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
|
+
|
|
6
|
+
const cardHeaderVariants = tv({
|
|
7
|
+
base: 'flex flex-col space-y-1.5',
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: 'p-6',
|
|
11
|
+
compact: 'p-4',
|
|
12
|
+
bordered: 'p-6 border-b',
|
|
13
|
+
shaded: 'p-6 bg-muted/50',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
variant: 'default',
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type CardHeaderVariants = VariantProps<typeof cardHeaderVariants>;
|
|
4
22
|
|
|
5
23
|
interface Props {
|
|
24
|
+
/** Visual variant */
|
|
25
|
+
variant?: CardHeaderVariants['variant'];
|
|
6
26
|
class?: string;
|
|
7
27
|
children: Snippet;
|
|
8
28
|
[key: string]: unknown;
|
|
9
29
|
}
|
|
10
30
|
|
|
11
|
-
let { class: className, children, ...restProps }: Props = $props();
|
|
31
|
+
let { variant = 'default', class: className, children, ...restProps }: Props = $props();
|
|
12
32
|
</script>
|
|
13
33
|
|
|
14
|
-
<div class={cn(
|
|
34
|
+
<div class={cn(cardHeaderVariants({ variant }), className)} {...restProps}>
|
|
15
35
|
{@render children()}
|
|
16
36
|
</div>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
2
|
+
declare const CardHeader: import("svelte").Component<{
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
/** Visual variant */
|
|
5
|
+
variant?: "default" | "compact" | "bordered" | "shaded" | undefined;
|
|
3
6
|
class?: string;
|
|
4
7
|
children: Snippet;
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
declare const CardHeader: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
}, {}, "">;
|
|
8
9
|
type CardHeader = ReturnType<typeof CardHeader>;
|
|
9
10
|
export default CardHeader;
|
|
@@ -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;
|
|
@@ -42,6 +42,12 @@
|
|
|
42
42
|
subtitle: 'text-xl max-w-2xl mx-auto',
|
|
43
43
|
actionsWrapper: 'mt-6 justify-center',
|
|
44
44
|
},
|
|
45
|
+
form: {
|
|
46
|
+
container: 'text-center mb-8',
|
|
47
|
+
title: 'text-3xl md:text-4xl font-light tracking-tight text-brand-core-2',
|
|
48
|
+
subtitle: 'mt-3 text-base md:text-lg text-gray-600',
|
|
49
|
+
actionsWrapper: 'mt-6 justify-center',
|
|
50
|
+
},
|
|
45
51
|
},
|
|
46
52
|
},
|
|
47
53
|
defaultVariants: {
|
|
@@ -18,7 +18,7 @@ declare const PageHeader: import("svelte").Component<{
|
|
|
18
18
|
/** Optional subtitle text */
|
|
19
19
|
subtitle?: string;
|
|
20
20
|
/** Visual variant */
|
|
21
|
-
variant?: "default" | "hero" | "centered" | undefined;
|
|
21
|
+
variant?: "default" | "form" | "hero" | "centered" | undefined;
|
|
22
22
|
/** Additional classes for the container */
|
|
23
23
|
class?: string;
|
|
24
24
|
/** Optional action buttons/content */
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AuthLayout - Layout for authentication pages
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Centered card layout for auth forms
|
|
7
|
+
* - Logo with optional subtitle
|
|
8
|
+
* - Footer links (privacy, terms, etc.)
|
|
9
|
+
* - Optional background decoration
|
|
10
|
+
* - Responsive design
|
|
11
|
+
* - Composes AppShell for consistent base structure
|
|
12
|
+
*
|
|
13
|
+
* Use this layout for: login, signup, password reset, forgot password,
|
|
14
|
+
* email verification, 2FA, and other authentication flows.
|
|
15
|
+
*/
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
import { cn } from '../../utils.js';
|
|
18
|
+
import AppShell from './AppShell.svelte';
|
|
19
|
+
import LogoMain from '../LogoMain.svelte';
|
|
20
|
+
|
|
21
|
+
interface FooterLink {
|
|
22
|
+
/** Link label */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Link URL */
|
|
25
|
+
href: string;
|
|
26
|
+
/** Opens in new tab */
|
|
27
|
+
external?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
/** Custom logo snippet */
|
|
32
|
+
logo?: Snippet;
|
|
33
|
+
/** Logo subtitle for default logo (e.g., "Sign in to your account") */
|
|
34
|
+
logoSubtitle?: string;
|
|
35
|
+
/** Logo environment indicator for default logo */
|
|
36
|
+
logoEnvironment?: 'local' | 'dev' | 'demo';
|
|
37
|
+
/** Footer links (privacy policy, terms, etc.) */
|
|
38
|
+
footerLinks?: FooterLink[];
|
|
39
|
+
/** Custom footer content (replaces footer links) */
|
|
40
|
+
footer?: Snippet;
|
|
41
|
+
/** Show decorative background */
|
|
42
|
+
showBackground?: boolean;
|
|
43
|
+
/** Background variant */
|
|
44
|
+
backgroundVariant?: 'default' | 'gradient' | 'pattern';
|
|
45
|
+
/** Maximum width of content card */
|
|
46
|
+
maxWidth?: 'sm' | 'md' | 'lg';
|
|
47
|
+
/** Additional classes for the container */
|
|
48
|
+
class?: string;
|
|
49
|
+
/** Main content (auth form) */
|
|
50
|
+
children: Snippet;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
logo,
|
|
55
|
+
logoSubtitle,
|
|
56
|
+
logoEnvironment,
|
|
57
|
+
footerLinks = [],
|
|
58
|
+
footer,
|
|
59
|
+
showBackground = false,
|
|
60
|
+
backgroundVariant = 'default',
|
|
61
|
+
maxWidth = 'sm',
|
|
62
|
+
class: className,
|
|
63
|
+
children,
|
|
64
|
+
}: Props = $props();
|
|
65
|
+
|
|
66
|
+
const maxWidthClasses = {
|
|
67
|
+
sm: 'max-w-sm',
|
|
68
|
+
md: 'max-w-md',
|
|
69
|
+
lg: 'max-w-lg',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const backgroundClasses = {
|
|
73
|
+
default: 'bg-content-bg',
|
|
74
|
+
gradient: 'bg-gradient-to-br from-primary/5 via-content-bg to-accent/5',
|
|
75
|
+
pattern:
|
|
76
|
+
'bg-content-bg bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-content-bg to-content-bg',
|
|
77
|
+
};
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<AppShell>
|
|
81
|
+
<div
|
|
82
|
+
class={cn(
|
|
83
|
+
'flex h-screen flex-col items-center justify-center overflow-auto bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
|
|
84
|
+
showBackground && backgroundClasses[backgroundVariant],
|
|
85
|
+
className
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
<!-- Logo -->
|
|
89
|
+
<div class="mb-6 flex flex-col items-center">
|
|
90
|
+
{#if logo}
|
|
91
|
+
{@render logo()}
|
|
92
|
+
{:else}
|
|
93
|
+
<LogoMain
|
|
94
|
+
variant="stacked"
|
|
95
|
+
color="dark"
|
|
96
|
+
size="lg"
|
|
97
|
+
subtitle={logoSubtitle}
|
|
98
|
+
environment={logoEnvironment}
|
|
99
|
+
/>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Main Content Card -->
|
|
104
|
+
<main
|
|
105
|
+
id="main-content"
|
|
106
|
+
class={cn('w-full rounded-lg bg-background p-6 shadow-lg sm:p-8', maxWidthClasses[maxWidth])}
|
|
107
|
+
>
|
|
108
|
+
{@render children()}
|
|
109
|
+
</main>
|
|
110
|
+
|
|
111
|
+
<!-- Footer Links -->
|
|
112
|
+
{#if footer}
|
|
113
|
+
<footer class="mt-6">
|
|
114
|
+
{@render footer()}
|
|
115
|
+
</footer>
|
|
116
|
+
{:else if footerLinks.length > 0}
|
|
117
|
+
<footer class="mt-6">
|
|
118
|
+
<nav class="flex flex-wrap justify-center gap-x-6 gap-y-2" aria-label="Footer">
|
|
119
|
+
{#each footerLinks as link}
|
|
120
|
+
<a
|
|
121
|
+
href={link.href}
|
|
122
|
+
class="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
123
|
+
target={link.external ? '_blank' : undefined}
|
|
124
|
+
rel={link.external ? 'noopener noreferrer' : undefined}
|
|
125
|
+
>
|
|
126
|
+
{link.label}
|
|
127
|
+
</a>
|
|
128
|
+
{/each}
|
|
129
|
+
</nav>
|
|
130
|
+
</footer>
|
|
131
|
+
{/if}
|
|
132
|
+
</div>
|
|
133
|
+
</AppShell>
|