@page-speed/forms 0.2.2 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
- <img width="1200" height="330" alt="page-speed-forms-library" src="https://github.com/user-attachments/assets/abbcd5be-f5ea-4ae8-a51e-cee16c71c0ab" />
1
+ <img width="896" height="330" alt="page-speed-forms" src="https://github.com/user-attachments/assets/6db9cf70-5488-472d-b1a7-b79cc74a8cf2" />
2
2
 
3
- # @page-speed/forms
3
+ # ⚡@page-speed/forms
4
4
 
5
5
  Type-safe form state management and validation for React applications.
6
6
 
@@ -8,6 +8,12 @@ Type-safe form state management and validation for React applications.
8
8
 
9
9
  OpenSite Page Speed Forms is a high-performance library designed to streamline form state management, validation, and submission handling in React applications. This library is part of OpenSite AI's open-source ecosystem, built for performance and open collaboration. By emphasizing type safety and modularity, it aligns with OpenSite's goal to create scalable, open, and developer-friendly performance tooling.
10
10
 
11
+ [![npm version](https://img.shields.io/npm/v/@page-speed/forms?style=flat-square)](https://www.npmjs.com/package/@page-speed/forms)
12
+ [![npm downloads](https://img.shields.io/npm/dm/@page-speed/forms?style=flat-square)](https://www.npmjs.com/package/@page-speed/forms)
13
+ [![License](https://img.shields.io/npm/l/@page-speed/forms?style=flat-square)](./LICENSE)
14
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue?style=flat-square)](./tsconfig.json)
15
+ [![Tree-Shakeable](https://img.shields.io/badge/Tree%20Shakeable-Yes-brightgreen?style=flat-square)](#tree-shaking)
16
+
11
17
  Learn more at [OpenSite.ai Developers](https://opensite.ai/developers).
12
18
 
13
19
  ## Key Features
@@ -77,6 +83,502 @@ const form = useForm({
77
83
  });
78
84
  ```
79
85
 
86
+ ## Advanced Validation Features
87
+
88
+ ### Cross-Field Validation
89
+
90
+ Validate fields that depend on other field values using the `crossFieldValidator` utility or by accessing `allValues` in your validator:
91
+
92
+ ```typescript
93
+ import { useForm, crossFieldValidator } from '@page-speed/forms/validation';
94
+
95
+ // Method 1: Using crossFieldValidator helper
96
+ const form = useForm({
97
+ initialValues: { password: '', confirmPassword: '' },
98
+ validationSchema: {
99
+ confirmPassword: crossFieldValidator(
100
+ ['password', 'confirmPassword'],
101
+ (values) => {
102
+ if (values.password !== values.confirmPassword) {
103
+ return 'Passwords must match';
104
+ }
105
+ return undefined;
106
+ }
107
+ )
108
+ }
109
+ });
110
+
111
+ // Method 2: Direct access to allValues
112
+ const form = useForm({
113
+ initialValues: { password: '', confirmPassword: '' },
114
+ validationSchema: {
115
+ confirmPassword: (value, allValues) => {
116
+ if (value !== allValues.password) {
117
+ return 'Passwords must match';
118
+ }
119
+ return undefined;
120
+ }
121
+ }
122
+ });
123
+ ```
124
+
125
+ ### Async Validation with Debouncing
126
+
127
+ Optimize async validators (like API calls) with built-in debouncing to prevent excessive requests:
128
+
129
+ ```typescript
130
+ import { useForm, asyncValidator } from '@page-speed/forms/validation';
131
+
132
+ const checkUsernameAvailability = async (username: string) => {
133
+ const response = await fetch(`/api/check-username?username=${username}`);
134
+ const { available } = await response.json();
135
+ return available ? undefined : 'Username already taken';
136
+ };
137
+
138
+ const form = useForm({
139
+ initialValues: { username: '' },
140
+ validationSchema: {
141
+ // Debounce async validation by 500ms
142
+ username: asyncValidator(
143
+ checkUsernameAvailability,
144
+ { delay: 500, trailing: true }
145
+ )
146
+ }
147
+ });
148
+ ```
149
+
150
+ **Debounce Options:**
151
+ - `delay`: Milliseconds to wait (default: 300ms)
152
+ - `leading`: Validate immediately on first change (default: false)
153
+ - `trailing`: Validate after delay expires (default: true)
154
+
155
+ The `asyncValidator` wrapper also includes automatic race condition prevention, ensuring only the latest validation result is used.
156
+
157
+ ### Validation Rules Library
158
+
159
+ Use pre-built, tree-shakable validation rules for common scenarios:
160
+
161
+ ```typescript
162
+ import {
163
+ required,
164
+ email,
165
+ url,
166
+ phone,
167
+ minLength,
168
+ maxLength,
169
+ min,
170
+ max,
171
+ pattern,
172
+ matches,
173
+ oneOf,
174
+ creditCard,
175
+ postalCode,
176
+ alpha,
177
+ alphanumeric,
178
+ numeric,
179
+ integer,
180
+ compose
181
+ } from '@page-speed/forms/validation/rules';
182
+
183
+ const form = useForm({
184
+ initialValues: {
185
+ email: '',
186
+ password: '',
187
+ confirmPassword: '',
188
+ age: 0,
189
+ username: '',
190
+ cardNumber: ''
191
+ },
192
+ validationSchema: {
193
+ email: compose(
194
+ required({ message: 'Email is required' }),
195
+ email({ message: 'Invalid email format' })
196
+ ),
197
+ password: compose(
198
+ required(),
199
+ minLength(8, { message: 'Password must be at least 8 characters' })
200
+ ),
201
+ confirmPassword: matches('password', { message: 'Passwords must match' }),
202
+ age: compose(
203
+ required(),
204
+ numeric({ message: 'Age must be a number' }),
205
+ min(18, { message: 'Must be 18 or older' })
206
+ ),
207
+ username: compose(
208
+ required(),
209
+ alphanumeric({ message: 'Only letters and numbers allowed' }),
210
+ minLength(3),
211
+ maxLength(20)
212
+ ),
213
+ cardNumber: creditCard({ message: 'Invalid credit card number' })
214
+ }
215
+ });
216
+ ```
217
+
218
+ **Available Validators:**
219
+
220
+ | Validator | Description | Example |
221
+ |-----------|-------------|---------|
222
+ | `required()` | Field must have a value | `required({ message: 'Required' })` |
223
+ | `email()` | Valid email format (RFC 5322) | `email()` |
224
+ | `url()` | Valid URL format | `url()` |
225
+ | `phone()` | US phone number format | `phone()` |
226
+ | `minLength(n)` | Minimum string/array length | `minLength(3)` |
227
+ | `maxLength(n)` | Maximum string/array length | `maxLength(100)` |
228
+ | `min(n)` | Minimum numeric value | `min(0)` |
229
+ | `max(n)` | Maximum numeric value | `max(100)` |
230
+ | `pattern(regex)` | Custom regex pattern | `pattern(/^[A-Z]+$/)` |
231
+ | `matches(field)` | Match another field | `matches('password')` |
232
+ | `oneOf(values)` | Value in allowed list | `oneOf(['a', 'b', 'c'])` |
233
+ | `creditCard()` | Valid credit card (Luhn) | `creditCard()` |
234
+ | `postalCode()` | US ZIP code format | `postalCode()` |
235
+ | `alpha()` | Alphabetic characters only | `alpha()` |
236
+ | `alphanumeric()` | Letters and numbers only | `alphanumeric()` |
237
+ | `numeric()` | Valid number | `numeric()` |
238
+ | `integer()` | Whole number | `integer()` |
239
+ | `compose(...)` | Combine multiple validators | `compose(required(), email())` |
240
+
241
+ ### Custom Error Messages & Internationalization
242
+
243
+ Customize error messages globally for internationalization support:
244
+
245
+ ```typescript
246
+ import { setErrorMessages } from '@page-speed/forms/validation/utils';
247
+
248
+ // Set custom messages (e.g., Spanish translations)
249
+ setErrorMessages({
250
+ required: 'Este campo es obligatorio',
251
+ email: 'Por favor ingrese un correo electrónico válido',
252
+ minLength: ({ min }) => `Debe tener al menos ${min} caracteres`,
253
+ maxLength: ({ max }) => `No debe exceder ${max} caracteres`,
254
+ phone: 'Por favor ingrese un número de teléfono válido'
255
+ });
256
+
257
+ // Use with validation rules
258
+ import { required, email, minLength } from '@page-speed/forms/validation/rules';
259
+
260
+ const form = useForm({
261
+ initialValues: { email: '', password: '' },
262
+ validationSchema: {
263
+ email: compose(required(), email()),
264
+ password: compose(required(), minLength(8))
265
+ }
266
+ });
267
+ ```
268
+
269
+ **Message Template Functions:**
270
+
271
+ Error messages support template functions with parameter interpolation:
272
+
273
+ ```typescript
274
+ setErrorMessages({
275
+ minLength: ({ min }) => `Must be at least ${min} characters`,
276
+ max: ({ max }) => `Cannot exceed ${max}`,
277
+ matches: ({ field }) => `Must match ${field}`
278
+ });
279
+ ```
280
+
281
+ **Per-Field Custom Messages:**
282
+
283
+ Override global messages on a per-field basis:
284
+
285
+ ```typescript
286
+ const form = useForm({
287
+ initialValues: { email: '' },
288
+ validationSchema: {
289
+ email: required({ message: 'Please provide your email address' })
290
+ }
291
+ });
292
+ ```
293
+
294
+ ### Conditional Validation
295
+
296
+ Validate fields only when certain conditions are met:
297
+
298
+ ```typescript
299
+ import { when, required, minLength } from '@page-speed/forms/validation';
300
+
301
+ const form = useForm({
302
+ initialValues: { accountType: 'personal', companyName: '' },
303
+ validationSchema: {
304
+ // Only require company name for business accounts
305
+ companyName: when(
306
+ (allValues) => allValues.accountType === 'business',
307
+ compose(
308
+ required({ message: 'Company name is required for business accounts' }),
309
+ minLength(3)
310
+ )
311
+ )
312
+ }
313
+ });
314
+ ```
315
+
316
+ ## Built-in Input Components
317
+
318
+ `@page-speed/forms` includes a comprehensive set of accessible, production-ready input components that work seamlessly with the form hooks.
319
+
320
+ ### Basic Inputs
321
+
322
+ #### TextInput
323
+ Standard text input with support for various types (text, email, password, etc.):
324
+
325
+ ```typescript
326
+ import { TextInput } from '@page-speed/forms/inputs';
327
+
328
+ <Field name="email" label="Email">
329
+ {({ field }) => <TextInput {...field} type="email" placeholder="Enter email" />}
330
+ </Field>
331
+ ```
332
+
333
+ #### TextArea
334
+ Multi-line text input:
335
+
336
+ ```typescript
337
+ import { TextArea } from '@page-speed/forms/inputs';
338
+
339
+ <Field name="description" label="Description">
340
+ {({ field }) => <TextArea {...field} rows={5} placeholder="Enter description" />}
341
+ </Field>
342
+ ```
343
+
344
+ #### Checkbox & CheckboxGroup
345
+ Single checkbox or group of checkboxes:
346
+
347
+ ```typescript
348
+ import { Checkbox, CheckboxGroup } from '@page-speed/forms/inputs';
349
+
350
+ // Single checkbox
351
+ <Field name="terms" label="Terms">
352
+ {({ field }) => <Checkbox {...field} label="I agree to the terms" />}
353
+ </Field>
354
+
355
+ // Checkbox group
356
+ <Field name="interests" label="Interests">
357
+ {({ field }) => (
358
+ <CheckboxGroup
359
+ {...field}
360
+ options={[
361
+ { label: 'Sports', value: 'sports' },
362
+ { label: 'Music', value: 'music' },
363
+ { label: 'Travel', value: 'travel' }
364
+ ]}
365
+ />
366
+ )}
367
+ </Field>
368
+ ```
369
+
370
+ #### Radio
371
+ Radio button group:
372
+
373
+ ```typescript
374
+ import { Radio } from '@page-speed/forms/inputs';
375
+
376
+ <Field name="plan" label="Select Plan">
377
+ {({ field }) => (
378
+ <Radio
379
+ {...field}
380
+ options={[
381
+ { label: 'Basic', value: 'basic' },
382
+ { label: 'Pro', value: 'pro' },
383
+ { label: 'Enterprise', value: 'enterprise' }
384
+ ]}
385
+ />
386
+ )}
387
+ </Field>
388
+ ```
389
+
390
+ #### Select
391
+ Dropdown select with support for single and multi-select:
392
+
393
+ ```typescript
394
+ import { Select } from '@page-speed/forms/inputs';
395
+
396
+ // Single select
397
+ <Field name="country" label="Country">
398
+ {({ field }) => (
399
+ <Select
400
+ {...field}
401
+ options={[
402
+ { label: 'United States', value: 'us' },
403
+ { label: 'Canada', value: 'ca' },
404
+ { label: 'United Kingdom', value: 'uk' }
405
+ ]}
406
+ searchable
407
+ clearable
408
+ />
409
+ )}
410
+ </Field>
411
+
412
+ // Multi-select
413
+ <Field name="skills" label="Skills">
414
+ {({ field }) => (
415
+ <Select
416
+ {...field}
417
+ multiple
418
+ options={[
419
+ { label: 'JavaScript', value: 'js' },
420
+ { label: 'TypeScript', value: 'ts' },
421
+ { label: 'React', value: 'react' }
422
+ ]}
423
+ searchable
424
+ />
425
+ )}
426
+ </Field>
427
+ ```
428
+
429
+ ### Advanced Inputs
430
+
431
+ #### DatePicker
432
+ Date selection with calendar popup:
433
+
434
+ ```typescript
435
+ import { DatePicker } from '@page-speed/forms/inputs';
436
+
437
+ <Field name="birthdate" label="Birth Date">
438
+ {({ field }) => (
439
+ <DatePicker
440
+ {...field}
441
+ placeholder="Select date"
442
+ dateFormat="MM/dd/yyyy"
443
+ minDate={new Date(1900, 0, 1)}
444
+ maxDate={new Date()}
445
+ clearable
446
+ />
447
+ )}
448
+ </Field>
449
+ ```
450
+
451
+ **Props:**
452
+ - `dateFormat`: Date display format (default: "MM/dd/yyyy")
453
+ - `minDate`, `maxDate`: Restrict selectable dates
454
+ - `isDateDisabled`: Custom function to disable specific dates
455
+ - `clearable`: Show clear button
456
+ - `showTodayButton`: Show "Today" button
457
+
458
+ #### TimePicker
459
+ Time selection with hour/minute/period selectors:
460
+
461
+ ```typescript
462
+ import { TimePicker } from '@page-speed/forms/inputs';
463
+
464
+ <Field name="appointmentTime" label="Appointment Time">
465
+ {({ field }) => (
466
+ <TimePicker
467
+ {...field}
468
+ placeholder="Select time"
469
+ use24Hour={false}
470
+ minuteStep={15}
471
+ clearable
472
+ />
473
+ )}
474
+ </Field>
475
+ ```
476
+
477
+ **Props:**
478
+ - `use24Hour`: Use 24-hour format (default: false)
479
+ - `minuteStep`: Minute increment (default: 1)
480
+ - `clearable`: Show clear button
481
+
482
+ #### DateRangePicker
483
+ Date range selection with start and end dates:
484
+
485
+ ```typescript
486
+ import { DateRangePicker } from '@page-speed/forms/inputs';
487
+
488
+ <Field name="dateRange" label="Date Range">
489
+ {({ field }) => (
490
+ <DateRangePicker
491
+ {...field}
492
+ placeholder="Select date range"
493
+ separator=" - "
494
+ minDate={new Date()}
495
+ clearable
496
+ />
497
+ )}
498
+ </Field>
499
+ ```
500
+
501
+ **Props:**
502
+ - `separator`: String between start and end dates (default: " - ")
503
+ - `minDate`, `maxDate`: Restrict selectable dates
504
+ - `isDateDisabled`: Custom function to disable specific dates
505
+ - `clearable`: Show clear button
506
+
507
+ #### RichTextEditor
508
+ WYSIWYG and Markdown editor with toolbar:
509
+
510
+ ```typescript
511
+ import { RichTextEditor } from '@page-speed/forms/inputs';
512
+
513
+ <Field name="content" label="Content">
514
+ {({ field }) => (
515
+ <RichTextEditor
516
+ {...field}
517
+ placeholder="Enter content..."
518
+ minHeight="200px"
519
+ maxHeight="600px"
520
+ allowModeSwitch
521
+ defaultMode="wysiwyg"
522
+ />
523
+ )}
524
+ </Field>
525
+ ```
526
+
527
+ **Props:**
528
+ - `defaultMode`: "wysiwyg" or "markdown" (default: "wysiwyg")
529
+ - `allowModeSwitch`: Enable mode toggle button
530
+ - `minHeight`, `maxHeight`: Editor height constraints
531
+ - `customButtons`: Add custom toolbar buttons
532
+
533
+ **Features:**
534
+ - WYSIWYG mode: Bold, Italic, Underline, Headings, Lists, Links
535
+ - Markdown mode: Direct markdown editing
536
+ - Automatic HTML ↔ Markdown conversion
537
+
538
+ #### FileInput
539
+ File upload with drag-and-drop, progress indicators, and image cropping:
540
+
541
+ ```typescript
542
+ import { FileInput } from '@page-speed/forms/inputs';
543
+
544
+ <Field name="avatar" label="Profile Picture">
545
+ {({ field }) => (
546
+ <FileInput
547
+ {...field}
548
+ accept="image/*"
549
+ maxSize={5 * 1024 * 1024} // 5MB
550
+ maxFiles={1}
551
+ showProgress
552
+ uploadProgress={uploadProgress}
553
+ enableCropping
554
+ cropAspectRatio={1}
555
+ onCropComplete={(file) => console.log('Cropped:', file)}
556
+ />
557
+ )}
558
+ </Field>
559
+ ```
560
+
561
+ **Props:**
562
+ - `accept`: File type filter (e.g., "image/*", ".pdf")
563
+ - `multiple`: Allow multiple files
564
+ - `maxFiles`: Maximum number of files
565
+ - `maxSize`: Maximum file size in bytes
566
+ - `showPreview`: Show file previews
567
+ - `showProgress`: Display upload progress bars
568
+ - `uploadProgress`: Object mapping filenames to progress percentages
569
+ - `enableCropping`: Enable image cropping for image files
570
+ - `cropAspectRatio`: Crop aspect ratio (e.g., 16/9, 1 for square)
571
+ - `onCropComplete`: Callback when cropping is complete
572
+
573
+ **Features:**
574
+ - Drag-and-drop support
575
+ - File type and size validation
576
+ - Image previews with thumbnails
577
+ - Upload progress indicators with percentage
578
+ - Interactive image cropping with zoom
579
+ - Multiple file support
580
+ - Accessible file selection
581
+
80
582
  ## Performance Notes
81
583
 
82
584
  Performance is a core facet of everything we build at OpenSite AI. The library is optimized for minimal re-renders and efficient form state updates, ensuring your applications remain responsive and fast.
package/dist/core.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  var React2 = require('react');
4
4
  var react = require('@legendapp/state/react');
5
+ var useMap = require('@opensite/hooks/core/useMap');
5
6
 
6
7
  function _interopNamespace(e) {
7
8
  if (e && e.__esModule) return e;
@@ -45,12 +46,20 @@ function useForm(options) {
45
46
  hasValidated: {}
46
47
  });
47
48
  const validationInProgress = React2.useRef(/* @__PURE__ */ new Set());
49
+ const [, fieldMetadataActions] = useMap.useMap();
48
50
  const validateField = React2.useCallback(
49
51
  async (field) => {
50
52
  const validators = validationSchema?.[field];
51
53
  if (!validators) return void 0;
52
54
  const fieldKey = String(field);
53
55
  validationInProgress.current.add(fieldKey);
56
+ const currentMeta = fieldMetadataActions.get(fieldKey) || {
57
+ validationCount: 0
58
+ };
59
+ fieldMetadataActions.set(fieldKey, {
60
+ lastValidated: Date.now(),
61
+ validationCount: currentMeta.validationCount + 1
62
+ });
54
63
  try {
55
64
  const value = state$.values[field].get();
56
65
  const allValues = state$.values.get();
@@ -73,7 +82,7 @@ function useForm(options) {
73
82
  return errorMessage;
74
83
  }
75
84
  },
76
- [validationSchema, state$]
85
+ [validationSchema, state$, fieldMetadataActions]
77
86
  );
78
87
  const validateForm = React2.useCallback(async () => {
79
88
  if (!validationSchema) return {};
@@ -123,10 +132,11 @@ function useForm(options) {
123
132
  state$.isSubmitting.set(false);
124
133
  state$.status.set("idle");
125
134
  state$.hasValidated.set({});
135
+ fieldMetadataActions.clear();
126
136
  if (debug) {
127
137
  console.log("[useForm] Form reset");
128
138
  }
129
- }, [state$, debug]);
139
+ }, [state$, fieldMetadataActions, debug]);
130
140
  const handleSubmit = React2.useCallback(
131
141
  async (e) => {
132
142
  e?.preventDefault();
@@ -202,14 +212,18 @@ function useForm(options) {
202
212
  const getFieldMeta = React2.useCallback(
203
213
  (field) => {
204
214
  const fieldKey = String(field);
215
+ const metadata = fieldMetadataActions.get(fieldKey);
205
216
  return {
206
217
  error: state$.errors[field].get(),
207
218
  touched: state$.touched[field].get() ?? false,
208
219
  isDirty: state$.values[field].get() !== state$.initialValues[field].get(),
209
- isValidating: validationInProgress.current.has(fieldKey)
220
+ isValidating: validationInProgress.current.has(fieldKey),
221
+ // Additional metadata from @opensite/hooks
222
+ validationCount: metadata?.validationCount,
223
+ lastValidated: metadata?.lastValidated
210
224
  };
211
225
  },
212
- [state$]
226
+ [state$, fieldMetadataActions]
213
227
  );
214
228
  const values = react.useSelector(() => state$.values.get());
215
229
  const errors = react.useSelector(() => state$.errors.get());