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