Skip to content

Commit f69e563

Browse files
committed
feat(form): enhance form component with computed fields and dynamic lists, improve field management and validation logic
1 parent a86047d commit f69e563

File tree

4 files changed

+444
-23
lines changed

4 files changed

+444
-23
lines changed

primitives/filed-form /src/react/components/ComputedField.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
'use client';
22
/* eslint-disable react-hooks/exhaustive-deps */
33
/* eslint-disable react/hook-use-state */
4+
5+
/**
6+
* ComputedField component for reactive computed form fields
7+
* Automatically recalculates its value based on dependencies and renders as read-only
8+
*/
9+
410
import { Slot } from '@radix-ui/react-slot';
511
import type { ReactElement } from 'react';
612
import { useEffect, useState } from 'react';
@@ -12,15 +18,52 @@ import { useFieldContext } from '../hooks/FieldContext';
1218
import type { InternalFormInstance } from '../hooks/FieldContext';
1319

1420
export type ComputedFieldProps<Values, T extends AllPathsKeys<Values> = AllPathsKeys<Values>> = {
21+
/** Child element to render with computed value */
1522
children?: ReactElement;
23+
/** Function to compute the field value based on dependencies */
1624
compute: (get: (n: T) => StoreValue, all: Values) => StoreValue;
25+
/** Array of field names that this computed field depends on */
1726
deps: T[];
27+
/** Name/path of this computed field */
1828
name: T;
29+
/** Whether to preserve field value after component unmount */
1930
preserve?: boolean;
31+
/** Validation rules for the computed field */
2032
rules?: Rule[];
33+
/** Name of the prop to pass the computed value to child component */
2134
valuePropName?: string;
2235
};
2336

