@svgrid/enterprise 2.2.1 → 2.3.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 +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -1,794 +1,849 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
type EditRow = Record<string, unknown>
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<script lang="ts" generics="TData extends EditRow">
|
|
6
|
-
/**
|
|
7
|
-
* SvGridEditPanel - schema-driven create / edit form. The commercial
|
|
8
|
-
* counterpart to the grid: it renders fields from an `EntitySchema` (via
|
|
9
|
-
* `schemaToFormFields`), validates them (built-in + Standard Schema), and
|
|
10
|
-
* hands a ready payload to `onSubmit` so you can call a `ServerController`'s
|
|
11
|
-
* `createRow` / `updateRow`.
|
|
12
|
-
*
|
|
13
|
-
* Presents as a right-hand **drawer** (default), a centered **modal**, or
|
|
14
|
-
* **inline**. Themed with the grid's `--sg-*` tokens, so it follows light /
|
|
15
|
-
* dark automatically.
|
|
16
|
-
*
|
|
17
|
-
* <SvGridEditPanel {schema} row={editing} presentation="drawer"
|
|
18
|
-
* onCancel={() => (editing = undefined)}
|
|
19
|
-
* onSubmit={async ({ mode, id, values }) => {
|
|
20
|
-
* if (mode === 'create') await controller.createRow(values)
|
|
21
|
-
* else await controller.updateRow(id, values)
|
|
22
|
-
* editing = undefined
|
|
23
|
-
* }} />
|
|
24
|
-
*/
|
|
25
|
-
import { fade, fly } from 'svelte/transition'
|
|
26
|
-
import { cubicOut } from 'svelte/easing'
|
|
27
|
-
import { SvGridDropdown, SvNumberInput, SvColorInput, SvPasswordInput, SvSlider, SvSwitchButton, SvPhoneInput, SvCountryInput, SvMaskedInput, SvDateTimePicker, SvTagsInput, type CellEditorOption } from '@svgrid/grid'
|
|
28
|
-
import { schemaToFormFields, type EntitySchema, type FormFieldDescriptor } from './schema'
|
|
29
|
-
import SvFileInput from './SvFileInput.svelte'
|
|
30
|
-
import {
|
|
31
|
-
buildInitialValues,
|
|
32
|
-
controlKind,
|
|
33
|
-
editMode,
|
|
34
|
-
rowId,
|
|
35
|
-
toSubmitValues,
|
|
36
|
-
validateAll,
|
|
37
|
-
toDateString,
|
|
38
|
-
toDateTimeString,
|
|
39
|
-
toNumberValue,
|
|
40
|
-
fromNumberValue,
|
|
41
|
-
toSliderValue,
|
|
42
|
-
toTags,
|
|
43
|
-
type EditMode,
|
|
44
|
-
} from './edit-panel'
|
|
45
|
-
import SvLookupInput from './SvLookupInput.svelte'
|
|
46
|
-
import type { RelationLookup } from './sources/relation-lookup'
|
|
47
|
-
|
|
48
|
-
type SubmitPayload = { mode: EditMode; id: string | null; values: Partial<TData> }
|
|
49
|
-
type Presentation = 'drawer' | 'modal' | 'inline'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
type Props = {
|
|
53
|
-
schema: EntitySchema<TData>
|
|
54
|
-
row?: TData | null
|
|
55
|
-
open?: boolean
|
|
56
|
-
title?: string
|
|
57
|
-
submitLabel?: string
|
|
58
|
-
/** 'drawer' (right slide-over, default), 'modal' (centered popup), or 'inline'. */
|
|
59
|
-
presentation?: Presentation
|
|
60
|
-
/**
|
|
61
|
-
* Lookup providers keyed by field name. A `relation` field with a lookup
|
|
62
|
-
* renders a searchable picker (search the related entity, store the key).
|
|
63
|
-
* Build them with `createRelationLookup`.
|
|
64
|
-
*/
|
|
65
|
-
lookups?: Record<string, RelationLookup>
|
|
66
|
-
/** File/image upload handlers by field name (return the URL to store). */
|
|
67
|
-
uploads?: Record<string, (file: File) => Promise<string>>
|
|
68
|
-
/**
|
|
69
|
-
* Dependent (cascading) options by field name - compute a field's choices
|
|
70
|
-
* from the current form values, e.g. City from Country. The field's value is
|
|
71
|
-
* cleared when it stops being a valid option.
|
|
72
|
-
*/
|
|
73
|
-
dependentOptions?: Record<string, (values: Partial<TData>) => ReadonlyArray<CellEditorOption>>
|
|
74
|
-
/**
|
|
75
|
-
* Remember the floating modal's window layout (pin / size / maximized) in
|
|
76
|
-
* localStorage under this key, so it reopens where the user left it.
|
|
77
|
-
*/
|
|
78
|
-
persistKey?: string
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (key
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (pin
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
{
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
{
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
{:else if
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
{
|
|
462
|
-
|
|
463
|
-
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
{
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
{
|
|
497
|
-
|
|
498
|
-
{
|
|
499
|
-
|
|
500
|
-
{
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
{/if}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
{
|
|
513
|
-
|
|
514
|
-
<
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
border:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
display: flex;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
width:
|
|
601
|
-
height:
|
|
602
|
-
border:
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
.sv-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
.sv-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
.sv-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
color: var(--ep-
|
|
699
|
-
background: var(--ep-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
.sv-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
.sv-ep-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
.sv-ep-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
.sv-ep-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
font
|
|
753
|
-
color: var(--ep-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
.sv-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
.sv-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
type EditRow = Record<string, unknown>
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts" generics="TData extends EditRow">
|
|
6
|
+
/**
|
|
7
|
+
* SvGridEditPanel - schema-driven create / edit form. The commercial
|
|
8
|
+
* counterpart to the grid: it renders fields from an `EntitySchema` (via
|
|
9
|
+
* `schemaToFormFields`), validates them (built-in + Standard Schema), and
|
|
10
|
+
* hands a ready payload to `onSubmit` so you can call a `ServerController`'s
|
|
11
|
+
* `createRow` / `updateRow`.
|
|
12
|
+
*
|
|
13
|
+
* Presents as a right-hand **drawer** (default), a centered **modal**, or
|
|
14
|
+
* **inline**. Themed with the grid's `--sg-*` tokens, so it follows light /
|
|
15
|
+
* dark automatically.
|
|
16
|
+
*
|
|
17
|
+
* <SvGridEditPanel {schema} row={editing} presentation="drawer"
|
|
18
|
+
* onCancel={() => (editing = undefined)}
|
|
19
|
+
* onSubmit={async ({ mode, id, values }) => {
|
|
20
|
+
* if (mode === 'create') await controller.createRow(values)
|
|
21
|
+
* else await controller.updateRow(id, values)
|
|
22
|
+
* editing = undefined
|
|
23
|
+
* }} />
|
|
24
|
+
*/
|
|
25
|
+
import { fade, fly } from 'svelte/transition'
|
|
26
|
+
import { cubicOut } from 'svelte/easing'
|
|
27
|
+
import { SvGridDropdown, SvNumberInput, SvColorInput, SvPasswordInput, SvSlider, SvSwitchButton, SvPhoneInput, SvCountryInput, SvMaskedInput, SvDateTimePicker, SvTagsInput, type CellEditorOption } from '@svgrid/grid'
|
|
28
|
+
import { schemaToFormFields, type EntitySchema, type FormFieldDescriptor } from './schema'
|
|
29
|
+
import SvFileInput from './SvFileInput.svelte'
|
|
30
|
+
import {
|
|
31
|
+
buildInitialValues,
|
|
32
|
+
controlKind,
|
|
33
|
+
editMode,
|
|
34
|
+
rowId,
|
|
35
|
+
toSubmitValues,
|
|
36
|
+
validateAll,
|
|
37
|
+
toDateString,
|
|
38
|
+
toDateTimeString,
|
|
39
|
+
toNumberValue,
|
|
40
|
+
fromNumberValue,
|
|
41
|
+
toSliderValue,
|
|
42
|
+
toTags,
|
|
43
|
+
type EditMode,
|
|
44
|
+
} from './edit-panel'
|
|
45
|
+
import SvLookupInput from './SvLookupInput.svelte'
|
|
46
|
+
import type { RelationLookup } from './sources/relation-lookup'
|
|
47
|
+
|
|
48
|
+
type SubmitPayload = { mode: EditMode; id: string | null; values: Partial<TData> }
|
|
49
|
+
type Presentation = 'drawer' | 'modal' | 'inline'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type Props = {
|
|
53
|
+
schema: EntitySchema<TData>
|
|
54
|
+
row?: TData | null
|
|
55
|
+
open?: boolean
|
|
56
|
+
title?: string
|
|
57
|
+
submitLabel?: string
|
|
58
|
+
/** 'drawer' (right slide-over, default), 'modal' (centered popup), or 'inline'. */
|
|
59
|
+
presentation?: Presentation
|
|
60
|
+
/**
|
|
61
|
+
* Lookup providers keyed by field name. A `relation` field with a lookup
|
|
62
|
+
* renders a searchable picker (search the related entity, store the key).
|
|
63
|
+
* Build them with `createRelationLookup`.
|
|
64
|
+
*/
|
|
65
|
+
lookups?: Record<string, RelationLookup>
|
|
66
|
+
/** File/image upload handlers by field name (return the URL to store). */
|
|
67
|
+
uploads?: Record<string, (file: File) => Promise<string>>
|
|
68
|
+
/**
|
|
69
|
+
* Dependent (cascading) options by field name - compute a field's choices
|
|
70
|
+
* from the current form values, e.g. City from Country. The field's value is
|
|
71
|
+
* cleared when it stops being a valid option.
|
|
72
|
+
*/
|
|
73
|
+
dependentOptions?: Record<string, (values: Partial<TData>) => ReadonlyArray<CellEditorOption>>
|
|
74
|
+
/**
|
|
75
|
+
* Remember the floating modal's window layout (pin / size / maximized) in
|
|
76
|
+
* localStorage under this key, so it reopens where the user left it.
|
|
77
|
+
*/
|
|
78
|
+
persistKey?: string
|
|
79
|
+
/** Form column count (default 2). A field with `span: 2` spans all columns. */
|
|
80
|
+
columns?: 1 | 2 | 3
|
|
81
|
+
/** Restrict + order the form fields (by field name). Default: all schema fields. */
|
|
82
|
+
formFields?: string[]
|
|
83
|
+
/** Group fields into titled fieldsets. Fields not in any section render in a
|
|
84
|
+
* trailing default group. When set, `formFields` ordering is per-section. */
|
|
85
|
+
sections?: Array<{ title?: string; fields: string[] }>
|
|
86
|
+
/** Dialog width for the modal / drawer presentations. Default 'md'. */
|
|
87
|
+
formSize?: 'sm' | 'md' | 'lg'
|
|
88
|
+
onSubmit: (payload: SubmitPayload) => void | Promise<void>
|
|
89
|
+
onCancel?: () => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let {
|
|
93
|
+
schema,
|
|
94
|
+
row = null,
|
|
95
|
+
open = true,
|
|
96
|
+
title,
|
|
97
|
+
submitLabel,
|
|
98
|
+
presentation = 'drawer',
|
|
99
|
+
lookups,
|
|
100
|
+
uploads,
|
|
101
|
+
dependentOptions,
|
|
102
|
+
persistKey,
|
|
103
|
+
columns = 2,
|
|
104
|
+
formFields,
|
|
105
|
+
sections,
|
|
106
|
+
formSize = 'md',
|
|
107
|
+
onSubmit,
|
|
108
|
+
onCancel,
|
|
109
|
+
}: Props = $props()
|
|
110
|
+
|
|
111
|
+
const allFields = $derived(schemaToFormFields(schema))
|
|
112
|
+
// Apply the include/order list (formFields), else keep the schema order.
|
|
113
|
+
const fields = $derived(
|
|
114
|
+
formFields && formFields.length
|
|
115
|
+
? (formFields.map((name) => allFields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[])
|
|
116
|
+
: allFields,
|
|
117
|
+
)
|
|
118
|
+
// Grouped layout: each section's resolvable fields, plus a trailing group for
|
|
119
|
+
// anything not assigned to a section (so no field is ever silently dropped).
|
|
120
|
+
const fieldGroups = $derived.by((): Array<{ title?: string; fields: FormFieldDescriptor[] }> => {
|
|
121
|
+
if (!sections || !sections.length) return [{ fields }]
|
|
122
|
+
const assigned = new Set<string>()
|
|
123
|
+
const groups = sections.map((s) => {
|
|
124
|
+
const gf = s.fields.map((name) => fields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[]
|
|
125
|
+
for (const f of gf) assigned.add(f.field)
|
|
126
|
+
return { title: s.title, fields: gf }
|
|
127
|
+
})
|
|
128
|
+
const rest = fields.filter((f) => !assigned.has(f.field))
|
|
129
|
+
return rest.length ? [...groups, { fields: rest }] : groups
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Options for the custom dropdown: prepend a blank "clear" option for
|
|
133
|
+
// non-required fields (parity with the native select's empty option).
|
|
134
|
+
const selectOptions = (f: FormFieldDescriptor) =>
|
|
135
|
+
f.required ? (f.options ?? []) : [{ value: '', label: '—' }, ...(f.options ?? [])]
|
|
136
|
+
const mode = $derived(editMode(row))
|
|
137
|
+
const heading = $derived(
|
|
138
|
+
title ?? `${mode === 'create' ? 'New' : 'Edit'} ${schema.label ?? schema.name}`,
|
|
139
|
+
)
|
|
140
|
+
const overlayed = $derived(presentation !== 'inline')
|
|
141
|
+
|
|
142
|
+
let values = $state<Record<string, any>>({})
|
|
143
|
+
let errors = $state<Record<string, string>>({})
|
|
144
|
+
let submitting = $state(false)
|
|
145
|
+
let submitError = $state<string | null>(null)
|
|
146
|
+
|
|
147
|
+
$effect(() => {
|
|
148
|
+
values = buildInitialValues(schema, row)
|
|
149
|
+
errors = {}
|
|
150
|
+
submitError = null
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Cascade: when a parent field changes, clear a dependent field whose value is
|
|
154
|
+
// no longer among its (recomputed) options.
|
|
155
|
+
$effect(() => {
|
|
156
|
+
if (!dependentOptions) return
|
|
157
|
+
for (const [field, optionsOf] of Object.entries(dependentOptions)) {
|
|
158
|
+
const current = values[field]
|
|
159
|
+
if (current == null || current === '') continue
|
|
160
|
+
if (!optionsOf(values as Partial<TData>).some((o) => String(o.value) === String(current))) values[field] = ''
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Live recompute: a computed (derived) field re-evaluates as the form changes.
|
|
165
|
+
// The value is read-only in the form and stripped from the submit payload.
|
|
166
|
+
$effect(() => {
|
|
167
|
+
for (const f of fields) {
|
|
168
|
+
if (!f.computed) continue
|
|
169
|
+
const next = f.computed(values as Record<string, unknown>)
|
|
170
|
+
if (values[f.field] !== next) values[f.field] = next
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
submitError = null
|
|
177
|
+
const found = await validateAll(schema, values)
|
|
178
|
+
errors = found
|
|
179
|
+
if (Object.keys(found).length > 0) return
|
|
180
|
+
submitting = true
|
|
181
|
+
try {
|
|
182
|
+
await onSubmit({
|
|
183
|
+
mode,
|
|
184
|
+
id: mode === 'edit' && row ? rowId(schema, row) : null,
|
|
185
|
+
values: toSubmitValues(schema, values),
|
|
186
|
+
})
|
|
187
|
+
} catch (err) {
|
|
188
|
+
submitError = err instanceof Error ? err.message : String(err)
|
|
189
|
+
} finally {
|
|
190
|
+
submitting = false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function close() {
|
|
195
|
+
onCancel?.()
|
|
196
|
+
}
|
|
197
|
+
function onKeydown(e: KeyboardEvent) {
|
|
198
|
+
if (e.key === 'Escape' && onCancel) close()
|
|
199
|
+
}
|
|
200
|
+
function stop(e: Event) {
|
|
201
|
+
e.stopPropagation()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Pop entrance for the modal: fade + lift + scale, so it doesn't fight the
|
|
205
|
+
// flex-centered wrapper (a transform-based centering would clash with this).
|
|
206
|
+
function pop(_node: Element, { duration = 240 } = {}) {
|
|
207
|
+
return {
|
|
208
|
+
duration,
|
|
209
|
+
easing: cubicOut,
|
|
210
|
+
css: (t: number) => `opacity:${t};transform:translateY(${(1 - t) * 14}px) scale(${0.97 + 0.03 * t})`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Floating window: drag, resize, edge-pin, maximize (modal presentation) -
|
|
215
|
+
const floating = $derived(presentation === 'modal')
|
|
216
|
+
type Pin = 'none' | 'left' | 'right' | 'top' | 'bottom'
|
|
217
|
+
let pin = $state<Pin>('none')
|
|
218
|
+
let pos = $state<{ x: number; y: number } | null>(null) // null = centered by the wrap's flex
|
|
219
|
+
let size = $state<{ w: number; h: number } | null>(null)
|
|
220
|
+
let maximized = $state(false)
|
|
221
|
+
let panelEl = $state<HTMLElement>()
|
|
222
|
+
|
|
223
|
+
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
|
224
|
+
|
|
225
|
+
// Restore the saved window layout (pin / size / maximized), so it reopens
|
|
226
|
+
// where the user left it. A function so the prop read stays inside a closure.
|
|
227
|
+
const lsKey = () => (persistKey ? `svgrid.editpanel.${persistKey}` : null)
|
|
228
|
+
{
|
|
229
|
+
const key = lsKey()
|
|
230
|
+
if (key && typeof localStorage !== 'undefined') {
|
|
231
|
+
try {
|
|
232
|
+
const saved = JSON.parse(localStorage.getItem(key) ?? 'null')
|
|
233
|
+
if (saved) {
|
|
234
|
+
pin = saved.pin ?? 'none'
|
|
235
|
+
size = saved.size ?? null
|
|
236
|
+
maximized = !!saved.maximized
|
|
237
|
+
}
|
|
238
|
+
} catch { /* ignore bad json */ }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
$effect(() => {
|
|
242
|
+
const key = lsKey()
|
|
243
|
+
if (!key || typeof localStorage === 'undefined') return
|
|
244
|
+
const layout = { pin, size, maximized } // read so the effect tracks them
|
|
245
|
+
try { localStorage.setItem(key, JSON.stringify(layout)) } catch { /* quota / private mode */ }
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const panelStyle = $derived.by(() => {
|
|
249
|
+
if (!floating) return ''
|
|
250
|
+
if (maximized) return 'position:fixed;inset:0;width:100vw;height:100vh;max-width:none;max-height:none;border-radius:0;'
|
|
251
|
+
const w = size?.w
|
|
252
|
+
const h = size?.h
|
|
253
|
+
const edge = 'position:fixed;max-width:none;max-height:none;border-radius:0;'
|
|
254
|
+
if (pin === 'left') return `${edge}left:0;top:0;height:100vh;width:${w ?? 440}px;`
|
|
255
|
+
if (pin === 'right') return `${edge}right:0;top:0;height:100vh;width:${w ?? 440}px;`
|
|
256
|
+
if (pin === 'top') return `${edge}top:0;left:0;width:100vw;height:${h ?? 380}px;`
|
|
257
|
+
if (pin === 'bottom') return `${edge}bottom:0;left:0;width:100vw;height:${h ?? 380}px;`
|
|
258
|
+
if (pos) return `position:fixed;left:${pos.x}px;top:${pos.y}px;width:${w ?? 460}px;${h ? `height:${h}px;` : ''}max-height:none;`
|
|
259
|
+
// still centered by the wrap; only apply an explicit size if the user resized
|
|
260
|
+
return `${w ? `width:${w}px;` : ''}${h ? `height:${h}px;max-height:none;` : ''}`
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
function setPin(p: Pin) {
|
|
264
|
+
maximized = false
|
|
265
|
+
pin = pin === p ? 'none' : p
|
|
266
|
+
if (pin !== 'none') pos = null
|
|
267
|
+
}
|
|
268
|
+
function toggleMax() {
|
|
269
|
+
maximized = !maximized
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function startDrag(e: PointerEvent) {
|
|
273
|
+
if (!floating || pin !== 'none' || maximized) return
|
|
274
|
+
if ((e.target as HTMLElement).closest('button, input, select, textarea')) return
|
|
275
|
+
const r = panelEl?.getBoundingClientRect()
|
|
276
|
+
if (!r) return
|
|
277
|
+
if (!size) size = { w: r.width, h: r.height }
|
|
278
|
+
pos = { x: r.left, y: r.top }
|
|
279
|
+
const ox = r.left, oy = r.top, sx = e.clientX, sy = e.clientY, w = r.width
|
|
280
|
+
const move = (ev: PointerEvent) => {
|
|
281
|
+
pos = {
|
|
282
|
+
x: clamp(ox + ev.clientX - sx, 0, Math.max(0, window.innerWidth - w)),
|
|
283
|
+
y: clamp(oy + ev.clientY - sy, 0, Math.max(0, window.innerHeight - 40)),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const up = () => {
|
|
287
|
+
window.removeEventListener('pointermove', move)
|
|
288
|
+
window.removeEventListener('pointerup', up)
|
|
289
|
+
}
|
|
290
|
+
window.addEventListener('pointermove', move)
|
|
291
|
+
window.addEventListener('pointerup', up)
|
|
292
|
+
e.preventDefault()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function startResize(e: PointerEvent, dir: string) {
|
|
296
|
+
const r = panelEl?.getBoundingClientRect()
|
|
297
|
+
if (!r) return
|
|
298
|
+
if (pin === 'none' && !pos) pos = { x: r.left, y: r.top }
|
|
299
|
+
const sx = e.clientX, sy = e.clientY, sw = r.width, sh = r.height
|
|
300
|
+
const move = (ev: PointerEvent) => {
|
|
301
|
+
let w = sw, h = sh
|
|
302
|
+
const dx = ev.clientX - sx, dy = ev.clientY - sy
|
|
303
|
+
if (dir.includes('e')) w = Math.max(300, sw + dx)
|
|
304
|
+
if (dir.includes('w')) w = Math.max(300, sw - dx)
|
|
305
|
+
if (dir.includes('s')) h = Math.max(220, sh + dy)
|
|
306
|
+
if (dir.includes('n')) h = Math.max(220, sh - dy)
|
|
307
|
+
size = { w, h }
|
|
308
|
+
}
|
|
309
|
+
const up = () => {
|
|
310
|
+
window.removeEventListener('pointermove', move)
|
|
311
|
+
window.removeEventListener('pointerup', up)
|
|
312
|
+
}
|
|
313
|
+
window.addEventListener('pointermove', move)
|
|
314
|
+
window.addEventListener('pointerup', up)
|
|
315
|
+
e.preventDefault()
|
|
316
|
+
e.stopPropagation()
|
|
317
|
+
}
|
|
318
|
+
</script>
|
|
319
|
+
|
|
320
|
+
<svelte:window onkeydown={onKeydown} />
|
|
321
|
+
|
|
322
|
+
{#snippet pinIcon(side: 'left' | 'right' | 'top' | 'bottom')}
|
|
323
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
324
|
+
<rect x="1.5" y="1.5" width="11" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.2" />
|
|
325
|
+
{#if side === 'left'}<rect x="1.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
|
|
326
|
+
{#if side === 'right'}<rect x="8.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
|
|
327
|
+
{#if side === 'top'}<rect x="1.5" y="1.5" width="11" height="4" fill="currentColor" />{/if}
|
|
328
|
+
{#if side === 'bottom'}<rect x="1.5" y="8.5" width="11" height="4" fill="currentColor" />{/if}
|
|
329
|
+
</svg>
|
|
330
|
+
{/snippet}
|
|
331
|
+
|
|
332
|
+
{#snippet maxIcon(isMax: boolean)}
|
|
333
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3">
|
|
334
|
+
{#if isMax}
|
|
335
|
+
<rect x="4" y="1.5" width="8.5" height="8.5" rx="1.3" />
|
|
336
|
+
<rect x="1.5" y="4" width="8.5" height="8.5" rx="1.3" fill="var(--ep-bg)" />
|
|
337
|
+
{:else}
|
|
338
|
+
<rect x="1.5" y="1.5" width="11" height="11" rx="1.5" />
|
|
339
|
+
{/if}
|
|
340
|
+
</svg>
|
|
341
|
+
{/snippet}
|
|
342
|
+
|
|
343
|
+
{#snippet panelInner()}
|
|
344
|
+
<form class="sv-ep__form" onsubmit={handleSubmit}>
|
|
345
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
346
|
+
<header
|
|
347
|
+
class="sv-ep__header"
|
|
348
|
+
class:sv-ep__header--drag={floating && pin === 'none' && !maximized}
|
|
349
|
+
onpointerdown={floating ? startDrag : undefined}
|
|
350
|
+
>
|
|
351
|
+
<h2>{heading}</h2>
|
|
352
|
+
<div class="sv-ep__tools">
|
|
353
|
+
{#if floating}
|
|
354
|
+
{#each ['left', 'top', 'bottom', 'right'] as const as side (side)}
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
class="sv-ep__pin"
|
|
358
|
+
class:is-active={pin === side}
|
|
359
|
+
title={pin === side ? 'Unpin' : `Pin ${side}`}
|
|
360
|
+
aria-label={pin === side ? 'Unpin' : `Pin ${side}`}
|
|
361
|
+
onclick={() => setPin(side)}
|
|
362
|
+
>{@render pinIcon(side)}</button>
|
|
363
|
+
{/each}
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
class="sv-ep__pin"
|
|
367
|
+
class:is-active={maximized}
|
|
368
|
+
title={maximized ? 'Restore' : 'Maximize'}
|
|
369
|
+
aria-label={maximized ? 'Restore' : 'Maximize'}
|
|
370
|
+
onclick={toggleMax}
|
|
371
|
+
>{@render maxIcon(maximized)}</button>
|
|
372
|
+
{/if}
|
|
373
|
+
{#if onCancel}
|
|
374
|
+
<button type="button" class="sv-ep__close" aria-label="Close" onclick={close}>×</button>
|
|
375
|
+
{/if}
|
|
376
|
+
</div>
|
|
377
|
+
</header>
|
|
378
|
+
|
|
379
|
+
{#if fieldGroups.length > 1 || fieldGroups[0]?.title}
|
|
380
|
+
{#each fieldGroups as g, gi (gi)}
|
|
381
|
+
<div class="sv-ep__section">
|
|
382
|
+
{#if g.title}<h4 class="sv-ep__section-title">{g.title}</h4>{/if}
|
|
383
|
+
<div class="sv-ep__body" style="--sv-ep-cols: {columns}">
|
|
384
|
+
{#each g.fields as f (f.field)}{@render fieldRow(f)}{/each}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
{/each}
|
|
388
|
+
{:else}
|
|
389
|
+
<div class="sv-ep__body" style="--sv-ep-cols: {columns}">
|
|
390
|
+
{#each fields as f (f.field)}{@render fieldRow(f)}{/each}
|
|
391
|
+
</div>
|
|
392
|
+
{/if}
|
|
393
|
+
|
|
394
|
+
{#if submitError}<p class="sv-ep__submit-err" role="alert">{submitError}</p>{/if}
|
|
395
|
+
|
|
396
|
+
<footer class="sv-ep__footer">
|
|
397
|
+
{#if onCancel}
|
|
398
|
+
<button type="button" class="sv-ep__btn" onclick={close} disabled={submitting}>Cancel</button>
|
|
399
|
+
{/if}
|
|
400
|
+
<button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
|
|
401
|
+
{submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
|
|
402
|
+
</button>
|
|
403
|
+
</footer>
|
|
404
|
+
</form>
|
|
405
|
+
{/snippet}
|
|
406
|
+
|
|
407
|
+
{#snippet fieldRow(f: FormFieldDescriptor)}
|
|
408
|
+
{@const kind = controlKind(f)}
|
|
409
|
+
{@const lk = f.relation ? lookups?.[f.field] : undefined}
|
|
410
|
+
{@const depOptions = dependentOptions?.[f.field]}
|
|
411
|
+
<div class="sv-ep-field" class:sv-ep-field--wide={f.span === 2} class:sv-ep-field--error={!!errors[f.field]}>
|
|
412
|
+
<label for={`sv-ef-${f.field}`}>
|
|
413
|
+
{f.label}{#if f.required && !f.readonly}<span class="sv-ep-field__req" aria-hidden="true"> *</span>{/if}
|
|
414
|
+
</label>
|
|
415
|
+
|
|
416
|
+
{#if f.upload}
|
|
417
|
+
<SvFileInput
|
|
418
|
+
id={`sv-ef-${f.field}`}
|
|
419
|
+
value={values[f.field]}
|
|
420
|
+
accept={f.upload.accept}
|
|
421
|
+
image={f.upload.image}
|
|
422
|
+
disabled={f.readonly}
|
|
423
|
+
upload={uploads?.[f.field]}
|
|
424
|
+
onChange={(u) => (values[f.field] = u)}
|
|
425
|
+
/>
|
|
426
|
+
{:else if depOptions}
|
|
427
|
+
<div class="sv-ep-field__dropdown">
|
|
428
|
+
<SvGridDropdown
|
|
429
|
+
options={depOptions(values as Partial<TData>)}
|
|
430
|
+
value={values[f.field]}
|
|
431
|
+
autoOpen={false}
|
|
432
|
+
placeholder={f.required ? 'Select…' : '—'}
|
|
433
|
+
onChange={(v) => (values[f.field] = v)}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
{:else if lk}
|
|
437
|
+
<SvLookupInput
|
|
438
|
+
id={`sv-ef-${f.field}`}
|
|
439
|
+
value={values[f.field]}
|
|
440
|
+
lookup={lk}
|
|
441
|
+
onSelect={(v) => (values[f.field] = v)}
|
|
442
|
+
required={f.required}
|
|
443
|
+
disabled={f.readonly}
|
|
444
|
+
placeholder={f.placeholder ?? 'Search…'}
|
|
445
|
+
/>
|
|
446
|
+
{:else if f.editorType === 'checkbox'}
|
|
447
|
+
<!-- Boolean fields render as the suite's switch (nicer than a raw checkbox). -->
|
|
448
|
+
<SvSwitchButton id={`sv-ef-${f.field}`} ariaLabel={f.label} checked={!!values[f.field]} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
|
|
449
|
+
{:else if f.editorType === 'number'}
|
|
450
|
+
<SvNumberInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toNumberValue(values[f.field])} min={f.min} max={f.max} step={f.step} precision={f.precision} prefix={f.prefix} suffix={f.suffix} disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
|
|
451
|
+
{:else if f.editorType === 'color'}
|
|
452
|
+
<SvColorInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!errors[f.field]} onChange={(v) => (values[f.field] = v)} />
|
|
453
|
+
{:else if f.editorType === 'password'}
|
|
454
|
+
<SvPasswordInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
455
|
+
{:else if f.editorType === 'rating' || f.editorType === 'slider'}
|
|
456
|
+
{@const rmin = f.min ?? 0}
|
|
457
|
+
{@const rmax = f.max ?? (f.editorType === 'rating' ? 5 : 100)}
|
|
458
|
+
<SvSlider id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toSliderValue(values[f.field], rmin)} min={rmin} max={rmax} step={1} ticks={f.editorType === 'rating' ? rmax - rmin + 1 : undefined} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
|
|
459
|
+
{:else if f.editorType === 'phone'}
|
|
460
|
+
<SvPhoneInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
461
|
+
{:else if f.editorType === 'country'}
|
|
462
|
+
<SvCountryInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!errors[f.field]} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
463
|
+
{:else if f.editorType === 'mask'}
|
|
464
|
+
<SvMaskedInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
|
|
465
|
+
{:else if f.editorType === 'date'}
|
|
466
|
+
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
|
|
467
|
+
{:else if f.editorType === 'datetime'}
|
|
468
|
+
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!errors[f.field]} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
|
|
469
|
+
{:else if f.editorType === 'chips'}
|
|
470
|
+
<!-- Multi-value entry: stores a string[]. (Previously a chips field fell back to a single-select.) -->
|
|
471
|
+
<SvTagsInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!errors[f.field]} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
|
|
472
|
+
{:else if kind === 'select'}
|
|
473
|
+
{#if f.readonly}
|
|
474
|
+
<input id={`sv-ef-${f.field}`} type="text" value={values[f.field] ?? ''} disabled />
|
|
475
|
+
{:else}
|
|
476
|
+
<!-- Custom dropdown: its panel portals to <body> (position:fixed),
|
|
477
|
+
so it floats above the modal and never grows the form body
|
|
478
|
+
(no scrollbar), unlike an in-flow popup. -->
|
|
479
|
+
<div class="sv-ep-field__dropdown">
|
|
480
|
+
<SvGridDropdown
|
|
481
|
+
options={selectOptions(f)}
|
|
482
|
+
value={values[f.field]}
|
|
483
|
+
autoOpen={false}
|
|
484
|
+
placeholder={f.required ? 'Select…' : '—'}
|
|
485
|
+
onChange={(v) => (values[f.field] = v)}
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
{/if}
|
|
489
|
+
{:else if kind === 'textarea'}
|
|
490
|
+
<textarea id={`sv-ef-${f.field}`} bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder}></textarea>
|
|
491
|
+
{:else if kind === 'number'}
|
|
492
|
+
<input id={`sv-ef-${f.field}`} type="number" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
493
|
+
{:else if kind === 'date'}
|
|
494
|
+
<input id={`sv-ef-${f.field}`} type="date" bind:value={values[f.field]} disabled={f.readonly} />
|
|
495
|
+
{:else if kind === 'datetime'}
|
|
496
|
+
<input id={`sv-ef-${f.field}`} type="datetime-local" bind:value={values[f.field]} disabled={f.readonly} />
|
|
497
|
+
{:else if kind === 'time'}
|
|
498
|
+
<input id={`sv-ef-${f.field}`} type="time" bind:value={values[f.field]} disabled={f.readonly} />
|
|
499
|
+
{:else}
|
|
500
|
+
<input id={`sv-ef-${f.field}`} type="text" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
501
|
+
{/if}
|
|
502
|
+
|
|
503
|
+
{#if f.help && !errors[f.field]}<p class="sv-ep-field__help">{f.help}</p>{/if}
|
|
504
|
+
{#if errors[f.field]}<p class="sv-ep-field__err">{errors[f.field]}</p>{/if}
|
|
505
|
+
</div>
|
|
506
|
+
{/snippet}
|
|
507
|
+
|
|
508
|
+
{#if open}
|
|
509
|
+
{#if overlayed}
|
|
510
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
511
|
+
<div class="sv-ep-wrap sv-ep-wrap--{presentation}" transition:fade={{ duration: 160 }} onclick={close} role="presentation">
|
|
512
|
+
{#if presentation === 'drawer'}
|
|
513
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
514
|
+
<div class="sv-ep sv-ep--drawer sv-ep--sz-{formSize}" role="dialog" aria-modal="true" aria-label={heading} tabindex="-1" onclick={stop} transition:fly={{ x: 460, duration: 300, easing: cubicOut }}>
|
|
515
|
+
{@render panelInner()}
|
|
516
|
+
</div>
|
|
517
|
+
{:else}
|
|
518
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
519
|
+
<div
|
|
520
|
+
class="sv-ep sv-ep--modal sv-ep--sz-{formSize}"
|
|
521
|
+
class:sv-ep--floating={floating}
|
|
522
|
+
data-pin={pin}
|
|
523
|
+
bind:this={panelEl}
|
|
524
|
+
style={panelStyle}
|
|
525
|
+
role="dialog"
|
|
526
|
+
aria-modal="true"
|
|
527
|
+
aria-label={heading}
|
|
528
|
+
tabindex="-1"
|
|
529
|
+
onclick={stop}
|
|
530
|
+
transition:pop
|
|
531
|
+
>
|
|
532
|
+
{@render panelInner()}
|
|
533
|
+
{#if floating && !maximized}
|
|
534
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
535
|
+
{#if pin === 'none'}
|
|
536
|
+
<div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
|
|
537
|
+
<div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
|
|
538
|
+
<div class="sv-ep__rz sv-ep__rz--se" onpointerdown={(e) => startResize(e, 'se')}></div>
|
|
539
|
+
{:else if pin === 'left'}
|
|
540
|
+
<div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
|
|
541
|
+
{:else if pin === 'right'}
|
|
542
|
+
<div class="sv-ep__rz sv-ep__rz--w" onpointerdown={(e) => startResize(e, 'w')}></div>
|
|
543
|
+
{:else if pin === 'top'}
|
|
544
|
+
<div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
|
|
545
|
+
{:else if pin === 'bottom'}
|
|
546
|
+
<div class="sv-ep__rz sv-ep__rz--n" onpointerdown={(e) => startResize(e, 'n')}></div>
|
|
547
|
+
{/if}
|
|
548
|
+
{/if}
|
|
549
|
+
</div>
|
|
550
|
+
{/if}
|
|
551
|
+
</div>
|
|
552
|
+
{:else}
|
|
553
|
+
<div class="sv-ep sv-ep--inline" role="dialog" aria-label={heading}>
|
|
554
|
+
{@render panelInner()}
|
|
555
|
+
</div>
|
|
556
|
+
{/if}
|
|
557
|
+
{/if}
|
|
558
|
+
|
|
559
|
+
<style>
|
|
560
|
+
.sv-ep,
|
|
561
|
+
.sv-ep-wrap {
|
|
562
|
+
--ep-bg: var(--sg-bg, #ffffff);
|
|
563
|
+
--ep-fg: var(--sg-fg, #0f172a);
|
|
564
|
+
--ep-muted: var(--sg-muted, #64748b);
|
|
565
|
+
--ep-border: var(--sg-border, #e2e8f0);
|
|
566
|
+
--ep-subtle: var(--sg-header-bg, #f8fafc);
|
|
567
|
+
--ep-input-bg: var(--sg-input-bg, #ffffff);
|
|
568
|
+
--ep-input-border: var(--sg-input-border, #cbd5e1);
|
|
569
|
+
--ep-accent: var(--sg-accent, #2563eb);
|
|
570
|
+
--ep-on-accent: var(--sg-on-accent, #ffffff);
|
|
571
|
+
--ep-danger: #ef4444;
|
|
572
|
+
--ep-radius: var(--sg-radius, 10px);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
.sv-ep-wrap {
|
|
576
|
+
position: fixed;
|
|
577
|
+
inset: 0;
|
|
578
|
+
z-index: 2147483000;
|
|
579
|
+
display: flex;
|
|
580
|
+
background: rgba(2, 6, 23, 0.5);
|
|
581
|
+
backdrop-filter: blur(2px);
|
|
582
|
+
}
|
|
583
|
+
.sv-ep-wrap--drawer {
|
|
584
|
+
justify-content: flex-end;
|
|
585
|
+
}
|
|
586
|
+
.sv-ep-wrap--modal {
|
|
587
|
+
align-items: center;
|
|
588
|
+
justify-content: center;
|
|
589
|
+
padding: 16px;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.sv-ep {
|
|
593
|
+
box-sizing: border-box;
|
|
594
|
+
background: var(--ep-bg);
|
|
595
|
+
color: var(--ep-fg);
|
|
596
|
+
font: inherit;
|
|
597
|
+
font-family: var(--sg-font, inherit);
|
|
598
|
+
}
|
|
599
|
+
.sv-ep--drawer {
|
|
600
|
+
width: min(440px, 100vw);
|
|
601
|
+
height: 100%;
|
|
602
|
+
border-left: 1px solid var(--ep-border);
|
|
603
|
+
box-shadow: -14px 0 44px rgba(0, 0, 0, 0.28);
|
|
604
|
+
display: flex;
|
|
605
|
+
flex-direction: column;
|
|
606
|
+
}
|
|
607
|
+
/* Dialog size (modal width / drawer width), unless the user has pinned/resized. */
|
|
608
|
+
.sv-ep--sz-sm.sv-ep--modal { width: min(400px, 100%); }
|
|
609
|
+
.sv-ep--sz-lg.sv-ep--modal { width: min(760px, 100%); }
|
|
610
|
+
.sv-ep--sz-sm.sv-ep--drawer { width: min(360px, 100vw); }
|
|
611
|
+
.sv-ep--sz-lg.sv-ep--drawer { width: min(600px, 100vw); }
|
|
612
|
+
.sv-ep--modal {
|
|
613
|
+
width: min(520px, 100%);
|
|
614
|
+
max-height: 88vh;
|
|
615
|
+
border: 1px solid var(--ep-border);
|
|
616
|
+
border-radius: var(--ep-radius);
|
|
617
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
|
618
|
+
display: flex;
|
|
619
|
+
flex-direction: column;
|
|
620
|
+
}
|
|
621
|
+
.sv-ep--inline {
|
|
622
|
+
width: 100%;
|
|
623
|
+
max-width: 460px;
|
|
624
|
+
border: 1px solid var(--ep-border);
|
|
625
|
+
border-radius: var(--ep-radius);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.sv-ep__form {
|
|
629
|
+
display: flex;
|
|
630
|
+
flex-direction: column;
|
|
631
|
+
min-height: 0;
|
|
632
|
+
flex: 1;
|
|
633
|
+
}
|
|
634
|
+
.sv-ep__header {
|
|
635
|
+
display: flex;
|
|
636
|
+
align-items: center;
|
|
637
|
+
justify-content: space-between;
|
|
638
|
+
padding: 16px 18px;
|
|
639
|
+
border-bottom: 1px solid var(--ep-border);
|
|
640
|
+
}
|
|
641
|
+
.sv-ep__header h2 {
|
|
642
|
+
margin: 0;
|
|
643
|
+
font-size: 15px;
|
|
644
|
+
font-weight: 650;
|
|
645
|
+
}
|
|
646
|
+
.sv-ep__close {
|
|
647
|
+
display: inline-flex;
|
|
648
|
+
align-items: center;
|
|
649
|
+
justify-content: center;
|
|
650
|
+
width: 30px;
|
|
651
|
+
height: 30px;
|
|
652
|
+
border: 0;
|
|
653
|
+
background: none;
|
|
654
|
+
color: var(--ep-muted);
|
|
655
|
+
font-size: 22px;
|
|
656
|
+
line-height: 1;
|
|
657
|
+
cursor: pointer;
|
|
658
|
+
border-radius: 6px;
|
|
659
|
+
}
|
|
660
|
+
.sv-ep__close:hover {
|
|
661
|
+
background: var(--ep-subtle);
|
|
662
|
+
color: var(--ep-fg);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/* --- Floating window: drag handle, pin controls, resize handles --------- */
|
|
666
|
+
.sv-ep--floating {
|
|
667
|
+
position: relative; /* anchors the resize handles; inline `position:fixed` overrides once moved/pinned */
|
|
668
|
+
}
|
|
669
|
+
.sv-ep__header--drag {
|
|
670
|
+
cursor: move;
|
|
671
|
+
user-select: none;
|
|
672
|
+
touch-action: none;
|
|
673
|
+
}
|
|
674
|
+
.sv-ep__tools {
|
|
675
|
+
display: flex;
|
|
676
|
+
align-items: center;
|
|
677
|
+
gap: 2px;
|
|
678
|
+
flex: 0 0 auto;
|
|
679
|
+
}
|
|
680
|
+
.sv-ep__pin {
|
|
681
|
+
display: inline-flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
justify-content: center;
|
|
684
|
+
width: 26px;
|
|
685
|
+
height: 26px;
|
|
686
|
+
padding: 0;
|
|
687
|
+
border: 0;
|
|
688
|
+
background: none;
|
|
689
|
+
color: var(--ep-muted);
|
|
690
|
+
cursor: pointer;
|
|
691
|
+
border-radius: 6px;
|
|
692
|
+
}
|
|
693
|
+
.sv-ep__pin:hover {
|
|
694
|
+
background: var(--ep-subtle);
|
|
695
|
+
color: var(--ep-fg);
|
|
696
|
+
}
|
|
697
|
+
.sv-ep__pin.is-active {
|
|
698
|
+
color: var(--ep-accent);
|
|
699
|
+
background: color-mix(in srgb, var(--ep-accent) 14%, transparent);
|
|
700
|
+
}
|
|
701
|
+
.sv-ep__rz {
|
|
702
|
+
position: absolute;
|
|
703
|
+
z-index: 5;
|
|
704
|
+
touch-action: none;
|
|
705
|
+
}
|
|
706
|
+
.sv-ep__rz--e { top: 0; right: 0; width: 8px; height: 100%; cursor: ew-resize; }
|
|
707
|
+
.sv-ep__rz--w { top: 0; left: 0; width: 8px; height: 100%; cursor: ew-resize; }
|
|
708
|
+
.sv-ep__rz--s { left: 0; bottom: 0; height: 8px; width: 100%; cursor: ns-resize; }
|
|
709
|
+
.sv-ep__rz--n { left: 0; top: 0; height: 8px; width: 100%; cursor: ns-resize; }
|
|
710
|
+
.sv-ep__rz--se { right: 0; bottom: 0; width: 15px; height: 15px; cursor: nwse-resize; }
|
|
711
|
+
|
|
712
|
+
.sv-ep__body {
|
|
713
|
+
display: grid;
|
|
714
|
+
grid-template-columns: repeat(var(--sv-ep-cols, 2), minmax(0, 1fr));
|
|
715
|
+
gap: 14px 16px;
|
|
716
|
+
padding: 18px;
|
|
717
|
+
overflow: auto;
|
|
718
|
+
flex: 1; /* fill the form so a resized/pinned panel grows its scroll area */
|
|
719
|
+
min-height: 0;
|
|
720
|
+
align-content: start;
|
|
721
|
+
}
|
|
722
|
+
.sv-ep--drawer .sv-ep__body {
|
|
723
|
+
grid-template-columns: 1fr;
|
|
724
|
+
}
|
|
725
|
+
/* Grouped layout: one scroll region holds the titled fieldsets. */
|
|
726
|
+
.sv-ep__section { display: flex; flex-direction: column; }
|
|
727
|
+
.sv-ep__section + .sv-ep__section { border-top: 1px solid var(--sg-border, #e6e8ec); }
|
|
728
|
+
.sv-ep__section-title { margin: 0; padding: 14px 18px 0; font-size: 12.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
|
|
729
|
+
.sv-ep__section .sv-ep__body { flex: 0 0 auto; overflow: visible; }
|
|
730
|
+
.sv-ep-field {
|
|
731
|
+
display: flex;
|
|
732
|
+
flex-direction: column;
|
|
733
|
+
gap: 6px;
|
|
734
|
+
min-width: 0;
|
|
735
|
+
}
|
|
736
|
+
.sv-ep-field--wide {
|
|
737
|
+
grid-column: 1 / -1;
|
|
738
|
+
}
|
|
739
|
+
.sv-ep-field label {
|
|
740
|
+
font-size: 12px;
|
|
741
|
+
font-weight: 550;
|
|
742
|
+
color: var(--ep-muted);
|
|
743
|
+
}
|
|
744
|
+
.sv-ep-field__req {
|
|
745
|
+
color: var(--ep-danger);
|
|
746
|
+
}
|
|
747
|
+
.sv-ep-field input,
|
|
748
|
+
.sv-ep-field textarea {
|
|
749
|
+
box-sizing: border-box;
|
|
750
|
+
width: 100%;
|
|
751
|
+
padding: 8px 10px;
|
|
752
|
+
font: inherit;
|
|
753
|
+
color: var(--ep-fg);
|
|
754
|
+
background: var(--ep-input-bg);
|
|
755
|
+
border: 1px solid var(--ep-input-border);
|
|
756
|
+
border-radius: 8px;
|
|
757
|
+
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
758
|
+
}
|
|
759
|
+
.sv-ep-field input:focus,
|
|
760
|
+
.sv-ep-field textarea:focus {
|
|
761
|
+
outline: none;
|
|
762
|
+
border-color: var(--ep-accent);
|
|
763
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
|
|
764
|
+
}
|
|
765
|
+
.sv-ep-field textarea {
|
|
766
|
+
min-height: 76px;
|
|
767
|
+
resize: vertical;
|
|
768
|
+
}
|
|
769
|
+
.sv-ep-field--error input,
|
|
770
|
+
.sv-ep-field--error textarea,
|
|
771
|
+
.sv-ep-field--error .sv-ep-field__dropdown {
|
|
772
|
+
border-color: var(--ep-danger);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* Wrapper that gives the custom dropdown an input-like frame; the dropdown
|
|
776
|
+
itself fills it (its trigger is border-less) and its panel portals out. */
|
|
777
|
+
.sv-ep-field__dropdown {
|
|
778
|
+
position: relative;
|
|
779
|
+
height: 38px;
|
|
780
|
+
box-sizing: border-box;
|
|
781
|
+
border: 1px solid var(--ep-input-border);
|
|
782
|
+
border-radius: 8px;
|
|
783
|
+
background: var(--ep-input-bg);
|
|
784
|
+
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
785
|
+
}
|
|
786
|
+
.sv-ep-field__dropdown:focus-within {
|
|
787
|
+
border-color: var(--ep-accent);
|
|
788
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
|
|
789
|
+
}
|
|
790
|
+
.sv-ep-field__dropdown :global(.sv-grid-dropdown),
|
|
791
|
+
.sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger) {
|
|
792
|
+
background: transparent;
|
|
793
|
+
color: var(--ep-fg);
|
|
794
|
+
border-radius: 8px;
|
|
795
|
+
}
|
|
796
|
+
.sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger:focus) {
|
|
797
|
+
outline: none;
|
|
798
|
+
}
|
|
799
|
+
.sv-ep-field__help {
|
|
800
|
+
margin: 0;
|
|
801
|
+
font-size: 11px;
|
|
802
|
+
color: var(--ep-muted);
|
|
803
|
+
}
|
|
804
|
+
.sv-ep-field__err {
|
|
805
|
+
margin: 0;
|
|
806
|
+
font-size: 11px;
|
|
807
|
+
font-weight: 500;
|
|
808
|
+
color: var(--ep-danger);
|
|
809
|
+
}
|
|
810
|
+
.sv-ep__submit-err {
|
|
811
|
+
margin: 0;
|
|
812
|
+
padding: 0 18px 4px;
|
|
813
|
+
font-size: 12px;
|
|
814
|
+
color: var(--ep-danger);
|
|
815
|
+
}
|
|
816
|
+
.sv-ep__footer {
|
|
817
|
+
display: flex;
|
|
818
|
+
justify-content: flex-end;
|
|
819
|
+
gap: 8px;
|
|
820
|
+
padding: 14px 18px;
|
|
821
|
+
border-top: 1px solid var(--ep-border);
|
|
822
|
+
background: var(--ep-subtle);
|
|
823
|
+
}
|
|
824
|
+
.sv-ep__btn {
|
|
825
|
+
padding: 8px 16px;
|
|
826
|
+
font: inherit;
|
|
827
|
+
font-weight: 550;
|
|
828
|
+
color: var(--ep-fg);
|
|
829
|
+
background: var(--ep-bg);
|
|
830
|
+
border: 1px solid var(--ep-border);
|
|
831
|
+
border-radius: 8px;
|
|
832
|
+
cursor: pointer;
|
|
833
|
+
}
|
|
834
|
+
.sv-ep__btn:hover:not(:disabled) {
|
|
835
|
+
background: var(--ep-subtle);
|
|
836
|
+
}
|
|
837
|
+
.sv-ep__btn--primary {
|
|
838
|
+
color: var(--ep-on-accent);
|
|
839
|
+
background: var(--ep-accent);
|
|
840
|
+
border-color: var(--ep-accent);
|
|
841
|
+
}
|
|
842
|
+
.sv-ep__btn--primary:hover:not(:disabled) {
|
|
843
|
+
filter: brightness(1.06);
|
|
844
|
+
}
|
|
845
|
+
.sv-ep__btn:disabled {
|
|
846
|
+
opacity: 0.6;
|
|
847
|
+
cursor: default;
|
|
848
|
+
}
|
|
849
|
+
</style>
|