@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,793 @@
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
+ }
553
+ .sv-ep--drawer {
554
+ width: min(440px, 100vw);
555
+ height: 100%;
556
+ border-left: 1px solid var(--ep-border);
557
+ box-shadow: -14px 0 44px rgba(0, 0, 0, 0.28);
558
+ display: flex;
559
+ flex-direction: column;
560
+ }
561
+ .sv-ep--modal {
562
+ width: min(520px, 100%);
563
+ max-height: 88vh;
564
+ border: 1px solid var(--ep-border);
565
+ border-radius: var(--ep-radius);
566
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
567
+ display: flex;
568
+ flex-direction: column;
569
+ }
570
+ .sv-ep--inline {
571
+ width: 100%;
572
+ max-width: 460px;
573
+ border: 1px solid var(--ep-border);
574
+ border-radius: var(--ep-radius);
575
+ }
576
+
577
+ .sv-ep__form {
578
+ display: flex;
579
+ flex-direction: column;
580
+ min-height: 0;
581
+ flex: 1;
582
+ }
583
+ .sv-ep__header {
584
+ display: flex;
585
+ align-items: center;
586
+ justify-content: space-between;
587
+ padding: 16px 18px;
588
+ border-bottom: 1px solid var(--ep-border);
589
+ }
590
+ .sv-ep__header h2 {
591
+ margin: 0;
592
+ font-size: 15px;
593
+ font-weight: 650;
594
+ }
595
+ .sv-ep__close {
596
+ display: inline-flex;
597
+ align-items: center;
598
+ justify-content: center;
599
+ width: 30px;
600
+ height: 30px;
601
+ border: 0;
602
+ background: none;
603
+ color: var(--ep-muted);
604
+ font-size: 22px;
605
+ line-height: 1;
606
+ cursor: pointer;
607
+ border-radius: 6px;
608
+ }
609
+ .sv-ep__close:hover {
610
+ background: var(--ep-subtle);
611
+ color: var(--ep-fg);
612
+ }
613
+
614
+ /* --- Floating window: drag handle, pin controls, resize handles --------- */
615
+ .sv-ep--floating {
616
+ position: relative; /* anchors the resize handles; inline `position:fixed` overrides once moved/pinned */
617
+ }
618
+ .sv-ep__header--drag {
619
+ cursor: move;
620
+ user-select: none;
621
+ touch-action: none;
622
+ }
623
+ .sv-ep__tools {
624
+ display: flex;
625
+ align-items: center;
626
+ gap: 2px;
627
+ flex: 0 0 auto;
628
+ }
629
+ .sv-ep__pin {
630
+ display: inline-flex;
631
+ align-items: center;
632
+ justify-content: center;
633
+ width: 26px;
634
+ height: 26px;
635
+ padding: 0;
636
+ border: 0;
637
+ background: none;
638
+ color: var(--ep-muted);
639
+ cursor: pointer;
640
+ border-radius: 6px;
641
+ }
642
+ .sv-ep__pin:hover {
643
+ background: var(--ep-subtle);
644
+ color: var(--ep-fg);
645
+ }
646
+ .sv-ep__pin.is-active {
647
+ color: var(--ep-accent);
648
+ background: color-mix(in srgb, var(--ep-accent) 14%, transparent);
649
+ }
650
+ .sv-ep__rz {
651
+ position: absolute;
652
+ z-index: 5;
653
+ touch-action: none;
654
+ }
655
+ .sv-ep__rz--e { top: 0; right: 0; width: 8px; height: 100%; cursor: ew-resize; }
656
+ .sv-ep__rz--w { top: 0; left: 0; width: 8px; height: 100%; cursor: ew-resize; }
657
+ .sv-ep__rz--s { left: 0; bottom: 0; height: 8px; width: 100%; cursor: ns-resize; }
658
+ .sv-ep__rz--n { left: 0; top: 0; height: 8px; width: 100%; cursor: ns-resize; }
659
+ .sv-ep__rz--se { right: 0; bottom: 0; width: 15px; height: 15px; cursor: nwse-resize; }
660
+
661
+ .sv-ep__body {
662
+ display: grid;
663
+ grid-template-columns: 1fr 1fr;
664
+ gap: 14px 16px;
665
+ padding: 18px;
666
+ overflow: auto;
667
+ flex: 1; /* fill the form so a resized/pinned panel grows its scroll area */
668
+ min-height: 0;
669
+ align-content: start;
670
+ }
671
+ .sv-ep--drawer .sv-ep__body {
672
+ grid-template-columns: 1fr;
673
+ }
674
+ .sv-ep-field {
675
+ display: flex;
676
+ flex-direction: column;
677
+ gap: 6px;
678
+ min-width: 0;
679
+ }
680
+ .sv-ep-field--wide {
681
+ grid-column: 1 / -1;
682
+ }
683
+ .sv-ep-field label {
684
+ font-size: 12px;
685
+ font-weight: 550;
686
+ color: var(--ep-muted);
687
+ }
688
+ .sv-ep-field__req {
689
+ color: var(--ep-danger);
690
+ }
691
+ .sv-ep-field input,
692
+ .sv-ep-field textarea {
693
+ box-sizing: border-box;
694
+ width: 100%;
695
+ padding: 8px 10px;
696
+ font: inherit;
697
+ color: var(--ep-fg);
698
+ background: var(--ep-input-bg);
699
+ border: 1px solid var(--ep-input-border);
700
+ border-radius: 8px;
701
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
702
+ }
703
+ .sv-ep-field input:focus,
704
+ .sv-ep-field textarea:focus {
705
+ outline: none;
706
+ border-color: var(--ep-accent);
707
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
708
+ }
709
+ .sv-ep-field textarea {
710
+ min-height: 76px;
711
+ resize: vertical;
712
+ }
713
+ .sv-ep-field--error input,
714
+ .sv-ep-field--error textarea,
715
+ .sv-ep-field--error .sv-ep-field__dropdown {
716
+ border-color: var(--ep-danger);
717
+ }
718
+
719
+ /* Wrapper that gives the custom dropdown an input-like frame; the dropdown
720
+ itself fills it (its trigger is border-less) and its panel portals out. */
721
+ .sv-ep-field__dropdown {
722
+ position: relative;
723
+ height: 38px;
724
+ box-sizing: border-box;
725
+ border: 1px solid var(--ep-input-border);
726
+ border-radius: 8px;
727
+ background: var(--ep-input-bg);
728
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
729
+ }
730
+ .sv-ep-field__dropdown:focus-within {
731
+ border-color: var(--ep-accent);
732
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
733
+ }
734
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown),
735
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger) {
736
+ background: transparent;
737
+ color: var(--ep-fg);
738
+ border-radius: 8px;
739
+ }
740
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger:focus) {
741
+ outline: none;
742
+ }
743
+ .sv-ep-field__help {
744
+ margin: 0;
745
+ font-size: 11px;
746
+ color: var(--ep-muted);
747
+ }
748
+ .sv-ep-field__err {
749
+ margin: 0;
750
+ font-size: 11px;
751
+ font-weight: 500;
752
+ color: var(--ep-danger);
753
+ }
754
+ .sv-ep__submit-err {
755
+ margin: 0;
756
+ padding: 0 18px 4px;
757
+ font-size: 12px;
758
+ color: var(--ep-danger);
759
+ }
760
+ .sv-ep__footer {
761
+ display: flex;
762
+ justify-content: flex-end;
763
+ gap: 8px;
764
+ padding: 14px 18px;
765
+ border-top: 1px solid var(--ep-border);
766
+ background: var(--ep-subtle);
767
+ }
768
+ .sv-ep__btn {
769
+ padding: 8px 16px;
770
+ font: inherit;
771
+ font-weight: 550;
772
+ color: var(--ep-fg);
773
+ background: var(--ep-bg);
774
+ border: 1px solid var(--ep-border);
775
+ border-radius: 8px;
776
+ cursor: pointer;
777
+ }
778
+ .sv-ep__btn:hover:not(:disabled) {
779
+ background: var(--ep-subtle);
780
+ }
781
+ .sv-ep__btn--primary {
782
+ color: var(--ep-on-accent);
783
+ background: var(--ep-accent);
784
+ border-color: var(--ep-accent);
785
+ }
786
+ .sv-ep__btn--primary:hover:not(:disabled) {
787
+ filter: brightness(1.06);
788
+ }
789
+ .sv-ep__btn:disabled {
790
+ opacity: 0.6;
791
+ cursor: default;
792
+ }
793
+ </style>