@rokkit/forms 1.0.0-next.137 → 1.0.0-next.139
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/src/lib/builder.svelte.d.ts +0 -18
- package/dist/src/lib/lookup.svelte.d.ts +18 -0
- package/package.json +1 -1
- package/src/FormRenderer.svelte +26 -53
- package/src/display/DisplayCardGrid.svelte +21 -16
- package/src/display/DisplayTable.svelte +18 -22
- package/src/display/DisplayValue.svelte +10 -16
- package/src/input/InputRadio.svelte +2 -2
- package/src/lib/builder.svelte.js +346 -210
- package/src/lib/lookup.svelte.js +226 -228
- package/src/lib/validation.js +128 -98
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @typedef {Object} FormElement
|
|
3
|
-
* @property {string} scope - JSON Pointer path (e.g., '#/email', '#/user/name')
|
|
4
|
-
* @property {string} type - Input type (text, number, range, checkbox, select, etc.)
|
|
5
|
-
* @property {any} value - Current value from data
|
|
6
|
-
* @property {boolean} override - Whether to use custom child snippet (from layout)
|
|
7
|
-
* @property {Object} props - Merged properties from schema + layout + validation
|
|
8
|
-
* @property {string} [props.label] - Display label (from layout)
|
|
9
|
-
* @property {string} [props.description] - Help text (from layout)
|
|
10
|
-
* @property {string} [props.placeholder] - Placeholder text (from layout)
|
|
11
|
-
* @property {boolean} [props.required] - Required flag (from schema)
|
|
12
|
-
* @property {number} [props.min] - Minimum value (from schema)
|
|
13
|
-
* @property {number} [props.max] - Maximum value (from schema)
|
|
14
|
-
* @property {Object} [props.message] - Validation message object
|
|
15
|
-
* @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
|
|
16
|
-
* @property {string} [props.message.text] - Message text content
|
|
17
|
-
* @property {boolean} [props.dirty] - Whether field value differs from initial
|
|
18
|
-
*/
|
|
19
1
|
/**
|
|
20
2
|
* FormBuilder class for dynamically generating forms from data structures
|
|
21
3
|
*/
|
|
@@ -16,6 +16,24 @@ export function createLookupManager(lookupConfigs: {
|
|
|
16
16
|
* Clears the entire lookup cache
|
|
17
17
|
*/
|
|
18
18
|
export function clearLookupCache(): void;
|
|
19
|
+
export type HookConfig = {
|
|
20
|
+
fetchHook: Function;
|
|
21
|
+
cacheKeyFn?: Function;
|
|
22
|
+
cacheTime: number;
|
|
23
|
+
transform?: Function;
|
|
24
|
+
};
|
|
25
|
+
export type UrlConfig = {
|
|
26
|
+
url: string;
|
|
27
|
+
cacheTime: number;
|
|
28
|
+
transform?: Function;
|
|
29
|
+
};
|
|
30
|
+
export type LookupApi = {
|
|
31
|
+
state: Object;
|
|
32
|
+
meta: Object;
|
|
33
|
+
fetch: Function;
|
|
34
|
+
clearCache: Function;
|
|
35
|
+
reset: Function;
|
|
36
|
+
};
|
|
19
37
|
export type LookupConfig = {
|
|
20
38
|
/**
|
|
21
39
|
* - URL template with optional placeholders (e.g., '/api/cities?country={country}')
|
package/package.json
CHANGED
package/src/FormRenderer.svelte
CHANGED
|
@@ -80,87 +80,60 @@
|
|
|
80
80
|
if (layout !== null && layout !== undefined) formBuilder.layout = layout
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
// Apply external validation result for a field path
|
|
84
|
+
function applyExternalValidation(fieldPath, value, event) {
|
|
85
|
+
if (!onvalidate) return
|
|
86
|
+
const result = onvalidate(fieldPath, value, event)
|
|
87
|
+
if (result && typeof result === 'object' && result.state) {
|
|
88
|
+
formBuilder.setFieldValidation(fieldPath, result)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
// Handle field value changes
|
|
84
93
|
function handleFieldChange(element, newValue) {
|
|
85
94
|
const fieldPath = element.scope.replace(/^#\//, '')
|
|
86
|
-
|
|
87
|
-
// Update builder (source of truth)
|
|
88
95
|
formBuilder.updateField(fieldPath, newValue)
|
|
89
|
-
|
|
90
|
-
// Sync builder data back to bindable prop
|
|
91
96
|
data = formBuilder.data
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
onupdate(data)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Built-in validation on change
|
|
99
|
-
if (validateOn === 'change') {
|
|
100
|
-
formBuilder.validateField(fieldPath)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// External validation callback (backward compat)
|
|
104
|
-
if (onvalidate) {
|
|
105
|
-
const result = onvalidate(fieldPath, newValue, 'change')
|
|
106
|
-
if (result && typeof result === 'object' && result.state) {
|
|
107
|
-
formBuilder.setFieldValidation(fieldPath, result)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
97
|
+
if (onupdate) onupdate(data)
|
|
98
|
+
if (validateOn === 'change') formBuilder.validateField(fieldPath)
|
|
99
|
+
applyExternalValidation(fieldPath, newValue, 'change')
|
|
110
100
|
}
|
|
111
101
|
|
|
112
102
|
// Handle blur events for validation
|
|
113
103
|
function handleFieldBlur(element) {
|
|
114
104
|
const fieldPath = element.scope.replace(/^#\//, '')
|
|
115
105
|
const currentValue = formBuilder.getValue(fieldPath)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (validateOn === 'blur') {
|
|
119
|
-
formBuilder.validateField(fieldPath)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// External validation callback (backward compat)
|
|
123
|
-
if (onvalidate) {
|
|
124
|
-
const result = onvalidate(fieldPath, currentValue, 'blur')
|
|
125
|
-
if (result && typeof result === 'object' && result.state) {
|
|
126
|
-
formBuilder.setFieldValidation(fieldPath, result)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
106
|
+
if (validateOn === 'blur') formBuilder.validateField(fieldPath)
|
|
107
|
+
applyExternalValidation(fieldPath, currentValue, 'blur')
|
|
129
108
|
}
|
|
130
109
|
|
|
131
110
|
// Submission state
|
|
132
111
|
let submitting = $state(false)
|
|
133
112
|
let formRoot = $state(null)
|
|
134
113
|
|
|
114
|
+
// Focus the first invalid field in the form
|
|
115
|
+
function focusFirstError() {
|
|
116
|
+
const firstError = formBuilder.errors[0]
|
|
117
|
+
if (!firstError || !formRoot) return
|
|
118
|
+
const field = formRoot.querySelector(
|
|
119
|
+
`[data-scope="#/${firstError.path}"] input, [data-scope="#/${firstError.path}"] select, [data-scope="#/${firstError.path}"] textarea`
|
|
120
|
+
)
|
|
121
|
+
field?.focus?.()
|
|
122
|
+
}
|
|
123
|
+
|
|
135
124
|
// Handle form submission: validate → focus first error → call onsubmit → snapshot
|
|
136
125
|
async function handleSubmit(e) {
|
|
137
126
|
e.preventDefault()
|
|
138
127
|
if (submitting || !onsubmit) return
|
|
139
128
|
|
|
140
|
-
// Validate all fields
|
|
141
129
|
formBuilder.validate()
|
|
130
|
+
applyExternalValidation('*', data, 'submit')
|
|
142
131
|
|
|
143
|
-
// External form-level validation
|
|
144
|
-
if (onvalidate) {
|
|
145
|
-
const result = onvalidate('*', data, 'submit')
|
|
146
|
-
if (result && typeof result === 'object' && result.state) {
|
|
147
|
-
formBuilder.setFieldValidation('*', result)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// If invalid, focus first error field
|
|
152
132
|
if (!formBuilder.isValid) {
|
|
153
|
-
|
|
154
|
-
if (firstError && formRoot) {
|
|
155
|
-
const field = formRoot.querySelector(
|
|
156
|
-
`[data-scope="#/${firstError.path}"] input, [data-scope="#/${firstError.path}"] select, [data-scope="#/${firstError.path}"] textarea`
|
|
157
|
-
)
|
|
158
|
-
field?.focus?.()
|
|
159
|
-
}
|
|
133
|
+
focusFirstError()
|
|
160
134
|
return
|
|
161
135
|
}
|
|
162
136
|
|
|
163
|
-
// Submit
|
|
164
137
|
submitting = true
|
|
165
138
|
try {
|
|
166
139
|
await onsubmit(formBuilder.getVisibleData(), { isValid: true, errors: [] })
|
|
@@ -4,29 +4,34 @@
|
|
|
4
4
|
* Each card displays fields with formatted values.
|
|
5
5
|
*/
|
|
6
6
|
import DisplayValue from './DisplayValue.svelte'
|
|
7
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
7
8
|
|
|
8
9
|
let { data = [], fields = [], select, title, onselect, class: className = '' } = $props()
|
|
9
10
|
|
|
10
11
|
let selectedIndex = $state(-1)
|
|
11
|
-
let selectedIndices =
|
|
12
|
+
let selectedIndices = new SvelteSet()
|
|
12
13
|
|
|
13
|
-
function
|
|
14
|
-
|
|
14
|
+
function selectOne(item, index) {
|
|
15
|
+
selectedIndex = index
|
|
16
|
+
onselect?.(item, item)
|
|
17
|
+
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
next.delete(index)
|
|
23
|
-
} else {
|
|
24
|
-
next.add(index)
|
|
25
|
-
}
|
|
26
|
-
selectedIndices = next
|
|
27
|
-
const selected = data.filter((_, i) => next.has(i))
|
|
28
|
-
onselect?.(selected, item)
|
|
19
|
+
function selectMany(item, index) {
|
|
20
|
+
const next = new SvelteSet(selectedIndices)
|
|
21
|
+
if (next.has(index)) {
|
|
22
|
+
next.delete(index)
|
|
23
|
+
} else {
|
|
24
|
+
next.add(index)
|
|
29
25
|
}
|
|
26
|
+
selectedIndices = next
|
|
27
|
+
const selected = data.filter((_, i) => next.has(i))
|
|
28
|
+
onselect?.(selected, item)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleCardClick(item, index) {
|
|
32
|
+
if (!select) return
|
|
33
|
+
if (select === 'one') selectOne(item, index)
|
|
34
|
+
else if (select === 'many') selectMany(item, index)
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function isSelected(index) {
|
|
@@ -19,36 +19,32 @@
|
|
|
19
19
|
}))
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
+
function formatDuration(minutes) {
|
|
23
|
+
const h = Math.floor(minutes / 60)
|
|
24
|
+
const m = minutes % 60
|
|
25
|
+
if (h > 0 && m > 0) return `${h}h ${m}m`
|
|
26
|
+
if (h > 0) return `${h}h`
|
|
27
|
+
return `${m}m`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const formatters = {
|
|
31
|
+
currency: (v) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v),
|
|
32
|
+
datetime: (v) => new Date(v).toLocaleString(),
|
|
33
|
+
duration: (v) => formatDuration(v),
|
|
34
|
+
number: (v) => new Intl.NumberFormat().format(v),
|
|
35
|
+
boolean: (v) => (v ? '✓' : '✗')
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
39
|
* Create a cell formatter function for a given display format.
|
|
24
40
|
* @param {string} format
|
|
25
41
|
* @returns {(value: unknown) => string}
|
|
26
42
|
*/
|
|
27
43
|
function createFormatter(format) {
|
|
44
|
+
const fn = formatters[format] ?? String
|
|
28
45
|
return (value) => {
|
|
29
46
|
if (value === null || value === undefined) return '—'
|
|
30
|
-
|
|
31
|
-
case 'currency':
|
|
32
|
-
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
|
|
33
|
-
/** @type {number} */ (value)
|
|
34
|
-
)
|
|
35
|
-
case 'datetime':
|
|
36
|
-
return new Date(/** @type {string|number} */ (value)).toLocaleString()
|
|
37
|
-
case 'duration': {
|
|
38
|
-
const minutes = /** @type {number} */ (value)
|
|
39
|
-
const h = Math.floor(minutes / 60)
|
|
40
|
-
const m = minutes % 60
|
|
41
|
-
if (h > 0 && m > 0) return `${h}h ${m}m`
|
|
42
|
-
if (h > 0) return `${h}h`
|
|
43
|
-
return `${m}m`
|
|
44
|
-
}
|
|
45
|
-
case 'number':
|
|
46
|
-
return new Intl.NumberFormat().format(/** @type {number} */ (value))
|
|
47
|
-
case 'boolean':
|
|
48
|
-
return value ? '✓' : '✗'
|
|
49
|
-
default:
|
|
50
|
-
return String(value)
|
|
51
|
-
}
|
|
47
|
+
return fn(value)
|
|
52
48
|
}
|
|
53
49
|
}
|
|
54
50
|
</script>
|
|
@@ -20,24 +20,18 @@
|
|
|
20
20
|
return `${m}m`
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
const valueFormatters = {
|
|
24
|
+
currency: (v) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v),
|
|
25
|
+
datetime: (v) => new Date(v).toLocaleString(),
|
|
26
|
+
duration: (v) => formatDuration(v),
|
|
27
|
+
number: (v) => new Intl.NumberFormat().format(v),
|
|
28
|
+
boolean: (v) => (v ? '✓' : '✗')
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
const formatted = $derived.by(() => {
|
|
24
32
|
if (value === null || value === undefined) return '—'
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
|
|
28
|
-
case 'datetime':
|
|
29
|
-
return new Date(value).toLocaleString()
|
|
30
|
-
case 'duration':
|
|
31
|
-
return formatDuration(value)
|
|
32
|
-
case 'number':
|
|
33
|
-
return new Intl.NumberFormat().format(value)
|
|
34
|
-
case 'boolean':
|
|
35
|
-
return value ? '✓' : '✗'
|
|
36
|
-
case 'badge':
|
|
37
|
-
return String(value)
|
|
38
|
-
default:
|
|
39
|
-
return String(value)
|
|
40
|
-
}
|
|
33
|
+
const fn = valueFormatters[format] ?? String
|
|
34
|
+
return fn(value)
|
|
41
35
|
})
|
|
42
36
|
</script>
|
|
43
37
|
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
...rest
|
|
29
29
|
} = $props()
|
|
30
30
|
|
|
31
|
+
let currentIndex = $derived(getIndexForItem(options, value))
|
|
32
|
+
|
|
31
33
|
const handleChange = () => {
|
|
32
34
|
value = getItemAtIndex(options, currentIndex)
|
|
33
35
|
onchange?.(value)
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
let currentIndex = $derived(getIndexForItem(options, value))
|
|
37
|
-
|
|
38
38
|
// $effect.pre(() => {
|
|
39
39
|
// currentIndex = getIndexForItem(options, value)
|
|
40
40
|
// })
|