@svgrid/enterprise 2.6.1 → 2.6.3

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/ExpressionEditorHarness.svelte +47 -0
  3. package/dist/ExpressionEditorHarness.svelte.d.ts +15 -0
  4. package/dist/SvAdvancedFilter.svelte +208 -0
  5. package/dist/SvAdvancedFilter.svelte.d.ts +37 -0
  6. package/dist/SvExpressionEditor.svelte +251 -123
  7. package/dist/SvGridEditPanel.svelte +1157 -1157
  8. package/dist/advanced-filter/expr-columns-from-grid.d.ts +30 -0
  9. package/dist/advanced-filter/expr-columns-from-grid.js +44 -0
  10. package/dist/advanced-filter-enable.d.ts +5 -0
  11. package/dist/advanced-filter-enable.js +35 -0
  12. package/dist/cdn/svgrid-enterprise.svelte-external.js +8274 -7706
  13. package/dist/designer/assets/GridMenus-DuogVgW0.js +7 -0
  14. package/dist/designer/assets/SvGrid.helpers-C6wxF55r.js +1 -0
  15. package/dist/designer/assets/SvGridCellEditor-CMtmQINJ.js +2 -0
  16. package/dist/designer/assets/{SvListBox-BVzVfCG3.js → SvListBox-CWCIPM4P.js} +1 -1
  17. package/dist/designer/assets/editor-registry-BB1Qb5mp.js +1 -0
  18. package/dist/designer/assets/{index-CKdwDseq.js → index-aTVomkp-.js} +43 -43
  19. package/dist/designer/assets/js-scroller.svelte-C7LaeUEW.js +110 -0
  20. package/dist/designer/assets/row-drag-touch-IuJPEt0z.js +1 -0
  21. package/dist/designer/assets/src-B5lLfIhK.css +1 -0
  22. package/dist/designer/assets/src-DLoHsbp5.js +2893 -0
  23. package/dist/designer/index.html +7 -5
  24. package/dist/expressions/builder-tree.d.ts +62 -0
  25. package/dist/expressions/builder-tree.js +133 -0
  26. package/dist/expressions/compile.d.ts +14 -0
  27. package/dist/expressions/compile.js +286 -0
  28. package/dist/expressions/expression-columns.d.ts +1 -11
  29. package/dist/expressions/expression-columns.js +40 -11
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +4 -0
  32. package/dist/install.js +2 -0
  33. package/dist/node/studio.js +61 -13
  34. package/dist/sources/rest.js +25 -0
  35. package/dist/sources/supabase.js +49 -3
  36. package/dist/studio/emit-project.js +2125 -2125
  37. package/dist/studio/ui-components.generated.js +33 -0
  38. package/dist/sveltekit/in-memory.js +96 -3
  39. package/dist/sveltekit/query-plan.d.ts +32 -1
  40. package/dist/sveltekit/query-plan.js +88 -1
  41. package/dist/sveltekit/sql.d.ts +20 -0
  42. package/dist/sveltekit/sql.js +26 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +4 -4
  46. package/dist/designer/assets/GridMenus-CW-rnHS9.js +0 -7
  47. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +0 -110
  48. package/dist/designer/assets/src-DCKhP5Xy.css +0 -1
  49. package/dist/designer/assets/src-DLsSdh3J.js +0 -2893
