@rokkit/forms 1.0.0-next.136 → 1.0.0-next.138

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/forms",
3
- "version": "1.0.0-next.136",
3
+ "version": "1.0.0-next.138",
4
4
  "description": "Form building components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- // Call onupdate callback if provided
94
- if (onupdate) {
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
- // Built-in validation on blur (default mode)
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
- const firstError = formBuilder.errors[0]
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 = $state(new Set())
12
+ let selectedIndices = new SvelteSet()
12
13
 
13
- function handleCardClick(item, index) {
14
- if (!select) return
14
+ function selectOne(item, index) {
15
+ selectedIndex = index
16
+ onselect?.(item, item)
17
+ }
15
18
 
16
- if (select === 'one') {
17
- selectedIndex = index
18
- onselect?.(item, item)
19
- } else if (select === 'many') {
20
- const next = new Set(selectedIndices)
21
- if (next.has(index)) {
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
- switch (format) {
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
- switch (format) {
26
- case 'currency':
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
  // })