@svgrid/grid 2.1.21 → 2.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/DockNodeView.svelte +306 -0
- package/dist/DockNodeView.svelte.d.ts +7 -0
- package/dist/DockPopoutHost.svelte +17 -0
- package/dist/DockPopoutHost.svelte.d.ts +14 -0
- package/dist/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +20 -42
- package/dist/SvColorInput.svelte +4 -3
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- package/dist/SvCountryInput.svelte +1 -1
- package/dist/SvDateRangeInput.svelte +1 -1
- package/dist/SvDateTimePicker.svelte +9 -3
- package/dist/SvDateTimePicker.svelte.d.ts +3 -0
- package/dist/SvDockLayout.svelte +244 -0
- package/dist/SvDockLayout.svelte.d.ts +77 -0
- package/dist/SvDockManager.svelte +922 -0
- package/dist/SvDockManager.svelte.d.ts +106 -0
- package/dist/SvDrawer.svelte +69 -47
- package/dist/SvDrawer.svelte.d.ts +4 -0
- package/dist/SvDropDownList.svelte +85 -13
- package/dist/SvDropDownList.svelte.d.ts +3 -0
- package/dist/SvField.svelte +21 -4
- package/dist/SvField.svelte.d.ts +1 -0
- package/dist/SvForm.svelte +201 -91
- package/dist/SvForm.svelte.d.ts +15 -22
- package/dist/SvGrid.controller.svelte.d.ts +32 -1
- package/dist/SvGrid.controller.svelte.js +156 -27
- package/dist/SvGrid.css +193 -5
- package/dist/SvGrid.svelte +357 -12
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridDropdown.svelte +8 -1
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +1 -1
- package/dist/SvMenuList.svelte +23 -71
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +4 -3
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- package/dist/SvPagination.svelte +18 -18
- package/dist/SvPasswordInput.svelte +3 -2
- package/dist/SvPasswordInput.svelte.d.ts +1 -1
- package/dist/SvPhoneInput.svelte +3 -2
- package/dist/SvPhoneInput.svelte.d.ts +1 -1
- package/dist/SvPopover.svelte +1 -1
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +12 -23
- package/dist/SvTour.svelte +32 -4
- package/dist/SvTreeSelect.svelte +1 -0
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10907 -6444
- package/dist/cdn/svgrid.svelte-external.js +9622 -5475
- package/dist/columns.js +6 -0
- package/dist/conditional-formatting.d.ts +67 -3
- package/dist/conditional-formatting.js +124 -17
- package/dist/createCarousel.svelte.d.ts +72 -0
- package/dist/createCarousel.svelte.js +80 -0
- package/dist/createCommand.svelte.d.ts +62 -0
- package/dist/createCommand.svelte.js +109 -0
- package/dist/createForm.svelte.d.ts +62 -0
- package/dist/createForm.svelte.js +374 -0
- package/dist/createMenu.svelte.d.ts +88 -0
- package/dist/createMenu.svelte.js +153 -0
- package/dist/createOverlay.svelte.d.ts +38 -0
- package/dist/createOverlay.svelte.js +68 -0
- package/dist/createPagination.d.ts +74 -0
- package/dist/createPagination.js +71 -0
- package/dist/createPopoverSelect.svelte.js +6 -12
- package/dist/createStepper.d.ts +44 -0
- package/dist/createStepper.js +26 -0
- package/dist/createTooltip.svelte.d.ts +32 -0
- package/dist/createTooltip.svelte.js +61 -0
- package/dist/datetime/timezone.d.ts +36 -0
- package/dist/datetime/timezone.js +110 -0
- package/dist/dock-context.d.ts +52 -0
- package/dist/dock-context.js +1 -0
- package/dist/dock-manager-model.d.ts +136 -0
- package/dist/dock-manager-model.js +514 -0
- package/dist/dock-model.d.ts +105 -0
- package/dist/dock-model.js +288 -0
- package/dist/dock-popout.d.ts +30 -0
- package/dist/dock-popout.js +81 -0
- package/dist/editor-contract.d.ts +2 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +35 -2
- package/dist/filtering/excel-filters.d.ts +18 -1
- package/dist/filtering/excel-filters.js +56 -0
- package/dist/form-field.d.ts +73 -0
- package/dist/form-field.js +15 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +40 -4
- package/dist/list-nav.d.ts +18 -0
- package/dist/list-nav.js +30 -0
- package/dist/list-option.d.ts +2 -0
- package/dist/menu-item.d.ts +20 -0
- package/dist/menu-item.js +1 -0
- package/dist/menus.d.ts +5 -0
- package/dist/menus.js +68 -16
- package/dist/panel-resize.d.ts +25 -0
- package/dist/panel-resize.js +40 -0
- package/dist/popover.d.ts +29 -3
- package/dist/popover.js +22 -6
- package/dist/recurrence.d.ts +33 -7
- package/dist/recurrence.js +151 -13
- package/dist/row-drag.d.ts +8 -0
- package/dist/row-drag.js +11 -0
- package/dist/scheduler-ical.d.ts +30 -0
- package/dist/scheduler-ical.js +180 -0
- package/dist/scheduler-model.d.ts +269 -0
- package/dist/scheduler-model.js +602 -0
- package/dist/scheduler-view.svelte.d.ts +22 -0
- package/dist/scheduler-view.svelte.js +13 -0
- package/dist/spreadsheet.js +20 -12
- package/dist/ui-app.types.d.ts +3 -0
- package/package.json +5 -1
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +20 -42
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +4 -3
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- package/src/SvCountryInput.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvDateTimePicker.svelte +9 -3
- package/src/SvDockLayout.svelte +244 -0
- package/src/SvDockManager.svelte +922 -0
- package/src/SvDrawer.svelte +69 -47
- package/src/SvDropDownList.svelte +85 -13
- package/src/SvField.svelte +21 -4
- package/src/SvForm.svelte +201 -91
- package/src/SvForm.test.ts +299 -0
- package/src/SvGrid.controller.svelte.ts +152 -28
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +357 -12
- package/src/SvGrid.types.ts +463 -1
- package/src/SvGridDropdown.svelte +8 -1
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +1 -1
- package/src/SvMenu.test.ts +24 -0
- package/src/SvMenuList.svelte +23 -71
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +4 -3
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +1 -1
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +12 -23
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +32 -4
- package/src/SvTreeSelect.svelte +1 -0
- package/src/build-api.ts +74 -51
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCommand.svelte.ts +125 -0
- package/src/createForm.svelte.ts +333 -0
- package/src/createMenu.svelte.ts +202 -0
- package/src/createOverlay.svelte.ts +101 -0
- package/src/createPagination.test.ts +65 -0
- package/src/createPagination.ts +84 -0
- package/src/createPopoverSelect.svelte.ts +5 -9
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- package/src/datetime/timezone.test.ts +69 -0
- package/src/datetime/timezone.ts +134 -0
- package/src/dock-context.ts +47 -0
- package/src/dock-manager-model.test.ts +275 -0
- package/src/dock-manager-model.ts +593 -0
- package/src/dock-model.test.ts +163 -0
- package/src/dock-model.ts +368 -0
- package/src/dock-popout.ts +94 -0
- package/src/editor-contract.ts +2 -0
- package/src/filter-operators.test.ts +59 -0
- package/src/filter-operators.ts +35 -2
- package/src/filtering/excel-filters.test.ts +86 -1
- package/src/filtering/excel-filters.ts +65 -0
- package/src/form-field.ts +87 -0
- package/src/index.ts +158 -4
- package/src/list-nav.test.ts +49 -0
- package/src/list-nav.ts +29 -0
- package/src/list-option.ts +2 -0
- package/src/menu-item.ts +21 -0
- package/src/menus.test.ts +48 -0
- package/src/menus.ts +73 -16
- package/src/panel-resize.ts +52 -0
- package/src/popover.bounds.test.ts +93 -0
- package/src/popover.ts +51 -7
- package/src/recurrence.test.ts +88 -1
- package/src/recurrence.ts +166 -15
- package/src/row-drag.ts +10 -0
- package/src/scheduler-ical.test.ts +68 -0
- package/src/scheduler-ical.ts +180 -0
- package/src/scheduler-model.test.ts +546 -0
- package/src/scheduler-model.ts +853 -0
- package/src/scheduler-view.svelte.ts +33 -0
- package/src/spreadsheet.ts +20 -12
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createForm - the HEADLESS core behind <SvForm>: values / errors / touched
|
|
3
|
+
* state, required + rule + custom validation (validate-on-blur-then-live), and a
|
|
4
|
+
* submit that validates every field first. No markup, no controls - you render
|
|
5
|
+
* the fields however you like and read/write through the core.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createForm } from '@svgrid/grid'
|
|
10
|
+
* const form = createForm({ fields: () => fields, onSubmit: (v) => save(v) })
|
|
11
|
+
* </script>
|
|
12
|
+
* <form onsubmit={(e) => { e.preventDefault(); form.submit() }}>
|
|
13
|
+
* {#each fields as f}
|
|
14
|
+
* <input value={form.value(f.name) ?? ''}
|
|
15
|
+
* oninput={(e) => form.setValue(f.name, e.currentTarget.value)}
|
|
16
|
+
* onblur={() => form.handleBlur(f.name)} />
|
|
17
|
+
* {#if form.error(f.name)}<span>{form.error(f.name)}</span>{/if}
|
|
18
|
+
* {/each}
|
|
19
|
+
* </form>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { flattenFields, type FormField, type FormEntry } from './form-field'
|
|
23
|
+
import { runRules } from './validators'
|
|
24
|
+
|
|
25
|
+
export type FormConfig = {
|
|
26
|
+
/** The schema - a mix of flat fields and titled sections. */
|
|
27
|
+
fields: () => ReadonlyArray<FormEntry>
|
|
28
|
+
/** Seed values on creation. */
|
|
29
|
+
initial?: Record<string, any>
|
|
30
|
+
/** Called with the (visible-field) values on a valid submit. May be async;
|
|
31
|
+
* `submitting` stays true until the returned promise settles. */
|
|
32
|
+
onSubmit?: (values: Record<string, any>) => void | Promise<void>
|
|
33
|
+
onChange?: (values: Record<string, any>) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Deep-ish equality for dirty tracking: identity for primitives, structural
|
|
37
|
+
* (JSON) for arrays/objects. Good enough for form values. */
|
|
38
|
+
function eq(a: unknown, b: unknown): boolean {
|
|
39
|
+
if (Object.is(a, b)) return true
|
|
40
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
41
|
+
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a `boolean | (values) => boolean` field flag. */
|
|
47
|
+
function resolveFlag(
|
|
48
|
+
flag: boolean | ((values: Record<string, any>) => boolean) | undefined,
|
|
49
|
+
dflt: boolean,
|
|
50
|
+
values: Record<string, any>,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (flag == null) return dflt
|
|
53
|
+
return typeof flag === 'function' ? !!flag(values) : !!flag
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createForm(config: FormConfig) {
|
|
57
|
+
let values = $state<Record<string, any>>({ ...(config.initial ?? {}) })
|
|
58
|
+
let errors = $state<Record<string, string>>({})
|
|
59
|
+
let touched = $state<Record<string, boolean>>({})
|
|
60
|
+
let submitting = $state(false)
|
|
61
|
+
let validating = $state<Record<string, boolean>>({})
|
|
62
|
+
// Baseline for dirty tracking + reset. Rebased when `reset(next)` is passed.
|
|
63
|
+
let baseline = { ...(config.initial ?? {}) }
|
|
64
|
+
// Async-validation bookkeeping: debounce timers + a per-field run sequence so
|
|
65
|
+
// only the latest response wins (stale responses are dropped).
|
|
66
|
+
const asyncTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
67
|
+
const asyncSeq = new Map<string, number>()
|
|
68
|
+
|
|
69
|
+
// The flat field list (sections flattened) drives all values/validation.
|
|
70
|
+
const flat = () => flattenFields(config.fields())
|
|
71
|
+
const fieldOf = (name: string) => flat().find((f) => f.name === name)
|
|
72
|
+
const isVisible = (name: string) => resolveFlag(fieldOf(name)?.visible, true, values)
|
|
73
|
+
const isDisabled = (name: string) => resolveFlag(fieldOf(name)?.disabled, false, values)
|
|
74
|
+
|
|
75
|
+
function setError(name: string, err: string | null | undefined) {
|
|
76
|
+
const next = { ...errors }
|
|
77
|
+
if (err) next[name] = err
|
|
78
|
+
else delete next[name]
|
|
79
|
+
errors = next
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The synchronous checks: required, then declarative rules, then a sync
|
|
83
|
+
* `validate`. Returns the first error message, or null. `ctx` is the values
|
|
84
|
+
* object rules/validate see - the form values, or a single item for array
|
|
85
|
+
* rows (so within-row cross-field rules work). */
|
|
86
|
+
function syncError(field: FormField, v: any, ctx: Record<string, any> = values): string | null {
|
|
87
|
+
if (field.required && (v == null || v === '' || (Array.isArray(v) && !v.length)))
|
|
88
|
+
return `${field.label} is required`
|
|
89
|
+
if (field.rules) { const e = runRules(v, field.rules, ctx); if (e) return e }
|
|
90
|
+
if (field.validate) { const e = field.validate(v, ctx); if (e) return e }
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function cancelAsync(name: string) {
|
|
95
|
+
const t = asyncTimers.get(name)
|
|
96
|
+
if (t) { clearTimeout(t); asyncTimers.delete(name) }
|
|
97
|
+
// Bump the sequence so any in-flight response is treated as stale.
|
|
98
|
+
asyncSeq.set(name, (asyncSeq.get(name) ?? 0) + 1)
|
|
99
|
+
if (validating[name]) validating = { ...validating, [name]: false }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run a field's async validator now (guarded against stale responses). */
|
|
103
|
+
async function runAsync(name: string): Promise<boolean> {
|
|
104
|
+
const field = fieldOf(name)
|
|
105
|
+
if (!field?.asyncValidate || !isVisible(name)) return true
|
|
106
|
+
if (syncError(field, values[name])) return false // sync error already shown
|
|
107
|
+
const seq = (asyncSeq.get(name) ?? 0) + 1
|
|
108
|
+
asyncSeq.set(name, seq)
|
|
109
|
+
validating = { ...validating, [name]: true }
|
|
110
|
+
try {
|
|
111
|
+
const err = await field.asyncValidate(values[name], values)
|
|
112
|
+
if (asyncSeq.get(name) !== seq) return true // superseded - ignore
|
|
113
|
+
setError(name, err ?? null)
|
|
114
|
+
return !err
|
|
115
|
+
} finally {
|
|
116
|
+
if (asyncSeq.get(name) === seq) validating = { ...validating, [name]: false }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scheduleAsync(name: string) {
|
|
121
|
+
const field = fieldOf(name)
|
|
122
|
+
if (!field?.asyncValidate) return
|
|
123
|
+
const prev = asyncTimers.get(name)
|
|
124
|
+
if (prev) clearTimeout(prev)
|
|
125
|
+
asyncTimers.set(name, setTimeout(() => { asyncTimers.delete(name); runAsync(name) }, field.asyncDebounce ?? 300))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateField(name: string): boolean {
|
|
129
|
+
const field = fieldOf(name)
|
|
130
|
+
if (!field) return true
|
|
131
|
+
// Hidden fields never block submit; clear any stale error + pending check.
|
|
132
|
+
if (!isVisible(name)) { setError(name, null); cancelAsync(name); return true }
|
|
133
|
+
if (field.type === 'array') return validateArray(field)
|
|
134
|
+
const err = syncError(field, values[name])
|
|
135
|
+
setError(name, err)
|
|
136
|
+
if (err) { cancelAsync(name); return false }
|
|
137
|
+
// Sync passed - kick off the debounced async check if there is one.
|
|
138
|
+
if (field.asyncValidate) scheduleAsync(name)
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Field arrays (repeatable groups) -----------------------------------
|
|
143
|
+
const arrayItems = (name: string): Record<string, any>[] =>
|
|
144
|
+
Array.isArray(values[name]) ? values[name] : []
|
|
145
|
+
const itemValue = (name: string, i: number, field: string) => arrayItems(name)[i]?.[field]
|
|
146
|
+
const itemError = (name: string, i: number, field: string) => errors[`${name}.${i}.${field}`]
|
|
147
|
+
|
|
148
|
+
function clearNamespacedErrors(name: string) {
|
|
149
|
+
const prefix = `${name}.`
|
|
150
|
+
const next = { ...errors }
|
|
151
|
+
let changed = false
|
|
152
|
+
for (const k of Object.keys(next)) if (k.startsWith(prefix)) { delete next[k]; changed = true }
|
|
153
|
+
if (changed) errors = next
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setItems(name: string, next: Record<string, any>[]) {
|
|
157
|
+
values = { ...values, [name]: next }
|
|
158
|
+
config.onChange?.(values)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function addItem(name: string, item?: Record<string, any>) {
|
|
162
|
+
const seed = item ?? Object.fromEntries((fieldOf(name)?.itemFields ?? []).map((f) => [f.name, undefined]))
|
|
163
|
+
setItems(name, [...arrayItems(name), seed])
|
|
164
|
+
}
|
|
165
|
+
function removeItem(name: string, i: number) {
|
|
166
|
+
const items = arrayItems(name).slice()
|
|
167
|
+
items.splice(i, 1)
|
|
168
|
+
clearNamespacedErrors(name) // item indices shift - drop stale keys
|
|
169
|
+
setItems(name, items)
|
|
170
|
+
}
|
|
171
|
+
function moveItem(name: string, from: number, to: number) {
|
|
172
|
+
const items = arrayItems(name).slice()
|
|
173
|
+
if (from < 0 || from >= items.length || to < 0 || to >= items.length) return
|
|
174
|
+
const [it] = items.splice(from, 1)
|
|
175
|
+
items.splice(to, 0, it as Record<string, any>)
|
|
176
|
+
clearNamespacedErrors(name)
|
|
177
|
+
setItems(name, items)
|
|
178
|
+
}
|
|
179
|
+
function setItemValue(name: string, i: number, field: string, v: any) {
|
|
180
|
+
setItems(name, arrayItems(name).map((it, ix) => (ix === i ? { ...it, [field]: v } : it)))
|
|
181
|
+
if (touched[`${name}.${i}.${field}`]) validateItemField(name, i, field)
|
|
182
|
+
}
|
|
183
|
+
function handleItemBlur(name: string, i: number, field: string) {
|
|
184
|
+
touched = { ...touched, [`${name}.${i}.${field}`]: true }
|
|
185
|
+
validateItemField(name, i, field)
|
|
186
|
+
}
|
|
187
|
+
function validateItemField(name: string, i: number, fieldName: string): boolean {
|
|
188
|
+
const itemField = fieldOf(name)?.itemFields?.find((f) => f.name === fieldName)
|
|
189
|
+
if (!itemField) return true
|
|
190
|
+
const item = arrayItems(name)[i] ?? {}
|
|
191
|
+
const err = syncError(itemField, item[fieldName], item)
|
|
192
|
+
setError(`${name}.${i}.${fieldName}`, err)
|
|
193
|
+
return !err
|
|
194
|
+
}
|
|
195
|
+
function validateArray(field: FormField): boolean {
|
|
196
|
+
const name = field.name
|
|
197
|
+
const items = arrayItems(name)
|
|
198
|
+
let arrErr: string | null = null
|
|
199
|
+
if (field.required && items.length === 0) arrErr = `${field.label} is required`
|
|
200
|
+
else if (field.minItems != null && items.length < field.minItems) arrErr = `Add at least ${field.minItems} item(s)`
|
|
201
|
+
else if (field.maxItems != null && items.length > field.maxItems) arrErr = `At most ${field.maxItems} item(s)`
|
|
202
|
+
setError(name, arrErr)
|
|
203
|
+
let ok = !arrErr
|
|
204
|
+
clearNamespacedErrors(name) // rebuild fresh per-item errors
|
|
205
|
+
for (let i = 0; i < items.length; i++)
|
|
206
|
+
for (const itf of field.itemFields ?? [])
|
|
207
|
+
if (!validateItemField(name, i, itf.name)) ok = false
|
|
208
|
+
return ok
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function setValue(name: string, v: any) {
|
|
212
|
+
values = { ...values, [name]: v }
|
|
213
|
+
config.onChange?.(values)
|
|
214
|
+
// Re-validate live once the field has been blurred at least once.
|
|
215
|
+
if (touched[name]) validateField(name)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleBlur(name: string) {
|
|
219
|
+
touched = { ...touched, [name]: true }
|
|
220
|
+
validateField(name)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function submit(): Promise<boolean> {
|
|
224
|
+
let ok = true
|
|
225
|
+
const t: Record<string, boolean> = { ...touched }
|
|
226
|
+
for (const f of flat()) {
|
|
227
|
+
if (!isVisible(f.name)) continue
|
|
228
|
+
t[f.name] = true
|
|
229
|
+
if (!validateField(f.name)) ok = false
|
|
230
|
+
}
|
|
231
|
+
touched = t
|
|
232
|
+
if (!ok) return false
|
|
233
|
+
// Await async validators (run now, skipping their debounce). Any failure blocks.
|
|
234
|
+
const asyncFields = flat().filter((f) => f.asyncValidate && isVisible(f.name))
|
|
235
|
+
if (asyncFields.length) {
|
|
236
|
+
for (const f of asyncFields) { const timer = asyncTimers.get(f.name); if (timer) { clearTimeout(timer); asyncTimers.delete(f.name) } }
|
|
237
|
+
const results = await Promise.all(asyncFields.map((f) => runAsync(f.name)))
|
|
238
|
+
if (results.some((r) => !r)) return false
|
|
239
|
+
}
|
|
240
|
+
// Exclude hidden fields from the submitted payload; keep any non-field keys.
|
|
241
|
+
const payload = { ...values }
|
|
242
|
+
for (const f of flat()) if (!isVisible(f.name)) delete payload[f.name]
|
|
243
|
+
submitting = true
|
|
244
|
+
try {
|
|
245
|
+
await config.onSubmit?.(payload)
|
|
246
|
+
} finally {
|
|
247
|
+
submitting = false
|
|
248
|
+
}
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Validate a subset of fields by name (sync + their async validators). Used
|
|
253
|
+
* to gate a wizard step before advancing. Returns true when all pass. */
|
|
254
|
+
async function validateFields(names: string[]): Promise<boolean> {
|
|
255
|
+
let ok = true
|
|
256
|
+
const t = { ...touched }
|
|
257
|
+
for (const name of names) {
|
|
258
|
+
if (!isVisible(name)) continue
|
|
259
|
+
t[name] = true
|
|
260
|
+
if (!validateField(name)) ok = false
|
|
261
|
+
}
|
|
262
|
+
touched = t
|
|
263
|
+
if (!ok) return false
|
|
264
|
+
const asyncNames = names.filter((n) => fieldOf(n)?.asyncValidate && isVisible(n))
|
|
265
|
+
if (asyncNames.length) {
|
|
266
|
+
for (const n of asyncNames) { const timer = asyncTimers.get(n); if (timer) { clearTimeout(timer); asyncTimers.delete(n) } }
|
|
267
|
+
const results = await Promise.all(asyncNames.map((n) => runAsync(n)))
|
|
268
|
+
if (results.some((r) => !r)) return false
|
|
269
|
+
}
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Restore values to the baseline (or a new one), clearing errors + touched. */
|
|
274
|
+
function reset(next?: Record<string, any>) {
|
|
275
|
+
if (next) baseline = { ...next }
|
|
276
|
+
values = { ...baseline }
|
|
277
|
+
errors = {}
|
|
278
|
+
touched = {}
|
|
279
|
+
config.onChange?.(values)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Inject errors (e.g. server-side validation after submit) and mark those
|
|
283
|
+
* fields touched so the messages show. Empty message clears a field's error. */
|
|
284
|
+
function setErrors(map: Record<string, string>) {
|
|
285
|
+
const nextErr = { ...errors }
|
|
286
|
+
const nextTouched = { ...touched }
|
|
287
|
+
for (const [k, msg] of Object.entries(map)) {
|
|
288
|
+
if (msg) { nextErr[k] = msg; nextTouched[k] = true }
|
|
289
|
+
else delete nextErr[k]
|
|
290
|
+
}
|
|
291
|
+
errors = nextErr
|
|
292
|
+
touched = nextTouched
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const isFieldDirty = (name: string) => !eq(values[name], baseline[name])
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
get values() { return values },
|
|
299
|
+
get submitting() { return submitting },
|
|
300
|
+
get isDirty() {
|
|
301
|
+
const keys = new Set([...Object.keys(values), ...Object.keys(baseline)])
|
|
302
|
+
for (const k of keys) if (!eq(values[k], baseline[k])) return true
|
|
303
|
+
return false
|
|
304
|
+
},
|
|
305
|
+
value: (name: string) => values[name],
|
|
306
|
+
error: (name: string): string | undefined => errors[name],
|
|
307
|
+
isTouched: (name: string) => !!touched[name],
|
|
308
|
+
hasError: (name: string) => !!errors[name],
|
|
309
|
+
isValidating: (name: string) => !!validating[name],
|
|
310
|
+
isFieldDirty,
|
|
311
|
+
isVisible,
|
|
312
|
+
isDisabled,
|
|
313
|
+
entries: () => config.fields(),
|
|
314
|
+
// Field arrays
|
|
315
|
+
arrayItems,
|
|
316
|
+
itemValue,
|
|
317
|
+
itemError,
|
|
318
|
+
addItem,
|
|
319
|
+
removeItem,
|
|
320
|
+
moveItem,
|
|
321
|
+
setItemValue,
|
|
322
|
+
handleItemBlur,
|
|
323
|
+
setValue,
|
|
324
|
+
handleBlur,
|
|
325
|
+
validateField,
|
|
326
|
+
validateFields,
|
|
327
|
+
submit,
|
|
328
|
+
reset,
|
|
329
|
+
setErrors,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export type Form = ReturnType<typeof createForm>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createMenu - the HEADLESS core behind <SvMenuList> / <SvMenu>, in the same
|
|
3
|
+
* spirit as `createListbox`: a runes-based state machine for ONE menu level -
|
|
4
|
+
* roving focus over enabled items, submenu open/collapse, and the full WAI-ARIA
|
|
5
|
+
* menu keyboard contract (arrows, Home/End, Enter/Space, Escape, Tab, and
|
|
6
|
+
* rtl-aware ArrowLeft/Right) - with **prop-getters** you spread onto your own
|
|
7
|
+
* markup. No styles, no DOM assumptions.
|
|
8
|
+
*
|
|
9
|
+
* A menu tree is recursive, so you create one core per nesting level (exactly
|
|
10
|
+
* what the recursive <SvMenuList> does). The styled component is just one
|
|
11
|
+
* renderer over this core.
|
|
12
|
+
*
|
|
13
|
+
* ```svelte
|
|
14
|
+
* <script lang="ts">
|
|
15
|
+
* import { createMenu, type MenuItem } from '@svgrid/grid'
|
|
16
|
+
* let listEl = $state<HTMLElement | null>(null)
|
|
17
|
+
* const menu = createMenu({
|
|
18
|
+
* items: () => items,
|
|
19
|
+
* onSelect: (it) => run(it),
|
|
20
|
+
* onClose: () => close(),
|
|
21
|
+
* focusItem: (i) => queueMicrotask(() => listEl?.querySelector(`[data-mi="${i}"]`)?.focus()),
|
|
22
|
+
* })
|
|
23
|
+
* </script>
|
|
24
|
+
* <div bind:this={listEl} {...menu.listProps()}>
|
|
25
|
+
* {#each items as item, i}
|
|
26
|
+
* <button {...menu.itemProps(i)}>{item.label}</button>
|
|
27
|
+
* {/each}
|
|
28
|
+
* </div>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import type { MenuItem } from './menu-item'
|
|
32
|
+
import type { EditorDir } from './editor-contract'
|
|
33
|
+
import { enabledIndices, wrapMove } from './list-nav'
|
|
34
|
+
|
|
35
|
+
/** Reactive inputs are getters so the core tracks live prop changes (the same
|
|
36
|
+
* controlled pattern as `createListbox`). */
|
|
37
|
+
export type MenuConfig = {
|
|
38
|
+
items: () => ReadonlyArray<MenuItem>
|
|
39
|
+
/** Report the chosen leaf item (a non-parent menuitem). */
|
|
40
|
+
onSelect: (item: MenuItem) => void
|
|
41
|
+
/** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
|
|
42
|
+
onClose: () => void
|
|
43
|
+
/** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
|
|
44
|
+
* collapse back into the opener. The root level has nowhere to collapse. */
|
|
45
|
+
submenu?: () => boolean
|
|
46
|
+
/** Collapse just THIS submenu level and refocus the item that opened it. */
|
|
47
|
+
onCollapse?: () => void
|
|
48
|
+
/** Text direction; under `rtl` the open/collapse arrow keys swap. */
|
|
49
|
+
dir?: () => EditorDir | undefined
|
|
50
|
+
/** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
|
|
51
|
+
* core stays DOM-free. Receives the item index that should take focus. */
|
|
52
|
+
focusItem?: (index: number) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type MenuItemProps = {
|
|
56
|
+
role: 'menuitem'
|
|
57
|
+
'data-mi': number
|
|
58
|
+
tabindex: 0 | -1
|
|
59
|
+
'aria-haspopup': 'menu' | undefined
|
|
60
|
+
'aria-expanded': boolean | undefined
|
|
61
|
+
'aria-disabled': true | undefined
|
|
62
|
+
disabled: boolean | undefined
|
|
63
|
+
onkeydown: (event: KeyboardEvent) => void
|
|
64
|
+
onpointerenter: () => void
|
|
65
|
+
onclick: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let uid = 0
|
|
69
|
+
|
|
70
|
+
export function createMenu(config: MenuConfig) {
|
|
71
|
+
const items = () => config.items()
|
|
72
|
+
const submenu = () => config.submenu?.() ?? false
|
|
73
|
+
const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
|
|
74
|
+
|
|
75
|
+
// -1 = nothing focused yet (the first enabled item is the tab stop).
|
|
76
|
+
let active = $state(-1)
|
|
77
|
+
let openSub = $state(-1)
|
|
78
|
+
|
|
79
|
+
// Real (non-separator, non-disabled) item indices, for roving focus.
|
|
80
|
+
const focusables = $derived(
|
|
81
|
+
enabledIndices(items().length, (i) => {
|
|
82
|
+
const it = items()[i]
|
|
83
|
+
return !it || it.separator === true || it.disabled === true
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
/** Move focus to item `i` (keyboard): highlight it, close any open submenu at
|
|
88
|
+
* this level, and drive real DOM focus through the host's hook. */
|
|
89
|
+
function focus(i: number) {
|
|
90
|
+
active = i
|
|
91
|
+
openSub = -1
|
|
92
|
+
config.focusItem?.(i)
|
|
93
|
+
}
|
|
94
|
+
function move(delta: number) {
|
|
95
|
+
const next = wrapMove(focusables, active, delta)
|
|
96
|
+
if (next >= 0) focus(next)
|
|
97
|
+
}
|
|
98
|
+
function first() {
|
|
99
|
+
const f = focusables[0]
|
|
100
|
+
if (f != null) focus(f)
|
|
101
|
+
}
|
|
102
|
+
function last() {
|
|
103
|
+
const l = focusables.at(-1)
|
|
104
|
+
if (l != null) focus(l)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
|
|
108
|
+
* item's submenu (mirrors the pointerenter behaviour). */
|
|
109
|
+
function hover(i: number) {
|
|
110
|
+
const it = items()[i]
|
|
111
|
+
if (!it || it.disabled || it.separator) return
|
|
112
|
+
active = i
|
|
113
|
+
openSub = it.children?.length ? i : -1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function choose(item: MenuItem) {
|
|
117
|
+
if (item.disabled || item.separator) return
|
|
118
|
+
if (item.children?.length) {
|
|
119
|
+
openSub = items().indexOf(item)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
item.onSelect?.()
|
|
123
|
+
config.onSelect(item)
|
|
124
|
+
config.onClose()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onItemKeydown(e: KeyboardEvent, i: number) {
|
|
128
|
+
const item = items()[i]
|
|
129
|
+
// Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
|
|
130
|
+
const openThis = () => {
|
|
131
|
+
if (item?.children?.length) {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
openSub = i
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Collapse just THIS list back into its parent (opposite key under rtl).
|
|
137
|
+
const collapseThis = () => {
|
|
138
|
+
if (submenu()) {
|
|
139
|
+
e.preventDefault()
|
|
140
|
+
config.onCollapse?.()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
switch (e.key) {
|
|
144
|
+
case 'ArrowDown': e.preventDefault(); move(1); break
|
|
145
|
+
case 'ArrowUp': e.preventDefault(); move(-1); break
|
|
146
|
+
case 'Home': e.preventDefault(); first(); break
|
|
147
|
+
case 'End': e.preventDefault(); last(); break
|
|
148
|
+
case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
|
|
149
|
+
case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
|
|
150
|
+
case 'Enter':
|
|
151
|
+
case ' ': e.preventDefault(); if (item) choose(item); break
|
|
152
|
+
case 'Escape': e.preventDefault(); config.onClose(); break
|
|
153
|
+
// Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
|
|
154
|
+
// whole tree rather than trapping focus inside a portalled panel.
|
|
155
|
+
case 'Tab': config.onClose(); break
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function itemTabIndex(i: number): 0 | -1 {
|
|
160
|
+
return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
get active() { return active },
|
|
165
|
+
get openSub() { return openSub },
|
|
166
|
+
/** Enabled (focusable) item indices. */
|
|
167
|
+
get focusables() { return focusables },
|
|
168
|
+
isActive: (i: number) => i === active,
|
|
169
|
+
isSubOpen: (i: number) => i === openSub,
|
|
170
|
+
itemTabIndex,
|
|
171
|
+
move,
|
|
172
|
+
first,
|
|
173
|
+
last,
|
|
174
|
+
/** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
|
|
175
|
+
focus,
|
|
176
|
+
hover,
|
|
177
|
+
choose,
|
|
178
|
+
onItemKeydown,
|
|
179
|
+
/** Spread onto the menu container element. */
|
|
180
|
+
listProps: () => ({ role: 'menu' as const }),
|
|
181
|
+
/** Spread onto the menuitem element at `i`. */
|
|
182
|
+
itemProps: (i: number): MenuItemProps => {
|
|
183
|
+
const it = items()[i]
|
|
184
|
+
const hasKids = !!it?.children?.length
|
|
185
|
+
return {
|
|
186
|
+
role: 'menuitem',
|
|
187
|
+
'data-mi': i,
|
|
188
|
+
tabindex: itemTabIndex(i),
|
|
189
|
+
'aria-haspopup': hasKids ? 'menu' : undefined,
|
|
190
|
+
'aria-expanded': hasKids ? openSub === i : undefined,
|
|
191
|
+
'aria-disabled': it?.disabled ? true : undefined,
|
|
192
|
+
disabled: it?.disabled,
|
|
193
|
+
onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
|
|
194
|
+
onpointerenter: () => hover(i),
|
|
195
|
+
onclick: () => { if (it) choose(it) },
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type Menu = ReturnType<typeof createMenu>
|
|
202
|
+
export type { MenuItem }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createOverlay - the HEADLESS core behind the dialog-type overlays (<SvModal>,
|
|
3
|
+
* <SvDrawer>): the open -> (focus-trap + scroll-lock + Escape/backdrop dismissal)
|
|
4
|
+
* -> restore lifecycle, wired to the shared a11y primitives so nested overlays
|
|
5
|
+
* close top-first via the layer stack. Owns no markup and no styles - the
|
|
6
|
+
* component renders the backdrop/panel and spreads `dialogProps()` for the ARIA.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createOverlay } from '@svgrid/grid'
|
|
11
|
+
* let dialogEl = $state<HTMLElement | null>(null)
|
|
12
|
+
* const overlay = createOverlay({
|
|
13
|
+
* open: () => open,
|
|
14
|
+
* getDialog: () => dialogEl,
|
|
15
|
+
* onClose: () => (open = false),
|
|
16
|
+
* })
|
|
17
|
+
* </script>
|
|
18
|
+
* {#if open}
|
|
19
|
+
* <div class="backdrop">
|
|
20
|
+
* <div bind:this={dialogEl} {...overlay.dialogProps({ labelledBy: titleId })}>…</div>
|
|
21
|
+
* </div>
|
|
22
|
+
* {/if}
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import { createFocusTrap } from './a11y/focus-trap'
|
|
26
|
+
import { lockScroll } from './a11y/scroll-lock'
|
|
27
|
+
import { createDismissableLayer } from './a11y/dismissable'
|
|
28
|
+
|
|
29
|
+
export type OverlayConfig = {
|
|
30
|
+
/** Whether the overlay is currently open. */
|
|
31
|
+
open: () => boolean
|
|
32
|
+
/** The dialog panel element (component owns `bind:this`, passes a getter). */
|
|
33
|
+
getDialog: () => HTMLElement | null
|
|
34
|
+
/** Close the overlay (set your `open` state false + notify). */
|
|
35
|
+
onClose: () => void
|
|
36
|
+
/** Allow Escape to close. Default true. */
|
|
37
|
+
closeOnEsc?: () => boolean
|
|
38
|
+
/** Allow an outside/backdrop click to close. Default true. */
|
|
39
|
+
closeOnBackdrop?: () => boolean
|
|
40
|
+
/** Run once each time the overlay opens - reset transient state (drag, resize,
|
|
41
|
+
* swipe offset) before the trap/lock/layer are wired. */
|
|
42
|
+
onOpen?: () => void
|
|
43
|
+
/** Trap focus inside the dialog while open. Default true. */
|
|
44
|
+
trapFocus?: () => boolean
|
|
45
|
+
/** Lock body scroll while open. Default true. */
|
|
46
|
+
scrollLock?: () => boolean
|
|
47
|
+
/** Element to focus first when the dialog opens (e.g. a search input).
|
|
48
|
+
* Defaults to the trap's first focusable. */
|
|
49
|
+
initialFocus?: () => HTMLElement | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DialogProps = {
|
|
53
|
+
role: 'dialog'
|
|
54
|
+
'aria-modal': 'true'
|
|
55
|
+
'aria-labelledby': string | undefined
|
|
56
|
+
'aria-label': string | undefined
|
|
57
|
+
tabindex: -1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createOverlay(config: OverlayConfig) {
|
|
61
|
+
// While open: reset transient state, then hand focus, scroll-lock and
|
|
62
|
+
// Escape/backdrop dismissal to the shared primitives (one tested
|
|
63
|
+
// implementation; nested overlays close top-first via the layer stack).
|
|
64
|
+
$effect(() => {
|
|
65
|
+
if (!config.open() || !config.getDialog()) return
|
|
66
|
+
config.onOpen?.()
|
|
67
|
+
const el = config.getDialog()!
|
|
68
|
+
const trap = (config.trapFocus?.() ?? true)
|
|
69
|
+
? createFocusTrap(el, config.initialFocus ? { initialFocus: config.initialFocus } : undefined)
|
|
70
|
+
: null
|
|
71
|
+
trap?.activate()
|
|
72
|
+
const unlockScroll = (config.scrollLock?.() ?? true) ? lockScroll() : null
|
|
73
|
+
const layer = createDismissableLayer({
|
|
74
|
+
element: () => config.getDialog(),
|
|
75
|
+
onDismiss: (reason) => {
|
|
76
|
+
const allow = reason === 'escape' ? (config.closeOnEsc?.() ?? true) : (config.closeOnBackdrop?.() ?? true)
|
|
77
|
+
if (allow) config.onClose()
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
layer.activate()
|
|
81
|
+
return () => {
|
|
82
|
+
layer.release()
|
|
83
|
+
unlockScroll?.()
|
|
84
|
+
trap?.release()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
/** Spread onto the dialog panel element. Pass `labelledBy` (an id, when there
|
|
90
|
+
* is a visible title) or `label` (an accessible name when there isn't). */
|
|
91
|
+
dialogProps: (opts?: { labelledBy?: string; label?: string }): DialogProps => ({
|
|
92
|
+
role: 'dialog',
|
|
93
|
+
'aria-modal': 'true',
|
|
94
|
+
'aria-labelledby': opts?.labelledBy,
|
|
95
|
+
'aria-label': opts?.label,
|
|
96
|
+
tabindex: -1,
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type Overlay = ReturnType<typeof createOverlay>
|