37+
/**
38+
* ComputedField component that creates a reactive computed field
39+
* The field automatically recalculates when its dependencies change
40+
* and renders as a read-only field
41+
*
42+
* @example
43+
* ```tsx
44+
* // Example: Calculate total price based on quantity and unit price
45+
* <Form>
46+
* <Field name="quantity" >
47+
* <Input />
48+
* </Field>
49+
* <Field name="unitPrice" >
50+
* <Input />
51+
* </Field>
52+
*
53+
* <ComputedField
54+
* name="totalPrice"
55+
* deps={['quantity', 'unitPrice']}
56+
* compute={(get) => {
57+
* const quantity = get('quantity') || 0;
58+
* const unitPrice = get('unitPrice') || 0;
59+
* return quantity * unitPrice;
60+
* }}
61+
* >
62+
* <Input placeholder="Total Price (calculated)" />
63+
* </ComputedField>
64+
* </Form>
65+
* ```
66+
*/
2467
function ComputedField<Values = any>({
2568
children,
2669
compute,
@@ -30,18 +73,24 @@ function ComputedField<Values = any>({
3073
rules,
3174
valuePropName = 'value'
3275
}: ComputedFieldProps<Values>) {
76+
// Get form context to access form methods
3377
const fieldContext = useFieldContext<Values>();
3478

79+
// Extract form instance methods
3580
const { getFieldValue, getInternalHooks } = fieldContext as unknown as InternalFormInstance<Values>;
3681

82+
// Local state to track computed value for re-rendering
3783
const [value, updateValue] = useState(getFieldValue(name));
3884

85+
// Get internal hooks for field registration and rule setting
3986
const { registerComputed, registerField, setFieldRules } = getInternalHooks();
4087

4188
useEffect(() => {
89+
// Register this field as a computed field with its dependencies
4290
const unregister = registerComputed(name, deps, compute);
4391

44-
// Subscribe to current field changes → force refresh
92+
// Register field entity to receive value change notifications
93+
// This ensures the component re-renders when the computed value changes
4594
const unsub = registerField({
4695
changeValue: newValue => {
4796
updateValue(newValue);
@@ -51,19 +100,23 @@ function ComputedField<Values = any>({
51100
preserve
52101
});
53102

103+
// Set validation rules if provided
54104
if (rules) setFieldRules(name, rules);
55105

106+
// Cleanup: unregister computed field and field entity
56107
return () => {
57108
unregister();
58109
unsub();
59110
};
60111
}, []);
61112

113+
// Prepare props to pass to child component
62114
const slotProps = {
63-
readOnly: true,
64-
[valuePropName]: value ?? ''
115+
readOnly: true, // Computed fields are always read-only
116+
[valuePropName]: value ?? '' // Pass computed value using specified prop name
65117
};
66118

119+
// Render child component with computed value and read-only state
67120
return <Slot {...slotProps}>{children}</Slot>;
68121
}
69122

primitives/filed-form /src/react/components/Field.tsx

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,108 @@
11
'use client';
22
/* eslint-disable react-hooks/exhaustive-deps */
33
/* eslint-disable react/hook-use-state */
4+
5+
/**
6+
* Field component for form input fields with validation and state management
7+
* Supports both controlled and uncontrolled modes with flexible event handling
8+
*/
9+
410
import { Slot } from '@radix-ui/react-slot';
511
import type { ReactElement } from 'react';
612
import { useEffect, useId, useRef, useState } from 'react';
713
import type { AllPathsKeys } from 'skyroc-type-utils';
814
import { capitalize, getEventValue, isEqual, isNil, omitUndefined, toArray } from 'skyroc-utils';
915

10-
import { ChangeTag } from '../../form-core/event';
11-
import type { StoreValue } from '../../form-core/types';
16+
import type { EventArgs, StoreValue } from '../../form-core/types';
1217
import type { Rule } from '../../form-core/validation';
13-
import type { EventArgs } from '../../types/shared-types';
1418
import type { InternalFormInstance } from '../hooks/FieldContext';
1519
import { useFieldContext } from '../hooks/FieldContext';
1620

1721
export type FieldProps<Values> = {
22+
/** Child element to render as the form field */
1823
children?: ReactElement;
24+
/** Control mode: 'controlled' for React controlled components, 'uncontrolled' for DOM-based */
1925
controlMode?: 'controlled' | 'uncontrolled';
26+
/** Custom function to extract value from event arguments */
2027
getValueFromEvent?: (...args: EventArgs) => StoreValue;
28+
/** Function to transform value before passing to child component */
2129
getValueProps?: (value: StoreValue) => StoreValue;
30+
/** Initial value for the field */
2231
initialValue?: StoreValue;
32+
/** Field name path in the form */
2333
name: AllPathsKeys<Values>;
34+
/** Function to normalize/transform the value after change */
2435
normalize?: (value: StoreValue, prevValue: StoreValue, allValues: Values) => StoreValue;
36+
/** Whether to preserve field value after component unmount */
2537
preserve?: boolean;
38+
/** Validation rules for the field */
2639
rules?: Rule[];
40+
/** Event name that triggers value change (default: 'onChange') */
2741
trigger?: string;
42+
/** Custom function to update uncontrolled component value */
2843
unControlledValueChange?: (ref: any, newValue: StoreValue) => void;
44+
/** Event name(s) that trigger validation */
2945
validateTrigger?: string | string[] | false;
46+
/** Name of the prop to pass the field value (default: 'value') */
3047
valuePropName?: string;
3148
} & Record<string, any>;
3249

50+
/**
51+
* Field component that wraps form input elements with state management and validation
52+
* Supports both controlled and uncontrolled modes with flexible customization options
53+
*
54+
* @example
55+
* ```tsx
56+
* // Basic usage with validation
57+
* <Form>
58+
* <Field
59+
* name="email"
60+
* rules={[
61+
* { required: true, message: 'Email is required' },
62+
* { type: 'email', message: 'Invalid email format' }
63+
* ]}
64+
* >
65+
* <Input placeholder="Enter your email" />
66+
* </Field>
67+
* </Form>
68+
* ```
69+
*
70+
* @example
71+
* ```tsx
72+
* // Controlled mode with custom validation trigger
73+
* <Field
74+
* name="password"
75+
* controlMode="controlled"
76+
* validateTrigger={['onChange', 'onBlur']}
77+
* rules={[
78+
* { required: true, message: 'Password is required' },
79+
* { min: 8, message: 'Password must be at least 8 characters' }
80+
* ]}
81+
* >
82+
* <Input type="password" placeholder="Enter password" />
83+
* </Field>
84+
* ```
85+
*
86+
* @example
87+
* ```tsx
88+
* // Custom value extraction and normalization
89+
* <Field
90+
* name="phone"
91+
* getValueFromEvent={(e) => e.target.value.replace(/\D/g, '')}
92+
* normalize={(value) => {
93+
* // Format phone number: (123) 456-7890
94+
* const cleaned = value.replace(/\D/g, '');
95+
* const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
96+
* return match ? `(${match[1]}) ${match[2]}-${match[3]}` : value;
97+
* }}
98+
* rules={[{ required: true, message: 'Phone number is required' }]}
99+
* >
100+
* <Input placeholder="(123) 456-7890" />
101+
* </Field>
102+
* ```
103+
*/
33104
function Field<Values = any>(props: FieldProps<Values>) {
105+
// Destructure props with default values
34106
const {
35107
children,
36108
controlMode = 'uncontrolled',
@@ -47,16 +119,22 @@ function Field<Values = any>(props: FieldProps<Values>) {
47119
...rest
48120
} = props;
49121

122+
// State for forcing re-renders in controlled mode
50123
const [_, forceUpdate] = useState({});
51124

125+
// Get form context to access form methods
52126
const fieldContext = useFieldContext<Values>();
53127

128+
// Track if value was changed by normalization
54129
const normalizedChangedRef = useRef(false);
55130

131+
// Unique key for React reconciliation
56132
const key = useId();
57133

134+
// Reference to the child component
58135
const cref = useRef<any>(null);
59136

137+
// Extract form instance methods
60138
const {
61139
getFieldsValue,
62140
getFieldValue,
@@ -66,19 +144,25 @@ function Field<Values = any>(props: FieldProps<Values>) {
66144
validateTrigger: fieldValidateTrigger
67145
} = fieldContext as unknown as InternalFormInstance<Values>;
68146

147+
// Get internal hooks for field registration and rule setting
69148
const { registerField, setFieldRules } = getInternalHooks();
70149

150+
// Determine if field should be controlled
71151
const isControlled = controlMode === 'controlled';
72152

153+
// Merge field-level and form-level validation triggers
73154
const mergedValidateTrigger = validateTrigger || fieldValidateTrigger;
74155

156+
// Convert validation triggers to array format
75157
const validateTriggerList: string[] = toArray(mergedValidateTrigger);
76158

159+
// Helper function to create validation trigger handlers
77160
const make =
78161
(evt: string) =>
79162
(..._args: any[]) =>
80163
validateField(name, { trigger: evt });
81164

165+
// Create handlers for validation triggers that are not the main trigger
82166
const restValidateTriggerList = validateTriggerList
83167
.filter(item => item !== trigger)
84168
.reduce(
@@ -90,23 +174,30 @@ function Field<Values = any>(props: FieldProps<Values>) {
90174
{} as Record<string, (...args: any[]) => void>
91175
);
92176

177+
// Get current field value or use initial value as fallback
93178
const value = getFieldValue(name) || initialValue;
94179

180+
// Prepare value props based on control mode
181+
// Controlled: use 'value' prop, Uncontrolled: use 'defaultValue' prop
95182
const valueProps = isControlled ? { [valuePropName]: value } : { [`default${capitalize(valuePropName)}`]: value };
96183

184+
// Create controlled props with change handler
97185
const controlledProps = omitUndefined({
98186
[trigger]: name
99187
? (...args: any[]) => {
100188
let newValue: StoreValue;
101189

190+
// Get current value for comparison
102191
const oldValue = getFieldValue(name);
103192

193+
// Extract new value from event using custom or default extractor
104194
if (getValueFromEvent) {
105195
newValue = getValueFromEvent(...args);
106196
} else {
107197
newValue = getEventValue(valuePropName, ...args);
108198
}
109199

200+
// Apply normalization if provided
110201
if (normalize) {
111202
const norm = normalize(newValue, oldValue, getFieldsValue() as Values);
112203

@@ -116,10 +207,12 @@ function Field<Values = any>(props: FieldProps<Values>) {
116207
}
117208
}
118209

210+
// Update field value if it changed
119211
if (newValue !== oldValue) {
120212
setFieldValue(name, newValue);
121213
}
122214

215+
// Trigger validation if this event is a validation trigger
123216
if (validateTriggerList.includes(trigger)) {
124217
validateField(name, { trigger });
125218
}
@@ -128,19 +221,24 @@ function Field<Values = any>(props: FieldProps<Values>) {
128221
});
129222

130223
useEffect(() => {
224+
// Register field with form store
131225
const unregister = registerField({
132226
changeValue: newValue => {
133227
if (isControlled) {
228+
// Force re-render for controlled components
134229
forceUpdate({});
135230
return;
136231
}
232+
// Handle uncontrolled component value updates
137233
const el = cref.current;
138234

139235
if (!el) return;
140236

141237
if (unControlledValueChange) {
238+
// Use custom value update function
142239
unControlledValueChange(el, newValue);
143240
} else {
241+
// Default: update DOM element value directly
144242
el.value = isNil(newValue) ? '' : (newValue as any);
145243
}
146244
},
@@ -149,21 +247,24 @@ function Field<Values = any>(props: FieldProps<Values>) {
149247
preserve
150248
});
151249

250+
// Set validation rules for this field
152251
setFieldRules(name, rules);
153252

253+
// Cleanup: unregister field when component unmounts
154254
return () => {
155255
unregister();
156256
};
157257
}, []);
158258

259+
// Render child component with all necessary props
159260
return (
160261
<Slot
161262
key={key}
162-
{...(valueProps as any)}
163-
{...controlledProps}
164-
{...restValidateTriggerList}
165-
{...rest}
166-
ref={cref}
263+
{...(valueProps as any)} // Value props (value or defaultValue)
264+
{...controlledProps} // Change handler props
265+
{...restValidateTriggerList} // Additional validation trigger handlers
266+
{...rest} // Other props passed to Field
267+
ref={cref} // Reference to child component
167268
>
168269
{children}
169270
</Slot>

0 commit comments

Comments
 (0)