@@ -1,1157 +1,1157 @@
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, type FormSection } 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
- fieldState,
44
- sectionVisible,
45
- validateOne,
46
- type EditMode,
47
- type FieldState,
48
- } from './edit-panel'
49
- import SvLookupInput from './SvLookupInput.svelte'
50
- import type { RelationLookup } from './sources/relation-lookup'
51
-
52
- type SubmitPayload = { mode: EditMode; id: string | null; values: Partial<TData> }
53
- type Presentation = 'drawer' | 'modal' | 'inline'
54
-
55
-
56
- type Props = {
57
- schema: EntitySchema<TData>
58
- row?: TData | null
59
- open?: boolean
60
- title?: string
61
- submitLabel?: string
62
- /** 'drawer' (right slide-over, default), 'modal' (centered popup), or 'inline'. */
63
- presentation?: Presentation
64
- /**
65
- * Lookup providers keyed by field name. A `relation` field with a lookup
66
- * renders a searchable picker (search the related entity, store the key).
67
- * Build them with `createRelationLookup`.
68
- */
69
- lookups?: Record<string, RelationLookup>
70
- /** File/image upload handlers by field name (return the URL to store). */
71
- uploads?: Record<string, (file: File) => Promise<string>>
72
- /**
73
- * Dependent (cascading) options by field name - compute a field's choices
74
- * from the current form values, e.g. City from Country. The field's value is
75
- * cleared when it stops being a valid option.
76
- */
77
- dependentOptions?: Record<string, (values: Partial<TData>) => ReadonlyArray<CellEditorOption>>
78
- /**
79
- * Remember the floating modal's window layout (pin / size / maximized) in
80
- * localStorage under this key, so it reopens where the user left it.
81
- */
82
- persistKey?: string
83
- /** Form column count. Defaults to the schema's `form.columns`, else 2. A field
84
- * with `span: 2` spans all columns. */
85
- columns?: 1 | 2 | 3
86
- /** Restrict + order the form fields (by field name). Default: all schema fields. */
87
- formFields?: string[]
88
- /** Group fields into titled fieldsets, overriding the schema's `form.sections`.
89
- * Fields not in any section render in a trailing default group. When set,
90
- * `formFields` ordering is per-section. */
91
- sections?: FormSection[]
92
- /** Ask one section at a time (Back / Next). Defaults to the schema's `form.steps`. */
93
- steps?: boolean
94
- /** Dialog width for the modal / drawer presentations. Default 'md'. */
95
- formSize?: 'sm' | 'md' | 'lg'
96
- onSubmit: (payload: SubmitPayload) => void | Promise<void>
97
- onCancel?: () => void
98
- }
99
-
100
- let {
101
- schema,
102
- row = null,
103
- open = true,
104
- title,
105
- submitLabel,
106
- presentation = 'drawer',
107
- lookups,
108
- uploads,
109
- dependentOptions,
110
- persistKey,
111
- columns,
112
- formFields,
113
- sections,
114
- steps,
115
- formSize = 'md',
116
- onSubmit,
117
- onCancel,
118
- }: Props = $props()
119
-
120
- let values = $state<Record<string, any>>({})
121
- let errors = $state<Record<string, string>>({})
122
- let submitting = $state(false)
123
- let submitError = $state<string | null>(null)
124
- // Fields the user has finished with. A field is only allowed to show an error
125
- // once they have left it, so a form does not scold you for what you have not
126
- // typed yet; after a failed submit everything counts as visited.
127
- let touched = $state<Record<string, boolean>>({})
128
- let submitAttempted = $state(false)
129
-
130
- const allFields = $derived(schemaToFormFields(schema))
131
- // Apply the include/order list (formFields), else keep the schema order.
132
- const listedFields = $derived(
133
- formFields && formFields.length
134
- ? (formFields.map((name) => allFields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[])
135
- : allFields,
136
- )
137
- // Live state per field: a `when` condition re-evaluates against the current
138
- // values, so answering one question can reveal, lock, or demand another.
139
- const fieldStates = $derived(new Map(allFields.map((f) => [f.field, fieldState(f, values)] as const)))
140
- const stateOf = (f: FormFieldDescriptor): FieldState =>
141
- fieldStates.get(f.field) ?? { visible: true, disabled: f.readonly, required: f.required }
142
- const fields = $derived(listedFields.filter((f) => stateOf(f).visible))
143
- // The arrangement comes from the schema (so a built form travels with the
144
- // entity); the props override it for a one-off layout of a shared schema.
145
- const layoutColumns = $derived(columns ?? schema.form?.columns ?? 2)
146
- const layoutSections = $derived(sections ?? schema.form?.sections)
147
-
148
- // Grouped layout: each section's resolvable fields, plus a trailing group for
149
- // anything not assigned to a section (so no field is ever silently dropped).
150
- // Read straight off the layout, not off `fieldGroups` - the grouping needs to
151
- // know whether we are stepping, so it cannot be what decides it.
152
- const wantSteps = $derived(!!(steps ?? schema.form?.steps) && !!layoutSections?.length)
153
- const fieldGroups = $derived.by((): Array<{ title?: string; description?: string; columns?: 1 | 2 | 3; collapsible?: boolean; key: string; fields: FormFieldDescriptor[] }> => {
154
- if (!layoutSections || !layoutSections.length) return [{ key: '', fields }]
155
- const assigned = new Set<string>()
156
- const groups = layoutSections.map((s, i) => {
157
- const gf = s.fields.map((name) => fields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[]
158
- for (const f of gf) assigned.add(f.field)
159
- // A section can be conditional in its own right, the same way a field is.
160
- const shown = s.visibleWhen ? sectionVisible(s.visibleWhen, values) : true
161
- return { title: s.title, description: s.description, columns: s.columns, collapsible: s.collapsible, key: `${i}`, fields: shown ? gf : [] }
162
- })
163
- const rest = fields.filter((f) => !assigned.has(f.field))
164
- // A section whose fields are all hidden by a condition disappears with them,
165
- // rather than leaving a heading over nothing.
166
- const shown = groups.filter((g) => g.fields.length)
167
- if (!rest.length) return shown
168
- // Stepping, the leftovers join the last step rather than becoming a step of
169
- // their own: an untitled "Step 4" holding whatever nobody placed is a
170
- // mystery, and every step of a wizard should be deliberate.
171
- if (wantSteps && shown.length) {
172
- const last = shown[shown.length - 1]!
173
- return [...shown.slice(0, -1), { ...last, fields: [...last.fields, ...rest] }]
174
- }
175
- return [...shown, { key: 'rest', fields: rest }]
176
- })
177
-
178
- // Which collapsible sections the user has folded away. Seeded from the
179
- // layout's `collapsed`, then owned by the user for the life of the form.
180
- let folded = $state<Record<string, boolean>>({})
181
- $effect(() => {
182
- const seed: Record<string, boolean> = {}
183
- ;(layoutSections ?? []).forEach((s, i) => { if (s.collapsible && s.collapsed) seed[`${i}`] = true })
184
- folded = seed
185
- })
186
- /**
187
- * A folded section is a display state, not a condition - its fields are still
188
- * validated. So a section holding an error is forced open: a failed submit
189
- * must never point at something the user cannot see.
190
- */
191
- const groupHasError = (g: { fields: FormFieldDescriptor[] }) => g.fields.some((f) => shownErrors[f.field])
192
- // Never folded while stepping: a step is already one group at a time, and a
193
- // step you have to unfold before you can fill it in is just an extra click.
194
- const isFolded = (g: { key: string; collapsible?: boolean; fields: FormFieldDescriptor[] }) =>
195
- !stepped && !!g.collapsible && !!folded[g.key] && !groupHasError(g)
196
-
197
- // --- Steps -----------------------------------------------------------------
198
- // One section at a time. The groups are already the steps (a section hidden by
199
- // its condition has been filtered out upstream, so the count follows the
200
- // answers), which is why there is no second list to keep in sync.
201
- const stepped = $derived(wantSteps && fieldGroups.length > 1)
202
- let step = $state(0)
203
- // Clamp rather than reset: answering something that hides a later section must
204
- // not throw the user back to the beginning.
205
- const stepIndex = $derived(stepped ? Math.min(step, fieldGroups.length - 1) : 0)
206
- const visibleGroups = $derived(stepped ? [fieldGroups[stepIndex]!] : fieldGroups)
207
- const isLastStep = $derived(stepIndex === fieldGroups.length - 1)
208
-
209
- /**
210
- * Move to the next step, but only once this one is clean. Validating just the
211
- * step you are on is the point of a wizard: errors surface where they were
212
- * made instead of arriving in a heap at the end.
213
- */
214
- async function nextStep() {
215
- const group = fieldGroups[stepIndex]
216
- if (!group) return
217
- let bad = false
218
- for (const f of group.fields) {
219
- touched[f.field] = true
220
- const message = await validateOne(schema, f.field, values)
221
- if (message) { errors[f.field] = message; bad = true }
222
- else delete errors[f.field]
223
- }
224
- if (bad) {
225
- const first = group.fields.find((f) => errors[f.field])
226
- if (first) focusField(first.field)
227
- return
228
- }
229
- step = stepIndex + 1
230
- }
231
-
232
- // Options for the custom dropdown: prepend a blank "clear" option for
233
- // non-required fields (parity with the native select's empty option).
234
- const selectOptions = (f: FormFieldDescriptor) =>
235
- f.required ? (f.options ?? []) : [{ value: '', label: '—' }, ...(f.options ?? [])]
236
- const mode = $derived(editMode(row))
237
- const heading = $derived(
238
- title ?? `${mode === 'create' ? 'New' : 'Edit'} ${schema.label ?? schema.name}`,
239
- )
240
- const overlayed = $derived(presentation !== 'inline')
241
-
242
- // The values the form opened with, to tell an edited form from an untouched one.
243
- let initial = $state<Record<string, any>>({})
244
-
245
- $effect(() => {
246
- // Seed from a local, and copy from that local rather than from `values`:
247
- // reading the state this effect also writes makes the effect its own
248
- // dependency, which spins forever.
249
- const seeded = buildInitialValues(schema, row)
250
- values = seeded
251
- initial = { ...seeded }
252
- errors = {}
253
- touched = {}
254
- submitAttempted = false
255
- step = 0
256
- submitError = null
257
- confirmingDiscard = false
258
- })
259
-
260
- // Cascade: when a parent field changes, clear a dependent field whose value is
261
- // no longer among its (recomputed) options.
262
- $effect(() => {
263
- if (!dependentOptions) return
264
- for (const [field, optionsOf] of Object.entries(dependentOptions)) {
265
- const current = values[field]
266
- if (current == null || current === '') continue
267
- if (!optionsOf(values as Partial<TData>).some((o) => String(o.value) === String(current))) values[field] = ''
268
- }
269
- })
270
-
271
- // Live recompute: a computed (derived) field re-evaluates as the form changes.
272
- // The value is read-only in the form and stripped from the submit payload.
273
- $effect(() => {
274
- for (const f of fields) {
275
- if (!f.computed) continue
276
- const next = f.computed(values as Record<string, unknown>)
277
- if (values[f.field] !== next) values[f.field] = next
278
- }
279
- })
280
-
281
- /**
282
- * Re-check one field as the user leaves it. Once a field has an error it keeps
283
- * being checked on every keystroke, so a correction clears the message
284
- * immediately instead of making them submit again to find out.
285
- */
286
- async function checkField(field: string) {
287
- touched[field] = true
288
- const message = await validateOne(schema, field, values)
289
- if (message) errors[field] = message
290
- else delete errors[field]
291
- }
292
-
293
- // Live re-validation, but only for fields already showing an error.
294
- $effect(() => {
295
- void values
296
- for (const field of Object.keys(errors)) void checkField(field)
297
- })
298
-
299
- /** Errors the form is allowed to show: visited fields, or everything after a submit. */
300
- const shownErrors = $derived(
301
- Object.fromEntries(Object.entries(errors).filter(([field]) => submitAttempted || touched[field])),
302
- )
303
- const errorList = $derived(
304
- fields.filter((f) => shownErrors[f.field]).map((f) => ({ field: f.field, label: f.label, message: shownErrors[f.field]! })),
305
- )
306
-
307
- /** Send focus to a field from the error summary. */
308
- function focusField(field: string) {
309
- const el = document.getElementById(`sv-ef-${field}`)
310
- if (el instanceof HTMLElement) el.focus()
311
- }
312
-
313
- async function handleSubmit(event: SubmitEvent) {
314
- event.preventDefault()
315
- // Enter in a text field still submits a form even with no submit button on
316
- // screen. Mid-wizard that would save a half-filled record, so it advances
317
- // instead - the same thing Next does.
318
- if (stepped && !isLastStep) { void nextStep(); return }
319
- submitError = null
320
- submitAttempted = true
321
- const found = await validateAll(schema, values)
322
- errors = found
323
- if (Object.keys(found).length > 0) {
324
- // Put the user on the first thing they need to fix - and, when stepping,
325
- // on the step it is actually on, or the focus would land off-screen.
326
- const first = fields.find((f) => found[f.field])
327
- if (stepped && first) {
328
- const at = fieldGroups.findIndex((g) => g.fields.some((f) => found[f.field]))
329
- if (at >= 0) step = at
330
- }
331
- if (first) focusField(first.field)
332
- return
333
- }
334
- submitting = true
335
- try {
336
- await onSubmit({
337
- mode,
338
- id: mode === 'edit' && row ? rowId(schema, row) : null,
339
- values: toSubmitValues(schema, values),
340
- })
341
- } catch (err) {
342
- submitError = err instanceof Error ? err.message : String(err)
343
- } finally {
344
- submitting = false
345
- }
346
- }
347
-
348
- /**
349
- * Guard against throwing away edits. Closing a dirty form asks first, in the
350
- * footer rather than through a `confirm()` dialog, so the answer appears where
351
- * the user is already looking and the form stays on screen behind it.
352
- */
353
- let confirmingDiscard = $state(false)
354
- const isDirty = $derived(
355
- !submitting && fields.some((f) => !f.readonly && String(values[f.field] ?? '') !== String(initial[f.field] ?? '')),
356
- )
357
-
358
- function close() {
359
- if (isDirty && !confirmingDiscard) {
360
- confirmingDiscard = true
361
- return
362
- }
363
- confirmingDiscard = false
364
- onCancel?.()
365
- }
366
- function onKeydown(e: KeyboardEvent) {
367
- if (e.key !== 'Escape' || !onCancel) return
368
- // A second Escape confirms, so the keyboard path is not a dead end.
369
- close()
370
- }
371
- function stop(e: Event) {
372
- e.stopPropagation()
373
- }
374
-
375
- // Pop entrance for the modal: fade + lift + scale, so it doesn't fight the
376
- // flex-centered wrapper (a transform-based centering would clash with this).
377
- function pop(_node: Element, { duration = 240 } = {}) {
378
- return {
379
- duration,
380
- easing: cubicOut,
381
- css: (t: number) => `opacity:${t};transform:translateY(${(1 - t) * 14}px) scale(${0.97 + 0.03 * t})`,
382
- }
383
- }
384
-
385
- // --- Floating window: drag, resize, edge-pin, maximize (modal presentation) -
386
- const floating = $derived(presentation === 'modal')
387
- type Pin = 'none' | 'left' | 'right' | 'top' | 'bottom'
388
- let pin = $state<Pin>('none')
389
- let pos = $state<{ x: number; y: number } | null>(null) // null = centered by the wrap's flex
390
- let size = $state<{ w: number; h: number } | null>(null)
391
- let maximized = $state(false)
392
- let panelEl = $state<HTMLElement>()
393
-
394
- const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
395
-
396
- // Restore the saved window layout (pin / size / maximized), so it reopens
397
- // where the user left it. A function so the prop read stays inside a closure.
398
- const lsKey = () => (persistKey ? `svgrid.editpanel.${persistKey}` : null)
399
- {
400
- const key = lsKey()
401
- if (key && typeof localStorage !== 'undefined') {
402
- try {
403
- const saved = JSON.parse(localStorage.getItem(key) ?? 'null')
404
- if (saved) {
405
- pin = saved.pin ?? 'none'
406
- size = saved.size ?? null
407
- maximized = !!saved.maximized
408
- }
409
- } catch { /* ignore bad json */ }
410
- }
411
- }
412
- $effect(() => {
413
- const key = lsKey()
414
- if (!key || typeof localStorage === 'undefined') return
415
- const layout = { pin, size, maximized } // read so the effect tracks them
416
- try { localStorage.setItem(key, JSON.stringify(layout)) } catch { /* quota / private mode */ }
417
- })
418
-
419
- const panelStyle = $derived.by(() => {
420
- if (!floating) return ''
421
- if (maximized) return 'position:fixed;inset:0;width:100vw;height:100vh;max-width:none;max-height:none;border-radius:0;'
422
- const w = size?.w
423
- const h = size?.h
424
- const edge = 'position:fixed;max-width:none;max-height:none;border-radius:0;'
425
- if (pin === 'left') return `${edge}left:0;top:0;height:100vh;width:${w ?? 440}px;`
426
- if (pin === 'right') return `${edge}right:0;top:0;height:100vh;width:${w ?? 440}px;`
427
- if (pin === 'top') return `${edge}top:0;left:0;width:100vw;height:${h ?? 380}px;`
428
- if (pin === 'bottom') return `${edge}bottom:0;left:0;width:100vw;height:${h ?? 380}px;`
429
- if (pos) return `position:fixed;left:${pos.x}px;top:${pos.y}px;width:${w ?? 460}px;${h ? `height:${h}px;` : ''}max-height:none;`
430
- // still centered by the wrap; only apply an explicit size if the user resized
431
- return `${w ? `width:${w}px;` : ''}${h ? `height:${h}px;max-height:none;` : ''}`
432
- })
433
-
434
- function setPin(p: Pin) {
435
- maximized = false
436
- pin = pin === p ? 'none' : p
437
- if (pin !== 'none') pos = null
438
- }
439
- function toggleMax() {
440
- maximized = !maximized
441
- }
442
-
443
- function startDrag(e: PointerEvent) {
444
- if (!floating || pin !== 'none' || maximized) return
445
- if ((e.target as HTMLElement).closest('button, input, select, textarea')) return
446
- const r = panelEl?.getBoundingClientRect()
447
- if (!r) return
448
- if (!size) size = { w: r.width, h: r.height }
449
- pos = { x: r.left, y: r.top }
450
- const ox = r.left, oy = r.top, sx = e.clientX, sy = e.clientY, w = r.width
451
- const move = (ev: PointerEvent) => {
452
- pos = {
453
- x: clamp(ox + ev.clientX - sx, 0, Math.max(0, window.innerWidth - w)),
454
- y: clamp(oy + ev.clientY - sy, 0, Math.max(0, window.innerHeight - 40)),
455
- }
456
- }
457
- const up = () => {
458
- window.removeEventListener('pointermove', move)
459
- window.removeEventListener('pointerup', up)
460
- }
461
- window.addEventListener('pointermove', move)
462
- window.addEventListener('pointerup', up)
463
- e.preventDefault()
464
- }
465
-
466
- function startResize(e: PointerEvent, dir: string) {
467
- const r = panelEl?.getBoundingClientRect()
468
- if (!r) return
469
- if (pin === 'none' && !pos) pos = { x: r.left, y: r.top }
470
- const sx = e.clientX, sy = e.clientY, sw = r.width, sh = r.height
471
- const move = (ev: PointerEvent) => {
472
- let w = sw, h = sh
473
- const dx = ev.clientX - sx, dy = ev.clientY - sy
474
- if (dir.includes('e')) w = Math.max(300, sw + dx)
475
- if (dir.includes('w')) w = Math.max(300, sw - dx)
476
- if (dir.includes('s')) h = Math.max(220, sh + dy)
477
- if (dir.includes('n')) h = Math.max(220, sh - dy)
478
- size = { w, h }
479
- }
480
- const up = () => {
481
- window.removeEventListener('pointermove', move)
482
- window.removeEventListener('pointerup', up)
483
- }
484
- window.addEventListener('pointermove', move)
485
- window.addEventListener('pointerup', up)
486
- e.preventDefault()
487
- e.stopPropagation()
488
- }
489
- </script>
490
-
491
- <svelte:window onkeydown={onKeydown} />
492
-
493
- {#snippet pinIcon(side: 'left' | 'right' | 'top' | 'bottom')}
494
- <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
495
- <rect x="1.5" y="1.5" width="11" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.2" />
496
- {#if side === 'left'}<rect x="1.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
497
- {#if side === 'right'}<rect x="8.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
498
- {#if side === 'top'}<rect x="1.5" y="1.5" width="11" height="4" fill="currentColor" />{/if}
499
- {#if side === 'bottom'}<rect x="1.5" y="8.5" width="11" height="4" fill="currentColor" />{/if}
500
- </svg>
501
- {/snippet}
502
-
503
- {#snippet maxIcon(isMax: boolean)}
504
- <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3">
505
- {#if isMax}
506
- <rect x="4" y="1.5" width="8.5" height="8.5" rx="1.3" />
507
- <rect x="1.5" y="4" width="8.5" height="8.5" rx="1.3" fill="var(--ep-bg)" />
508
- {:else}
509
- <rect x="1.5" y="1.5" width="11" height="11" rx="1.5" />
510
- {/if}
511
- </svg>
512
- {/snippet}
513
-
514
- {#snippet panelInner()}
515
- <form class="sv-ep__form" onsubmit={handleSubmit}>
516
- <!-- svelte-ignore a11y_no_static_element_interactions -->
517
- <header
518
- class="sv-ep__header"
519
- class:sv-ep__header--drag={floating && pin === 'none' && !maximized}
520
- onpointerdown={floating ? startDrag : undefined}
521
- >
522
- <h2>{heading}</h2>
523
- <div class="sv-ep__tools">
524
- {#if floating}
525
- {#each ['left', 'top', 'bottom', 'right'] as const as side (side)}
526
- <button
527
- type="button"
528
- class="sv-ep__pin"
529
- class:is-active={pin === side}
530
- title={pin === side ? 'Unpin' : `Pin ${side}`}
531
- aria-label={pin === side ? 'Unpin' : `Pin ${side}`}
532
- onclick={() => setPin(side)}
533
- >{@render pinIcon(side)}</button>
534
- {/each}
535
- <button
536
- type="button"
537
- class="sv-ep__pin"
538
- class:is-active={maximized}
539
- title={maximized ? 'Restore' : 'Maximize'}
540
- aria-label={maximized ? 'Restore' : 'Maximize'}
541
- onclick={toggleMax}
542
- >{@render maxIcon(maximized)}</button>
543
- {/if}
544
- {#if onCancel}
545
- <button type="button" class="sv-ep__close" aria-label="Close" onclick={close}>&times;</button>
546
- {/if}
547
- </div>
548
- </header>
549
-
550
- {#if errorList.length > 1}
551
- <!-- One place to see everything that needs fixing, with a jump to each.
552
- Only worth showing when more than one field failed. -->
553
- <div class="sv-ep__summary" role="alert">
554
- <p class="sv-ep__summary-title">Please fix {errorList.length} fields:</p>
555
- <ul>
556
- {#each errorList as e (e.field)}
557
- <li><button type="button" onclick={() => focusField(e.field)}>{e.label}: {e.message}</button></li>
558
- {/each}
559
- </ul>
560
- </div>
561
- {/if}
562
-
563
- {#if stepped}
564
- <!-- Where you are, and how much is left. Named steps beat "3 of 7": the
565
- titles are already written, so use them. -->
566
- <ol class="sv-ep__steps" aria-label="Form steps">
567
- {#each fieldGroups as g, gi (gi)}
568
- <li class="sv-ep__step" class:is-current={gi === stepIndex} class:is-done={gi < stepIndex} aria-current={gi === stepIndex ? 'step' : undefined}>
569
- <span class="sv-ep__step-dot" aria-hidden="true">{gi < stepIndex ? '✓' : gi + 1}</span>
570
- <span class="sv-ep__step-label">{g.title ?? `Step ${gi + 1}`}</span>
571
- </li>
572
- {/each}
573
- </ol>
574
- {/if}
575
-
576
- {#if fieldGroups.length > 1 || fieldGroups[0]?.title}
577
- {#each visibleGroups as g, gi (gi)}
578
- {@const shut = isFolded(g)}
579
- <div class="sv-ep__section">
580
- {#if g.collapsible && g.title}
581
- <!-- The heading becomes the control, so the whole row is the target
582
- rather than a small chevron beside it. -->
583
- <h4 class="sv-ep__section-title">
584
- <button type="button" class="sv-ep__section-toggle" aria-expanded={!shut} aria-controls={`sv-eg-${gi}`} onclick={() => (folded[g.key] = !folded[g.key])}>
585
- <span class="sv-ep__section-caret" class:is-shut={shut} aria-hidden="true"></span>
586
- {g.title}
587
- {#if shut}<span class="sv-ep__section-count">{g.fields.length}</span>{/if}
588
- </button>
589
- </h4>
590
- {:else if g.title}
591
- <h4 class="sv-ep__section-title">{g.title}</h4>
592
- {/if}
593
- {#if g.description && !shut}<p class="sv-ep__section-desc">{g.description}</p>{/if}
594
- <div class="sv-ep__body" id={`sv-eg-${gi}`} hidden={shut} style="--sv-ep-cols: {g.columns ?? layoutColumns}">
595
- {#each g.fields as f (f.field)}{@render fieldRow(f)}{/each}
596
- </div>
597
- </div>
598
- {/each}
599
- {:else}
600
- <div class="sv-ep__body" style="--sv-ep-cols: {layoutColumns}">
601
- {#each fields as f (f.field)}{@render fieldRow(f)}{/each}
602
- </div>
603
- {/if}
604
-
605
- {#if submitError}<p class="sv-ep__submit-err" role="alert">{submitError}</p>{/if}
606
-
607
- <footer class="sv-ep__footer">
608
- {#if confirmingDiscard}
609
- <span class="sv-ep__discard" role="alert">Discard your changes?</span>
610
- <button type="button" class="sv-ep__btn" onclick={() => (confirmingDiscard = false)}>Keep editing</button>
611
- <button type="button" class="sv-ep__btn sv-ep__btn--danger" onclick={close}>Discard</button>
612
- {:else}
613
- {#if onCancel}
614
- <button type="button" class="sv-ep__btn" onclick={close} disabled={submitting}>Cancel</button>
615
- {/if}
616
- {#if stepped && stepIndex > 0}
617
- <button type="button" class="sv-ep__btn" onclick={() => (step = stepIndex - 1)} disabled={submitting}>Back</button>
618
- {/if}
619
- {#if stepped && !isLastStep}
620
- <!-- Not a submit button: Next validates this step only, and a stray
621
- Enter in a text field must not save a half-filled record. -->
622
- <button type="button" class="sv-ep__btn sv-ep__btn--primary" onclick={nextStep} disabled={submitting}>Next</button>
623
- {:else}
624
- <button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
625
- {submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
626
- </button>
627
- {/if}
628
- {/if}
629
- </footer>
630
- </form>
631
- {/snippet}
632
-
633
- {#snippet fieldRow(spec: FormFieldDescriptor)}
634
- {@const st = stateOf(spec)}
635
- <!-- Every control below already reads `readonly` / `required` off the
636
- descriptor, so folding the live condition state into a shadow copy
637
- makes all of them honour it without touching each branch. -->
638
- {@const f = st.disabled === spec.readonly && st.required === spec.required
639
- ? spec
640
- : { ...spec, readonly: st.disabled, required: st.required }}
641
- {@const kind = controlKind(f)}
642
- {@const lk = f.relation ? lookups?.[f.field] : undefined}
643
- {@const depOptions = dependentOptions?.[f.field]}
644
- {@const err = shownErrors[f.field]}
645
- <!-- Point screen readers at whichever of help / error is on screen. -->
646
- {@const describedBy = err ? `sv-ee-${f.field}` : f.help ? `sv-eh-${f.field}` : undefined}
647
- <div class="sv-ep-field" class:sv-ep-field--wide={f.span === 2} class:sv-ep-field--error={!!err}
648
- onfocusoutcapture={() => { if (!f.readonly) void checkField(f.field) }}>
649
- <label for={`sv-ef-${f.field}`}>
650
- {f.label}{#if f.required && !f.readonly}<span class="sv-ep-field__req" aria-hidden="true"> *</span>{/if}
651
- </label>
652
-
653
- {#if f.upload}
654
- <SvFileInput
655
- id={`sv-ef-${f.field}`}
656
- value={values[f.field]}
657
- accept={f.upload.accept}
658
- image={f.upload.image}
659
- disabled={f.readonly}
660
- upload={uploads?.[f.field]}
661
- onChange={(u) => (values[f.field] = u)}
662
- />
663
- {:else if depOptions}
664
- <div class="sv-ep-field__dropdown">
665
- <SvGridDropdown
666
- options={depOptions(values as Partial<TData>)}
667
- value={values[f.field]}
668
- autoOpen={false}
669
- placeholder={f.required ? 'Select…' : '—'}
670
- onChange={(v) => (values[f.field] = v)}
671
- />
672
- </div>
673
- {:else if lk}
674
- <SvLookupInput
675
- id={`sv-ef-${f.field}`}
676
- value={values[f.field]}
677
- lookup={lk}
678
- onSelect={(v) => (values[f.field] = v)}
679
- required={f.required}
680
- disabled={f.readonly}
681
- placeholder={f.placeholder ?? 'Search…'}
682
- />
683
- {:else if f.editorType === 'checkbox'}
684
- <!-- Boolean fields render as the suite's switch (nicer than a raw checkbox). -->
685
- <SvSwitchButton id={`sv-ef-${f.field}`} ariaLabel={f.label} checked={!!values[f.field]} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
686
- {:else if f.editorType === 'number'}
687
- <SvNumberInput block 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={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
688
- {:else if f.editorType === 'color'}
689
- <SvColorInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!err} onChange={(v) => (values[f.field] = v)} />
690
- {:else if f.editorType === 'password'}
691
- <SvPasswordInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
692
- {:else if f.editorType === 'rating' || f.editorType === 'slider'}
693
- {@const rmin = f.min ?? 0}
694
- {@const rmax = f.max ?? (f.editorType === 'rating' ? 5 : 100)}
695
- <SvSlider block 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)} />
696
- {:else if f.editorType === 'phone'}
697
- <SvPhoneInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
698
- {:else if f.editorType === 'country'}
699
- <SvCountryInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
700
- {:else if f.editorType === 'mask'}
701
- <SvMaskedInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
702
- {:else if f.editorType === 'date'}
703
- <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
704
- {:else if f.editorType === 'datetime'}
705
- <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
706
- {:else if f.editorType === 'chips'}
707
- <!-- Multi-value entry: stores a string[]. (Previously a chips field fell back to a single-select.) -->
708
- <SvTagsInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
709
- {:else if kind === 'select'}
710
- {#if f.readonly}
711
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" value={values[f.field] ?? ''} disabled />
712
- {:else}
713
- <!-- Custom dropdown: its panel portals to <body> (position:fixed),
714
- so it floats above the modal and never grows the form body
715
- (no scrollbar), unlike an in-flow popup. -->
716
- <div class="sv-ep-field__dropdown">
717
- <SvGridDropdown
718
- options={selectOptions(f)}
719
- value={values[f.field]}
720
- autoOpen={false}
721
- placeholder={f.required ? 'Select…' : '—'}
722
- onChange={(v) => (values[f.field] = v)}
723
- />
724
- </div>
725
- {/if}
726
- {:else if kind === 'textarea'}
727
- <textarea id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder}></textarea>
728
- {:else if kind === 'number'}
729
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="number" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
730
- {:else if kind === 'date'}
731
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="date" bind:value={values[f.field]} disabled={f.readonly} />
732
- {:else if kind === 'datetime'}
733
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="datetime-local" bind:value={values[f.field]} disabled={f.readonly} />
734
- {:else if kind === 'time'}
735
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="time" bind:value={values[f.field]} disabled={f.readonly} />
736
- {:else}
737
- <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
738
- {/if}
739
-
740
- {#if f.help && !err}<p class="sv-ep-field__help" id={`sv-eh-${f.field}`}>{f.help}</p>{/if}
741
- {#if err}<p class="sv-ep-field__err" id={`sv-ee-${f.field}`}>{err}</p>{/if}
742
- </div>
743
- {/snippet}
744
-
745
- {#if open}
746
- {#if overlayed}
747
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
748
- <div class="sv-ep-wrap sv-ep-wrap--{presentation}" transition:fade={{ duration: 160 }} onclick={close} role="presentation">
749
- {#if presentation === 'drawer'}
750
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
751
- <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 }}>
752
- {@render panelInner()}
753
- </div>
754
- {:else}
755
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
756
- <div
757
- class="sv-ep sv-ep--modal sv-ep--sz-{formSize}"
758
- class:sv-ep--floating={floating}
759
- data-pin={pin}
760
- bind:this={panelEl}
761
- style={panelStyle}
762
- role="dialog"
763
- aria-modal="true"
764
- aria-label={heading}
765
- tabindex="-1"
766
- onclick={stop}
767
- transition:pop
768
- >
769
- {@render panelInner()}
770
- {#if floating && !maximized}
771
- <!-- svelte-ignore a11y_no_static_element_interactions -->
772
- {#if pin === 'none'}
773
- <div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
774
- <div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
775
- <div class="sv-ep__rz sv-ep__rz--se" onpointerdown={(e) => startResize(e, 'se')}></div>
776
- {:else if pin === 'left'}
777
- <div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
778
- {:else if pin === 'right'}
779
- <div class="sv-ep__rz sv-ep__rz--w" onpointerdown={(e) => startResize(e, 'w')}></div>
780
- {:else if pin === 'top'}
781
- <div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
782
- {:else if pin === 'bottom'}
783
- <div class="sv-ep__rz sv-ep__rz--n" onpointerdown={(e) => startResize(e, 'n')}></div>
784
- {/if}
785
- {/if}
786
- </div>
787
- {/if}
788
- </div>
789
- {:else}
790
- <!-- The size class matters for inline too now: it fills its container by
791
- default, and `formSize` is what narrows it. -->
792
- <div class="sv-ep sv-ep--inline sv-ep--sz-{formSize}" role="dialog" aria-label={heading}>
793
- {@render panelInner()}
794
- </div>
795
- {/if}
796
- {/if}
797
-
798
- <style>
799
- .sv-ep,
800
- .sv-ep-wrap {
801
- --ep-bg: var(--sg-bg, #ffffff);
802
- --ep-fg: var(--sg-fg, #0f172a);
803
- --ep-muted: var(--sg-muted, #64748b);
804
- --ep-border: var(--sg-border, #e2e8f0);
805
- --ep-subtle: var(--sg-header-bg, #f8fafc);
806
- --ep-input-bg: var(--sg-input-bg, #ffffff);
807
- --ep-input-border: var(--sg-input-border, #cbd5e1);
808
- --ep-accent: var(--sg-accent, #2563eb);
809
- --ep-on-accent: var(--sg-on-accent, #ffffff);
810
- --ep-danger: #ef4444;
811
- --ep-radius: var(--sg-radius, 10px);
812
- }
813
-
814
- .sv-ep-wrap {
815
- position: fixed;
816
- inset: 0;
817
- z-index: 2147483000;
818
- display: flex;
819
- background: rgba(2, 6, 23, 0.5);
820
- backdrop-filter: blur(2px);
821
- }
822
- .sv-ep-wrap--drawer {
823
- justify-content: flex-end;
824
- }
825
- .sv-ep-wrap--modal {
826
- align-items: center;
827
- justify-content: center;
828
- padding: 16px;
829
- }
830
-
831
- .sv-ep {
832
- box-sizing: border-box;
833
- background: var(--ep-bg);
834
- color: var(--ep-fg);
835
- font: inherit;
836
- font-family: var(--sg-font, inherit);
837
- }
838
- .sv-ep--drawer {
839
- width: min(440px, 100vw);
840
- height: 100%;
841
- border-left: 1px solid var(--ep-border);
842
- box-shadow: -14px 0 44px rgba(0, 0, 0, 0.28);
843
- display: flex;
844
- flex-direction: column;
845
- }
846
- /* Dialog size (modal width / drawer width), unless the user has pinned/resized. */
847
- .sv-ep--sz-sm.sv-ep--modal { width: min(400px, 100%); }
848
- .sv-ep--sz-lg.sv-ep--modal { width: min(760px, 100%); }
849
- .sv-ep--sz-sm.sv-ep--drawer { width: min(360px, 100vw); }
850
- .sv-ep--sz-lg.sv-ep--drawer { width: min(600px, 100vw); }
851
- .sv-ep--modal {
852
- width: min(520px, 100%);
853
- max-height: 88vh;
854
- border: 1px solid var(--ep-border);
855
- border-radius: var(--ep-radius);
856
- box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
857
- display: flex;
858
- flex-direction: column;
859
- }
860
- /* Inline fills whatever it is placed in - a form block on a page is as wide as
861
- the block, and a fixed cap here made a wide block look broken. Narrow it
862
- with `formSize` when a full-width form is too much to read. */
863
- .sv-ep--inline {
864
- width: 100%;
865
- border: 1px solid var(--ep-border);
866
- border-radius: var(--ep-radius);
867
- }
868
- .sv-ep--sz-sm.sv-ep--inline { max-width: 460px; }
869
- .sv-ep--sz-lg.sv-ep--inline { max-width: 900px; }
870
-
871
- .sv-ep__form {
872
- display: flex;
873
- flex-direction: column;
874
- min-height: 0;
875
- flex: 1;
876
- }
877
- .sv-ep__header {
878
- display: flex;
879
- align-items: center;
880
- justify-content: space-between;
881
- padding: 16px 18px;
882
- border-bottom: 1px solid var(--ep-border);
883
- }
884
- .sv-ep__header h2 {
885
- margin: 0;
886
- font-size: 15px;
887
- font-weight: 650;
888
- }
889
- .sv-ep__close {
890
- display: inline-flex;
891
- align-items: center;
892
- justify-content: center;
893
- width: 30px;
894
- height: 30px;
895
- border: 0;
896
- background: none;
897
- color: var(--ep-muted);
898
- font-size: 22px;
899
- line-height: 1;
900
- cursor: pointer;
901
- border-radius: 6px;
902
- }
903
- .sv-ep__close:hover {
904
- background: var(--ep-subtle);
905
- color: var(--ep-fg);
906
- }
907
-
908
- /* --- Floating window: drag handle, pin controls, resize handles --------- */
909
- .sv-ep--floating {
910
- position: relative; /* anchors the resize handles; inline `position:fixed` overrides once moved/pinned */
911
- }
912
- .sv-ep__header--drag {
913
- cursor: move;
914
- user-select: none;
915
- touch-action: none;
916
- }
917
- .sv-ep__tools {
918
- display: flex;
919
- align-items: center;
920
- gap: 2px;
921
- flex: 0 0 auto;
922
- }
923
- .sv-ep__pin {
924
- display: inline-flex;
925
- align-items: center;
926
- justify-content: center;
927
- width: 26px;
928
- height: 26px;
929
- padding: 0;
930
- border: 0;
931
- background: none;
932
- color: var(--ep-muted);
933
- cursor: pointer;
934
- border-radius: 6px;
935
- }
936
- .sv-ep__pin:hover {
937
- background: var(--ep-subtle);
938
- color: var(--ep-fg);
939
- }
940
- .sv-ep__pin.is-active {
941
- color: var(--ep-accent);
942
- background: color-mix(in srgb, var(--ep-accent) 14%, transparent);
943
- }
944
- .sv-ep__rz {
945
- position: absolute;
946
- z-index: 5;
947
- touch-action: none;
948
- }
949
- .sv-ep__rz--e { top: 0; right: 0; width: 8px; height: 100%; cursor: ew-resize; }
950
- .sv-ep__rz--w { top: 0; left: 0; width: 8px; height: 100%; cursor: ew-resize; }
951
- .sv-ep__rz--s { left: 0; bottom: 0; height: 8px; width: 100%; cursor: ns-resize; }
952
- .sv-ep__rz--n { left: 0; top: 0; height: 8px; width: 100%; cursor: ns-resize; }
953
- .sv-ep__rz--se { right: 0; bottom: 0; width: 15px; height: 15px; cursor: nwse-resize; }
954
-
955
- .sv-ep__body {
956
- display: grid;
957
- grid-template-columns: repeat(var(--sv-ep-cols, 2), minmax(0, 1fr));
958
- gap: 14px 16px;
959
- padding: 18px;
960
- overflow: auto;
961
- flex: 1; /* fill the form so a resized/pinned panel grows its scroll area */
962
- min-height: 0;
963
- align-content: start;
964
- }
965
- .sv-ep--drawer .sv-ep__body {
966
- grid-template-columns: 1fr;
967
- }
968
- /* Grouped layout: one scroll region holds the titled fieldsets. */
969
- .sv-ep__section { display: flex; flex-direction: column; }
970
- .sv-ep__section + .sv-ep__section { border-top: 1px solid var(--sg-border, #e6e8ec); }
971
- /* A section heading is a heading, not a label: it reads at the same weight as
972
- the dialog title, one size down. An all-caps micro-label got lost among the
973
- field labels, which are themselves small and muted. */
974
- .sv-ep__section-title { margin: 0; padding: 16px 18px 0; font-size: 13.5px; font-weight: 650; color: var(--ep-fg); }
975
- .sv-ep__section-desc { margin: 3px 0 0; padding: 0 18px; font-size: 12px; line-height: 1.45; color: var(--sg-muted, #64748b); }
976
- /* The step rail. Wraps rather than scrolls: a wizard with six steps in a
977
- narrow drawer should read as two rows, not run off the edge. */
978
- .sv-ep__steps { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; padding: 14px 18px 0; list-style: none; }
979
- .sv-ep__step { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }
980
- .sv-ep__step-dot { display: inline-flex; align-items: center; justify-content: center; width: 19px; height: 19px; border-radius: 50%; font-size: 10.5px; font-weight: 700; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
981
- .sv-ep__step.is-current { color: var(--ep-fg); font-weight: 600; }
982
- .sv-ep__step.is-current .sv-ep__step-dot { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }
983
- .sv-ep__step.is-done .sv-ep__step-dot { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 18%, transparent); color: var(--sg-accent, #4f46e5); }
984
- .sv-ep__section-toggle { display: flex; align-items: center; gap: 7px; width: 100%; padding: 0; font: inherit; text-align: left; border: 0; background: none; color: inherit; cursor: pointer; }
985
- .sv-ep__section-caret { width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid currentColor; transition: transform 120ms ease; }
986
- .sv-ep__section-caret.is-shut { transform: rotate(-90deg); }
987
- /* How many fields are hidden in there - a folded group should not look empty. */
988
- .sv-ep__section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 9px; font-size: 10px; font-weight: 600; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
989
- /* The heading owns the gap above its fields, so the group reads as one thing. */
990
- .sv-ep__section-title + .sv-ep__body, .sv-ep__section-desc + .sv-ep__body { padding-top: 10px; }
991
- .sv-ep__discard { margin-right: auto; font-size: 12.5px; font-weight: 600; color: var(--ep-danger); }
992
- .sv-ep__btn--danger { border-color: var(--ep-danger); background: var(--ep-danger); color: #fff; }
993
- .sv-ep__section .sv-ep__body { flex: 0 0 auto; overflow: visible; }
994
- .sv-ep-field {
995
- display: flex;
996
- flex-direction: column;
997
- gap: 6px;
998
- min-width: 0;
999
- }
1000
- .sv-ep-field--wide {
1001
- grid-column: 1 / -1;
1002
- }
1003
- .sv-ep-field label {
1004
- font-size: 12px;
1005
- font-weight: 550;
1006
- /* The label names the thing you are about to type in, so it reads at full
1007
- strength; muted text is for the hint underneath it. */
1008
- color: var(--ep-fg);
1009
- }
1010
- .sv-ep-field--error label {
1011
- color: var(--ep-danger);
1012
- }
1013
- .sv-ep-field__req {
1014
- color: var(--ep-danger);
1015
- }
1016
- /* Controls fill their cell via the suite's own `block` prop - see the markup.
1017
- The boolean switch deliberately does not: a full-width toggle is not what
1018
- anybody means by a checkbox. */
1019
- .sv-ep-field input,
1020
- .sv-ep-field textarea {
1021
- box-sizing: border-box;
1022
- width: 100%;
1023
- padding: 8px 10px;
1024
- font: inherit;
1025
- color: var(--ep-fg);
1026
- background: var(--ep-input-bg);
1027
- border: 1px solid var(--ep-input-border);
1028
- border-radius: 8px;
1029
- transition: border-color 0.12s ease, box-shadow 0.12s ease;
1030
- }
1031
- .sv-ep-field input:focus,
1032
- .sv-ep-field textarea:focus {
1033
- outline: none;
1034
- border-color: var(--ep-accent);
1035
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
1036
- }
1037
- .sv-ep-field textarea {
1038
- min-height: 76px;
1039
- resize: vertical;
1040
- }
1041
- .sv-ep-field--error input,
1042
- .sv-ep-field--error textarea,
1043
- .sv-ep-field--error .sv-ep-field__dropdown {
1044
- border-color: var(--ep-danger);
1045
- }
1046
-
1047
- /* Wrapper that gives the custom dropdown an input-like frame; the dropdown
1048
- itself fills it (its trigger is border-less) and its panel portals out. */
1049
- .sv-ep-field__dropdown {
1050
- position: relative;
1051
- height: 38px;
1052
- box-sizing: border-box;
1053
- border: 1px solid var(--ep-input-border);
1054
- border-radius: 8px;
1055
- background: var(--ep-input-bg);
1056
- transition: border-color 0.12s ease, box-shadow 0.12s ease;
1057
- }
1058
- .sv-ep-field__dropdown:focus-within {
1059
- border-color: var(--ep-accent);
1060
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
1061
- }
1062
- .sv-ep-field__dropdown :global(.sv-grid-dropdown),
1063
- .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger) {
1064
- background: transparent;
1065
- color: var(--ep-fg);
1066
- border-radius: 8px;
1067
- }
1068
- .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger:focus) {
1069
- outline: none;
1070
- }
1071
- .sv-ep-field__help {
1072
- margin: 0;
1073
- font-size: 11px;
1074
- color: var(--ep-muted);
1075
- }
1076
- .sv-ep-field__err {
1077
- margin: 0;
1078
- font-size: 11px;
1079
- font-weight: 500;
1080
- color: var(--ep-danger);
1081
- }
1082
- .sv-ep__summary {
1083
- margin: 0 18px 12px;
1084
- padding: 10px 12px;
1085
- border: 1px solid var(--ep-danger);
1086
- border-left-width: 3px;
1087
- border-radius: 6px;
1088
- background: color-mix(in srgb, var(--ep-danger) 7%, var(--sg-bg, #fff));
1089
- }
1090
- .sv-ep__summary-title {
1091
- margin: 0 0 6px;
1092
- font-size: 12.5px;
1093
- font-weight: 600;
1094
- color: var(--ep-danger);
1095
- }
1096
- .sv-ep__summary ul {
1097
- margin: 0;
1098
- padding-left: 18px;
1099
- display: flex;
1100
- flex-direction: column;
1101
- gap: 3px;
1102
- }
1103
- .sv-ep__summary li {
1104
- font-size: 12px;
1105
- color: var(--sg-fg, #0f172a);
1106
- }
1107
- .sv-ep__summary button {
1108
- background: none;
1109
- border: 0;
1110
- padding: 0;
1111
- font: inherit;
1112
- color: inherit;
1113
- text-align: left;
1114
- cursor: pointer;
1115
- text-decoration: underline;
1116
- text-underline-offset: 2px;
1117
- }
1118
- .sv-ep__submit-err {
1119
- margin: 0;
1120
- padding: 0 18px 4px;
1121
- font-size: 12px;
1122
- color: var(--ep-danger);
1123
- }
1124
- .sv-ep__footer {
1125
- display: flex;
1126
- justify-content: flex-end;
1127
- gap: 8px;
1128
- padding: 14px 18px;
1129
- border-top: 1px solid var(--ep-border);
1130
- background: var(--ep-subtle);
1131
- }
1132
- .sv-ep__btn {
1133
- padding: 8px 16px;
1134
- font: inherit;
1135
- font-weight: 550;
1136
- color: var(--ep-fg);
1137
- background: var(--ep-bg);
1138
- border: 1px solid var(--ep-border);
1139
- border-radius: 8px;
1140
- cursor: pointer;
1141
- }
1142
- .sv-ep__btn:hover:not(:disabled) {
1143
- background: var(--ep-subtle);
1144
- }
1145
- .sv-ep__btn--primary {
1146
- color: var(--ep-on-accent);
1147
- background: var(--ep-accent);
1148
- border-color: var(--ep-accent);
1149
- }
1150
- .sv-ep__btn--primary:hover:not(:disabled) {
1151
- filter: brightness(1.06);
1152
- }
1153
- .sv-ep__btn:disabled {
1154
- opacity: 0.6;
1155
- cursor: default;
1156
- }
1157
- </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, type FormSection } 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
+ fieldState,
44
+ sectionVisible,
45
+ validateOne,
46
+ type EditMode,
47
+ type FieldState,
48
+ } from './edit-panel'
49
+ import SvLookupInput from './SvLookupInput.svelte'
50
+ import type { RelationLookup } from './sources/relation-lookup'
51
+
52
+ type SubmitPayload = { mode: EditMode; id: string | null; values: Partial<TData> }
53
+ type Presentation = 'drawer' | 'modal' | 'inline'
54
+
55
+
56
+ type Props = {
57
+ schema: EntitySchema<TData>
58
+ row?: TData | null
59
+ open?: boolean
60
+ title?: string
61
+ submitLabel?: string
62
+ /** 'drawer' (right slide-over, default), 'modal' (centered popup), or 'inline'. */
63
+ presentation?: Presentation
64
+ /**
65
+ * Lookup providers keyed by field name. A `relation` field with a lookup
66
+ * renders a searchable picker (search the related entity, store the key).
67
+ * Build them with `createRelationLookup`.
68
+ */
69
+ lookups?: Record<string, RelationLookup>
70
+ /** File/image upload handlers by field name (return the URL to store). */
71
+ uploads?: Record<string, (file: File) => Promise<string>>
72
+ /**
73
+ * Dependent (cascading) options by field name - compute a field's choices
74
+ * from the current form values, e.g. City from Country. The field's value is
75
+ * cleared when it stops being a valid option.
76
+ */
77
+ dependentOptions?: Record<string, (values: Partial<TData>) => ReadonlyArray<CellEditorOption>>
78
+ /**
79
+ * Remember the floating modal's window layout (pin / size / maximized) in
80
+ * localStorage under this key, so it reopens where the user left it.
81
+ */
82
+ persistKey?: string
83
+ /** Form column count. Defaults to the schema's `form.columns`, else 2. A field
84
+ * with `span: 2` spans all columns. */
85
+ columns?: 1 | 2 | 3
86
+ /** Restrict + order the form fields (by field name). Default: all schema fields. */
87
+ formFields?: string[]
88
+ /** Group fields into titled fieldsets, overriding the schema's `form.sections`.
89
+ * Fields not in any section render in a trailing default group. When set,
90
+ * `formFields` ordering is per-section. */
91
+ sections?: FormSection[]
92
+ /** Ask one section at a time (Back / Next). Defaults to the schema's `form.steps`. */
93
+ steps?: boolean
94
+ /** Dialog width for the modal / drawer presentations. Default 'md'. */
95
+ formSize?: 'sm' | 'md' | 'lg'
96
+ onSubmit: (payload: SubmitPayload) => void | Promise<void>
97
+ onCancel?: () => void
98
+ }
99
+
100
+ let {
101
+ schema,
102
+ row = null,
103
+ open = true,
104
+ title,
105
+ submitLabel,
106
+ presentation = 'drawer',
107
+ lookups,
108
+ uploads,
109
+ dependentOptions,
110
+ persistKey,
111
+ columns,
112
+ formFields,
113
+ sections,
114
+ steps,
115
+ formSize = 'md',
116
+ onSubmit,
117
+ onCancel,
118
+ }: Props = $props()
119
+
120
+ let values = $state<Record<string, any>>({})
121
+ let errors = $state<Record<string, string>>({})
122
+ let submitting = $state(false)
123
+ let submitError = $state<string | null>(null)
124
+ // Fields the user has finished with. A field is only allowed to show an error
125
+ // once they have left it, so a form does not scold you for what you have not
126
+ // typed yet; after a failed submit everything counts as visited.
127
+ let touched = $state<Record<string, boolean>>({})
128
+ let submitAttempted = $state(false)
129
+
130
+ const allFields = $derived(schemaToFormFields(schema))
131
+ // Apply the include/order list (formFields), else keep the schema order.
132
+ const listedFields = $derived(
133
+ formFields && formFields.length
134
+ ? (formFields.map((name) => allFields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[])
135
+ : allFields,
136
+ )
137
+ // Live state per field: a `when` condition re-evaluates against the current
138
+ // values, so answering one question can reveal, lock, or demand another.
139
+ const fieldStates = $derived(new Map(allFields.map((f) => [f.field, fieldState(f, values)] as const)))
140
+ const stateOf = (f: FormFieldDescriptor): FieldState =>
141
+ fieldStates.get(f.field) ?? { visible: true, disabled: f.readonly, required: f.required }
142
+ const fields = $derived(listedFields.filter((f) => stateOf(f).visible))
143
+ // The arrangement comes from the schema (so a built form travels with the
144
+ // entity); the props override it for a one-off layout of a shared schema.
145
+ const layoutColumns = $derived(columns ?? schema.form?.columns ?? 2)
146
+ const layoutSections = $derived(sections ?? schema.form?.sections)
147
+
148
+ // Grouped layout: each section's resolvable fields, plus a trailing group for
149
+ // anything not assigned to a section (so no field is ever silently dropped).
150
+ // Read straight off the layout, not off `fieldGroups` - the grouping needs to
151
+ // know whether we are stepping, so it cannot be what decides it.
152
+ const wantSteps = $derived(!!(steps ?? schema.form?.steps) && !!layoutSections?.length)
153
+ const fieldGroups = $derived.by((): Array<{ title?: string; description?: string; columns?: 1 | 2 | 3; collapsible?: boolean; key: string; fields: FormFieldDescriptor[] }> => {
154
+ if (!layoutSections || !layoutSections.length) return [{ key: '', fields }]
155
+ const assigned = new Set<string>()
156
+ const groups = layoutSections.map((s, i) => {
157
+ const gf = s.fields.map((name) => fields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[]
158
+ for (const f of gf) assigned.add(f.field)
159
+ // A section can be conditional in its own right, the same way a field is.
160
+ const shown = s.visibleWhen ? sectionVisible(s.visibleWhen, values) : true
161
+ return { title: s.title, description: s.description, columns: s.columns, collapsible: s.collapsible, key: `${i}`, fields: shown ? gf : [] }
162
+ })
163
+ const rest = fields.filter((f) => !assigned.has(f.field))
164
+ // A section whose fields are all hidden by a condition disappears with them,
165
+ // rather than leaving a heading over nothing.
166
+ const shown = groups.filter((g) => g.fields.length)
167
+ if (!rest.length) return shown
168
+ // Stepping, the leftovers join the last step rather than becoming a step of
169
+ // their own: an untitled "Step 4" holding whatever nobody placed is a
170
+ // mystery, and every step of a wizard should be deliberate.
171
+ if (wantSteps && shown.length) {
172
+ const last = shown[shown.length - 1]!
173
+ return [...shown.slice(0, -1), { ...last, fields: [...last.fields, ...rest] }]
174
+ }
175
+ return [...shown, { key: 'rest', fields: rest }]
176
+ })
177
+
178
+ // Which collapsible sections the user has folded away. Seeded from the
179
+ // layout's `collapsed`, then owned by the user for the life of the form.
180
+ let folded = $state<Record<string, boolean>>({})
181
+ $effect(() => {
182
+ const seed: Record<string, boolean> = {}
183
+ ;(layoutSections ?? []).forEach((s, i) => { if (s.collapsible && s.collapsed) seed[`${i}`] = true })
184
+ folded = seed
185
+ })
186
+ /**
187
+ * A folded section is a display state, not a condition - its fields are still
188
+ * validated. So a section holding an error is forced open: a failed submit
189
+ * must never point at something the user cannot see.
190
+ */
191
+ const groupHasError = (g: { fields: FormFieldDescriptor[] }) => g.fields.some((f) => shownErrors[f.field])
192
+ // Never folded while stepping: a step is already one group at a time, and a
193
+ // step you have to unfold before you can fill it in is just an extra click.
194
+ const isFolded = (g: { key: string; collapsible?: boolean; fields: FormFieldDescriptor[] }) =>
195
+ !stepped && !!g.collapsible && !!folded[g.key] && !groupHasError(g)
196
+
197
+ // --- Steps -----------------------------------------------------------------
198
+ // One section at a time. The groups are already the steps (a section hidden by
199
+ // its condition has been filtered out upstream, so the count follows the
200
+ // answers), which is why there is no second list to keep in sync.
201
+ const stepped = $derived(wantSteps && fieldGroups.length > 1)
202
+ let step = $state(0)
203
+ // Clamp rather than reset: answering something that hides a later section must
204
+ // not throw the user back to the beginning.
205
+ const stepIndex = $derived(stepped ? Math.min(step, fieldGroups.length - 1) : 0)
206
+ const visibleGroups = $derived(stepped ? [fieldGroups[stepIndex]!] : fieldGroups)
207
+ const isLastStep = $derived(stepIndex === fieldGroups.length - 1)
208
+
209
+ /**
210
+ * Move to the next step, but only once this one is clean. Validating just the
211
+ * step you are on is the point of a wizard: errors surface where they were
212
+ * made instead of arriving in a heap at the end.
213
+ */
214
+ async function nextStep() {
215
+ const group = fieldGroups[stepIndex]
216
+ if (!group) return
217
+ let bad = false
218
+ for (const f of group.fields) {
219
+ touched[f.field] = true
220
+ const message = await validateOne(schema, f.field, values)
221
+ if (message) { errors[f.field] = message; bad = true }
222
+ else delete errors[f.field]
223
+ }
224
+ if (bad) {
225
+ const first = group.fields.find((f) => errors[f.field])
226
+ if (first) focusField(first.field)
227
+ return
228
+ }
229
+ step = stepIndex + 1
230
+ }
231
+
232
+ // Options for the custom dropdown: prepend a blank "clear" option for
233
+ // non-required fields (parity with the native select's empty option).
234
+ const selectOptions = (f: FormFieldDescriptor) =>
235
+ f.required ? (f.options ?? []) : [{ value: '', label: '—' }, ...(f.options ?? [])]
236
+ const mode = $derived(editMode(row))
237
+ const heading = $derived(
238
+ title ?? `${mode === 'create' ? 'New' : 'Edit'} ${schema.label ?? schema.name}`,
239
+ )
240
+ const overlayed = $derived(presentation !== 'inline')
241
+
242
+ // The values the form opened with, to tell an edited form from an untouched one.
243
+ let initial = $state<Record<string, any>>({})
244
+
245
+ $effect(() => {
246
+ // Seed from a local, and copy from that local rather than from `values`:
247
+ // reading the state this effect also writes makes the effect its own
248
+ // dependency, which spins forever.
249
+ const seeded = buildInitialValues(schema, row)
250
+ values = seeded
251
+ initial = { ...seeded }
252
+ errors = {}
253
+ touched = {}
254
+ submitAttempted = false
255
+ step = 0
256
+ submitError = null
257
+ confirmingDiscard = false
258
+ })
259
+
260
+ // Cascade: when a parent field changes, clear a dependent field whose value is
261
+ // no longer among its (recomputed) options.
262
+ $effect(() => {
263
+ if (!dependentOptions) return
264
+ for (const [field, optionsOf] of Object.entries(dependentOptions)) {
265
+ const current = values[field]
266
+ if (current == null || current === '') continue
267
+ if (!optionsOf(values as Partial<TData>).some((o) => String(o.value) === String(current))) values[field] = ''
268
+ }
269
+ })
270
+
271
+ // Live recompute: a computed (derived) field re-evaluates as the form changes.
272
+ // The value is read-only in the form and stripped from the submit payload.
273
+ $effect(() => {
274
+ for (const f of fields) {
275
+ if (!f.computed) continue
276
+ const next = f.computed(values as Record<string, unknown>)
277
+ if (values[f.field] !== next) values[f.field] = next
278
+ }
279
+ })
280
+
281
+ /**
282
+ * Re-check one field as the user leaves it. Once a field has an error it keeps
283
+ * being checked on every keystroke, so a correction clears the message
284
+ * immediately instead of making them submit again to find out.
285
+ */
286
+ async function checkField(field: string) {
287
+ touched[field] = true
288
+ const message = await validateOne(schema, field, values)
289
+ if (message) errors[field] = message
290
+ else delete errors[field]
291
+ }
292
+
293
+ // Live re-validation, but only for fields already showing an error.
294
+ $effect(() => {
295
+ void values
296
+ for (const field of Object.keys(errors)) void checkField(field)
297
+ })
298
+
299
+ /** Errors the form is allowed to show: visited fields, or everything after a submit. */
300
+ const shownErrors = $derived(
301
+ Object.fromEntries(Object.entries(errors).filter(([field]) => submitAttempted || touched[field])),
302
+ )
303
+ const errorList = $derived(
304
+ fields.filter((f) => shownErrors[f.field]).map((f) => ({ field: f.field, label: f.label, message: shownErrors[f.field]! })),
305
+ )
306
+
307
+ /** Send focus to a field from the error summary. */
308
+ function focusField(field: string) {
309
+ const el = document.getElementById(`sv-ef-${field}`)
310
+ if (el instanceof HTMLElement) el.focus()
311
+ }
312
+
313
+ async function handleSubmit(event: SubmitEvent) {
314
+ event.preventDefault()
315
+ // Enter in a text field still submits a form even with no submit button on
316
+ // screen. Mid-wizard that would save a half-filled record, so it advances
317
+ // instead - the same thing Next does.
318
+ if (stepped && !isLastStep) { void nextStep(); return }
319
+ submitError = null
320
+ submitAttempted = true
321
+ const found = await validateAll(schema, values)
322
+ errors = found
323
+ if (Object.keys(found).length > 0) {
324
+ // Put the user on the first thing they need to fix - and, when stepping,
325
+ // on the step it is actually on, or the focus would land off-screen.
326
+ const first = fields.find((f) => found[f.field])
327
+ if (stepped && first) {
328
+ const at = fieldGroups.findIndex((g) => g.fields.some((f) => found[f.field]))
329
+ if (at >= 0) step = at
330
+ }
331
+ if (first) focusField(first.field)
332
+ return
333
+ }
334
+ submitting = true
335
+ try {
336
+ await onSubmit({
337
+ mode,
338
+ id: mode === 'edit' && row ? rowId(schema, row) : null,
339
+ values: toSubmitValues(schema, values),
340
+ })
341
+ } catch (err) {
342
+ submitError = err instanceof Error ? err.message : String(err)
343
+ } finally {
344
+ submitting = false
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Guard against throwing away edits. Closing a dirty form asks first, in the
350
+ * footer rather than through a `confirm()` dialog, so the answer appears where
351
+ * the user is already looking and the form stays on screen behind it.
352
+ */
353
+ let confirmingDiscard = $state(false)
354
+ const isDirty = $derived(
355
+ !submitting && fields.some((f) => !f.readonly && String(values[f.field] ?? '') !== String(initial[f.field] ?? '')),
356
+ )
357
+
358
+ function close() {
359
+ if (isDirty && !confirmingDiscard) {
360
+ confirmingDiscard = true
361
+ return
362
+ }
363
+ confirmingDiscard = false
364
+ onCancel?.()
365
+ }
366
+ function onKeydown(e: KeyboardEvent) {
367
+ if (e.key !== 'Escape' || !onCancel) return
368
+ // A second Escape confirms, so the keyboard path is not a dead end.
369
+ close()
370
+ }
371
+ function stop(e: Event) {
372
+ e.stopPropagation()
373
+ }
374
+
375
+ // Pop entrance for the modal: fade + lift + scale, so it doesn't fight the
376
+ // flex-centered wrapper (a transform-based centering would clash with this).
377
+ function pop(_node: Element, { duration = 240 } = {}) {
378
+ return {
379
+ duration,
380
+ easing: cubicOut,
381
+ css: (t: number) => `opacity:${t};transform:translateY(${(1 - t) * 14}px) scale(${0.97 + 0.03 * t})`,
382
+ }
383
+ }
384
+
385
+ // --- Floating window: drag, resize, edge-pin, maximize (modal presentation) -
386
+ const floating = $derived(presentation === 'modal')
387
+ type Pin = 'none' | 'left' | 'right' | 'top' | 'bottom'
388
+ let pin = $state<Pin>('none')
389
+ let pos = $state<{ x: number; y: number } | null>(null) // null = centered by the wrap's flex
390
+ let size = $state<{ w: number; h: number } | null>(null)
391
+ let maximized = $state(false)
392
+ let panelEl = $state<HTMLElement>()
393
+
394
+ const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
395
+
396
+ // Restore the saved window layout (pin / size / maximized), so it reopens
397
+ // where the user left it. A function so the prop read stays inside a closure.
398
+ const lsKey = () => (persistKey ? `svgrid.editpanel.${persistKey}` : null)
399
+ {
400
+ const key = lsKey()
401
+ if (key && typeof localStorage !== 'undefined') {
402
+ try {
403
+ const saved = JSON.parse(localStorage.getItem(key) ?? 'null')
404
+ if (saved) {
405
+ pin = saved.pin ?? 'none'
406
+ size = saved.size ?? null
407
+ maximized = !!saved.maximized
408
+ }
409
+ } catch { /* ignore bad json */ }
410
+ }
411
+ }
412
+ $effect(() => {
413
+ const key = lsKey()
414
+ if (!key || typeof localStorage === 'undefined') return
415
+ const layout = { pin, size, maximized } // read so the effect tracks them
416
+ try { localStorage.setItem(key, JSON.stringify(layout)) } catch { /* quota / private mode */ }
417
+ })
418
+
419
+ const panelStyle = $derived.by(() => {
420
+ if (!floating) return ''
421
+ if (maximized) return 'position:fixed;inset:0;width:100vw;height:100vh;max-width:none;max-height:none;border-radius:0;'
422
+ const w = size?.w
423
+ const h = size?.h
424
+ const edge = 'position:fixed;max-width:none;max-height:none;border-radius:0;'
425
+ if (pin === 'left') return `${edge}left:0;top:0;height:100vh;width:${w ?? 440}px;`
426
+ if (pin === 'right') return `${edge}right:0;top:0;height:100vh;width:${w ?? 440}px;`
427
+ if (pin === 'top') return `${edge}top:0;left:0;width:100vw;height:${h ?? 380}px;`
428
+ if (pin === 'bottom') return `${edge}bottom:0;left:0;width:100vw;height:${h ?? 380}px;`
429
+ if (pos) return `position:fixed;left:${pos.x}px;top:${pos.y}px;width:${w ?? 460}px;${h ? `height:${h}px;` : ''}max-height:none;`
430
+ // still centered by the wrap; only apply an explicit size if the user resized
431
+ return `${w ? `width:${w}px;` : ''}${h ? `height:${h}px;max-height:none;` : ''}`
432
+ })
433
+
434
+ function setPin(p: Pin) {
435
+ maximized = false
436
+ pin = pin === p ? 'none' : p
437
+ if (pin !== 'none') pos = null
438
+ }
439
+ function toggleMax() {
440
+ maximized = !maximized
441
+ }
442
+
443
+ function startDrag(e: PointerEvent) {
444
+ if (!floating || pin !== 'none' || maximized) return
445
+ if ((e.target as HTMLElement).closest('button, input, select, textarea')) return
446
+ const r = panelEl?.getBoundingClientRect()
447
+ if (!r) return
448
+ if (!size) size = { w: r.width, h: r.height }
449
+ pos = { x: r.left, y: r.top }
450
+ const ox = r.left, oy = r.top, sx = e.clientX, sy = e.clientY, w = r.width
451
+ const move = (ev: PointerEvent) => {
452
+ pos = {
453
+ x: clamp(ox + ev.clientX - sx, 0, Math.max(0, window.innerWidth - w)),
454
+ y: clamp(oy + ev.clientY - sy, 0, Math.max(0, window.innerHeight - 40)),
455
+ }
456
+ }
457
+ const up = () => {
458
+ window.removeEventListener('pointermove', move)
459
+ window.removeEventListener('pointerup', up)
460
+ }
461
+ window.addEventListener('pointermove', move)
462
+ window.addEventListener('pointerup', up)
463
+ e.preventDefault()
464
+ }
465
+
466
+ function startResize(e: PointerEvent, dir: string) {
467
+ const r = panelEl?.getBoundingClientRect()
468
+ if (!r) return
469
+ if (pin === 'none' && !pos) pos = { x: r.left, y: r.top }
470
+ const sx = e.clientX, sy = e.clientY, sw = r.width, sh = r.height
471
+ const move = (ev: PointerEvent) => {
472
+ let w = sw, h = sh
473
+ const dx = ev.clientX - sx, dy = ev.clientY - sy
474
+ if (dir.includes('e')) w = Math.max(300, sw + dx)
475
+ if (dir.includes('w')) w = Math.max(300, sw - dx)
476
+ if (dir.includes('s')) h = Math.max(220, sh + dy)
477
+ if (dir.includes('n')) h = Math.max(220, sh - dy)
478
+ size = { w, h }
479
+ }
480
+ const up = () => {
481
+ window.removeEventListener('pointermove', move)
482
+ window.removeEventListener('pointerup', up)
483
+ }
484
+ window.addEventListener('pointermove', move)
485
+ window.addEventListener('pointerup', up)
486
+ e.preventDefault()
487
+ e.stopPropagation()
488
+ }
489
+ </script>
490
+
491
+ <svelte:window onkeydown={onKeydown} />
492
+
493
+ {#snippet pinIcon(side: 'left' | 'right' | 'top' | 'bottom')}
494
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
495
+ <rect x="1.5" y="1.5" width="11" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.2" />
496
+ {#if side === 'left'}<rect x="1.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
497
+ {#if side === 'right'}<rect x="8.5" y="1.5" width="4" height="11" fill="currentColor" />{/if}
498
+ {#if side === 'top'}<rect x="1.5" y="1.5" width="11" height="4" fill="currentColor" />{/if}
499
+ {#if side === 'bottom'}<rect x="1.5" y="8.5" width="11" height="4" fill="currentColor" />{/if}
500
+ </svg>
501
+ {/snippet}
502
+
503
+ {#snippet maxIcon(isMax: boolean)}
504
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3">
505
+ {#if isMax}
506
+ <rect x="4" y="1.5" width="8.5" height="8.5" rx="1.3" />
507
+ <rect x="1.5" y="4" width="8.5" height="8.5" rx="1.3" fill="var(--ep-bg)" />
508
+ {:else}
509
+ <rect x="1.5" y="1.5" width="11" height="11" rx="1.5" />
510
+ {/if}
511
+ </svg>
512
+ {/snippet}
513
+
514
+ {#snippet panelInner()}
515
+ <form class="sv-ep__form" onsubmit={handleSubmit}>
516
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
517
+ <header
518
+ class="sv-ep__header"
519
+ class:sv-ep__header--drag={floating && pin === 'none' && !maximized}
520
+ onpointerdown={floating ? startDrag : undefined}
521
+ >
522
+ <h2>{heading}</h2>
523
+ <div class="sv-ep__tools">
524
+ {#if floating}
525
+ {#each ['left', 'top', 'bottom', 'right'] as const as side (side)}
526
+ <button
527
+ type="button"
528
+ class="sv-ep__pin"
529
+ class:is-active={pin === side}
530
+ title={pin === side ? 'Unpin' : `Pin ${side}`}
531
+ aria-label={pin === side ? 'Unpin' : `Pin ${side}`}
532
+ onclick={() => setPin(side)}
533
+ >{@render pinIcon(side)}</button>
534
+ {/each}
535
+ <button
536
+ type="button"
537
+ class="sv-ep__pin"
538
+ class:is-active={maximized}
539
+ title={maximized ? 'Restore' : 'Maximize'}
540
+ aria-label={maximized ? 'Restore' : 'Maximize'}
541
+ onclick={toggleMax}
542
+ >{@render maxIcon(maximized)}</button>
543
+ {/if}
544
+ {#if onCancel}
545
+ <button type="button" class="sv-ep__close" aria-label="Close" onclick={close}>&times;</button>
546
+ {/if}
547
+ </div>
548
+ </header>
549
+
550
+ {#if errorList.length > 1}
551
+ <!-- One place to see everything that needs fixing, with a jump to each.
552
+ Only worth showing when more than one field failed. -->
553
+ <div class="sv-ep__summary" role="alert">
554
+ <p class="sv-ep__summary-title">Please fix {errorList.length} fields:</p>
555
+ <ul>
556
+ {#each errorList as e (e.field)}
557
+ <li><button type="button" onclick={() => focusField(e.field)}>{e.label}: {e.message}</button></li>
558
+ {/each}
559
+ </ul>
560
+ </div>
561
+ {/if}
562
+
563
+ {#if stepped}
564
+ <!-- Where you are, and how much is left. Named steps beat "3 of 7": the
565
+ titles are already written, so use them. -->
566
+ <ol class="sv-ep__steps" aria-label="Form steps">
567
+ {#each fieldGroups as g, gi (gi)}
568
+ <li class="sv-ep__step" class:is-current={gi === stepIndex} class:is-done={gi < stepIndex} aria-current={gi === stepIndex ? 'step' : undefined}>
569
+ <span class="sv-ep__step-dot" aria-hidden="true">{gi < stepIndex ? '✓' : gi + 1}</span>
570
+ <span class="sv-ep__step-label">{g.title ?? `Step ${gi + 1}`}</span>
571
+ </li>
572
+ {/each}
573
+ </ol>
574
+ {/if}
575
+
576
+ {#if fieldGroups.length > 1 || fieldGroups[0]?.title}
577
+ {#each visibleGroups as g, gi (gi)}
578
+ {@const shut = isFolded(g)}
579
+ <div class="sv-ep__section">
580
+ {#if g.collapsible && g.title}
581
+ <!-- The heading becomes the control, so the whole row is the target
582
+ rather than a small chevron beside it. -->
583
+ <h4 class="sv-ep__section-title">
584
+ <button type="button" class="sv-ep__section-toggle" aria-expanded={!shut} aria-controls={`sv-eg-${gi}`} onclick={() => (folded[g.key] = !folded[g.key])}>
585
+ <span class="sv-ep__section-caret" class:is-shut={shut} aria-hidden="true"></span>
586
+ {g.title}
587
+ {#if shut}<span class="sv-ep__section-count">{g.fields.length}</span>{/if}
588
+ </button>
589
+ </h4>
590
+ {:else if g.title}
591
+ <h4 class="sv-ep__section-title">{g.title}</h4>
592
+ {/if}
593
+ {#if g.description && !shut}<p class="sv-ep__section-desc">{g.description}</p>{/if}
594
+ <div class="sv-ep__body" id={`sv-eg-${gi}`} hidden={shut} style="--sv-ep-cols: {g.columns ?? layoutColumns}">
595
+ {#each g.fields as f (f.field)}{@render fieldRow(f)}{/each}
596
+ </div>
597
+ </div>
598
+ {/each}
599
+ {:else}
600
+ <div class="sv-ep__body" style="--sv-ep-cols: {layoutColumns}">
601
+ {#each fields as f (f.field)}{@render fieldRow(f)}{/each}
602
+ </div>
603
+ {/if}
604
+
605
+ {#if submitError}<p class="sv-ep__submit-err" role="alert">{submitError}</p>{/if}
606
+
607
+ <footer class="sv-ep__footer">
608
+ {#if confirmingDiscard}
609
+ <span class="sv-ep__discard" role="alert">Discard your changes?</span>
610
+ <button type="button" class="sv-ep__btn" onclick={() => (confirmingDiscard = false)}>Keep editing</button>
611
+ <button type="button" class="sv-ep__btn sv-ep__btn--danger" onclick={close}>Discard</button>
612
+ {:else}
613
+ {#if onCancel}
614
+ <button type="button" class="sv-ep__btn" onclick={close} disabled={submitting}>Cancel</button>
615
+ {/if}
616
+ {#if stepped && stepIndex > 0}
617
+ <button type="button" class="sv-ep__btn" onclick={() => (step = stepIndex - 1)} disabled={submitting}>Back</button>
618
+ {/if}
619
+ {#if stepped && !isLastStep}
620
+ <!-- Not a submit button: Next validates this step only, and a stray
621
+ Enter in a text field must not save a half-filled record. -->
622
+ <button type="button" class="sv-ep__btn sv-ep__btn--primary" onclick={nextStep} disabled={submitting}>Next</button>
623
+ {:else}
624
+ <button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
625
+ {submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
626
+ </button>
627
+ {/if}
628
+ {/if}
629
+ </footer>
630
+ </form>
631
+ {/snippet}
632
+
633
+ {#snippet fieldRow(spec: FormFieldDescriptor)}
634
+ {@const st = stateOf(spec)}
635
+ <!-- Every control below already reads `readonly` / `required` off the
636
+ descriptor, so folding the live condition state into a shadow copy
637
+ makes all of them honour it without touching each branch. -->
638
+ {@const f = st.disabled === spec.readonly && st.required === spec.required
639
+ ? spec
640
+ : { ...spec, readonly: st.disabled, required: st.required }}
641
+ {@const kind = controlKind(f)}
642
+ {@const lk = f.relation ? lookups?.[f.field] : undefined}
643
+ {@const depOptions = dependentOptions?.[f.field]}
644
+ {@const err = shownErrors[f.field]}
645
+ <!-- Point screen readers at whichever of help / error is on screen. -->
646
+ {@const describedBy = err ? `sv-ee-${f.field}` : f.help ? `sv-eh-${f.field}` : undefined}
647
+ <div class="sv-ep-field" class:sv-ep-field--wide={f.span === 2} class:sv-ep-field--error={!!err}
648
+ onfocusoutcapture={() => { if (!f.readonly) void checkField(f.field) }}>
649
+ <label for={`sv-ef-${f.field}`}>
650
+ {f.label}{#if f.required && !f.readonly}<span class="sv-ep-field__req" aria-hidden="true"> *</span>{/if}
651
+ </label>
652
+
653
+ {#if f.upload}
654
+ <SvFileInput
655
+ id={`sv-ef-${f.field}`}
656
+ value={values[f.field]}
657
+ accept={f.upload.accept}
658
+ image={f.upload.image}
659
+ disabled={f.readonly}
660
+ upload={uploads?.[f.field]}
661
+ onChange={(u) => (values[f.field] = u)}
662
+ />
663
+ {:else if depOptions}
664
+ <div class="sv-ep-field__dropdown">
665
+ <SvGridDropdown
666
+ options={depOptions(values as Partial<TData>)}
667
+ value={values[f.field]}
668
+ autoOpen={false}
669
+ placeholder={f.required ? 'Select…' : '—'}
670
+ onChange={(v) => (values[f.field] = v)}
671
+ />
672
+ </div>
673
+ {:else if lk}
674
+ <SvLookupInput
675
+ id={`sv-ef-${f.field}`}
676
+ value={values[f.field]}
677
+ lookup={lk}
678
+ onSelect={(v) => (values[f.field] = v)}
679
+ required={f.required}
680
+ disabled={f.readonly}
681
+ placeholder={f.placeholder ?? 'Search…'}
682
+ />
683
+ {:else if f.editorType === 'checkbox'}
684
+ <!-- Boolean fields render as the suite's switch (nicer than a raw checkbox). -->
685
+ <SvSwitchButton id={`sv-ef-${f.field}`} ariaLabel={f.label} checked={!!values[f.field]} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
686
+ {:else if f.editorType === 'number'}
687
+ <SvNumberInput block 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={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
688
+ {:else if f.editorType === 'color'}
689
+ <SvColorInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!err} onChange={(v) => (values[f.field] = v)} />
690
+ {:else if f.editorType === 'password'}
691
+ <SvPasswordInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
692
+ {:else if f.editorType === 'rating' || f.editorType === 'slider'}
693
+ {@const rmin = f.min ?? 0}
694
+ {@const rmax = f.max ?? (f.editorType === 'rating' ? 5 : 100)}
695
+ <SvSlider block 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)} />
696
+ {:else if f.editorType === 'phone'}
697
+ <SvPhoneInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
698
+ {:else if f.editorType === 'country'}
699
+ <SvCountryInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
700
+ {:else if f.editorType === 'mask'}
701
+ <SvMaskedInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
702
+ {:else if f.editorType === 'date'}
703
+ <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
704
+ {:else if f.editorType === 'datetime'}
705
+ <SvDateTimePicker block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
706
+ {:else if f.editorType === 'chips'}
707
+ <!-- Multi-value entry: stores a string[]. (Previously a chips field fell back to a single-select.) -->
708
+ <SvTagsInput block id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
709
+ {:else if kind === 'select'}
710
+ {#if f.readonly}
711
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" value={values[f.field] ?? ''} disabled />
712
+ {:else}
713
+ <!-- Custom dropdown: its panel portals to <body> (position:fixed),
714
+ so it floats above the modal and never grows the form body
715
+ (no scrollbar), unlike an in-flow popup. -->
716
+ <div class="sv-ep-field__dropdown">
717
+ <SvGridDropdown
718
+ options={selectOptions(f)}
719
+ value={values[f.field]}
720
+ autoOpen={false}
721
+ placeholder={f.required ? 'Select…' : '—'}
722
+ onChange={(v) => (values[f.field] = v)}
723
+ />
724
+ </div>
725
+ {/if}
726
+ {:else if kind === 'textarea'}
727
+ <textarea id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder}></textarea>
728
+ {:else if kind === 'number'}
729
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="number" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
730
+ {:else if kind === 'date'}
731
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="date" bind:value={values[f.field]} disabled={f.readonly} />
732
+ {:else if kind === 'datetime'}
733
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="datetime-local" bind:value={values[f.field]} disabled={f.readonly} />
734
+ {:else if kind === 'time'}
735
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="time" bind:value={values[f.field]} disabled={f.readonly} />
736
+ {:else}
737
+ <input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
738
+ {/if}
739
+
740
+ {#if f.help && !err}<p class="sv-ep-field__help" id={`sv-eh-${f.field}`}>{f.help}</p>{/if}
741
+ {#if err}<p class="sv-ep-field__err" id={`sv-ee-${f.field}`}>{err}</p>{/if}
742
+ </div>
743
+ {/snippet}
744
+
745
+ {#if open}
746
+ {#if overlayed}
747
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
748
+ <div class="sv-ep-wrap sv-ep-wrap--{presentation}" transition:fade={{ duration: 160 }} onclick={close} role="presentation">
749
+ {#if presentation === 'drawer'}
750
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
751
+ <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 }}>
752
+ {@render panelInner()}
753
+ </div>
754
+ {:else}
755
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
756
+ <div
757
+ class="sv-ep sv-ep--modal sv-ep--sz-{formSize}"
758
+ class:sv-ep--floating={floating}
759
+ data-pin={pin}
760
+ bind:this={panelEl}
761
+ style={panelStyle}
762
+ role="dialog"
763
+ aria-modal="true"
764
+ aria-label={heading}
765
+ tabindex="-1"
766
+ onclick={stop}
767
+ transition:pop
768
+ >
769
+ {@render panelInner()}
770
+ {#if floating && !maximized}
771
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
772
+ {#if pin === 'none'}
773
+ <div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
774
+ <div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
775
+ <div class="sv-ep__rz sv-ep__rz--se" onpointerdown={(e) => startResize(e, 'se')}></div>
776
+ {:else if pin === 'left'}
777
+ <div class="sv-ep__rz sv-ep__rz--e" onpointerdown={(e) => startResize(e, 'e')}></div>
778
+ {:else if pin === 'right'}
779
+ <div class="sv-ep__rz sv-ep__rz--w" onpointerdown={(e) => startResize(e, 'w')}></div>
780
+ {:else if pin === 'top'}
781
+ <div class="sv-ep__rz sv-ep__rz--s" onpointerdown={(e) => startResize(e, 's')}></div>
782
+ {:else if pin === 'bottom'}
783
+ <div class="sv-ep__rz sv-ep__rz--n" onpointerdown={(e) => startResize(e, 'n')}></div>
784
+ {/if}
785
+ {/if}
786
+ </div>
787
+ {/if}
788
+ </div>
789
+ {:else}
790
+ <!-- The size class matters for inline too now: it fills its container by
791
+ default, and `formSize` is what narrows it. -->
792
+ <div class="sv-ep sv-ep--inline sv-ep--sz-{formSize}" role="dialog" aria-label={heading}>
793
+ {@render panelInner()}
794
+ </div>
795
+ {/if}
796
+ {/if}
797
+
798
+ <style>
799
+ .sv-ep,
800
+ .sv-ep-wrap {
801
+ --ep-bg: var(--sg-bg, #ffffff);
802
+ --ep-fg: var(--sg-fg, #0f172a);
803
+ --ep-muted: var(--sg-muted, #64748b);
804
+ --ep-border: var(--sg-border, #e2e8f0);
805
+ --ep-subtle: var(--sg-header-bg, #f8fafc);
806
+ --ep-input-bg: var(--sg-input-bg, #ffffff);
807
+ --ep-input-border: var(--sg-input-border, #cbd5e1);
808
+ --ep-accent: var(--sg-accent, #2563eb);
809
+ --ep-on-accent: var(--sg-on-accent, #ffffff);
810
+ --ep-danger: #ef4444;
811
+ --ep-radius: var(--sg-radius, 10px);
812
+ }
813
+
814
+ .sv-ep-wrap {
815
+ position: fixed;
816
+ inset: 0;
817
+ z-index: 2147483000;
818
+ display: flex;
819
+ background: rgba(2, 6, 23, 0.5);
820
+ backdrop-filter: blur(2px);
821
+ }
822
+ .sv-ep-wrap--drawer {
823
+ justify-content: flex-end;
824
+ }
825
+ .sv-ep-wrap--modal {
826
+ align-items: center;
827
+ justify-content: center;
828
+ padding: 16px;
829
+ }
830
+
831
+ .sv-ep {
832
+ box-sizing: border-box;
833
+ background: var(--ep-bg);
834
+ color: var(--ep-fg);
835
+ font: inherit;
836
+ font-family: var(--sg-font, inherit);
837
+ }
838
+ .sv-ep--drawer {
839
+ width: min(440px, 100vw);
840
+ height: 100%;
841
+ border-left: 1px solid var(--ep-border);
842
+ box-shadow: -14px 0 44px rgba(0, 0, 0, 0.28);
843
+ display: flex;
844
+ flex-direction: column;
845
+ }
846
+ /* Dialog size (modal width / drawer width), unless the user has pinned/resized. */
847
+ .sv-ep--sz-sm.sv-ep--modal { width: min(400px, 100%); }
848
+ .sv-ep--sz-lg.sv-ep--modal { width: min(760px, 100%); }
849
+ .sv-ep--sz-sm.sv-ep--drawer { width: min(360px, 100vw); }
850
+ .sv-ep--sz-lg.sv-ep--drawer { width: min(600px, 100vw); }
851
+ .sv-ep--modal {
852
+ width: min(520px, 100%);
853
+ max-height: 88vh;
854
+ border: 1px solid var(--ep-border);
855
+ border-radius: var(--ep-radius);
856
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
857
+ display: flex;
858
+ flex-direction: column;
859
+ }
860
+ /* Inline fills whatever it is placed in - a form block on a page is as wide as
861
+ the block, and a fixed cap here made a wide block look broken. Narrow it
862
+ with `formSize` when a full-width form is too much to read. */
863
+ .sv-ep--inline {
864
+ width: 100%;
865
+ border: 1px solid var(--ep-border);
866
+ border-radius: var(--ep-radius);
867
+ }
868
+ .sv-ep--sz-sm.sv-ep--inline { max-width: 460px; }
869
+ .sv-ep--sz-lg.sv-ep--inline { max-width: 900px; }
870
+
871
+ .sv-ep__form {
872
+ display: flex;
873
+ flex-direction: column;
874
+ min-height: 0;
875
+ flex: 1;
876
+ }
877
+ .sv-ep__header {
878
+ display: flex;
879
+ align-items: center;
880
+ justify-content: space-between;
881
+ padding: 16px 18px;
882
+ border-bottom: 1px solid var(--ep-border);
883
+ }
884
+ .sv-ep__header h2 {
885
+ margin: 0;
886
+ font-size: 15px;
887
+ font-weight: 650;
888
+ }
889
+ .sv-ep__close {
890
+ display: inline-flex;
891
+ align-items: center;
892
+ justify-content: center;
893
+ width: 30px;
894
+ height: 30px;
895
+ border: 0;
896
+ background: none;
897
+ color: var(--ep-muted);
898
+ font-size: 22px;
899
+ line-height: 1;
900
+ cursor: pointer;
901
+ border-radius: 6px;
902
+ }
903
+ .sv-ep__close:hover {
904
+ background: var(--ep-subtle);
905
+ color: var(--ep-fg);
906
+ }
907
+
908
+ /* --- Floating window: drag handle, pin controls, resize handles --------- */
909
+ .sv-ep--floating {
910
+ position: relative; /* anchors the resize handles; inline `position:fixed` overrides once moved/pinned */
911
+ }
912
+ .sv-ep__header--drag {
913
+ cursor: move;
914
+ user-select: none;
915
+ touch-action: none;
916
+ }
917
+ .sv-ep__tools {
918
+ display: flex;
919
+ align-items: center;
920
+ gap: 2px;
921
+ flex: 0 0 auto;
922
+ }
923
+ .sv-ep__pin {
924
+ display: inline-flex;
925
+ align-items: center;
926
+ justify-content: center;
927
+ width: 26px;
928
+ height: 26px;
929
+ padding: 0;
930
+ border: 0;
931
+ background: none;
932
+ color: var(--ep-muted);
933
+ cursor: pointer;
934
+ border-radius: 6px;
935
+ }
936
+ .sv-ep__pin:hover {
937
+ background: var(--ep-subtle);
938
+ color: var(--ep-fg);
939
+ }
940
+ .sv-ep__pin.is-active {
941
+ color: var(--ep-accent);
942
+ background: color-mix(in srgb, var(--ep-accent) 14%, transparent);
943
+ }
944
+ .sv-ep__rz {
945
+ position: absolute;
946
+ z-index: 5;
947
+ touch-action: none;
948
+ }
949
+ .sv-ep__rz--e { top: 0; right: 0; width: 8px; height: 100%; cursor: ew-resize; }
950
+ .sv-ep__rz--w { top: 0; left: 0; width: 8px; height: 100%; cursor: ew-resize; }
951
+ .sv-ep__rz--s { left: 0; bottom: 0; height: 8px; width: 100%; cursor: ns-resize; }
952
+ .sv-ep__rz--n { left: 0; top: 0; height: 8px; width: 100%; cursor: ns-resize; }
953
+ .sv-ep__rz--se { right: 0; bottom: 0; width: 15px; height: 15px; cursor: nwse-resize; }
954
+
955
+ .sv-ep__body {
956
+ display: grid;
957
+ grid-template-columns: repeat(var(--sv-ep-cols, 2), minmax(0, 1fr));
958
+ gap: 14px 16px;
959
+ padding: 18px;
960
+ overflow: auto;
961
+ flex: 1; /* fill the form so a resized/pinned panel grows its scroll area */
962
+ min-height: 0;
963
+ align-content: start;
964
+ }
965
+ .sv-ep--drawer .sv-ep__body {
966
+ grid-template-columns: 1fr;
967
+ }
968
+ /* Grouped layout: one scroll region holds the titled fieldsets. */
969
+ .sv-ep__section { display: flex; flex-direction: column; }
970
+ .sv-ep__section + .sv-ep__section { border-top: 1px solid var(--sg-border, #e6e8ec); }
971
+ /* A section heading is a heading, not a label: it reads at the same weight as
972
+ the dialog title, one size down. An all-caps micro-label got lost among the
973
+ field labels, which are themselves small and muted. */
974
+ .sv-ep__section-title { margin: 0; padding: 16px 18px 0; font-size: 13.5px; font-weight: 650; color: var(--ep-fg); }
975
+ .sv-ep__section-desc { margin: 3px 0 0; padding: 0 18px; font-size: 12px; line-height: 1.45; color: var(--sg-muted, #64748b); }
976
+ /* The step rail. Wraps rather than scrolls: a wizard with six steps in a
977
+ narrow drawer should read as two rows, not run off the edge. */
978
+ .sv-ep__steps { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; padding: 14px 18px 0; list-style: none; }
979
+ .sv-ep__step { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }
980
+ .sv-ep__step-dot { display: inline-flex; align-items: center; justify-content: center; width: 19px; height: 19px; border-radius: 50%; font-size: 10.5px; font-weight: 700; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
981
+ .sv-ep__step.is-current { color: var(--ep-fg); font-weight: 600; }
982
+ .sv-ep__step.is-current .sv-ep__step-dot { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }
983
+ .sv-ep__step.is-done .sv-ep__step-dot { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 18%, transparent); color: var(--sg-accent, #4f46e5); }
984
+ .sv-ep__section-toggle { display: flex; align-items: center; gap: 7px; width: 100%; padding: 0; font: inherit; text-align: left; border: 0; background: none; color: inherit; cursor: pointer; }
985
+ .sv-ep__section-caret { width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid currentColor; transition: transform 120ms ease; }
986
+ .sv-ep__section-caret.is-shut { transform: rotate(-90deg); }
987
+ /* How many fields are hidden in there - a folded group should not look empty. */
988
+ .sv-ep__section-count { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 9px; font-size: 10px; font-weight: 600; background: var(--sg-muted-bg, #eef2f7); color: var(--sg-muted, #64748b); }
989
+ /* The heading owns the gap above its fields, so the group reads as one thing. */
990
+ .sv-ep__section-title + .sv-ep__body, .sv-ep__section-desc + .sv-ep__body { padding-top: 10px; }
991
+ .sv-ep__discard { margin-right: auto; font-size: 12.5px; font-weight: 600; color: var(--ep-danger); }
992
+ .sv-ep__btn--danger { border-color: var(--ep-danger); background: var(--ep-danger); color: #fff; }
993
+ .sv-ep__section .sv-ep__body { flex: 0 0 auto; overflow: visible; }
994
+ .sv-ep-field {
995
+ display: flex;
996
+ flex-direction: column;
997
+ gap: 6px;
998
+ min-width: 0;
999
+ }
1000
+ .sv-ep-field--wide {
1001
+ grid-column: 1 / -1;
1002
+ }
1003
+ .sv-ep-field label {
1004
+ font-size: 12px;
1005
+ font-weight: 550;
1006
+ /* The label names the thing you are about to type in, so it reads at full
1007
+ strength; muted text is for the hint underneath it. */
1008
+ color: var(--ep-fg);
1009
+ }
1010
+ .sv-ep-field--error label {
1011
+ color: var(--ep-danger);
1012
+ }
1013
+ .sv-ep-field__req {
1014
+ color: var(--ep-danger);
1015
+ }
1016
+ /* Controls fill their cell via the suite's own `block` prop - see the markup.
1017
+ The boolean switch deliberately does not: a full-width toggle is not what
1018
+ anybody means by a checkbox. */
1019
+ .sv-ep-field input,
1020
+ .sv-ep-field textarea {
1021
+ box-sizing: border-box;
1022
+ width: 100%;
1023
+ padding: 8px 10px;
1024
+ font: inherit;
1025
+ color: var(--ep-fg);
1026
+ background: var(--ep-input-bg);
1027
+ border: 1px solid var(--ep-input-border);
1028
+ border-radius: 8px;
1029
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
1030
+ }
1031
+ .sv-ep-field input:focus,
1032
+ .sv-ep-field textarea:focus {
1033
+ outline: none;
1034
+ border-color: var(--ep-accent);
1035
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
1036
+ }
1037
+ .sv-ep-field textarea {
1038
+ min-height: 76px;
1039
+ resize: vertical;
1040
+ }
1041
+ .sv-ep-field--error input,
1042
+ .sv-ep-field--error textarea,
1043
+ .sv-ep-field--error .sv-ep-field__dropdown {
1044
+ border-color: var(--ep-danger);
1045
+ }
1046
+
1047
+ /* Wrapper that gives the custom dropdown an input-like frame; the dropdown
1048
+ itself fills it (its trigger is border-less) and its panel portals out. */
1049
+ .sv-ep-field__dropdown {
1050
+ position: relative;
1051
+ height: 38px;
1052
+ box-sizing: border-box;
1053
+ border: 1px solid var(--ep-input-border);
1054
+ border-radius: 8px;
1055
+ background: var(--ep-input-bg);
1056
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
1057
+ }
1058
+ .sv-ep-field__dropdown:focus-within {
1059
+ border-color: var(--ep-accent);
1060
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ep-accent) 22%, transparent);
1061
+ }
1062
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown),
1063
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger) {
1064
+ background: transparent;
1065
+ color: var(--ep-fg);
1066
+ border-radius: 8px;
1067
+ }
1068
+ .sv-ep-field__dropdown :global(.sv-grid-dropdown-trigger:focus) {
1069
+ outline: none;
1070
+ }
1071
+ .sv-ep-field__help {
1072
+ margin: 0;
1073
+ font-size: 11px;
1074
+ color: var(--ep-muted);
1075
+ }
1076
+ .sv-ep-field__err {
1077
+ margin: 0;
1078
+ font-size: 11px;
1079
+ font-weight: 500;
1080
+ color: var(--ep-danger);
1081
+ }
1082
+ .sv-ep__summary {
1083
+ margin: 0 18px 12px;
1084
+ padding: 10px 12px;
1085
+ border: 1px solid var(--ep-danger);
1086
+ border-left-width: 3px;
1087
+ border-radius: 6px;
1088
+ background: color-mix(in srgb, var(--ep-danger) 7%, var(--sg-bg, #fff));
1089
+ }
1090
+ .sv-ep__summary-title {
1091
+ margin: 0 0 6px;
1092
+ font-size: 12.5px;
1093
+ font-weight: 600;
1094
+ color: var(--ep-danger);
1095
+ }
1096
+ .sv-ep__summary ul {
1097
+ margin: 0;
1098
+ padding-left: 18px;
1099
+ display: flex;
1100
+ flex-direction: column;
1101
+ gap: 3px;
1102
+ }
1103
+ .sv-ep__summary li {
1104
+ font-size: 12px;
1105
+ color: var(--sg-fg, #0f172a);
1106
+ }
1107
+ .sv-ep__summary button {
1108
+ background: none;
1109
+ border: 0;
1110
+ padding: 0;
1111
+ font: inherit;
1112
+ color: inherit;
1113
+ text-align: left;
1114
+ cursor: pointer;
1115
+ text-decoration: underline;
1116
+ text-underline-offset: 2px;
1117
+ }
1118
+ .sv-ep__submit-err {
1119
+ margin: 0;
1120
+ padding: 0 18px 4px;
1121
+ font-size: 12px;
1122
+ color: var(--ep-danger);
1123
+ }
1124
+ .sv-ep__footer {
1125
+ display: flex;
1126
+ justify-content: flex-end;
1127
+ gap: 8px;
1128
+ padding: 14px 18px;
1129
+ border-top: 1px solid var(--ep-border);
1130
+ background: var(--ep-subtle);
1131
+ }
1132
+ .sv-ep__btn {
1133
+ padding: 8px 16px;
1134
+ font: inherit;
1135
+ font-weight: 550;
1136
+ color: var(--ep-fg);
1137
+ background: var(--ep-bg);
1138
+ border: 1px solid var(--ep-border);
1139
+ border-radius: 8px;
1140
+ cursor: pointer;
1141
+ }
1142
+ .sv-ep__btn:hover:not(:disabled) {
1143
+ background: var(--ep-subtle);
1144
+ }
1145
+ .sv-ep__btn--primary {
1146
+ color: var(--ep-on-accent);
1147
+ background: var(--ep-accent);
1148
+ border-color: var(--ep-accent);
1149
+ }
1150
+ .sv-ep__btn--primary:hover:not(:disabled) {
1151
+ filter: brightness(1.06);
1152
+ }
1153
+ .sv-ep__btn:disabled {
1154
+ opacity: 0.6;
1155
+ cursor: default;
1156
+ }
1157
+ </style>