@classic-homes/theme-svelte 0.1.6 → 0.1.8
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/Alert.svelte.d.ts +1 -1
- package/dist/lib/components/Badge.svelte.d.ts +1 -1
- package/dist/lib/components/Card.svelte +31 -8
- package/dist/lib/components/Card.svelte.d.ts +16 -4
- package/dist/lib/components/CardFooter.svelte +20 -2
- package/dist/lib/components/CardFooter.svelte.d.ts +20 -2
- package/dist/lib/components/Toast.svelte.d.ts +1 -1
- package/dist/lib/components/layout/Footer.svelte +58 -53
- package/dist/lib/composables/index.d.ts +1 -0
- package/dist/lib/composables/index.js +2 -0
- package/dist/lib/composables/usePersistedForm.svelte.d.ts +126 -0
- package/dist/lib/composables/usePersistedForm.svelte.js +396 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/schemas/common.d.ts +2 -2
- package/dist/lib/types/layout.d.ts +16 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
declare const Alert: import("svelte").Component<{
|
|
3
3
|
[key: string]: unknown;
|
|
4
|
-
variant?: "default" | "destructive" | "
|
|
4
|
+
variant?: "default" | "destructive" | "warning" | "error" | "success" | "info" | undefined;
|
|
5
5
|
class?: string;
|
|
6
6
|
children: Snippet;
|
|
7
7
|
}, {}, "">;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
declare const Badge: import("svelte").Component<{
|
|
3
3
|
[key: string]: unknown;
|
|
4
|
-
variant?: "default" | "secondary" | "destructive" | "outline" | "
|
|
4
|
+
variant?: "default" | "secondary" | "destructive" | "outline" | "warning" | "success" | "info" | undefined;
|
|
5
5
|
size?: "default" | "sm" | "dot" | undefined;
|
|
6
6
|
clickable?: boolean;
|
|
7
7
|
class?: string;
|
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
* Features:
|
|
6
6
|
* - Clean border styling with sharp look
|
|
7
7
|
* - Optional hover shadow effect for interactive cards
|
|
8
|
-
* -
|
|
8
|
+
* - Multiple variants: default, featured, highlight, warning
|
|
9
9
|
* - Consistent with brand guidelines
|
|
10
|
+
*
|
|
11
|
+
* Variants:
|
|
12
|
+
* - default: Standard card with 1px border
|
|
13
|
+
* - featured: Thicker 2px border for emphasis
|
|
14
|
+
* - highlight: Teal border (brand-core-1) for positive/info callouts
|
|
15
|
+
* - warning: Red border (brand-red-primary) for warnings/alerts
|
|
10
16
|
*/
|
|
11
17
|
import type { Snippet } from 'svelte';
|
|
12
18
|
import { cn } from '../utils.js';
|
|
@@ -14,9 +20,15 @@
|
|
|
14
20
|
interface Props {
|
|
15
21
|
/** Enable hover shadow effect for interactive cards */
|
|
16
22
|
interactive?: boolean;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Card variant
|
|
25
|
+
* - default: Standard 1px border
|
|
26
|
+
* - featured: Thicker 2px border
|
|
27
|
+
* - highlight: Teal border for positive/info callouts
|
|
28
|
+
* - warning: Red border for warnings/alerts
|
|
29
|
+
*/
|
|
30
|
+
variant?: 'default' | 'featured' | 'highlight' | 'warning';
|
|
31
|
+
/** Border color override (ignored for highlight/warning variants) */
|
|
20
32
|
borderColor?: 'default' | 'primary' | 'muted';
|
|
21
33
|
/** Additional classes */
|
|
22
34
|
class?: string;
|
|
@@ -39,15 +51,26 @@
|
|
|
39
51
|
primary: 'border-primary',
|
|
40
52
|
muted: 'border-gray-200',
|
|
41
53
|
};
|
|
54
|
+
|
|
55
|
+
// Variant-specific border colors (highlight and warning override borderColor)
|
|
56
|
+
const variantBorderClasses = $derived({
|
|
57
|
+
default: borderColorClasses[borderColor],
|
|
58
|
+
featured: borderColorClasses[borderColor],
|
|
59
|
+
highlight: 'border-brand-core-1',
|
|
60
|
+
warning: 'border-brand-red-primary',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Variants with thick borders
|
|
64
|
+
const thickBorderVariants = ['featured', 'highlight', 'warning'];
|
|
42
65
|
</script>
|
|
43
66
|
|
|
44
67
|
<div
|
|
45
68
|
class={cn(
|
|
46
69
|
'rounded-md bg-card text-card-foreground',
|
|
47
|
-
// Border thickness
|
|
48
|
-
variant
|
|
49
|
-
// Border color
|
|
50
|
-
|
|
70
|
+
// Border thickness - thick for featured, highlight, warning
|
|
71
|
+
thickBorderVariants.includes(variant) ? 'border-2' : 'border',
|
|
72
|
+
// Border color based on variant
|
|
73
|
+
variantBorderClasses[variant],
|
|
51
74
|
// Interactive hover effect
|
|
52
75
|
interactive && 'hover:shadow-lg transition-shadow duration-200',
|
|
53
76
|
className
|
|
@@ -4,16 +4,28 @@
|
|
|
4
4
|
* Features:
|
|
5
5
|
* - Clean border styling with sharp look
|
|
6
6
|
* - Optional hover shadow effect for interactive cards
|
|
7
|
-
* -
|
|
7
|
+
* - Multiple variants: default, featured, highlight, warning
|
|
8
8
|
* - Consistent with brand guidelines
|
|
9
|
+
*
|
|
10
|
+
* Variants:
|
|
11
|
+
* - default: Standard card with 1px border
|
|
12
|
+
* - featured: Thicker 2px border for emphasis
|
|
13
|
+
* - highlight: Teal border (brand-core-1) for positive/info callouts
|
|
14
|
+
* - warning: Red border (brand-red-primary) for warnings/alerts
|
|
9
15
|
*/
|
|
10
16
|
import type { Snippet } from 'svelte';
|
|
11
17
|
interface Props {
|
|
12
18
|
/** Enable hover shadow effect for interactive cards */
|
|
13
19
|
interactive?: boolean;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Card variant
|
|
22
|
+
* - default: Standard 1px border
|
|
23
|
+
* - featured: Thicker 2px border
|
|
24
|
+
* - highlight: Teal border for positive/info callouts
|
|
25
|
+
* - warning: Red border for warnings/alerts
|
|
26
|
+
*/
|
|
27
|
+
variant?: 'default' | 'featured' | 'highlight' | 'warning';
|
|
28
|
+
/** Border color override (ignored for highlight/warning variants) */
|
|
17
29
|
borderColor?: 'default' | 'primary' | 'muted';
|
|
18
30
|
/** Additional classes */
|
|
19
31
|
class?: string;
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
declare const Toast: import("svelte").Component<{
|
|
3
3
|
[key: string]: unknown;
|
|
4
|
-
type?: "
|
|
4
|
+
type?: "warning" | "error" | "success" | "info" | undefined;
|
|
5
5
|
title?: string;
|
|
6
6
|
message: string;
|
|
7
7
|
class?: string;
|
|
@@ -42,72 +42,77 @@
|
|
|
42
42
|
class: className,
|
|
43
43
|
}: Props = $props();
|
|
44
44
|
|
|
45
|
-
const currentYear = new Date().getFullYear();
|
|
46
|
-
const defaultCopyright = `${currentYear} Classic Homes. All rights reserved.`;
|
|
47
|
-
|
|
48
45
|
const isDark = $derived(variant === 'dark');
|
|
46
|
+
const displayCopyright = $derived(
|
|
47
|
+
copyright || `${new Date().getFullYear()} Classic Homes. All rights reserved.`
|
|
48
|
+
);
|
|
49
49
|
</script>
|
|
50
50
|
|
|
51
51
|
<footer
|
|
52
|
+
aria-label="Site footer"
|
|
52
53
|
class={cn(
|
|
53
54
|
// Base styles
|
|
54
|
-
isDark ? 'bg-
|
|
55
|
+
isDark ? 'bg-gray-600 text-white' : 'bg-muted/30',
|
|
55
56
|
// Border styles - strong accent border or standard
|
|
56
|
-
strongBorder ? 'border-t-[10px] border-
|
|
57
|
+
strongBorder ? 'border-t-[10px] border-gray-500' : 'border-t border-border',
|
|
57
58
|
className
|
|
58
59
|
)}
|
|
59
60
|
>
|
|
60
61
|
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
|
61
62
|
{#if links.length > 0}
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class="
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
stroke
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
63
|
+
<nav aria-label="Footer navigation">
|
|
64
|
+
<div class="grid grid-cols-2 gap-6 sm:grid-cols-3 md:gap-8 lg:grid-cols-4">
|
|
65
|
+
{#each links as section}
|
|
66
|
+
<div>
|
|
67
|
+
{#if section.title}
|
|
68
|
+
<h3 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
|
|
69
|
+
{section.title}
|
|
70
|
+
</h3>
|
|
71
|
+
{/if}
|
|
72
|
+
<ul class="mt-4 space-y-2">
|
|
73
|
+
{#each section.items as item}
|
|
74
|
+
<li>
|
|
75
|
+
<a
|
|
76
|
+
href={item.href}
|
|
77
|
+
class={cn(
|
|
78
|
+
'text-sm transition-colors',
|
|
79
|
+
isDark
|
|
80
|
+
? 'text-white hover:text-primary'
|
|
81
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
82
|
+
)}
|
|
83
|
+
target={item.external ? '_blank' : undefined}
|
|
84
|
+
rel={item.external ? 'noopener noreferrer' : undefined}
|
|
85
|
+
>
|
|
86
|
+
{item.name}
|
|
87
|
+
{#if item.external}
|
|
88
|
+
<span class="sr-only">(opens in new tab)</span>
|
|
89
|
+
<svg
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
class="ml-1 inline-block h-3 w-3"
|
|
92
|
+
fill="none"
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
>
|
|
96
|
+
<path
|
|
97
|
+
stroke-linecap="round"
|
|
98
|
+
stroke-linejoin="round"
|
|
99
|
+
stroke-width="2"
|
|
100
|
+
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
101
|
+
/>
|
|
102
|
+
</svg>
|
|
103
|
+
{/if}
|
|
104
|
+
</a>
|
|
105
|
+
</li>
|
|
106
|
+
{/each}
|
|
107
|
+
</ul>
|
|
108
|
+
</div>
|
|
109
|
+
{/each}
|
|
110
|
+
</div>
|
|
111
|
+
</nav>
|
|
107
112
|
{/if}
|
|
108
113
|
|
|
109
114
|
{#if children}
|
|
110
|
-
<div class={cn('mt-8 border-t pt-8', isDark ? 'border-gray-
|
|
115
|
+
<div class={cn('mt-8 border-t pt-8', isDark ? 'border-gray-500' : 'border-border')}>
|
|
111
116
|
{@render children()}
|
|
112
117
|
</div>
|
|
113
118
|
{/if}
|
|
@@ -115,7 +120,7 @@
|
|
|
115
120
|
<div
|
|
116
121
|
class={cn(
|
|
117
122
|
'mt-8 flex flex-col items-center justify-between gap-4 border-t pt-8 md:flex-row',
|
|
118
|
-
isDark ? 'border-gray-
|
|
123
|
+
isDark ? 'border-gray-500' : 'border-border'
|
|
119
124
|
)}
|
|
120
125
|
>
|
|
121
126
|
{#if showLogo}
|
|
@@ -123,7 +128,7 @@
|
|
|
123
128
|
{/if}
|
|
124
129
|
|
|
125
130
|
<p class={cn('text-center text-sm', isDark ? 'text-gray-400' : 'text-muted-foreground')}>
|
|
126
|
-
{
|
|
131
|
+
{displayCopyright}
|
|
127
132
|
</p>
|
|
128
133
|
</div>
|
|
129
134
|
</div>
|
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { useForm, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, } from './useForm.svelte.js';
|
|
8
8
|
export { useAsync, runAsync, type UseAsyncOptions, type UseAsyncReturn, } from './useAsync.svelte.js';
|
|
9
|
+
export { usePersistedForm, type UsePersistedFormOptions, type UsePersistedFormReturn, type InferPersistedFormData, } from './usePersistedForm.svelte.js';
|
|
@@ -8,3 +8,5 @@
|
|
|
8
8
|
export { useForm, } from './useForm.svelte.js';
|
|
9
9
|
// Async operation composable
|
|
10
10
|
export { useAsync, runAsync, } from './useAsync.svelte.js';
|
|
11
|
+
// Persisted form composable with localStorage draft saving
|
|
12
|
+
export { usePersistedForm, } from './usePersistedForm.svelte.js';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePersistedForm - Form state management with localStorage persistence
|
|
3
|
+
*
|
|
4
|
+
* Extends useForm with:
|
|
5
|
+
* - Automatic draft saving to localStorage with debounce
|
|
6
|
+
* - Draft restoration on mount
|
|
7
|
+
* - Configurable expiration time
|
|
8
|
+
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
+
* - Duplicate submission prevention
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <script lang="ts">
|
|
14
|
+
* import { usePersistedForm } from '@classic-homes/theme-svelte';
|
|
15
|
+
* import { z } from 'zod';
|
|
16
|
+
*
|
|
17
|
+
* const schema = z.object({
|
|
18
|
+
* email: z.string().email(),
|
|
19
|
+
* name: z.string().min(1),
|
|
20
|
+
* consent: z.boolean(),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const form = usePersistedForm({
|
|
24
|
+
* schema,
|
|
25
|
+
* initialValues: { email: '', name: '', consent: false },
|
|
26
|
+
* onSubmit: async (data) => {
|
|
27
|
+
* await submitForm(data);
|
|
28
|
+
* },
|
|
29
|
+
* storageKey: 'my-form-draft',
|
|
30
|
+
* excludeFields: ['consent'], // Don't persist consent
|
|
31
|
+
* expirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
32
|
+
* onDraftRestored: () => {
|
|
33
|
+
* console.log('Draft was restored');
|
|
34
|
+
* },
|
|
35
|
+
* });
|
|
36
|
+
* </script>
|
|
37
|
+
*
|
|
38
|
+
* <form onsubmit={form.handleSubmit}>
|
|
39
|
+
* <input bind:value={form.data.email} />
|
|
40
|
+
* {#if form.draftRestored}
|
|
41
|
+
* <div>Your previous draft has been restored.</div>
|
|
42
|
+
* {/if}
|
|
43
|
+
* </form>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
import { z } from 'zod';
|
|
47
|
+
export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> {
|
|
48
|
+
/** Zod schema for validation */
|
|
49
|
+
schema: T;
|
|
50
|
+
/** Initial form values */
|
|
51
|
+
initialValues: z.infer<T>;
|
|
52
|
+
/** Called on successful validation and submission */
|
|
53
|
+
onSubmit: (data: z.infer<T>) => Promise<void> | void;
|
|
54
|
+
/** localStorage key for draft persistence */
|
|
55
|
+
storageKey: string;
|
|
56
|
+
/** Called when validation or submission fails */
|
|
57
|
+
onError?: (error: Error | z.ZodError) => void;
|
|
58
|
+
/** Whether to show toast on error (default: true) */
|
|
59
|
+
showToastOnError?: boolean;
|
|
60
|
+
/** Whether to reset form after successful submission (default: false) */
|
|
61
|
+
resetOnSuccess?: boolean;
|
|
62
|
+
/** Custom error message for toast */
|
|
63
|
+
errorMessage?: string;
|
|
64
|
+
/** Custom success message for toast (if set, shows toast on success) */
|
|
65
|
+
successMessage?: string;
|
|
66
|
+
/** Fields to exclude from persistence (e.g., consent fields that require re-confirmation) */
|
|
67
|
+
excludeFields?: (keyof z.infer<T>)[];
|
|
68
|
+
/** Expiration time in milliseconds (default: 7 days) */
|
|
69
|
+
expirationMs?: number;
|
|
70
|
+
/** Callback when a draft is restored */
|
|
71
|
+
onDraftRestored?: () => void;
|
|
72
|
+
/** Debounce time for saving drafts in milliseconds (default: 500ms) */
|
|
73
|
+
debounceMs?: number;
|
|
74
|
+
/** Cooldown time between submissions in milliseconds (default: 5000ms) */
|
|
75
|
+
submitCooldownMs?: number;
|
|
76
|
+
}
|
|
77
|
+
export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
|
|
78
|
+
/** Current form data (reactive) */
|
|
79
|
+
readonly data: z.infer<T>;
|
|
80
|
+
/** Field errors (reactive) */
|
|
81
|
+
readonly errors: Record<string, string>;
|
|
82
|
+
/** Whether form is submitting (reactive) */
|
|
83
|
+
readonly isSubmitting: boolean;
|
|
84
|
+
/** Whether form has been submitted (reactive) */
|
|
85
|
+
readonly isSubmitted: boolean;
|
|
86
|
+
/** Whether any field is dirty (reactive) */
|
|
87
|
+
readonly isDirty: boolean;
|
|
88
|
+
/** Whether form is currently valid (reactive) */
|
|
89
|
+
readonly isValid: boolean;
|
|
90
|
+
/** Whether a draft was restored on mount (reactive) */
|
|
91
|
+
readonly draftRestored: boolean;
|
|
92
|
+
/** Set a field value */
|
|
93
|
+
setField: <K extends keyof z.infer<T>>(field: K, value: z.infer<T>[K]) => void;
|
|
94
|
+
/** Set multiple field values */
|
|
95
|
+
setFields: (values: Partial<z.infer<T>>) => void;
|
|
96
|
+
/** Set a nested field value using dot notation */
|
|
97
|
+
setNestedField: (path: string, value: unknown) => void;
|
|
98
|
+
/** Validate a single field */
|
|
99
|
+
validateField: (field: keyof z.infer<T>) => boolean;
|
|
100
|
+
/** Validate the entire form */
|
|
101
|
+
validate: () => boolean;
|
|
102
|
+
/** Handle form submission */
|
|
103
|
+
handleSubmit: (event?: Event) => Promise<void>;
|
|
104
|
+
/** Reset form to initial values and clear draft */
|
|
105
|
+
reset: () => void;
|
|
106
|
+
/** Clear all errors */
|
|
107
|
+
clearErrors: () => void;
|
|
108
|
+
/** Set a specific error */
|
|
109
|
+
setError: (field: string, message: string) => void;
|
|
110
|
+
/** Mark a field as dirty */
|
|
111
|
+
markDirty: (field: string) => void;
|
|
112
|
+
/** Handle field blur - validates the field */
|
|
113
|
+
handleBlur: (field: keyof z.infer<T>) => void;
|
|
114
|
+
/** Clear the stored draft */
|
|
115
|
+
clearDraft: () => void;
|
|
116
|
+
/** Dismiss the draft restored notification */
|
|
117
|
+
dismissDraftNotification: () => void;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Create a form handler with localStorage persistence
|
|
121
|
+
*/
|
|
122
|
+
export declare function usePersistedForm<T extends z.ZodObject<z.ZodRawShape>>(options: UsePersistedFormOptions<T>): UsePersistedFormReturn<T>;
|
|
123
|
+
/**
|
|
124
|
+
* Type helper to extract form data type from schema
|
|
125
|
+
*/
|
|
126
|
+
export type InferPersistedFormData<T extends z.ZodObject<z.ZodRawShape>> = z.infer<T>;
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePersistedForm - Form state management with localStorage persistence
|
|
3
|
+
*
|
|
4
|
+
* Extends useForm with:
|
|
5
|
+
* - Automatic draft saving to localStorage with debounce
|
|
6
|
+
* - Draft restoration on mount
|
|
7
|
+
* - Configurable expiration time
|
|
8
|
+
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
+
* - Duplicate submission prevention
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <script lang="ts">
|
|
14
|
+
* import { usePersistedForm } from '@classic-homes/theme-svelte';
|
|
15
|
+
* import { z } from 'zod';
|
|
16
|
+
*
|
|
17
|
+
* const schema = z.object({
|
|
18
|
+
* email: z.string().email(),
|
|
19
|
+
* name: z.string().min(1),
|
|
20
|
+
* consent: z.boolean(),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const form = usePersistedForm({
|
|
24
|
+
* schema,
|
|
25
|
+
* initialValues: { email: '', name: '', consent: false },
|
|
26
|
+
* onSubmit: async (data) => {
|
|
27
|
+
* await submitForm(data);
|
|
28
|
+
* },
|
|
29
|
+
* storageKey: 'my-form-draft',
|
|
30
|
+
* excludeFields: ['consent'], // Don't persist consent
|
|
31
|
+
* expirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
32
|
+
* onDraftRestored: () => {
|
|
33
|
+
* console.log('Draft was restored');
|
|
34
|
+
* },
|
|
35
|
+
* });
|
|
36
|
+
* </script>
|
|
37
|
+
*
|
|
38
|
+
* <form onsubmit={form.handleSubmit}>
|
|
39
|
+
* <input bind:value={form.data.email} />
|
|
40
|
+
* {#if form.draftRestored}
|
|
41
|
+
* <div>Your previous draft has been restored.</div>
|
|
42
|
+
* {/if}
|
|
43
|
+
* </form>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
import { toastStore } from '../stores/toast.svelte.js';
|
|
47
|
+
const DEFAULT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
48
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
49
|
+
const DEFAULT_SUBMIT_COOLDOWN_MS = 5000;
|
|
50
|
+
/**
|
|
51
|
+
* Create a form handler with localStorage persistence
|
|
52
|
+
*/
|
|
53
|
+
export function usePersistedForm(options) {
|
|
54
|
+
const { schema, initialValues, onSubmit, storageKey, onError, showToastOnError = true, resetOnSuccess = false, errorMessage, successMessage, excludeFields = [], expirationMs = DEFAULT_EXPIRATION_MS, onDraftRestored, debounceMs = DEFAULT_DEBOUNCE_MS, submitCooldownMs = DEFAULT_SUBMIT_COOLDOWN_MS, } = options;
|
|
55
|
+
// Deep clone to avoid reference issues
|
|
56
|
+
const cloneInitialValues = () => JSON.parse(JSON.stringify(initialValues));
|
|
57
|
+
// Reactive state using Svelte 5 runes
|
|
58
|
+
let data = $state(cloneInitialValues());
|
|
59
|
+
let errors = $state({});
|
|
60
|
+
let isSubmitting = $state(false);
|
|
61
|
+
let isSubmitted = $state(false);
|
|
62
|
+
let dirtyFields = $state(new Set());
|
|
63
|
+
let draftRestored = $state(false);
|
|
64
|
+
let lastSubmitTime = $state(0);
|
|
65
|
+
let debounceTimer = null;
|
|
66
|
+
// Derived state
|
|
67
|
+
const isDirty = $derived(dirtyFields.size > 0);
|
|
68
|
+
const isValid = $derived(Object.keys(errors).length === 0);
|
|
69
|
+
/**
|
|
70
|
+
* Check if localStorage is available (also checks for browser environment)
|
|
71
|
+
*/
|
|
72
|
+
function isStorageAvailable() {
|
|
73
|
+
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const test = '__storage_test__';
|
|
78
|
+
localStorage.setItem(test, test);
|
|
79
|
+
localStorage.removeItem(test);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get data to persist (excludes specified fields)
|
|
88
|
+
*/
|
|
89
|
+
function getDataToPersist() {
|
|
90
|
+
const dataCopy = { ...data };
|
|
91
|
+
for (const field of excludeFields) {
|
|
92
|
+
delete dataCopy[field];
|
|
93
|
+
}
|
|
94
|
+
return dataCopy;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Save draft to localStorage with debounce
|
|
98
|
+
*/
|
|
99
|
+
function saveDraft() {
|
|
100
|
+
if (!isStorageAvailable())
|
|
101
|
+
return;
|
|
102
|
+
if (debounceTimer) {
|
|
103
|
+
clearTimeout(debounceTimer);
|
|
104
|
+
}
|
|
105
|
+
debounceTimer = setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
const draft = {
|
|
108
|
+
data: getDataToPersist(),
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
localStorage.setItem(storageKey, JSON.stringify(draft));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// Silently fail - localStorage may be full or unavailable
|
|
115
|
+
console.warn('Failed to save form draft', error);
|
|
116
|
+
}
|
|
117
|
+
}, debounceMs);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Load draft from localStorage
|
|
121
|
+
*/
|
|
122
|
+
function loadDraft() {
|
|
123
|
+
if (!isStorageAvailable())
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
const stored = localStorage.getItem(storageKey);
|
|
127
|
+
if (!stored)
|
|
128
|
+
return null;
|
|
129
|
+
const draft = JSON.parse(stored);
|
|
130
|
+
// Check if draft has expired
|
|
131
|
+
if (Date.now() - draft.timestamp > expirationMs) {
|
|
132
|
+
clearDraft();
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return draft.data;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Silently fail - corrupt data
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Clear draft from localStorage
|
|
144
|
+
*/
|
|
145
|
+
function clearDraft() {
|
|
146
|
+
if (!isStorageAvailable())
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
localStorage.removeItem(storageKey);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Silently fail
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Restore draft on initialization
|
|
157
|
+
*/
|
|
158
|
+
function restoreDraft() {
|
|
159
|
+
const draft = loadDraft();
|
|
160
|
+
if (draft && Object.keys(draft).length > 0) {
|
|
161
|
+
// Merge draft with initial data (to keep excluded fields at their defaults)
|
|
162
|
+
data = { ...cloneInitialValues(), ...draft };
|
|
163
|
+
draftRestored = true;
|
|
164
|
+
onDraftRestored?.();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Restore draft on initialization (only in browser)
|
|
168
|
+
if (typeof window !== 'undefined') {
|
|
169
|
+
restoreDraft();
|
|
170
|
+
}
|
|
171
|
+
// Watch for form changes and save draft
|
|
172
|
+
$effect(() => {
|
|
173
|
+
// Access data to create dependency
|
|
174
|
+
JSON.stringify(data);
|
|
175
|
+
if (dirtyFields.size > 0) {
|
|
176
|
+
saveDraft();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
/**
|
|
180
|
+
* Set nested value in object using dot notation path
|
|
181
|
+
*/
|
|
182
|
+
function setNestedValue(obj, path, value) {
|
|
183
|
+
const keys = path.split('.');
|
|
184
|
+
let current = obj;
|
|
185
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
186
|
+
const key = keys[i];
|
|
187
|
+
if (current[key] === undefined || current[key] === null) {
|
|
188
|
+
current[key] = {};
|
|
189
|
+
}
|
|
190
|
+
current = current[key];
|
|
191
|
+
}
|
|
192
|
+
current[keys[keys.length - 1]] = value;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Parse Zod errors into field-keyed error map
|
|
196
|
+
*/
|
|
197
|
+
function parseZodErrors(zodError) {
|
|
198
|
+
const fieldErrors = {};
|
|
199
|
+
for (const issue of zodError.issues) {
|
|
200
|
+
const path = issue.path.join('.');
|
|
201
|
+
// Only keep the first error for each field
|
|
202
|
+
if (!fieldErrors[path]) {
|
|
203
|
+
fieldErrors[path] = issue.message;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return fieldErrors;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Set a single field value
|
|
210
|
+
*/
|
|
211
|
+
function setField(field, value) {
|
|
212
|
+
data[field] = value;
|
|
213
|
+
markDirty(field);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Set multiple field values
|
|
217
|
+
*/
|
|
218
|
+
function setFields(values) {
|
|
219
|
+
for (const [key, value] of Object.entries(values)) {
|
|
220
|
+
data[key] = value;
|
|
221
|
+
markDirty(key);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Set a nested field value using dot notation
|
|
226
|
+
*/
|
|
227
|
+
function setNestedField(path, value) {
|
|
228
|
+
setNestedValue(data, path, value);
|
|
229
|
+
markDirty(path);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Mark a field as dirty
|
|
233
|
+
*/
|
|
234
|
+
function markDirty(field) {
|
|
235
|
+
dirtyFields = new Set([...dirtyFields, field]);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Validate a single field against the schema
|
|
239
|
+
*/
|
|
240
|
+
function validateField(field) {
|
|
241
|
+
const fieldSchema = schema.shape[field];
|
|
242
|
+
if (!fieldSchema)
|
|
243
|
+
return true;
|
|
244
|
+
const fieldValue = data[field];
|
|
245
|
+
const result = fieldSchema.safeParse(fieldValue);
|
|
246
|
+
if (result.success) {
|
|
247
|
+
// Clear error for this field
|
|
248
|
+
const newErrors = { ...errors };
|
|
249
|
+
delete newErrors[field];
|
|
250
|
+
errors = newErrors;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Set error for this field
|
|
255
|
+
errors = {
|
|
256
|
+
...errors,
|
|
257
|
+
[field]: result.error.issues[0]?.message || 'Invalid value',
|
|
258
|
+
};
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Validate the entire form
|
|
264
|
+
*/
|
|
265
|
+
function validate() {
|
|
266
|
+
const result = schema.safeParse(data);
|
|
267
|
+
if (result.success) {
|
|
268
|
+
errors = {};
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
errors = parseZodErrors(result.error);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Handle field blur - validates the field
|
|
278
|
+
*/
|
|
279
|
+
function handleBlur(field) {
|
|
280
|
+
markDirty(field);
|
|
281
|
+
validateField(field);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Handle form submission
|
|
285
|
+
*/
|
|
286
|
+
async function handleSubmit(event) {
|
|
287
|
+
event?.preventDefault();
|
|
288
|
+
// Check for duplicate submission
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
if (now - lastSubmitTime < submitCooldownMs) {
|
|
291
|
+
toastStore.warning('Please wait before submitting again');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
isSubmitted = true;
|
|
295
|
+
// Validate all fields
|
|
296
|
+
if (!validate()) {
|
|
297
|
+
const errorCount = Object.keys(errors).length;
|
|
298
|
+
if (showToastOnError) {
|
|
299
|
+
toastStore.error(errorMessage || `Please fix ${errorCount} validation error${errorCount > 1 ? 's' : ''}`);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
isSubmitting = true;
|
|
304
|
+
try {
|
|
305
|
+
await onSubmit(data);
|
|
306
|
+
lastSubmitTime = now;
|
|
307
|
+
// Clear draft on successful submission
|
|
308
|
+
clearDraft();
|
|
309
|
+
if (successMessage) {
|
|
310
|
+
toastStore.success(successMessage);
|
|
311
|
+
}
|
|
312
|
+
if (resetOnSuccess) {
|
|
313
|
+
reset();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
// Reset cooldown on error to allow retry
|
|
318
|
+
lastSubmitTime = 0;
|
|
319
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
320
|
+
if (onError) {
|
|
321
|
+
onError(err);
|
|
322
|
+
}
|
|
323
|
+
if (showToastOnError) {
|
|
324
|
+
toastStore.error(errorMessage || err.message || 'An error occurred');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
isSubmitting = false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Reset form to initial values and clear draft
|
|
333
|
+
*/
|
|
334
|
+
function reset() {
|
|
335
|
+
data = cloneInitialValues();
|
|
336
|
+
errors = {};
|
|
337
|
+
isSubmitted = false;
|
|
338
|
+
dirtyFields = new Set();
|
|
339
|
+
draftRestored = false;
|
|
340
|
+
clearDraft();
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Clear all errors
|
|
344
|
+
*/
|
|
345
|
+
function clearErrors() {
|
|
346
|
+
errors = {};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Set a specific error manually
|
|
350
|
+
*/
|
|
351
|
+
function setError(field, message) {
|
|
352
|
+
errors = { ...errors, [field]: message };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Dismiss the draft restored notification
|
|
356
|
+
*/
|
|
357
|
+
function dismissDraftNotification() {
|
|
358
|
+
draftRestored = false;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
get data() {
|
|
362
|
+
return data;
|
|
363
|
+
},
|
|
364
|
+
get errors() {
|
|
365
|
+
return errors;
|
|
366
|
+
},
|
|
367
|
+
get isSubmitting() {
|
|
368
|
+
return isSubmitting;
|
|
369
|
+
},
|
|
370
|
+
get isSubmitted() {
|
|
371
|
+
return isSubmitted;
|
|
372
|
+
},
|
|
373
|
+
get isDirty() {
|
|
374
|
+
return isDirty;
|
|
375
|
+
},
|
|
376
|
+
get isValid() {
|
|
377
|
+
return isValid;
|
|
378
|
+
},
|
|
379
|
+
get draftRestored() {
|
|
380
|
+
return draftRestored;
|
|
381
|
+
},
|
|
382
|
+
setField,
|
|
383
|
+
setFields,
|
|
384
|
+
setNestedField,
|
|
385
|
+
validateField,
|
|
386
|
+
validate,
|
|
387
|
+
handleSubmit,
|
|
388
|
+
reset,
|
|
389
|
+
clearErrors,
|
|
390
|
+
setError,
|
|
391
|
+
markDirty,
|
|
392
|
+
handleBlur,
|
|
393
|
+
clearDraft,
|
|
394
|
+
dismissDraftNotification,
|
|
395
|
+
};
|
|
396
|
+
}
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -72,5 +72,5 @@ export { cn } from './utils.js';
|
|
|
72
72
|
export { tv, type VariantProps } from 'tailwind-variants';
|
|
73
73
|
export { validateNonEmptyArray, validateRequired, validateOneOf, validateRange, validateProps, createValidator, type ValidationResult, } from './validation.js';
|
|
74
74
|
export { perfStart, perfEnd, measure, measureAsync, getPerformanceEntries, clearPerformanceEntries, createPerfMonitor, type PerformanceMark, } from './performance.js';
|
|
75
|
-
export { useForm, useAsync, runAsync, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, type UseAsyncOptions, type UseAsyncReturn, } from './composables/index.js';
|
|
75
|
+
export { useForm, useAsync, runAsync, usePersistedForm, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, type UseAsyncOptions, type UseAsyncReturn, type UsePersistedFormOptions, type UsePersistedFormReturn, type InferPersistedFormData, } from './composables/index.js';
|
|
76
76
|
export * from './schemas/index.js';
|
package/dist/lib/index.js
CHANGED
|
@@ -89,6 +89,6 @@ export { validateNonEmptyArray, validateRequired, validateOneOf, validateRange,
|
|
|
89
89
|
// Performance monitoring utilities (dev-only, tree-shaken in production)
|
|
90
90
|
export { perfStart, perfEnd, measure, measureAsync, getPerformanceEntries, clearPerformanceEntries, createPerfMonitor, } from './performance.js';
|
|
91
91
|
// Composables - reusable Svelte 5 state management patterns
|
|
92
|
-
export { useForm, useAsync, runAsync, } from './composables/index.js';
|
|
92
|
+
export { useForm, useAsync, runAsync, usePersistedForm, } from './composables/index.js';
|
|
93
93
|
// Validation schemas - Zod schemas for forms and data validation
|
|
94
94
|
export * from './schemas/index.js';
|
|
@@ -246,14 +246,14 @@ export declare function paginatedResponseSchema<T extends z.ZodTypeAny>(itemSche
|
|
|
246
246
|
pageSize: z.ZodNumber;
|
|
247
247
|
totalPages: z.ZodNumber;
|
|
248
248
|
}, "strip", z.ZodTypeAny, {
|
|
249
|
-
items: T["_output"][];
|
|
250
249
|
page: number;
|
|
250
|
+
items: T["_output"][];
|
|
251
251
|
pageSize: number;
|
|
252
252
|
total: number;
|
|
253
253
|
totalPages: number;
|
|
254
254
|
}, {
|
|
255
|
-
items: T["_input"][];
|
|
256
255
|
page: number;
|
|
256
|
+
items: T["_input"][];
|
|
257
257
|
pageSize: number;
|
|
258
258
|
total: number;
|
|
259
259
|
totalPages: number;
|
|
@@ -235,8 +235,16 @@ export interface PublicLayoutProps {
|
|
|
235
235
|
copyright?: string;
|
|
236
236
|
/** Custom logo snippet */
|
|
237
237
|
logo?: Snippet;
|
|
238
|
+
/** Logo subtitle for default logo (e.g., "THEME", "MY HOME") */
|
|
239
|
+
logoSubtitle?: string;
|
|
240
|
+
/** Logo environment indicator for default logo */
|
|
241
|
+
logoEnvironment?: 'local' | 'dev' | 'demo';
|
|
238
242
|
/** Custom header end content */
|
|
239
243
|
headerEnd?: Snippet;
|
|
244
|
+
/** Use strong accent border on footer (10px top border) */
|
|
245
|
+
strongFooterBorder?: boolean;
|
|
246
|
+
/** Use dark footer variant */
|
|
247
|
+
darkFooter?: boolean;
|
|
240
248
|
/** Header search configuration */
|
|
241
249
|
headerSearch?: HeaderSearchConfig;
|
|
242
250
|
/** Main content */
|
|
@@ -332,6 +340,10 @@ export interface HeaderProps {
|
|
|
332
340
|
/** Additional classes */
|
|
333
341
|
class?: string;
|
|
334
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Footer visual variant
|
|
345
|
+
*/
|
|
346
|
+
export type FooterVariant = 'default' | 'dark';
|
|
335
347
|
/**
|
|
336
348
|
* Props for Footer component
|
|
337
349
|
*/
|
|
@@ -342,6 +354,10 @@ export interface FooterProps {
|
|
|
342
354
|
copyright?: string;
|
|
343
355
|
/** Show logo in footer */
|
|
344
356
|
showLogo?: boolean;
|
|
357
|
+
/** Use strong accent border (10px top border) */
|
|
358
|
+
strongBorder?: boolean;
|
|
359
|
+
/** Dark variant with brand colors */
|
|
360
|
+
variant?: FooterVariant;
|
|
345
361
|
/** Custom content */
|
|
346
362
|
children?: Snippet;
|
|
347
363
|
/** Additional classes */
|