@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.
Files changed (55) hide show
  1. package/dist/lib/components/CardHeader.svelte +22 -2
  2. package/dist/lib/components/CardHeader.svelte.d.ts +5 -4
  3. package/dist/lib/components/Combobox.svelte +187 -0
  4. package/dist/lib/components/Combobox.svelte.d.ts +38 -0
  5. package/dist/lib/components/DateTimePicker.svelte +415 -0
  6. package/dist/lib/components/DateTimePicker.svelte.d.ts +31 -0
  7. package/dist/lib/components/HeaderSearch.svelte +340 -0
  8. package/dist/lib/components/HeaderSearch.svelte.d.ts +37 -0
  9. package/dist/lib/components/MultiSelect.svelte +244 -0
  10. package/dist/lib/components/MultiSelect.svelte.d.ts +40 -0
  11. package/dist/lib/components/NumberInput.svelte +205 -0
  12. package/dist/lib/components/NumberInput.svelte.d.ts +33 -0
  13. package/dist/lib/components/OTPInput.svelte +213 -0
  14. package/dist/lib/components/OTPInput.svelte.d.ts +23 -0
  15. package/dist/lib/components/PageHeader.svelte +6 -0
  16. package/dist/lib/components/PageHeader.svelte.d.ts +1 -1
  17. package/dist/lib/components/RadioGroup.svelte +124 -0
  18. package/dist/lib/components/RadioGroup.svelte.d.ts +31 -0
  19. package/dist/lib/components/Signature.svelte +1070 -0
  20. package/dist/lib/components/Signature.svelte.d.ts +74 -0
  21. package/dist/lib/components/Slider.svelte +136 -0
  22. package/dist/lib/components/Slider.svelte.d.ts +30 -0
  23. package/dist/lib/components/layout/AuthLayout.svelte +133 -0
  24. package/dist/lib/components/layout/AuthLayout.svelte.d.ts +48 -0
  25. package/dist/lib/components/layout/DashboardLayout.svelte +100 -74
  26. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +17 -10
  27. package/dist/lib/components/layout/ErrorLayout.svelte +206 -0
  28. package/dist/lib/components/layout/ErrorLayout.svelte.d.ts +52 -0
  29. package/dist/lib/components/layout/FormPageLayout.svelte +2 -8
  30. package/dist/lib/components/layout/Header.svelte +232 -41
  31. package/dist/lib/components/layout/Header.svelte.d.ts +71 -5
  32. package/dist/lib/components/layout/PublicLayout.svelte +54 -80
  33. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +3 -1
  34. package/dist/lib/components/layout/QuickLinks.svelte +49 -29
  35. package/dist/lib/components/layout/QuickLinks.svelte.d.ts +4 -2
  36. package/dist/lib/components/layout/Sidebar.svelte +345 -86
  37. package/dist/lib/components/layout/Sidebar.svelte.d.ts +12 -0
  38. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +182 -0
  39. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte.d.ts +18 -0
  40. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +378 -0
  41. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +25 -0
  42. package/dist/lib/components/layout/sidebar/SidebarSearch.svelte +121 -0
  43. package/dist/lib/components/layout/sidebar/SidebarSearch.svelte.d.ts +17 -0
  44. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +144 -0
  45. package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +25 -0
  46. package/dist/lib/components/layout/sidebar/index.d.ts +10 -0
  47. package/dist/lib/components/layout/sidebar/index.js +10 -0
  48. package/dist/lib/index.d.ts +13 -2
  49. package/dist/lib/index.js +11 -0
  50. package/dist/lib/schemas/auth.d.ts +6 -6
  51. package/dist/lib/stores/sidebar.svelte.d.ts +54 -0
  52. package/dist/lib/stores/sidebar.svelte.js +171 -1
  53. package/dist/lib/types/components.d.ts +105 -0
  54. package/dist/lib/types/layout.d.ts +203 -3
  55. package/package.json +1 -1
