what-core 0.1.1 → 0.3.0

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/form.js ADDED
@@ -0,0 +1,441 @@
1
+ // What Framework - Form Utilities
2
+ // Controlled inputs, validation, and form state management
3
+
4
+ import { signal, computed, batch, effect } from './reactive.js';
5
+ import { h } from './h.js';
6
+
7
+ // --- useForm Hook ---
8
+ // Complete form state management with validation
9
+
10
+ export function useForm(options = {}) {
11
+ const {
12
+ defaultValues = {},
13
+ mode = 'onSubmit', // 'onSubmit' | 'onChange' | 'onBlur'
14
+ reValidateMode = 'onChange',
15
+ resolver,
16
+ } = options;
17
+
18
+ // Form state
19
+ const values = signal({ ...defaultValues });
20
+ const errors = signal({});
21
+ const touched = signal({});
22
+ const isDirty = signal(false);
23
+ const isSubmitting = signal(false);
24
+ const isSubmitted = signal(false);
25
+ const submitCount = signal(0);
26
+
27
+ // Computed states
28
+ const isValid = computed(() => Object.keys(errors()).length === 0);
29
+ const dirtyFields = computed(() => {
30
+ const dirty = {};
31
+ const current = values();
32
+ for (const key in current) {
33
+ if (current[key] !== defaultValues[key]) {
34
+ dirty[key] = true;
35
+ }
36
+ }
37
+ return dirty;
38
+ });
39
+
40
+ // Validation
41
+ async function validate(fieldName) {
42
+ if (!resolver) return true;
43
+
44
+ const result = await resolver(values());
45
+
46
+ if (fieldName) {
47
+ // Validate single field
48
+ if (result.errors[fieldName]) {
49
+ errors.set({ ...errors.peek(), [fieldName]: result.errors[fieldName] });
50
+ return false;
51
+ } else {
52
+ const newErrors = { ...errors.peek() };
53
+ delete newErrors[fieldName];
54
+ errors.set(newErrors);
55
+ return true;
56
+ }
57
+ } else {
58
+ // Validate all fields
59
+ errors.set(result.errors || {});
60
+ return Object.keys(result.errors || {}).length === 0;
61
+ }
62
+ }
63
+
64
+ // Register a field
65
+ function register(name, options = {}) {
66
+ return {
67
+ name,
68
+ value: values()[name] ?? '',
69
+ onInput: (e) => {
70
+ const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
71
+ setValue(name, value);
72
+
73
+ if (mode === 'onChange' || (isSubmitted.peek() && reValidateMode === 'onChange')) {
74
+ validate(name);
75
+ }
76
+ },
77
+ onBlur: () => {
78
+ touched.set({ ...touched.peek(), [name]: true });
79
+
80
+ if (mode === 'onBlur' || (isSubmitted.peek() && reValidateMode === 'onBlur')) {
81
+ validate(name);
82
+ }
83
+ },
84
+ onFocus: () => {},
85
+ ref: options.ref,
86
+ };
87
+ }
88
+
89
+ // Set single field value
90
+ function setValue(name, value, options = {}) {
91
+ const { shouldValidate = false, shouldDirty = true } = options;
92
+
93
+ batch(() => {
94
+ values.set({ ...values.peek(), [name]: value });
95
+ if (shouldDirty) {
96
+ isDirty.set(true);
97
+ }
98
+ });
99
+
100
+ if (shouldValidate) {
101
+ validate(name);
102
+ }
103
+ }
104
+
105
+ // Get single field value
106
+ function getValue(name) {
107
+ return values()[name];
108
+ }
109
+
110
+ // Set error for a field
111
+ function setError(name, error) {
112
+ errors.set({ ...errors.peek(), [name]: error });
113
+ }
114
+
115
+ // Clear error for a field
116
+ function clearError(name) {
117
+ const newErrors = { ...errors.peek() };
118
+ delete newErrors[name];
119
+ errors.set(newErrors);
120
+ }
121
+
122
+ // Clear all errors
123
+ function clearErrors() {
124
+ errors.set({});
125
+ }
126
+
127
+ // Reset form
128
+ function reset(newValues = defaultValues) {
129
+ batch(() => {
130
+ values.set({ ...newValues });
131
+ errors.set({});
132
+ touched.set({});
133
+ isDirty.set(false);
134
+ isSubmitted.set(false);
135
+ });
136
+ }
137
+
138
+ // Handle submit
139
+ function handleSubmit(onValid, onInvalid) {
140
+ return async (e) => {
141
+ if (e) e.preventDefault();
142
+
143
+ isSubmitting.set(true);
144
+ isSubmitted.set(true);
145
+ submitCount.set(submitCount.peek() + 1);
146
+
147
+ const isFormValid = await validate();
148
+
149
+ if (isFormValid) {
150
+ await onValid(values.peek());
151
+ } else if (onInvalid) {
152
+ onInvalid(errors.peek());
153
+ }
154
+
155
+ isSubmitting.set(false);
156
+ };
157
+ }
158
+
159
+ // Watch a field
160
+ function watch(name) {
161
+ if (name) {
162
+ return computed(() => values()[name]);
163
+ }
164
+ return values;
165
+ }
166
+
167
+ return {
168
+ register,
169
+ handleSubmit,
170
+ setValue,
171
+ getValue,
172
+ setError,
173
+ clearError,
174
+ clearErrors,
175
+ reset,
176
+ watch,
177
+ validate,
178
+ // Form state
179
+ formState: {
180
+ values: () => values(),
181
+ errors: () => errors(),
182
+ touched: () => touched(),
183
+ isDirty: () => isDirty(),
184
+ isValid,
185
+ isSubmitting: () => isSubmitting(),
186
+ isSubmitted: () => isSubmitted(),
187
+ submitCount: () => submitCount(),
188
+ dirtyFields,
189
+ },
190
+ };
191
+ }
192
+
193
+ // --- Validation Resolvers ---
194
+
195
+ export function zodResolver(schema) {
196
+ return async (values) => {
197
+ try {
198
+ const result = await schema.parseAsync(values);
199
+ return { values: result, errors: {} };
200
+ } catch (e) {
201
+ const errors = {};
202
+ for (const issue of e.errors || []) {
203
+ const path = issue.path.join('.');
204
+ if (!errors[path]) {
205
+ errors[path] = { type: issue.code, message: issue.message };
206
+ }
207
+ }
208
+ return { values: {}, errors };
209
+ }
210
+ };
211
+ }
212
+
213
+ export function yupResolver(schema) {
214
+ return async (values) => {
215
+ try {
216
+ const result = await schema.validate(values, { abortEarly: false });
217
+ return { values: result, errors: {} };
218
+ } catch (e) {
219
+ const errors = {};
220
+ for (const err of e.inner || []) {
221
+ if (!errors[err.path]) {
222
+ errors[err.path] = { type: err.type, message: err.message };
223
+ }
224
+ }
225
+ return { values: {}, errors };
226
+ }
227
+ };
228
+ }
229
+
230
+ // Simple validation resolver
231
+ export function simpleResolver(rules) {
232
+ return async (values) => {
233
+ const errors = {};
234
+
235
+ for (const [field, fieldRules] of Object.entries(rules)) {
236
+ const value = values[field];
237
+
238
+ for (const rule of fieldRules) {
239
+ const error = rule(value, values);
240
+ if (error) {
241
+ errors[field] = { type: 'validation', message: error };
242
+ break;
243
+ }
244
+ }
245
+ }
246
+
247
+ return { values, errors };
248
+ };
249
+ }
250
+
251
+ // Built-in validation rules
252
+ export const rules = {
253
+ required: (message = 'This field is required') => (value) => {
254
+ if (value === undefined || value === null || value === '') {
255
+ return message;
256
+ }
257
+ },
258
+
259
+ minLength: (min, message) => (value) => {
260
+ if (typeof value === 'string' && value.length < min) {
261
+ return message || `Must be at least ${min} characters`;
262
+ }
263
+ },
264
+
265
+ maxLength: (max, message) => (value) => {
266
+ if (typeof value === 'string' && value.length > max) {
267
+ return message || `Must be at most ${max} characters`;
268
+ }
269
+ },
270
+
271
+ min: (min, message) => (value) => {
272
+ if (typeof value === 'number' && value < min) {
273
+ return message || `Must be at least ${min}`;
274
+ }
275
+ },
276
+
277
+ max: (max, message) => (value) => {
278
+ if (typeof value === 'number' && value > max) {
279
+ return message || `Must be at most ${max}`;
280
+ }
281
+ },
282
+
283
+ pattern: (regex, message = 'Invalid format') => (value) => {
284
+ if (typeof value === 'string' && !regex.test(value)) {
285
+ return message;
286
+ }
287
+ },
288
+
289
+ email: (message = 'Invalid email address') => (value) => {
290
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
291
+ if (typeof value === 'string' && !emailRegex.test(value)) {
292
+ return message;
293
+ }
294
+ },
295
+
296
+ url: (message = 'Invalid URL') => (value) => {
297
+ try {
298
+ if (typeof value === 'string' && value) {
299
+ new URL(value);
300
+ }
301
+ } catch {
302
+ return message;
303
+ }
304
+ },
305
+
306
+ match: (field, message) => (value, values) => {
307
+ if (value !== values[field]) {
308
+ return message || `Must match ${field}`;
309
+ }
310
+ },
311
+
312
+ custom: (validator) => validator,
313
+ };
314
+
315
+ // --- useField Hook ---
316
+ // Individual field control
317
+
318
+ export function useField(name, options = {}) {
319
+ const { validate: validateFn, defaultValue = '' } = options;
320
+
321
+ const value = signal(defaultValue);
322
+ const error = signal(null);
323
+ const isTouched = signal(false);
324
+ const isDirty = signal(false);
325
+
326
+ async function validate() {
327
+ if (!validateFn) return true;
328
+ const result = await validateFn(value.peek());
329
+ error.set(result || null);
330
+ return !result;
331
+ }
332
+
333
+ return {
334
+ name,
335
+ value: () => value(),
336
+ error: () => error(),
337
+ isTouched: () => isTouched(),
338
+ isDirty: () => isDirty(),
339
+ setValue: (v) => {
340
+ value.set(v);
341
+ isDirty.set(true);
342
+ },
343
+ setError: (e) => error.set(e),
344
+ validate,
345
+ reset: () => {
346
+ value.set(defaultValue);
347
+ error.set(null);
348
+ isTouched.set(false);
349
+ isDirty.set(false);
350
+ },
351
+ inputProps: () => ({
352
+ name,
353
+ value: value(),
354
+ onInput: (e) => {
355
+ value.set(e.target.value);
356
+ isDirty.set(true);
357
+ },
358
+ onBlur: () => {
359
+ isTouched.set(true);
360
+ validate();
361
+ },
362
+ }),
363
+ };
364
+ }
365
+
366
+ // --- Controlled Input Components ---
367
+
368
+ export function Input(props) {
369
+ const { register, error, ...rest } = props;
370
+ const registered = register ? register(props.name) : {};
371
+
372
+ return h('input', {
373
+ ...rest,
374
+ ...registered,
375
+ 'aria-invalid': error ? 'true' : undefined,
376
+ });
377
+ }
378
+
379
+ export function Textarea(props) {
380
+ const { register, error, ...rest } = props;
381
+ const registered = register ? register(props.name) : {};
382
+
383
+ return h('textarea', {
384
+ ...rest,
385
+ ...registered,
386
+ 'aria-invalid': error ? 'true' : undefined,
387
+ });
388
+ }
389
+
390
+ export function Select(props) {
391
+ const { register, error, children, ...rest } = props;
392
+ const registered = register ? register(props.name) : {};
393
+
394
+ return h('select', {
395
+ ...rest,
396
+ ...registered,
397
+ 'aria-invalid': error ? 'true' : undefined,
398
+ }, children);
399
+ }
400
+
401
+ export function Checkbox(props) {
402
+ const { register, ...rest } = props;
403
+ const registered = register ? register(props.name) : {};
404
+
405
+ return h('input', {
406
+ type: 'checkbox',
407
+ ...rest,
408
+ ...registered,
409
+ checked: registered.value,
410
+ });
411
+ }
412
+
413
+ export function Radio(props) {
414
+ const { register, value: radioValue, ...rest } = props;
415
+ const registered = register ? register(props.name) : {};
416
+
417
+ return h('input', {
418
+ type: 'radio',
419
+ value: radioValue,
420
+ ...rest,
421
+ checked: registered.value === radioValue,
422
+ onChange: (e) => {
423
+ if (e.target.checked && registered.onInput) {
424
+ registered.onInput({ target: { value: radioValue } });
425
+ }
426
+ },
427
+ });
428
+ }
429
+
430
+ // --- Form Error Display ---
431
+
432
+ export function ErrorMessage({ name, errors, render }) {
433
+ const error = errors ? errors()[name] : null;
434
+ if (!error) return null;
435
+
436
+ if (render) {
437
+ return render({ message: error.message, type: error.type });
438
+ }
439
+
440
+ return h('span', { class: 'what-error', role: 'alert' }, error.message);
441
+ }