@svadmin/lite 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.
- package/README.md +149 -0
- package/package.json +38 -0
- package/src/components/LiteAlert.svelte +19 -0
- package/src/components/LiteForm.svelte +138 -0
- package/src/components/LiteLayout.svelte +58 -0
- package/src/components/LiteLogin.svelte +41 -0
- package/src/components/LitePagination.svelte +73 -0
- package/src/components/LiteSearch.svelte +41 -0
- package/src/components/LiteShow.svelte +96 -0
- package/src/components/LiteTable.svelte +132 -0
- package/src/index.ts +32 -0
- package/src/lite.css +460 -0
- package/src/schema-generator.ts +145 -0
- package/src/server-adapter.ts +291 -0
- package/static/enhance.js +56 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svadmin/lite — Schema Generator
|
|
3
|
+
*
|
|
4
|
+
* Auto-generates Zod schemas from @svadmin/core FieldDefinitions.
|
|
5
|
+
* Used with sveltekit-superforms for server-side form validation.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert a single FieldDefinition to its corresponding Zod type.
|
|
12
|
+
*/
|
|
13
|
+
function fieldToZod(field: FieldDefinition): z.ZodTypeAny {
|
|
14
|
+
let schema: z.ZodTypeAny;
|
|
15
|
+
|
|
16
|
+
switch (field.type) {
|
|
17
|
+
case 'number':
|
|
18
|
+
schema = z.coerce.number({ invalid_type_error: `${field.label} must be a number` });
|
|
19
|
+
break;
|
|
20
|
+
case 'boolean':
|
|
21
|
+
schema = z.coerce.boolean();
|
|
22
|
+
break;
|
|
23
|
+
case 'email':
|
|
24
|
+
schema = z.string().email(`${field.label} must be a valid email`);
|
|
25
|
+
break;
|
|
26
|
+
case 'url':
|
|
27
|
+
schema = z.string().url(`${field.label} must be a valid URL`);
|
|
28
|
+
break;
|
|
29
|
+
case 'date':
|
|
30
|
+
schema = z.string().refine(
|
|
31
|
+
(v: string | undefined) => !v || !isNaN(Date.parse(v)),
|
|
32
|
+
{ message: `${field.label} must be a valid date` },
|
|
33
|
+
);
|
|
34
|
+
break;
|
|
35
|
+
case 'select':
|
|
36
|
+
if (field.options?.length) {
|
|
37
|
+
schema = z.enum(
|
|
38
|
+
field.options.map((o: { value: string | number }) => String(o.value)) as [string, ...string[]],
|
|
39
|
+
{ errorMap: () => ({ message: `${field.label} must be one of the options` }) },
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
schema = z.string();
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case 'multiselect':
|
|
46
|
+
case 'tags':
|
|
47
|
+
schema = z.string().transform((v: string) => v ? v.split(',').map((s: string) => s.trim()) : []);
|
|
48
|
+
break;
|
|
49
|
+
case 'textarea':
|
|
50
|
+
case 'richtext':
|
|
51
|
+
schema = z.string().max(50000, `${field.label} is too long`);
|
|
52
|
+
break;
|
|
53
|
+
case 'json':
|
|
54
|
+
schema = z.string().refine(
|
|
55
|
+
(v: string) => { try { JSON.parse(v); return true; } catch { return false; } },
|
|
56
|
+
{ message: `${field.label} must be valid JSON` },
|
|
57
|
+
);
|
|
58
|
+
break;
|
|
59
|
+
case 'phone':
|
|
60
|
+
schema = z.string().regex(/^[+\d\s()-]*$/, `${field.label} must be a valid phone number`);
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
schema = z.string();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wrap with optional/required
|
|
67
|
+
if (!field.required) {
|
|
68
|
+
schema = schema.optional().or(z.literal(''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return schema;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a Zod object schema from a ResourceDefinition's fields.
|
|
76
|
+
* Only includes fields that are relevant for form rendering.
|
|
77
|
+
*
|
|
78
|
+
* @param mode - 'create' | 'edit' to filter fields by showInCreate / showInEdit
|
|
79
|
+
*/
|
|
80
|
+
export function fieldsToZodSchema(
|
|
81
|
+
fields: FieldDefinition[],
|
|
82
|
+
mode: 'create' | 'edit' = 'create',
|
|
83
|
+
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
84
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
85
|
+
|
|
86
|
+
for (const field of fields) {
|
|
87
|
+
// Skip fields not shown in forms
|
|
88
|
+
if (field.showInForm === false) continue;
|
|
89
|
+
if (mode === 'create' && field.showInCreate === false) continue;
|
|
90
|
+
if (mode === 'edit' && field.showInEdit === false) continue;
|
|
91
|
+
|
|
92
|
+
shape[field.key] = fieldToZod(field);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return z.object(shape);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate a Zod schema from a ResourceDefinition.
|
|
100
|
+
* Convenience wrapper around fieldsToZodSchema.
|
|
101
|
+
*/
|
|
102
|
+
export function resourceToZodSchema(
|
|
103
|
+
resource: ResourceDefinition,
|
|
104
|
+
mode: 'create' | 'edit' = 'create',
|
|
105
|
+
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
106
|
+
return fieldsToZodSchema(resource.fields, mode);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Determine the appropriate HTML input type for an IE11-friendly `<input>`.
|
|
111
|
+
* Falls back to 'text' for unsupported HTML5 types.
|
|
112
|
+
*/
|
|
113
|
+
export function fieldToInputType(field: FieldDefinition): string {
|
|
114
|
+
switch (field.type) {
|
|
115
|
+
case 'number': return 'text'; // IE11 <input type="number"> has bugs
|
|
116
|
+
case 'email': return 'text'; // IE11 email validation is broken
|
|
117
|
+
case 'url': return 'text';
|
|
118
|
+
case 'phone': return 'tel';
|
|
119
|
+
case 'boolean': return 'checkbox';
|
|
120
|
+
case 'date': return 'text'; // IE11 doesn't support type="date"
|
|
121
|
+
case 'textarea':
|
|
122
|
+
case 'richtext': return 'textarea';
|
|
123
|
+
case 'select':
|
|
124
|
+
case 'multiselect': return 'select';
|
|
125
|
+
case 'image':
|
|
126
|
+
case 'images':
|
|
127
|
+
case 'file': return 'file';
|
|
128
|
+
default: return 'text';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generate an input placeholder with format hint for IE11
|
|
134
|
+
* (which cannot show native date pickers, etc.)
|
|
135
|
+
*/
|
|
136
|
+
export function fieldToPlaceholder(field: FieldDefinition): string {
|
|
137
|
+
switch (field.type) {
|
|
138
|
+
case 'date': return 'YYYY-MM-DD';
|
|
139
|
+
case 'email': return 'user@example.com';
|
|
140
|
+
case 'url': return 'https://example.com';
|
|
141
|
+
case 'phone': return '+1 (555) 000-0000';
|
|
142
|
+
case 'number': return '0';
|
|
143
|
+
default: return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svadmin/lite — Server Adapter
|
|
3
|
+
*
|
|
4
|
+
* Bridges @svadmin/core DataProvider into SvelteKit server loaders and form actions.
|
|
5
|
+
* All data fetching happens on the server — zero client-side JS required.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
DataProvider, AuthProvider,
|
|
9
|
+
ResourceDefinition, FieldDefinition,
|
|
10
|
+
Sort, Filter,
|
|
11
|
+
} from '@svadmin/core';
|
|
12
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
13
|
+
|
|
14
|
+
// ─── List Loader ──────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ListLoaderResult {
|
|
17
|
+
records: Record<string, unknown>[];
|
|
18
|
+
total: number;
|
|
19
|
+
page: number;
|
|
20
|
+
pageSize: number;
|
|
21
|
+
totalPages: number;
|
|
22
|
+
sort?: string;
|
|
23
|
+
order?: 'asc' | 'desc';
|
|
24
|
+
search?: string;
|
|
25
|
+
resource: ResourceDefinition;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a SvelteKit `load` function that fetches a resource list
|
|
30
|
+
* via the DataProvider. All state is driven by URL search params.
|
|
31
|
+
*/
|
|
32
|
+
export function createListLoader(
|
|
33
|
+
dp: DataProvider,
|
|
34
|
+
resource: ResourceDefinition,
|
|
35
|
+
) {
|
|
36
|
+
return async ({ url }: { url: URL }): Promise<ListLoaderResult> => {
|
|
37
|
+
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
|
38
|
+
const pageSize = resource.pageSize ?? 20;
|
|
39
|
+
const sort = url.searchParams.get('sort') ?? undefined;
|
|
40
|
+
const order = (url.searchParams.get('order') as 'asc' | 'desc') ?? 'asc';
|
|
41
|
+
const search = url.searchParams.get('q') ?? undefined;
|
|
42
|
+
|
|
43
|
+
const sorters: Sort[] = sort ? [{ field: sort, order }] :
|
|
44
|
+
resource.defaultSort ? [resource.defaultSort] : [];
|
|
45
|
+
|
|
46
|
+
const filters: Filter[] = [];
|
|
47
|
+
if (search) {
|
|
48
|
+
const searchable = resource.fields.find((f: FieldDefinition) => f.searchable);
|
|
49
|
+
if (searchable) {
|
|
50
|
+
filters.push({ field: searchable.key, operator: 'contains', value: search });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = await dp.getList({
|
|
55
|
+
resource: resource.name,
|
|
56
|
+
pagination: { current: page, pageSize },
|
|
57
|
+
sorters,
|
|
58
|
+
filters,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
records: result.data as Record<string, unknown>[],
|
|
63
|
+
total: result.total,
|
|
64
|
+
page,
|
|
65
|
+
pageSize,
|
|
66
|
+
totalPages: Math.ceil(result.total / pageSize),
|
|
67
|
+
sort,
|
|
68
|
+
order,
|
|
69
|
+
search,
|
|
70
|
+
resource,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Detail Loader ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export function createDetailLoader(
|
|
78
|
+
dp: DataProvider,
|
|
79
|
+
resource: ResourceDefinition,
|
|
80
|
+
) {
|
|
81
|
+
return async ({ params }: { params: { id: string } }) => {
|
|
82
|
+
const result = await dp.getOne({
|
|
83
|
+
resource: resource.name,
|
|
84
|
+
id: params.id,
|
|
85
|
+
});
|
|
86
|
+
return { record: result.data as Record<string, unknown>, resource };
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── CRUD Form Actions ────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates SvelteKit form actions for create / update / delete.
|
|
94
|
+
* Works with standard `<form method="POST">` submissions — no JS needed.
|
|
95
|
+
*/
|
|
96
|
+
export function createCrudActions(
|
|
97
|
+
dp: DataProvider,
|
|
98
|
+
resource: ResourceDefinition,
|
|
99
|
+
) {
|
|
100
|
+
const pk = resource.primaryKey ?? 'id';
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
create: async ({ request }: RequestEvent) => {
|
|
104
|
+
const formData = await request.formData();
|
|
105
|
+
const variables = formDataToObject(formData, resource.fields);
|
|
106
|
+
try {
|
|
107
|
+
const result = await dp.create({ resource: resource.name, variables });
|
|
108
|
+
return { success: true, id: (result.data as Record<string, unknown>)[pk] };
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { success: false, error: (e as Error).message, values: variables };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
update: async ({ request }: RequestEvent) => {
|
|
115
|
+
const formData = await request.formData();
|
|
116
|
+
const id = formData.get('_id') as string;
|
|
117
|
+
formData.delete('_id');
|
|
118
|
+
const variables = formDataToObject(formData, resource.fields);
|
|
119
|
+
try {
|
|
120
|
+
await dp.update({ resource: resource.name, id, variables });
|
|
121
|
+
return { success: true };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { success: false, error: (e as Error).message, values: variables };
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
delete: async ({ request }: RequestEvent) => {
|
|
128
|
+
const formData = await request.formData();
|
|
129
|
+
const id = formData.get('id') as string;
|
|
130
|
+
try {
|
|
131
|
+
await dp.deleteOne({ resource: resource.name, id });
|
|
132
|
+
return { success: true };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { success: false, error: (e as Error).message };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Auth Helpers ─────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Creates a SvelteKit server hook that checks auth via AuthProvider
|
|
144
|
+
* and redirects unauthenticated users to a login page.
|
|
145
|
+
*/
|
|
146
|
+
export function createAuthGuard(
|
|
147
|
+
authProvider: AuthProvider,
|
|
148
|
+
loginPath = '/lite/login',
|
|
149
|
+
) {
|
|
150
|
+
return async ({ event, resolve }: { event: RequestEvent; resolve: (event: RequestEvent) => Promise<Response> }) => {
|
|
151
|
+
// Skip auth check for the login page itself
|
|
152
|
+
if (event.url.pathname === loginPath) {
|
|
153
|
+
return resolve(event);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const check = await authProvider.check();
|
|
158
|
+
if (!check.authenticated) {
|
|
159
|
+
return new Response(null, {
|
|
160
|
+
status: 302,
|
|
161
|
+
headers: { Location: loginPath },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return new Response(null, {
|
|
166
|
+
status: 302,
|
|
167
|
+
headers: { Location: loginPath },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return resolve(event);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Creates login/logout form actions using AuthProvider.
|
|
177
|
+
*/
|
|
178
|
+
export function createAuthActions(authProvider: AuthProvider) {
|
|
179
|
+
return {
|
|
180
|
+
login: async ({ request, cookies }: RequestEvent) => {
|
|
181
|
+
const formData = await request.formData();
|
|
182
|
+
const params = Object.fromEntries(formData);
|
|
183
|
+
try {
|
|
184
|
+
const result = await authProvider.login(params);
|
|
185
|
+
if (result.success) {
|
|
186
|
+
// Store a session indicator in a cookie
|
|
187
|
+
cookies.set('svadmin-session', 'active', {
|
|
188
|
+
path: '/',
|
|
189
|
+
httpOnly: true,
|
|
190
|
+
sameSite: 'lax',
|
|
191
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
192
|
+
});
|
|
193
|
+
return { success: true, redirectTo: result.redirectTo ?? '/lite' };
|
|
194
|
+
}
|
|
195
|
+
return { success: false, error: result.error?.message ?? 'Login failed' };
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return { success: false, error: (e as Error).message };
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
logout: async ({ cookies }: RequestEvent) => {
|
|
202
|
+
try { await authProvider.logout(); } catch { /* ignore */ }
|
|
203
|
+
cookies.delete('svadmin-session', { path: '/' });
|
|
204
|
+
return { success: true };
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── UA Detection ─────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Detects legacy browsers (IE11) from the User-Agent header.
|
|
213
|
+
*/
|
|
214
|
+
export function isLegacyBrowser(userAgent: string): boolean {
|
|
215
|
+
return /MSIE|Trident|rv:11/.test(userAgent);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Creates a SvelteKit handle hook that auto-redirects legacy browsers to /lite/.
|
|
220
|
+
*/
|
|
221
|
+
export function createLegacyRedirectHook(litePrefix = '/lite') {
|
|
222
|
+
return async ({ event, resolve }: { event: RequestEvent; resolve: (event: RequestEvent) => Promise<Response> }) => {
|
|
223
|
+
const ua = event.request.headers.get('user-agent') ?? '';
|
|
224
|
+
if (isLegacyBrowser(ua) && !event.url.pathname.startsWith(litePrefix)) {
|
|
225
|
+
return new Response(null, {
|
|
226
|
+
status: 302,
|
|
227
|
+
headers: { Location: `${litePrefix}${event.url.pathname}` },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return resolve(event);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Utilities ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function formDataToObject(
|
|
237
|
+
formData: FormData,
|
|
238
|
+
fields: FieldDefinition[],
|
|
239
|
+
): Record<string, unknown> {
|
|
240
|
+
const obj: Record<string, unknown> = {};
|
|
241
|
+
for (const field of fields) {
|
|
242
|
+
if (field.showInForm === false) continue;
|
|
243
|
+
|
|
244
|
+
// Handle array types (like multiselect)
|
|
245
|
+
if (field.type === 'multiselect') {
|
|
246
|
+
const values = formData.getAll(field.key);
|
|
247
|
+
if (values.length > 0) {
|
|
248
|
+
obj[field.key] = values.map(v => String(v));
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const raw = formData.get(field.key);
|
|
254
|
+
if (raw === null) {
|
|
255
|
+
if (field.type === 'boolean') obj[field.key] = false;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Don't coerce File objects, but ignore empty native file inputs
|
|
260
|
+
if (raw instanceof File) {
|
|
261
|
+
if (raw.size > 0 && raw.name !== '') {
|
|
262
|
+
obj[field.key] = raw;
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const strRaw = String(raw);
|
|
268
|
+
|
|
269
|
+
switch (field.type) {
|
|
270
|
+
case 'number':
|
|
271
|
+
obj[field.key] = strRaw === '' ? null : Number(strRaw);
|
|
272
|
+
break;
|
|
273
|
+
case 'boolean':
|
|
274
|
+
obj[field.key] = strRaw === 'on' || strRaw === 'true' || strRaw === '1';
|
|
275
|
+
break;
|
|
276
|
+
case 'tags':
|
|
277
|
+
obj[field.key] = strRaw ? strRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
278
|
+
break;
|
|
279
|
+
case 'json':
|
|
280
|
+
try {
|
|
281
|
+
obj[field.key] = strRaw ? JSON.parse(strRaw) : null;
|
|
282
|
+
} catch {
|
|
283
|
+
obj[field.key] = strRaw; // Let Zod handle validation errors
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
obj[field.key] = strRaw;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return obj;
|
|
291
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svadmin/lite — Optional Progressive Enhancement (ES5)
|
|
3
|
+
*
|
|
4
|
+
* This script is 100% OPTIONAL and written in ES5 for IE11 compatibility.
|
|
5
|
+
* If included, it adds minor UX improvements. If not, everything still works.
|
|
6
|
+
*
|
|
7
|
+
* Include in your app.html:
|
|
8
|
+
* <script src="/enhance.js"></script>
|
|
9
|
+
*/
|
|
10
|
+
(function() {
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
// 1. Delete confirmation — close <details> when clicking outside
|
|
14
|
+
document.addEventListener('click', function(e) {
|
|
15
|
+
var details = document.querySelectorAll('details.lite-confirm-details');
|
|
16
|
+
for (var i = 0; i < details.length; i++) {
|
|
17
|
+
if (!details[i].contains(e.target)) {
|
|
18
|
+
details[i].removeAttribute('open');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// 2. Highlight current table row on hover (for older browsers without :hover)
|
|
24
|
+
var rows = document.querySelectorAll('.lite-table tbody tr');
|
|
25
|
+
for (var j = 0; j < rows.length; j++) {
|
|
26
|
+
rows[j].addEventListener('mouseenter', function() {
|
|
27
|
+
this.style.backgroundColor = '#f8fafc';
|
|
28
|
+
});
|
|
29
|
+
rows[j].addEventListener('mouseleave', function() {
|
|
30
|
+
this.style.backgroundColor = '';
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 3. Auto-focus first input on form pages
|
|
35
|
+
var firstInput = document.querySelector('.lite-form-group input, .lite-form-group textarea');
|
|
36
|
+
if (firstInput && firstInput.offsetParent !== null) {
|
|
37
|
+
firstInput.focus();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 4. Confirm before leaving dirty forms
|
|
41
|
+
var form = document.querySelector('form.lite-card');
|
|
42
|
+
if (form) {
|
|
43
|
+
var dirty = false;
|
|
44
|
+
var inputs = form.querySelectorAll('input, textarea, select');
|
|
45
|
+
for (var k = 0; k < inputs.length; k++) {
|
|
46
|
+
inputs[k].addEventListener('change', function() { dirty = true; });
|
|
47
|
+
}
|
|
48
|
+
window.addEventListener('beforeunload', function(e) {
|
|
49
|
+
if (dirty) {
|
|
50
|
+
e.returnValue = 'You have unsaved changes.';
|
|
51
|
+
return e.returnValue;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
form.addEventListener('submit', function() { dirty = false; });
|
|
55
|
+
}
|
|
56
|
+
})();
|