@@ -0,0 +1,1070 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { cn } from '../utils.js';
4
+ import { tv, type VariantProps } from 'tailwind-variants';
5
+ import type {
6
+ SignatureData,
7
+ SignatureStroke,
8
+ SignaturePoint,
9
+ SignatureMetadata,
10
+ SignatureFont,
11
+ SignatureValidationResult,
12
+ } from '../types/components.js';
13
+
14
+ // Default cursive fonts from Google Fonts
15
+ const defaultFonts: SignatureFont[] = [
16
+ { family: 'Dancing Script', label: 'Dancing Script', googleFont: true },
17
+ { family: 'Pacifico', label: 'Pacifico', googleFont: true },
18
+ { family: 'Satisfy', label: 'Satisfy', googleFont: true },
19
+ { family: 'Allura', label: 'Allura', googleFont: true },
20
+ { family: 'Great Vibes', label: 'Great Vibes', googleFont: true },
21
+ ];
22
+
23
+ // Styling variants
24
+ const signatureVariants = tv({
25
+ slots: {
26
+ container: 'space-y-3',
27
+ header: 'flex items-center justify-between gap-2',
28
+ labelText: 'text-sm font-medium leading-none',
29
+ modeToggle: 'inline-flex items-center rounded-lg bg-muted p-1',
30
+ modeButton:
31
+ 'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
32
+ modeButtonActive: 'bg-background text-foreground shadow-sm',
33
+ modeButtonInactive: 'text-muted-foreground hover:text-foreground',
34
+ canvasContainer: 'relative w-full overflow-hidden rounded-lg border bg-background',
35
+ canvas: 'block w-full cursor-crosshair touch-none',
36
+ toolbar: 'flex flex-wrap items-center gap-2',
37
+ toolButton:
38
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
39
+ toolGroup: 'flex items-center gap-1.5',
40
+ toolLabel: 'text-xs text-muted-foreground',
41
+ colorPicker:
42
+ 'h-8 w-8 cursor-pointer rounded-md border border-input p-0.5 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
43
+ thicknessSlider:
44
+ 'h-2 w-20 cursor-pointer appearance-none rounded-full bg-muted accent-primary',
45
+ typeInput: 'w-full',
46
+ fontPicker: 'flex flex-wrap gap-2',
47
+ fontButton: 'rounded-lg border-2 px-3 py-2 text-lg transition-colors hover:border-primary/50',
48
+ fontButtonSelected: 'border-primary bg-primary/5',
49
+ fontButtonUnselected: 'border-input',
50
+ consent: 'flex items-start gap-2',
51
+ consentCheckbox:
52
+ 'mt-0.5 h-4 w-4 shrink-0 rounded border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
53
+ consentLabel: 'text-sm leading-tight text-muted-foreground',
54
+ errorText: 'text-sm text-destructive',
55
+ hintText: 'text-sm text-muted-foreground',
56
+ },
57
+ variants: {
58
+ disabled: {
59
+ true: {
60
+ canvas: 'cursor-not-allowed opacity-50',
61
+ canvasContainer: 'opacity-50',
62
+ },
63
+ },
64
+ hasError: {
65
+ true: {
66
+ canvasContainer: 'border-destructive focus-within:ring-destructive',
67
+ },
68
+ },
69
+ },
70
+ });
71
+
72
+ type SignatureVariants = VariantProps<typeof signatureVariants>;
73
+
74
+ interface Props {
75
+ /** Signature data containing image output and metadata */
76
+ value?: SignatureData | null;
77
+ /** Callback when signature changes */
78
+ onValueChange?: (data: SignatureData | null) => void;
79
+ /** Callback when signature is completed (meets validation) */
80
+ onComplete?: (data: SignatureData) => void;
81
+ /** Callback when consent state changes */
82
+ onConsentChange?: (consented: boolean) => void;
83
+
84
+ /** Input mode: draw with stylus/mouse or type with fonts */
85
+ mode?: 'draw' | 'type';
86
+ /** Whether to show mode toggle */
87
+ showModeToggle?: boolean;
88
+ /** Default typed name for type mode */
89
+ typedName?: string;
90
+
91
+ /** Stroke color (default: currentColor) */
92
+ strokeColor?: string;
93
+ /** Stroke width in pixels */
94
+ strokeWidth?: number;
95
+ /** Minimum stroke width for pressure */
96
+ minStrokeWidth?: number;
97
+ /** Maximum stroke width for pressure */
98
+ maxStrokeWidth?: number;
99
+ /** Whether to show stroke customization */
100
+ showStrokeCustomization?: boolean;
101
+
102
+ /** Available fonts for typed signatures */
103
+ fonts?: SignatureFont[];
104
+ /** Selected font family */
105
+ selectedFont?: string;
106
+
107
+ /** Preferred output format */
108
+ outputFormat?: 'png' | 'svg' | 'dataUrl';
109
+ /** PNG/image quality (0-1) */
110
+ imageQuality?: number;
111
+ /** Background color */
112
+ backgroundColor?: 'transparent' | 'white';
113
+
114
+ /** Minimum stroke points required (draw mode) */
115
+ minStrokePoints?: number;
116
+ /** Minimum stroke length in pixels (draw mode) */
117
+ minStrokeLength?: number;
118
+ /** Minimum characters for typed signature */
119
+ minTypedLength?: number;
120
+ /** Custom validation function */
121
+ validate?: (data: SignatureData) => SignatureValidationResult;
122
+
123
+ /** Whether to show consent checkbox */
124
+ showConsent?: boolean;
125
+ /** Consent checkbox text */
126
+ consentText?: string;
127
+ /** Whether consent is required */
128
+ requireConsent?: boolean;
129
+ /** Current consent state */
130
+ consented?: boolean;
131
+
132
+ /** Input ID */
133
+ id?: string;
134
+ /** Name attribute for form submission */
135
+ name?: string;
136
+ /** Whether disabled */
137
+ disabled?: boolean;
138
+ /** Whether required */
139
+ required?: boolean;
140
+ /** Error message */
141
+ error?: string;
142
+ /** Hint text */
143
+ hint?: string;
144
+ /** Label text */
145
+ label?: string;
146
+
147
+ /** Canvas height (CSS value) */
148
+ height?: string;
149
+
150
+ /** Additional class */
151
+ class?: string;
152
+ }
153
+
154
+ let {
155
+ value = $bindable(null),
156
+ onValueChange,
157
+ onComplete,
158
+ onConsentChange,
159
+
160
+ mode: initialMode = 'draw',
161
+ showModeToggle = true,
162
+ typedName: initialTypedName = '',
163
+
164
+ strokeColor: initialStrokeColor = '#000000',
165
+ strokeWidth: initialStrokeWidth = 2,
166
+ minStrokeWidth = 1,
167
+ maxStrokeWidth = 5,
168
+ showStrokeCustomization = true,
169
+
170
+ fonts = defaultFonts,
171
+ selectedFont: initialSelectedFont,
172
+
173
+ outputFormat = 'dataUrl',
174
+ imageQuality = 0.92,
175
+ backgroundColor = 'white',
176
+
177
+ minStrokePoints = 10,
178
+ minStrokeLength = 50,
179
+ minTypedLength = 2,
180
+ validate: customValidate,
181
+
182
+ showConsent = true,
183
+ consentText = 'I agree to use this as my legal signature and consent to electronic signing.',
184
+ requireConsent = true,
185
+ consented: initialConsented = $bindable(false),
186
+
187
+ id,
188
+ name,
189
+ disabled = false,
190
+ required = false,
191
+ error,
192
+ hint,
193
+ label,
194
+
195
+ height = '200px',
196
+
197
+ class: className,
198
+ }: Props = $props();
199
+
200
+ // Mode state (untrack to intentionally capture initial value only)
201
+ let mode = $state<'draw' | 'type'>(untrack(() => initialMode));
202
+
203
+ // Canvas references
204
+ let canvasContainer: HTMLDivElement;
205
+ let canvas: HTMLCanvasElement;
206
+ let ctx: CanvasRenderingContext2D | null = null;
207
+
208
+ // Drawing state
209
+ let isDrawing = $state(false);
210
+ let currentStroke = $state<SignatureStroke | null>(null);
211
+ let strokes = $state<SignatureStroke[]>([]);
212
+ let devicePixelRatio = $state(1);
213
+
214
+ // Draw customization state (untrack to intentionally capture initial values only)
215
+ let strokeColor = $state(untrack(() => initialStrokeColor));
216
+ let strokeWidth = $state(untrack(() => initialStrokeWidth));
217
+
218
+ // Type mode state (untrack to intentionally capture initial values only)
219
+ let typedName = $state(untrack(() => initialTypedName));
220
+ let selectedFont = $state(
221
+ untrack(() => initialSelectedFont || fonts[0]?.family || 'Dancing Script')
222
+ );
223
+ let fontsLoaded = $state<Set<string>>(new Set());
224
+
225
+ // Consent state (untrack to intentionally capture initial value only)
226
+ let consented = $state(untrack(() => initialConsented));
227
+
228
+ // Validation state
229
+ let validationResult = $state<SignatureValidationResult>({ valid: false });
230
+
231
+ // Track if onComplete has been fired for current signature to prevent duplicates
232
+ let completeFired = $state(false);
233
+
234
+ // Get styles
235
+ const styles = $derived(signatureVariants({ disabled, hasError: !!error }));
236
+
237
+ // Derived: has signature
238
+ const hasSignature = $derived(mode === 'draw' ? strokes.length > 0 : typedName.trim().length > 0);
239
+
240
+ // Derived: is valid
241
+ const isValid = $derived(validationResult.valid && (!requireConsent || consented));
242
+
243
+ // Generate unique ID
244
+ function generateId(): string {
245
+ return `sig_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
246
+ }
247
+
248
+ // Calculate stroke length
249
+ function calculateStrokeLength(strokesData: SignatureStroke[]): number {
250
+ let total = 0;
251
+ for (const stroke of strokesData) {
252
+ for (let i = 1; i < stroke.points.length; i++) {
253
+ const dx = stroke.points[i].x - stroke.points[i - 1].x;
254
+ const dy = stroke.points[i].y - stroke.points[i - 1].y;
255
+ total += Math.sqrt(dx * dx + dy * dy);
256
+ }
257
+ }
258
+ return total;
259
+ }
260
+
261
+ // Count stroke points
262
+ function countStrokePoints(strokesData: SignatureStroke[]): number {
263
+ return strokesData.reduce((sum, s) => sum + s.points.length, 0);
264
+ }
265
+
266
+ // Run validation
267
+ function runValidation(): void {
268
+ if (mode === 'draw') {
269
+ if (strokes.length === 0) {
270
+ validationResult = { valid: false, error: 'Please draw your signature' };
271
+ return;
272
+ }
273
+ const points = countStrokePoints(strokes);
274
+ const length = calculateStrokeLength(strokes);
275
+ if (minStrokePoints && points < minStrokePoints) {
276
+ validationResult = {
277
+ valid: false,
278
+ error: 'Signature too simple. Please draw a more complete signature.',
279
+ };
280
+ return;
281
+ }
282
+ if (minStrokeLength && length < minStrokeLength) {
283
+ validationResult = {
284
+ valid: false,
285
+ error: 'Signature too short. Please draw a more complete signature.',
286
+ };
287
+ return;
288
+ }
289
+ } else {
290
+ const trimmed = typedName.trim();
291
+ if (!trimmed) {
292
+ validationResult = { valid: false, error: 'Please enter your name' };
293
+ return;
294
+ }
295
+ if (minTypedLength && trimmed.length < minTypedLength) {
296
+ validationResult = {
297
+ valid: false,
298
+ error: `Name must be at least ${minTypedLength} characters`,
299
+ };
300
+ return;
301
+ }
302
+ }
303
+ validationResult = { valid: true };
304
+ }
305
+
306
+ // Generate hash for tamper detection
307
+ async function calculateHash(data: string): Promise<string> {
308
+ const encoder = new TextEncoder();
309
+ const dataBuffer = encoder.encode(data);
310
+ const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
311
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
312
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
313
+ }
314
+
315
+ // Export as PNG data URL
316
+ function exportAsPNG(): string {
317
+ if (!canvas || !ctx) return '';
318
+ const exportCanvas = document.createElement('canvas');
319
+ exportCanvas.width = canvas.width;
320
+ exportCanvas.height = canvas.height;
321
+ const exportCtx = exportCanvas.getContext('2d')!;
322
+ if (backgroundColor === 'white') {
323
+ exportCtx.fillStyle = 'white';
324
+ exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
325
+ }
326
+ exportCtx.drawImage(canvas, 0, 0);
327
+ return exportCanvas.toDataURL('image/png', imageQuality);
328
+ }
329
+
330
+ // Export as PNG Blob
331
+ async function exportAsPNGBlob(): Promise<Blob | undefined> {
332
+ if (!canvas || !ctx) return undefined;
333
+ return new Promise((resolve) => {
334
+ const exportCanvas = document.createElement('canvas');
335
+ exportCanvas.width = canvas.width;
336
+ exportCanvas.height = canvas.height;
337
+ const exportCtx = exportCanvas.getContext('2d')!;
338
+ if (backgroundColor === 'white') {
339
+ exportCtx.fillStyle = 'white';
340
+ exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
341
+ }
342
+ exportCtx.drawImage(canvas, 0, 0);
343
+ exportCanvas.toBlob((blob) => resolve(blob || undefined), 'image/png', imageQuality);
344
+ });
345
+ }
346
+
347
+ // Escape XML special characters
348
+ function escapeXML(str: string): string {
349
+ return str
350
+ .replace(/&/g, '&amp;')
351
+ .replace(/</g, '&lt;')
352
+ .replace(/>/g, '&gt;')
353
+ .replace(/"/g, '&quot;')
354
+ .replace(/'/g, '&apos;');
355
+ }
356
+
357
+ // Export as SVG
358
+ function exportAsSVG(): string {
359
+ if (!canvas) return '';
360
+ const width = canvas.width / devicePixelRatio;
361
+ const height = canvas.height / devicePixelRatio;
362
+
363
+ let paths = '';
364
+
365
+ if (mode === 'draw') {
366
+ for (const stroke of strokes) {
367
+ if (stroke.points.length < 2) continue;
368
+ let d = `M ${stroke.points[0].x} ${stroke.points[0].y}`;
369
+ for (let i = 1; i < stroke.points.length; i++) {
370
+ const prev = stroke.points[i - 1];
371
+ const curr = stroke.points[i];
372
+ const midX = (prev.x + curr.x) / 2;
373
+ const midY = (prev.y + curr.y) / 2;
374
+ d += ` Q ${prev.x} ${prev.y} ${midX} ${midY}`;
375
+ }
376
+ const lastPoint = stroke.points[stroke.points.length - 1];
377
+ d += ` L ${lastPoint.x} ${lastPoint.y}`;
378
+ paths += `<path d="${d}" stroke="${stroke.color}" stroke-width="${stroke.width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
379
+ }
380
+ } else {
381
+ const fontSize = calculateFontSize(typedName, width * 0.9);
382
+ paths = `<text x="${width / 2}" y="${height / 2}" font-family="${selectedFont}" font-size="${fontSize}" fill="${strokeColor}" text-anchor="middle" dominant-baseline="middle">${escapeXML(typedName)}</text>`;
383
+ }
384
+
385
+ return `<?xml version="1.0" encoding="UTF-8"?>
386
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
387
+ ${backgroundColor === 'white' ? `<rect width="${width}" height="${height}" fill="white"/>` : ''}
388
+ ${paths}
389
+ </svg>`;
390
+ }
391
+
392
+ // Generate signature data
393
+ async function generateSignatureData(): Promise<SignatureData> {
394
+ const dataUrl = exportAsPNG();
395
+ const svg = exportAsSVG();
396
+ const png = outputFormat === 'png' ? await exportAsPNGBlob() : undefined;
397
+
398
+ const metadata: SignatureMetadata = {
399
+ timestamp: new Date().toISOString(),
400
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
401
+ dimensions: {
402
+ width: canvas ? canvas.width / devicePixelRatio : 0,
403
+ height: canvas ? canvas.height / devicePixelRatio : 0,
404
+ },
405
+ consented,
406
+ consentText: showConsent ? consentText : undefined,
407
+ };
408
+
409
+ // Calculate hash of signature data
410
+ const hashInput = mode === 'draw' ? JSON.stringify(strokes) : typedName;
411
+ metadata.hash = await calculateHash(hashInput + metadata.timestamp);
412
+
413
+ return {
414
+ id: generateId(),
415
+ mode,
416
+ dataUrl,
417
+ png,
418
+ svg,
419
+ typedName: mode === 'type' ? typedName : undefined,
420
+ font: mode === 'type' ? selectedFont : undefined,
421
+ strokes: mode === 'draw' ? [...strokes] : undefined,
422
+ metadata,
423
+ };
424
+ }
425
+
426
+ // Calculate font size to fit canvas
427
+ function calculateFontSize(text: string, maxWidth: number): number {
428
+ if (!ctx) return 48;
429
+ let fontSize = 72;
430
+ ctx.font = `${fontSize}px "${selectedFont}"`;
431
+ let textWidth = ctx.measureText(text).width;
432
+ while (textWidth > maxWidth && fontSize > 16) {
433
+ fontSize -= 2;
434
+ ctx.font = `${fontSize}px "${selectedFont}"`;
435
+ textWidth = ctx.measureText(text).width;
436
+ }
437
+ return fontSize;
438
+ }
439
+
440
+ // Load Google Font
441
+ async function loadFont(font: SignatureFont): Promise<void> {
442
+ if (fontsLoaded.has(font.family)) return;
443
+ if (!font.googleFont) {
444
+ fontsLoaded = new Set([...fontsLoaded, font.family]);
445
+ return;
446
+ }
447
+
448
+ const link = document.createElement('link');
449
+ link.href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/\s+/g, '+')}&display=swap`;
450
+ link.rel = 'stylesheet';
451
+ document.head.appendChild(link);
452
+
453
+ // Wait for font to load
454
+ await document.fonts.load(`48px "${font.family}"`);
455
+ fontsLoaded = new Set([...fontsLoaded, font.family]);
456
+ }
457
+
458
+ // Render typed signature on canvas
459
+ function renderTypedSignature(): void {
460
+ if (!ctx || !canvas) return;
461
+
462
+ const width = canvas.width / devicePixelRatio;
463
+ const height = canvas.height / devicePixelRatio;
464
+
465
+ // Clear canvas
466
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
467
+
468
+ if (!typedName.trim()) return;
469
+
470
+ // Calculate font size
471
+ const fontSize = calculateFontSize(typedName, width * 0.9);
472
+
473
+ // Render text
474
+ ctx.font = `${fontSize}px "${selectedFont}"`;
475
+ ctx.fillStyle = strokeColor;
476
+ ctx.textAlign = 'center';
477
+ ctx.textBaseline = 'middle';
478
+ ctx.fillText(typedName, width / 2, height / 2);
479
+ }
480
+
481
+ // Initialize canvas
482
+ function initializeCanvas(): void {
483
+ if (!canvas) return;
484
+
485
+ devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
486
+ const rect = canvas.getBoundingClientRect();
487
+
488
+ canvas.width = rect.width * devicePixelRatio;
489
+ canvas.height = rect.height * devicePixelRatio;
490
+
491
+ ctx = canvas.getContext('2d');
492
+ if (!ctx) return;
493
+
494
+ ctx.scale(devicePixelRatio, devicePixelRatio);
495
+ ctx.lineCap = 'round';
496
+ ctx.lineJoin = 'round';
497
+ ctx.strokeStyle = strokeColor;
498
+ ctx.lineWidth = strokeWidth;
499
+
500
+ // Redraw existing content
501
+ if (mode === 'draw') {
502
+ redrawStrokes();
503
+ } else {
504
+ renderTypedSignature();
505
+ }
506
+ }
507
+
508
+ // Redraw all strokes
509
+ function redrawStrokes(): void {
510
+ if (!ctx || !canvas) return;
511
+
512
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
513
+
514
+ for (const stroke of strokes) {
515
+ if (stroke.points.length < 2) continue;
516
+
517
+ ctx.strokeStyle = stroke.color;
518
+ ctx.lineWidth = stroke.width;
519
+ ctx.beginPath();
520
+ ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
521
+
522
+ for (let i = 1; i < stroke.points.length; i++) {
523
+ const prev = stroke.points[i - 1];
524
+ const curr = stroke.points[i];
525
+ const midX = (prev.x + curr.x) / 2;
526
+ const midY = (prev.y + curr.y) / 2;
527
+ ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
528
+ }
529
+
530
+ const lastPoint = stroke.points[stroke.points.length - 1];
531
+ ctx.lineTo(lastPoint.x, lastPoint.y);
532
+ ctx.stroke();
533
+ }
534
+ }
535
+
536
+ // Get canvas point from pointer event
537
+ function getCanvasPoint(e: PointerEvent): SignaturePoint {
538
+ const rect = canvas.getBoundingClientRect();
539
+ return {
540
+ x: e.clientX - rect.left,
541
+ y: e.clientY - rect.top,
542
+ pressure: e.pressure || 0.5,
543
+ time: Date.now(),
544
+ };
545
+ }
546
+
547
+ // Calculate dynamic stroke width based on pressure
548
+ function calculateDynamicWidth(pressure: number): number {
549
+ return minStrokeWidth + pressure * (maxStrokeWidth - minStrokeWidth);
550
+ }
551
+
552
+ // Scroll lock state
553
+ let scrollLocked = false;
554
+ let previousBodyOverflow = '';
555
+ let previousBodyPosition = '';
556
+ let previousBodyTop = '';
557
+ let previousBodyWidth = '';
558
+ let scrollY = 0;
559
+
560
+ // Lock scroll to prevent page scrolling while drawing on mobile
561
+ function lockScroll(): void {
562
+ if (typeof document === 'undefined' || scrollLocked) return;
563
+
564
+ scrollLocked = true;
565
+ scrollY = window.scrollY;
566
+
567
+ // Save current body styles
568
+ previousBodyOverflow = document.body.style.overflow;
569
+ previousBodyPosition = document.body.style.position;
570
+ previousBodyTop = document.body.style.top;
571
+ previousBodyWidth = document.body.style.width;
572
+
573
+ // Lock the body
574
+ document.body.style.overflow = 'hidden';
575
+ document.body.style.position = 'fixed';
576
+ document.body.style.top = `-${scrollY}px`;
577
+ document.body.style.width = '100%';
578
+
579
+ // Prevent touchmove on document
580
+ document.addEventListener('touchmove', preventTouchMove, { passive: false });
581
+ }
582
+
583
+ // Unlock scroll when done drawing
584
+ function unlockScroll(): void {
585
+ if (typeof document === 'undefined' || !scrollLocked) return;
586
+
587
+ scrollLocked = false;
588
+
589
+ // Restore body styles
590
+ document.body.style.overflow = previousBodyOverflow;
591
+ document.body.style.position = previousBodyPosition;
592
+ document.body.style.top = previousBodyTop;
593
+ document.body.style.width = previousBodyWidth;
594
+
595
+ // Restore scroll position
596
+ window.scrollTo(0, scrollY);
597
+
598
+ // Remove touchmove listener
599
+ document.removeEventListener('touchmove', preventTouchMove);
600
+ }
601
+
602
+ // Prevent touch move events while drawing
603
+ function preventTouchMove(e: TouchEvent): void {
604
+ if (scrollLocked) {
605
+ e.preventDefault();
606
+ }
607
+ }
608
+
609
+ // Start drawing
610
+ function handlePointerDown(e: PointerEvent): void {
611
+ if (disabled || mode !== 'draw') return;
612
+
613
+ e.preventDefault();
614
+ canvas.setPointerCapture(e.pointerId);
615
+
616
+ // Lock scroll on touch devices
617
+ if (e.pointerType === 'touch') {
618
+ lockScroll();
619
+ }
620
+
621
+ isDrawing = true;
622
+ const point = getCanvasPoint(e);
623
+
624
+ currentStroke = {
625
+ points: [point],
626
+ color: strokeColor,
627
+ width: strokeWidth,
628
+ startTime: Date.now(),
629
+ endTime: 0,
630
+ };
631
+
632
+ if (ctx) {
633
+ ctx.strokeStyle = strokeColor;
634
+ ctx.lineWidth = strokeWidth;
635
+ ctx.beginPath();
636
+ ctx.moveTo(point.x, point.y);
637
+ }
638
+ }
639
+
640
+ // Continue drawing
641
+ function handlePointerMove(e: PointerEvent): void {
642
+ if (!isDrawing || !currentStroke || !ctx || disabled) return;
643
+
644
+ e.preventDefault();
645
+ const point = getCanvasPoint(e);
646
+ currentStroke.points.push(point);
647
+
648
+ // Draw smooth curve
649
+ if (currentStroke.points.length > 1) {
650
+ const prev = currentStroke.points[currentStroke.points.length - 2];
651
+ const midX = (prev.x + point.x) / 2;
652
+ const midY = (prev.y + point.y) / 2;
653
+ ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
654
+ ctx.stroke();
655
+ ctx.beginPath();
656
+ ctx.moveTo(midX, midY);
657
+ }
658
+ }
659
+
660
+ // End drawing
661
+ function handlePointerUp(e: PointerEvent): void {
662
+ if (!isDrawing || !currentStroke || disabled) return;
663
+
664
+ canvas.releasePointerCapture(e.pointerId);
665
+ isDrawing = false;
666
+ currentStroke.endTime = Date.now();
667
+
668
+ // Unlock scroll when done drawing
669
+ unlockScroll();
670
+
671
+ // Only add stroke if it has enough points
672
+ if (currentStroke.points.length > 1) {
673
+ strokes = [...strokes, currentStroke];
674
+ }
675
+ currentStroke = null;
676
+
677
+ runValidation();
678
+ updateValue();
679
+ }
680
+
681
+ // Handle pointer leave
682
+ function handlePointerLeave(e: PointerEvent): void {
683
+ if (isDrawing) {
684
+ handlePointerUp(e);
685
+ }
686
+ }
687
+
688
+ // Clear signature
689
+ function clearSignature(): void {
690
+ strokes = [];
691
+ typedName = '';
692
+ completeFired = false;
693
+ if (ctx && canvas) {
694
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
695
+ }
696
+ runValidation();
697
+ updateValue();
698
+ announceToScreenReader('Signature cleared');
699
+ }
700
+
701
+ // Undo last stroke
702
+ function undoStroke(): void {
703
+ if (strokes.length === 0) return;
704
+ strokes = strokes.slice(0, -1);
705
+ redrawStrokes();
706
+ runValidation();
707
+ updateValue();
708
+ announceToScreenReader('Stroke undone');
709
+ }
710
+
711
+ // Update value
712
+ async function updateValue(): Promise<void> {
713
+ if (!hasSignature) {
714
+ value = null;
715
+ onValueChange?.(null);
716
+ completeFired = false;
717
+ return;
718
+ }
719
+
720
+ const data = await generateSignatureData();
721
+ value = data;
722
+ onValueChange?.(data);
723
+
724
+ // Only fire onComplete once per valid signature
725
+ if (isValid && hasSignature && !completeFired) {
726
+ completeFired = true;
727
+ onComplete?.(data);
728
+ }
729
+ }
730
+
731
+ // Handle consent change
732
+ function handleConsentChange(e: Event): void {
733
+ const target = e.target as HTMLInputElement;
734
+ consented = target.checked;
735
+ onConsentChange?.(consented);
736
+ updateValue();
737
+ }
738
+
739
+ // Handle mode change
740
+ function setMode(newMode: 'draw' | 'type'): void {
741
+ if (mode === newMode) return;
742
+ mode = newMode;
743
+ clearSignature();
744
+ announceToScreenReader(`Signature mode changed to ${newMode}`);
745
+ }
746
+
747
+ // Handle typed name change
748
+ function handleTypedNameChange(e: Event): void {
749
+ const target = e.target as HTMLInputElement;
750
+ typedName = target.value;
751
+ runValidation();
752
+ }
753
+
754
+ // Handle font selection
755
+ function selectFont(font: SignatureFont): void {
756
+ selectedFont = font.family;
757
+ loadFont(font).then(() => {
758
+ renderTypedSignature();
759
+ updateValue();
760
+ });
761
+ }
762
+
763
+ // Screen reader announcements
764
+ function announceToScreenReader(message: string): void {
765
+ if (typeof document === 'undefined') return;
766
+ const announcement = document.createElement('div');
767
+ announcement.setAttribute('role', 'status');
768
+ announcement.setAttribute('aria-live', 'polite');
769
+ announcement.setAttribute('aria-atomic', 'true');
770
+ announcement.className = 'sr-only';
771
+ announcement.textContent = message;
772
+ document.body.appendChild(announcement);
773
+ setTimeout(() => announcement.remove(), 1000);
774
+ }
775
+
776
+ // Handle keyboard shortcuts
777
+ function handleKeyDown(e: KeyboardEvent): void {
778
+ if (disabled) return;
779
+
780
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
781
+ e.preventDefault();
782
+ if (mode === 'draw') {
783
+ undoStroke();
784
+ }
785
+ }
786
+
787
+ if (e.key === 'Delete' || e.key === 'Backspace') {
788
+ if (document.activeElement === canvas) {
789
+ e.preventDefault();
790
+ clearSignature();
791
+ }
792
+ }
793
+ }
794
+
795
+ // Effects
796
+ $effect(() => {
797
+ // Load all fonts on mount
798
+ fonts.forEach((font) => loadFont(font));
799
+ });
800
+
801
+ $effect(() => {
802
+ // Initialize canvas after mount
803
+ if (canvas) {
804
+ initializeCanvas();
805
+ }
806
+ });
807
+
808
+ $effect(() => {
809
+ // Handle resize
810
+ if (!canvasContainer || typeof window === 'undefined') return;
811
+
812
+ const observer = new ResizeObserver(() => {
813
+ initializeCanvas();
814
+ });
815
+
816
+ observer.observe(canvasContainer);
817
+
818
+ return () => observer.disconnect();
819
+ });
820
+
821
+ $effect(() => {
822
+ // Cleanup scroll lock on unmount
823
+ return () => {
824
+ unlockScroll();
825
+ };
826
+ });
827
+
828
+ $effect(() => {
829
+ // Re-render typed signature when font or name changes
830
+ if (mode === 'type' && fontsLoaded.has(selectedFont)) {
831
+ renderTypedSignature();
832
+ updateValue();
833
+ }
834
+ });
835
+
836
+ $effect(() => {
837
+ // Update stroke styles when changed
838
+ if (ctx) {
839
+ ctx.strokeStyle = strokeColor;
840
+ ctx.lineWidth = strokeWidth;
841
+ }
842
+ });
843
+
844
+ // Computed IDs
845
+ const inputId = $derived(id || `signature-${Math.random().toString(36).substring(2, 9)}`);
846
+ const errorId = $derived(`${inputId}-error`);
847
+ const hintId = $derived(`${inputId}-hint`);
848
+ const ariaDescribedBy = $derived(
849
+ [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
850
+ );
851
+ </script>
852
+
853
+ <div
854
+ class={cn(styles.container(), className)}
855
+ role="group"
856
+ aria-labelledby={label ? `${inputId}-label` : undefined}
857
+ >
858
+ <!-- Header with label and mode toggle -->
859
+ <div class={styles.header()}>
860
+ {#if label}
861
+ <span id="{inputId}-label" class={styles.labelText()}>
862
+ {label}
863
+ {#if required}
864
+ <span class="text-destructive" aria-hidden="true">*</span>
865
+ <span class="sr-only">(required)</span>
866
+ {/if}
867
+ </span>
868
+ {/if}
869
+
870
+ {#if showModeToggle}
871
+ <div class={styles.modeToggle()} role="radiogroup" aria-label="Signature input method">
872
+ <button
873
+ type="button"
874
+ role="radio"
875
+ aria-checked={mode === 'draw'}
876
+ class={cn(
877
+ styles.modeButton(),
878
+ mode === 'draw' ? styles.modeButtonActive() : styles.modeButtonInactive()
879
+ )}
880
+ onclick={() => setMode('draw')}
881
+ {disabled}
882
+ >
883
+ Draw
884
+ </button>
885
+ <button
886
+ type="button"
887
+ role="radio"
888
+ aria-checked={mode === 'type'}
889
+ class={cn(
890
+ styles.modeButton(),
891
+ mode === 'type' ? styles.modeButtonActive() : styles.modeButtonInactive()
892
+ )}
893
+ onclick={() => setMode('type')}
894
+ {disabled}
895
+ >
896
+ Type
897
+ </button>
898
+ </div>
899
+ {/if}
900
+ </div>
901
+
902
+ <!-- Canvas container -->
903
+ <div bind:this={canvasContainer} class={styles.canvasContainer()} style="height: {height};">
904
+ <canvas
905
+ bind:this={canvas}
906
+ class={styles.canvas()}
907
+ style="height: 100%;"
908
+ tabindex={disabled ? -1 : 0}
909
+ aria-label={mode === 'draw'
910
+ ? 'Signature drawing area. Use mouse or touch to draw.'
911
+ : 'Signature preview area.'}
912
+ aria-describedby={ariaDescribedBy}
913
+ onpointerdown={handlePointerDown}
914
+ onpointermove={handlePointerMove}
915
+ onpointerup={handlePointerUp}
916
+ onpointerleave={handlePointerLeave}
917
+ onkeydown={handleKeyDown}
918
+ ></canvas>
919
+ </div>
920
+
921
+ <!-- Draw mode toolbar -->
922
+ {#if mode === 'draw'}
923
+ <div class={styles.toolbar()}>
924
+ <button
925
+ type="button"
926
+ class={styles.toolButton()}
927
+ onclick={undoStroke}
928
+ disabled={disabled || strokes.length === 0}
929
+ aria-label="Undo last stroke"
930
+ title="Undo (Ctrl+Z)"
931
+ >
932
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
933
+ <path d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4" />
934
+ </svg>
935
+ </button>
936
+
937
+ <button
938
+ type="button"
939
+ class={styles.toolButton()}
940
+ onclick={clearSignature}
941
+ disabled={disabled || strokes.length === 0}
942
+ aria-label="Clear signature"
943
+ title="Clear"
944
+ >
945
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
946
+ <path
947
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
948
+ />
949
+ </svg>
950
+ </button>
951
+
952
+ {#if showStrokeCustomization}
953
+ <div class={styles.toolGroup()}>
954
+ <label class={styles.toolLabel()} for="{inputId}-color">Color</label>
955
+ <input
956
+ id="{inputId}-color"
957
+ type="color"
958
+ bind:value={strokeColor}
959
+ class={styles.colorPicker()}
960
+ {disabled}
961
+ />
962
+ </div>
963
+
964
+ <div class={styles.toolGroup()}>
965
+ <label class={styles.toolLabel()} for="{inputId}-thickness">Thickness</label>
966
+ <input
967
+ id="{inputId}-thickness"
968
+ type="range"
969
+ min={minStrokeWidth}
970
+ max={maxStrokeWidth}
971
+ step="0.5"
972
+ bind:value={strokeWidth}
973
+ class={styles.thicknessSlider()}
974
+ {disabled}
975
+ />
976
+ </div>
977
+ {/if}
978
+ </div>
979
+ {/if}
980
+
981
+ <!-- Type mode controls -->
982
+ {#if mode === 'type'}
983
+ <div class="space-y-3">
984
+ <input
985
+ id={inputId}
986
+ type="text"
987
+ value={typedName}
988
+ oninput={handleTypedNameChange}
989
+ placeholder="Enter your full name"
990
+ class={cn(
991
+ 'flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
992
+ error && 'border-destructive focus-visible:ring-destructive'
993
+ )}
994
+ {disabled}
995
+ aria-describedby={ariaDescribedBy}
996
+ aria-invalid={error ? 'true' : undefined}
997
+ />
998
+
999
+ <div class={styles.fontPicker()} role="listbox" aria-label="Select signature font">
1000
+ {#each fonts as font}
1001
+ <button
1002
+ type="button"
1003
+ role="option"
1004
+ aria-selected={selectedFont === font.family}
1005
+ class={cn(
1006
+ styles.fontButton(),
1007
+ selectedFont === font.family
1008
+ ? styles.fontButtonSelected()
1009
+ : styles.fontButtonUnselected()
1010
+ )}
1011
+ style="font-family: '{font.family}', cursive;"
1012
+ onclick={() => selectFont(font)}
1013
+ {disabled}
1014
+ >
1015
+ {typedName || 'Preview'}
1016
+ </button>
1017
+ {/each}
1018
+ </div>
1019
+ </div>
1020
+ {/if}
1021
+
1022
+ <!-- Consent checkbox -->
1023
+ {#if showConsent}
1024
+ <div class={styles.consent()}>
1025
+ <input
1026
+ id="{inputId}-consent"
1027
+ type="checkbox"
1028
+ checked={consented}
1029
+ onchange={handleConsentChange}
1030
+ class={styles.consentCheckbox()}
1031
+ {disabled}
1032
+ aria-describedby={!consented && requireConsent ? `${inputId}-consent-required` : undefined}
1033
+ />
1034
+ <label for="{inputId}-consent" class={styles.consentLabel()}>
1035
+ {consentText}
1036
+ </label>
1037
+ </div>
1038
+ {#if !consented && requireConsent && hasSignature}
1039
+ <p id="{inputId}-consent-required" class={styles.errorText()} role="alert">
1040
+ You must agree to the terms to submit your signature
1041
+ </p>
1042
+ {/if}
1043
+ {/if}
1044
+
1045
+ <!-- Validation error (from signature) -->
1046
+ {#if hasSignature && !validationResult.valid && validationResult.error}
1047
+ <p class={styles.errorText()} role="alert">
1048
+ {validationResult.error}
1049
+ </p>
1050
+ {/if}
1051
+
1052
+ <!-- External error -->
1053
+ {#if error}
1054
+ <p id={errorId} class={styles.errorText()} role="alert">
1055
+ {error}
1056
+ </p>
1057
+ {/if}
1058
+
1059
+ <!-- Hint -->
1060
+ {#if hint && !error}
1061
+ <p id={hintId} class={styles.hintText()}>
1062
+ {hint}
1063
+ </p>
1064
+ {/if}
1065
+
1066
+ <!-- Hidden input for form submission -->
1067
+ {#if name && value}
1068
+ <input type="hidden" {name} value={value.dataUrl} />
1069
+ {/if}
1070
+ </div>