@classic-homes/theme-svelte 0.1.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 (124) hide show
  1. package/README.md +305 -0
  2. package/dist/lib/components/Alert.svelte +51 -0
  3. package/dist/lib/components/Alert.svelte.d.ts +9 -0
  4. package/dist/lib/components/AlertDescription.svelte +16 -0
  5. package/dist/lib/components/AlertDescription.svelte.d.ts +9 -0
  6. package/dist/lib/components/AlertDialog.svelte +136 -0
  7. package/dist/lib/components/AlertDialog.svelte.d.ts +79 -0
  8. package/dist/lib/components/AlertTitle.svelte +16 -0
  9. package/dist/lib/components/AlertTitle.svelte.d.ts +9 -0
  10. package/dist/lib/components/Avatar.svelte +56 -0
  11. package/dist/lib/components/Avatar.svelte.d.ts +26 -0
  12. package/dist/lib/components/AvatarFallback.svelte +31 -0
  13. package/dist/lib/components/AvatarFallback.svelte.d.ts +17 -0
  14. package/dist/lib/components/AvatarImage.svelte +29 -0
  15. package/dist/lib/components/AvatarImage.svelte.d.ts +12 -0
  16. package/dist/lib/components/Badge.svelte +73 -0
  17. package/dist/lib/components/Badge.svelte.d.ts +11 -0
  18. package/dist/lib/components/Button.svelte +130 -0
  19. package/dist/lib/components/Button.svelte.d.ts +17 -0
  20. package/dist/lib/components/Card.svelte +58 -0
  21. package/dist/lib/components/Card.svelte.d.ts +26 -0
  22. package/dist/lib/components/CardContent.svelte +16 -0
  23. package/dist/lib/components/CardContent.svelte.d.ts +9 -0
  24. package/dist/lib/components/CardDescription.svelte +16 -0
  25. package/dist/lib/components/CardDescription.svelte.d.ts +9 -0
  26. package/dist/lib/components/CardFooter.svelte +16 -0
  27. package/dist/lib/components/CardFooter.svelte.d.ts +9 -0
  28. package/dist/lib/components/CardHeader.svelte +16 -0
  29. package/dist/lib/components/CardHeader.svelte.d.ts +9 -0
  30. package/dist/lib/components/CardTitle.svelte +16 -0
  31. package/dist/lib/components/CardTitle.svelte.d.ts +9 -0
  32. package/dist/lib/components/Checkbox.svelte +65 -0
  33. package/dist/lib/components/Checkbox.svelte.d.ts +14 -0
  34. package/dist/lib/components/DataTable.svelte +334 -0
  35. package/dist/lib/components/DataTable.svelte.d.ts +103 -0
  36. package/dist/lib/components/Dialog.svelte +111 -0
  37. package/dist/lib/components/Dialog.svelte.d.ts +22 -0
  38. package/dist/lib/components/DropdownMenu.svelte +135 -0
  39. package/dist/lib/components/DropdownMenu.svelte.d.ts +33 -0
  40. package/dist/lib/components/FileUpload.svelte +448 -0
  41. package/dist/lib/components/FileUpload.svelte.d.ts +42 -0
  42. package/dist/lib/components/FormField.svelte +134 -0
  43. package/dist/lib/components/FormField.svelte.d.ts +37 -0
  44. package/dist/lib/components/Input.svelte +61 -0
  45. package/dist/lib/components/Input.svelte.d.ts +19 -0
  46. package/dist/lib/components/Label.svelte +33 -0
  47. package/dist/lib/components/Label.svelte.d.ts +11 -0
  48. package/dist/lib/components/LoadingLogo.svelte +124 -0
  49. package/dist/lib/components/LoadingLogo.svelte.d.ts +16 -0
  50. package/dist/lib/components/LogoMain.svelte +237 -0
  51. package/dist/lib/components/LogoMain.svelte.d.ts +20 -0
  52. package/dist/lib/components/PageHeader.svelte +90 -0
  53. package/dist/lib/components/PageHeader.svelte.d.ts +28 -0
  54. package/dist/lib/components/Section.svelte +44 -0
  55. package/dist/lib/components/Section.svelte.d.ts +28 -0
  56. package/dist/lib/components/Select.svelte +174 -0
  57. package/dist/lib/components/Select.svelte.d.ts +32 -0
  58. package/dist/lib/components/Separator.svelte +29 -0
  59. package/dist/lib/components/Separator.svelte.d.ts +9 -0
  60. package/dist/lib/components/Skeleton.svelte +35 -0
  61. package/dist/lib/components/Skeleton.svelte.d.ts +7 -0
  62. package/dist/lib/components/Spinner.svelte +50 -0
  63. package/dist/lib/components/Spinner.svelte.d.ts +8 -0
  64. package/dist/lib/components/Switch.svelte +56 -0
  65. package/dist/lib/components/Switch.svelte.d.ts +14 -0
  66. package/dist/lib/components/TabPanel.svelte +44 -0
  67. package/dist/lib/components/TabPanel.svelte.d.ts +12 -0
  68. package/dist/lib/components/Tabs.svelte +125 -0
  69. package/dist/lib/components/Tabs.svelte.d.ts +19 -0
  70. package/dist/lib/components/Textarea.svelte +54 -0
  71. package/dist/lib/components/Textarea.svelte.d.ts +16 -0
  72. package/dist/lib/components/Toast.svelte +116 -0
  73. package/dist/lib/components/Toast.svelte.d.ts +12 -0
  74. package/dist/lib/components/ToastContainer.svelte +56 -0
  75. package/dist/lib/components/ToastContainer.svelte.d.ts +8 -0
  76. package/dist/lib/components/Tooltip.svelte +55 -0
  77. package/dist/lib/components/Tooltip.svelte.d.ts +18 -0
  78. package/dist/lib/components/layout/AppShell.svelte +82 -0
  79. package/dist/lib/components/layout/AppShell.svelte.d.ts +44 -0
  80. package/dist/lib/components/layout/DashboardLayout.svelte +248 -0
  81. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +62 -0
  82. package/dist/lib/components/layout/Footer.svelte +130 -0
  83. package/dist/lib/components/layout/Footer.svelte.d.ts +32 -0
  84. package/dist/lib/components/layout/FormPageLayout.svelte +92 -0
  85. package/dist/lib/components/layout/FormPageLayout.svelte.d.ts +33 -0
  86. package/dist/lib/components/layout/Header.svelte +94 -0
  87. package/dist/lib/components/layout/Header.svelte.d.ts +30 -0
  88. package/dist/lib/components/layout/PublicLayout.svelte +180 -0
  89. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +39 -0
  90. package/dist/lib/components/layout/QuickLinks.svelte +112 -0
  91. package/dist/lib/components/layout/QuickLinks.svelte.d.ts +27 -0
  92. package/dist/lib/components/layout/Sidebar.svelte +243 -0
  93. package/dist/lib/components/layout/Sidebar.svelte.d.ts +48 -0
  94. package/dist/lib/composables/index.d.ts +8 -0
  95. package/dist/lib/composables/index.js +10 -0
  96. package/dist/lib/composables/useAsync.svelte.d.ts +102 -0
  97. package/dist/lib/composables/useAsync.svelte.js +210 -0
  98. package/dist/lib/composables/useForm.svelte.d.ts +123 -0
  99. package/dist/lib/composables/useForm.svelte.js +245 -0
  100. package/dist/lib/index.d.ts +65 -0
  101. package/dist/lib/index.js +83 -0
  102. package/dist/lib/performance.d.ts +79 -0
  103. package/dist/lib/performance.js +170 -0
  104. package/dist/lib/schemas/auth.d.ts +410 -0
  105. package/dist/lib/schemas/auth.js +216 -0
  106. package/dist/lib/schemas/common.d.ts +267 -0
  107. package/dist/lib/schemas/common.js +268 -0
  108. package/dist/lib/schemas/index.d.ts +24 -0
  109. package/dist/lib/schemas/index.js +32 -0
  110. package/dist/lib/stores/sidebar.svelte.d.ts +25 -0
  111. package/dist/lib/stores/sidebar.svelte.js +38 -0
  112. package/dist/lib/stores/theme.svelte.d.ts +72 -0
  113. package/dist/lib/stores/theme.svelte.js +150 -0
  114. package/dist/lib/stores/toast.svelte.d.ts +62 -0
  115. package/dist/lib/stores/toast.svelte.js +93 -0
  116. package/dist/lib/types/components.d.ts +85 -0
  117. package/dist/lib/types/components.js +7 -0
  118. package/dist/lib/types/layout.d.ts +258 -0
  119. package/dist/lib/types/layout.js +7 -0
  120. package/dist/lib/utils.d.ts +6 -0
  121. package/dist/lib/utils.js +9 -0
  122. package/dist/lib/validation.d.ts +101 -0
  123. package/dist/lib/validation.js +170 -0
  124. package/package.json +56 -0
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3
+ import { cn } from '../utils.js';
4
+ import type { Snippet } from 'svelte';
5
+
6
+ export interface DropdownMenuItem {
7
+ type?: 'item' | 'separator' | 'label';
8
+ label?: string;
9
+ value?: string;
10
+ disabled?: boolean;
11
+ onSelect?: () => void;
12
+ destructive?: boolean;
13
+ shortcut?: string;
14
+ }
15
+
16
+ export interface DropdownMenuGroup {
17
+ label?: string;
18
+ items: DropdownMenuItem[];
19
+ }
20
+
21
+ interface Props {
22
+ /** Menu items */
23
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
24
+ /** Trigger element */
25
+ trigger: Snippet;
26
+ /** Side of trigger to show menu */
27
+ side?: 'top' | 'right' | 'bottom' | 'left';
28
+ /** Alignment of menu relative to trigger */
29
+ align?: 'start' | 'center' | 'end';
30
+ /** Whether menu is open (controlled) */
31
+ open?: boolean;
32
+ /** Callback when open state changes */
33
+ onOpenChange?: (open: boolean) => void;
34
+ /** Additional class for menu content */
35
+ class?: string;
36
+ }
37
+
38
+ let {
39
+ items,
40
+ trigger,
41
+ side = 'bottom',
42
+ align = 'start',
43
+ open = $bindable(false),
44
+ onOpenChange,
45
+ class: className,
46
+ }: Props = $props();
47
+
48
+ function handleOpenChange(newOpen: boolean) {
49
+ open = newOpen;
50
+ onOpenChange?.(newOpen);
51
+ }
52
+
53
+ function isGroup(item: DropdownMenuItem | DropdownMenuGroup): item is DropdownMenuGroup {
54
+ return 'items' in item;
55
+ }
56
+ </script>
57
+
58
+ <DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
59
+ <DropdownMenuPrimitive.Trigger asChild>
60
+ {#snippet child({ props })}
61
+ <span {...props}>
62
+ {@render trigger()}
63
+ </span>
64
+ {/snippet}
65
+ </DropdownMenuPrimitive.Trigger>
66
+
67
+ <DropdownMenuPrimitive.Content
68
+ {side}
69
+ {align}
70
+ sideOffset={4}
71
+ class={cn(
72
+ 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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',
73
+ className
74
+ )}
75
+ >
76
+ {#each items as item}
77
+ {#if isGroup(item)}
78
+ <DropdownMenuPrimitive.Group>
79
+ {#if item.label}
80
+ <div class="px-2 py-1.5 text-sm font-semibold">
81
+ {item.label}
82
+ </div>
83
+ {/if}
84
+ {#each item.items as subItem}
85
+ {#if subItem.type === 'separator'}
86
+ <DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
87
+ {:else if subItem.type === 'label'}
88
+ <div class="px-2 py-1.5 text-sm font-semibold">
89
+ {subItem.label}
90
+ </div>
91
+ {:else}
92
+ <DropdownMenuPrimitive.Item
93
+ disabled={subItem.disabled}
94
+ onSelect={() => subItem.onSelect?.()}
95
+ class={cn(
96
+ 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
97
+ subItem.destructive && 'text-destructive focus:text-destructive'
98
+ )}
99
+ >
100
+ {subItem.label}
101
+ {#if subItem.shortcut}
102
+ <span class="ml-auto text-xs tracking-widest opacity-60">
103
+ {subItem.shortcut}
104
+ </span>
105
+ {/if}
106
+ </DropdownMenuPrimitive.Item>
107
+ {/if}
108
+ {/each}
109
+ </DropdownMenuPrimitive.Group>
110
+ {:else if item.type === 'separator'}
111
+ <DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
112
+ {:else if item.type === 'label'}
113
+ <div class="px-2 py-1.5 text-sm font-semibold">
114
+ {item.label}
115
+ </div>
116
+ {:else}
117
+ <DropdownMenuPrimitive.Item
118
+ disabled={item.disabled}
119
+ onSelect={() => item.onSelect?.()}
120
+ class={cn(
121
+ 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
122
+ item.destructive && 'text-destructive focus:text-destructive'
123
+ )}
124
+ >
125
+ {item.label}
126
+ {#if item.shortcut}
127
+ <span class="ml-auto text-xs tracking-widest opacity-60">
128
+ {item.shortcut}
129
+ </span>
130
+ {/if}
131
+ </DropdownMenuPrimitive.Item>
132
+ {/if}
133
+ {/each}
134
+ </DropdownMenuPrimitive.Content>
135
+ </DropdownMenuPrimitive.Root>
@@ -0,0 +1,33 @@
1
+ import type { Snippet } from 'svelte';
2
+ export interface DropdownMenuItem {
3
+ type?: 'item' | 'separator' | 'label';
4
+ label?: string;
5
+ value?: string;
6
+ disabled?: boolean;
7
+ onSelect?: () => void;
8
+ destructive?: boolean;
9
+ shortcut?: string;
10
+ }
11
+ export interface DropdownMenuGroup {
12
+ label?: string;
13
+ items: DropdownMenuItem[];
14
+ }
15
+ interface Props {
16
+ /** Menu items */
17
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
18
+ /** Trigger element */
19
+ trigger: Snippet;
20
+ /** Side of trigger to show menu */
21
+ side?: 'top' | 'right' | 'bottom' | 'left';
22
+ /** Alignment of menu relative to trigger */
23
+ align?: 'start' | 'center' | 'end';
24
+ /** Whether menu is open (controlled) */
25
+ open?: boolean;
26
+ /** Callback when open state changes */
27
+ onOpenChange?: (open: boolean) => void;
28
+ /** Additional class for menu content */
29
+ class?: string;
30
+ }
31
+ declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
32
+ type DropdownMenu = ReturnType<typeof DropdownMenu>;
33
+ export default DropdownMenu;
@@ -0,0 +1,448 @@
1
+ <script lang="ts">
2
+ /**
3
+ * FileUpload - Drag-and-drop file upload component
4
+ *
5
+ * Features:
6
+ * - Drag and drop zone with visual feedback
7
+ * - Click to browse files
8
+ * - File preview (thumbnails for images, icons for other types)
9
+ * - Size and type validation
10
+ * - Multiple file support
11
+ * - Remove file action
12
+ * - Accessible with keyboard navigation
13
+ */
14
+ import type { FileMetadata } from '../types/components.js';
15
+ import { cn } from '../utils.js';
16
+ import Button from './Button.svelte';
17
+
18
+ interface Props {
19
+ /** Input ID */
20
+ id?: string;
21
+ /** Array of uploaded files (bindable) */
22
+ files?: FileMetadata[];
23
+ /** Maximum number of files allowed */
24
+ maxFiles?: number;
25
+ /** Maximum file size in bytes (default: 10MB) */
26
+ maxSizeBytes?: number;
27
+ /** Accepted MIME types */
28
+ acceptedTypes?: string[];
29
+ /** Whether upload is disabled */
30
+ disabled?: boolean;
31
+ /** External error message */
32
+ error?: string;
33
+ /** Callback when files are selected */
34
+ onFilesChange?: (files: FileMetadata[]) => void;
35
+ /** Callback to handle file upload (optional - if not provided, files are stored locally) */
36
+ onUpload?: (file: File) => Promise<FileMetadata>;
37
+ /** Label text */
38
+ label?: string;
39
+ /** Hint text shown below the drop zone */
40
+ hint?: string;
41
+ /** Additional classes */
42
+ class?: string;
43
+ }
44
+
45
+ let {
46
+ id = `file-upload-${Math.random().toString(36).slice(2, 9)}`,
47
+ files = $bindable([]),
48
+ maxFiles = 10,
49
+ maxSizeBytes = 10 * 1024 * 1024, // 10MB
50
+ acceptedTypes = ['image/*', 'application/pdf'],
51
+ disabled = false,
52
+ error: externalError,
53
+ onFilesChange,
54
+ onUpload,
55
+ label,
56
+ hint,
57
+ class: className,
58
+ }: Props = $props();
59
+
60
+ let isDragging = $state(false);
61
+ let internalError = $state<string | undefined>(undefined);
62
+ let fileInput: HTMLInputElement;
63
+
64
+ /** Map of file IDs to their AbortControllers for cancellation */
65
+ const uploadControllers = new Map<string, AbortController>();
66
+
67
+ const displayError = $derived(externalError || internalError);
68
+
69
+ // Helper to generate unique ID
70
+ function generateId(): string {
71
+ return `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
72
+ }
73
+
74
+ // Validate file
75
+ function validateFile(file: File): string | null {
76
+ if (file.size > maxSizeBytes) {
77
+ const maxMB = Math.round(maxSizeBytes / (1024 * 1024));
78
+ return `File "${file.name}" exceeds maximum size of ${maxMB}MB`;
79
+ }
80
+
81
+ // Check if file type is accepted
82
+ const isAccepted = acceptedTypes.some((type) => {
83
+ if (type.endsWith('/*')) {
84
+ const category = type.slice(0, -2);
85
+ return file.type.startsWith(category);
86
+ }
87
+ return file.type === type;
88
+ });
89
+
90
+ if (!isAccepted) {
91
+ return `File type "${file.type}" is not allowed`;
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ // Format file size
98
+ function formatSize(bytes: number): string {
99
+ if (bytes < 1024) return `${bytes} B`;
100
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
101
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
102
+ }
103
+
104
+ // Get file icon based on type
105
+ function getFileIcon(contentType: string): string {
106
+ if (contentType.startsWith('image/')) {
107
+ return 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z';
108
+ }
109
+ if (contentType === 'application/pdf') {
110
+ return 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z';
111
+ }
112
+ return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z';
113
+ }
114
+
115
+ // Create FileMetadata from File
116
+ async function createFileMetadata(file: File): Promise<FileMetadata> {
117
+ const metadata: FileMetadata = {
118
+ id: generateId(),
119
+ filename: file.name,
120
+ contentType: file.type,
121
+ size: file.size,
122
+ uploading: !!onUpload,
123
+ progress: 0,
124
+ };
125
+
126
+ // Generate preview for images
127
+ if (file.type.startsWith('image/')) {
128
+ metadata.url = await new Promise<string>((resolve) => {
129
+ const reader = new FileReader();
130
+ reader.onload = () => resolve(reader.result as string);
131
+ reader.readAsDataURL(file);
132
+ });
133
+ }
134
+
135
+ return metadata;
136
+ }
137
+
138
+ /**
139
+ * Atomically update a specific file by ID
140
+ * This prevents race conditions when multiple files upload simultaneously
141
+ */
142
+ function updateFileById(id: string, updates: Partial<FileMetadata>): void {
143
+ files = files.map((f) => (f.id === id ? { ...f, ...updates } : f));
144
+ }
145
+
146
+ /**
147
+ * Process selected files with proper async handling
148
+ * Files are added immediately, then uploads happen in parallel with atomic state updates
149
+ */
150
+ async function processFiles(selectedFiles: FileList | File[]): Promise<void> {
151
+ if (disabled) return;
152
+
153
+ internalError = undefined;
154
+ const fileArray = Array.from(selectedFiles);
155
+
156
+ // Check max files limit
157
+ if (files.length + fileArray.length > maxFiles) {
158
+ internalError = `Maximum ${maxFiles} files allowed`;
159
+ return;
160
+ }
161
+
162
+ // Track upload promises for waiting on completion
163
+ const uploadPromises: Promise<void>[] = [];
164
+
165
+ for (const file of fileArray) {
166
+ const validationError = validateFile(file);
167
+ if (validationError) {
168
+ internalError = validationError;
169
+ continue;
170
+ }
171
+
172
+ const metadata = await createFileMetadata(file);
173
+
174
+ // Add file to list immediately with uploading state
175
+ files = [...files, metadata];
176
+
177
+ // Handle upload if handler provided
178
+ if (onUpload) {
179
+ const controller = new AbortController();
180
+ uploadControllers.set(metadata.id, controller);
181
+
182
+ const uploadPromise = (async () => {
183
+ try {
184
+ const result = await onUpload(file);
185
+
186
+ // Check if upload was cancelled (file removed during upload)
187
+ if (controller.signal.aborted) return;
188
+
189
+ // Atomic update on completion
190
+ updateFileById(metadata.id, {
191
+ ...result,
192
+ uploading: false,
193
+ progress: 100,
194
+ });
195
+ } catch (err) {
196
+ // Check if error is from abort (file removed during upload)
197
+ if (controller.signal.aborted) return;
198
+
199
+ // Atomic update on error
200
+ updateFileById(metadata.id, {
201
+ uploading: false,
202
+ error: err instanceof Error ? err.message : 'Upload failed',
203
+ });
204
+ } finally {
205
+ uploadControllers.delete(metadata.id);
206
+ }
207
+ })();
208
+
209
+ uploadPromises.push(uploadPromise);
210
+ } else {
211
+ // No upload handler - mark as complete immediately
212
+ updateFileById(metadata.id, {
213
+ uploading: false,
214
+ progress: 100,
215
+ });
216
+ }
217
+ }
218
+
219
+ // Wait for all uploads to complete before notifying
220
+ if (uploadPromises.length > 0) {
221
+ await Promise.allSettled(uploadPromises);
222
+ }
223
+
224
+ onFilesChange?.(files);
225
+ }
226
+
227
+ /**
228
+ * Remove a file by ID and cancel any pending upload
229
+ */
230
+ function removeFile(fileId: string): void {
231
+ // Cancel pending upload if exists
232
+ const controller = uploadControllers.get(fileId);
233
+ if (controller) {
234
+ controller.abort();
235
+ uploadControllers.delete(fileId);
236
+ }
237
+
238
+ files = files.filter((f) => f.id !== fileId);
239
+ onFilesChange?.(files);
240
+ }
241
+
242
+ // Handle drag events
243
+ function handleDragEnter(e: DragEvent) {
244
+ e.preventDefault();
245
+ if (!disabled) isDragging = true;
246
+ }
247
+
248
+ function handleDragLeave(e: DragEvent) {
249
+ e.preventDefault();
250
+ isDragging = false;
251
+ }
252
+
253
+ function handleDragOver(e: DragEvent) {
254
+ e.preventDefault();
255
+ }
256
+
257
+ function handleDrop(e: DragEvent) {
258
+ e.preventDefault();
259
+ isDragging = false;
260
+ if (e.dataTransfer?.files) {
261
+ processFiles(e.dataTransfer.files);
262
+ }
263
+ }
264
+
265
+ // Handle click to browse
266
+ function handleClick() {
267
+ if (!disabled) {
268
+ fileInput?.click();
269
+ }
270
+ }
271
+
272
+ // Handle file input change
273
+ function handleInputChange(e: Event) {
274
+ const input = e.target as HTMLInputElement;
275
+ if (input.files) {
276
+ processFiles(input.files);
277
+ // Reset input so same file can be selected again
278
+ input.value = '';
279
+ }
280
+ }
281
+
282
+ // Handle keyboard activation
283
+ function handleKeyDown(e: KeyboardEvent) {
284
+ if (e.key === 'Enter' || e.key === ' ') {
285
+ e.preventDefault();
286
+ handleClick();
287
+ }
288
+ }
289
+ </script>
290
+
291
+ <div class={cn('space-y-2', className)}>
292
+ {#if label}
293
+ <label for={id} class="block text-sm font-medium text-foreground">
294
+ {label}
295
+ </label>
296
+ {/if}
297
+
298
+ <!-- Drop Zone -->
299
+ <div
300
+ role="button"
301
+ tabindex={disabled ? -1 : 0}
302
+ aria-disabled={disabled}
303
+ aria-describedby={displayError ? `${id}-error` : undefined}
304
+ class={cn(
305
+ 'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors',
306
+ 'cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
307
+ isDragging && !disabled && 'border-primary bg-primary/5',
308
+ disabled && 'cursor-not-allowed opacity-50',
309
+ displayError && 'border-destructive',
310
+ !isDragging &&
311
+ !disabled &&
312
+ !displayError &&
313
+ 'border-muted-foreground/25 hover:border-muted-foreground/50'
314
+ )}
315
+ ondragenter={handleDragEnter}
316
+ ondragleave={handleDragLeave}
317
+ ondragover={handleDragOver}
318
+ ondrop={handleDrop}
319
+ onclick={handleClick}
320
+ onkeydown={handleKeyDown}
321
+ >
322
+ <!-- Cloud Upload Icon -->
323
+ <svg
324
+ class={cn('mb-3 h-10 w-10', isDragging ? 'text-primary' : 'text-muted-foreground')}
325
+ fill="none"
326
+ viewBox="0 0 24 24"
327
+ stroke="currentColor"
328
+ stroke-width="1.5"
329
+ aria-hidden="true"
330
+ >
331
+ <path
332
+ stroke-linecap="round"
333
+ stroke-linejoin="round"
334
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
335
+ />
336
+ </svg>
337
+
338
+ <p class="text-sm text-muted-foreground">
339
+ <span class="font-medium text-foreground">Click to upload</span> or drag and drop
340
+ </p>
341
+ <p class="mt-1 text-xs text-muted-foreground">
342
+ {acceptedTypes.join(', ')} (max {formatSize(maxSizeBytes)})
343
+ </p>
344
+
345
+ <!-- Hidden file input -->
346
+ <input
347
+ bind:this={fileInput}
348
+ type="file"
349
+ {id}
350
+ multiple={maxFiles > 1}
351
+ accept={acceptedTypes.join(',')}
352
+ {disabled}
353
+ class="sr-only"
354
+ onchange={handleInputChange}
355
+ aria-label="File upload"
356
+ />
357
+ </div>
358
+
359
+ <!-- Hint text -->
360
+ {#if hint && !displayError}
361
+ <p class="text-sm text-muted-foreground">{hint}</p>
362
+ {/if}
363
+
364
+ <!-- Error message -->
365
+ {#if displayError}
366
+ <p id={`${id}-error`} class="text-sm text-destructive" role="alert">
367
+ {displayError}
368
+ </p>
369
+ {/if}
370
+
371
+ <!-- File List -->
372
+ {#if files.length > 0}
373
+ <ul class="mt-4 space-y-2" aria-label="Uploaded files">
374
+ {#each files as file (file.id)}
375
+ <li
376
+ class={cn(
377
+ 'flex items-center gap-3 rounded-lg border p-3',
378
+ file.error && 'border-destructive bg-destructive/5'
379
+ )}
380
+ >
381
+ <!-- Preview/Icon -->
382
+ <div class="flex-shrink-0">
383
+ {#if file.url && file.contentType.startsWith('image/')}
384
+ <img src={file.url} alt={file.filename} class="h-10 w-10 rounded object-cover" />
385
+ {:else}
386
+ <div class="flex h-10 w-10 items-center justify-center rounded bg-muted">
387
+ <svg
388
+ class="h-5 w-5 text-muted-foreground"
389
+ fill="none"
390
+ viewBox="0 0 24 24"
391
+ stroke="currentColor"
392
+ stroke-width="2"
393
+ >
394
+ <path
395
+ stroke-linecap="round"
396
+ stroke-linejoin="round"
397
+ d={getFileIcon(file.contentType)}
398
+ />
399
+ </svg>
400
+ </div>
401
+ {/if}
402
+ </div>
403
+
404
+ <!-- File Info -->
405
+ <div class="min-w-0 flex-1">
406
+ <p class="truncate text-sm font-medium">{file.filename}</p>
407
+ <p class="text-xs text-muted-foreground">
408
+ {formatSize(file.size)}
409
+ {#if file.error}
410
+ <span class="text-destructive"> - {file.error}</span>
411
+ {/if}
412
+ </p>
413
+
414
+ <!-- Progress bar -->
415
+ {#if file.uploading}
416
+ <div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
417
+ <div
418
+ class="h-full bg-primary transition-all duration-300"
419
+ style="width: {file.progress || 0}%"
420
+ ></div>
421
+ </div>
422
+ {/if}
423
+ </div>
424
+
425
+ <!-- Remove button -->
426
+ <Button
427
+ variant="ghost"
428
+ size="icon"
429
+ class="h-8 w-8 flex-shrink-0"
430
+ onclick={() => removeFile(file.id)}
431
+ aria-label={`Remove ${file.filename}`}
432
+ disabled={file.uploading}
433
+ >
434
+ <svg
435
+ class="h-4 w-4"
436
+ fill="none"
437
+ viewBox="0 0 24 24"
438
+ stroke="currentColor"
439
+ stroke-width="2"
440
+ >
441
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
442
+ </svg>
443
+ </Button>
444
+ </li>
445
+ {/each}
446
+ </ul>
447
+ {/if}
448
+ </div>
@@ -0,0 +1,42 @@
1
+ /**
2
+ * FileUpload - Drag-and-drop file upload component
3
+ *
4
+ * Features:
5
+ * - Drag and drop zone with visual feedback
6
+ * - Click to browse files
7
+ * - File preview (thumbnails for images, icons for other types)
8
+ * - Size and type validation
9
+ * - Multiple file support
10
+ * - Remove file action
11
+ * - Accessible with keyboard navigation
12
+ */
13
+ import type { FileMetadata } from '../types/components.js';
14
+ interface Props {
15
+ /** Input ID */
16
+ id?: string;
17
+ /** Array of uploaded files (bindable) */
18
+ files?: FileMetadata[];
19
+ /** Maximum number of files allowed */
20
+ maxFiles?: number;
21
+ /** Maximum file size in bytes (default: 10MB) */
22
+ maxSizeBytes?: number;
23
+ /** Accepted MIME types */
24
+ acceptedTypes?: string[];
25
+ /** Whether upload is disabled */
26
+ disabled?: boolean;
27
+ /** External error message */
28
+ error?: string;
29
+ /** Callback when files are selected */
30
+ onFilesChange?: (files: FileMetadata[]) => void;
31
+ /** Callback to handle file upload (optional - if not provided, files are stored locally) */
32
+ onUpload?: (file: File) => Promise<FileMetadata>;
33
+ /** Label text */
34
+ label?: string;
35
+ /** Hint text shown below the drop zone */
36
+ hint?: string;
37
+ /** Additional classes */
38
+ class?: string;
39
+ }
40
+ declare const FileUpload: import("svelte").Component<Props, {}, "files">;
41
+ type FileUpload = ReturnType<typeof FileUpload>;
42
+ export default FileUpload;