@svgrid/enterprise 2.6.1 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ExpressionEditorHarness.svelte +47 -0
- package/dist/ExpressionEditorHarness.svelte.d.ts +15 -0
- package/dist/SvAdvancedFilter.svelte +208 -0
- package/dist/SvAdvancedFilter.svelte.d.ts +37 -0
- package/dist/SvExpressionEditor.svelte +251 -123
- package/dist/SvGridEditPanel.svelte +1157 -1157
- package/dist/advanced-filter/expr-columns-from-grid.d.ts +30 -0
- package/dist/advanced-filter/expr-columns-from-grid.js +44 -0
- package/dist/advanced-filter-enable.d.ts +5 -0
- package/dist/advanced-filter-enable.js +35 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +8274 -7706
- package/dist/designer/assets/GridMenus-Bjr19Tz_.js +7 -0
- package/dist/designer/assets/{SvListBox-BVzVfCG3.js → SvListBox-BB8rt4aP.js} +1 -1
- package/dist/designer/assets/{index-CKdwDseq.js → index-xjfKckuH.js} +43 -43
- package/dist/designer/assets/{js-scroller.svelte-tHanKJx0.js → js-scroller.svelte-CGkUUXwV.js} +2 -2
- package/dist/designer/assets/src-BT3LjWJL.css +1 -0
- package/dist/designer/assets/src-DDhAK0WP.js +2893 -0
- package/dist/designer/index.html +5 -5
- package/dist/expressions/builder-tree.d.ts +62 -0
- package/dist/expressions/builder-tree.js +133 -0
- package/dist/expressions/compile.d.ts +14 -0
- package/dist/expressions/compile.js +286 -0
- package/dist/expressions/expression-columns.d.ts +1 -11
- package/dist/expressions/expression-columns.js +40 -11
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/install.js +2 -0
- package/dist/node/studio.js +61 -13
- package/dist/sources/rest.js +25 -0
- package/dist/sources/supabase.js +49 -3
- package/dist/studio/emit-project.js +2125 -2125
- package/dist/studio/ui-components.generated.js +33 -0
- package/dist/sveltekit/in-memory.js +96 -3
- package/dist/sveltekit/query-plan.d.ts +32 -1
- package/dist/sveltekit/query-plan.js +88 -1
- package/dist/sveltekit/sql.d.ts +20 -0
- package/dist/sveltekit/sql.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/designer/assets/GridMenus-CW-rnHS9.js +0 -7
- package/dist/designer/assets/src-DCKhP5Xy.css +0 -1
- 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}>×</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}>×</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>
|