@idealyst/components 1.2.106 → 1.2.108
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/package.json +4 -4
- package/src/Dialog/Dialog.native.tsx +1 -1
- package/src/Form/Form.native.tsx +38 -0
- package/src/Form/Form.styles.tsx +14 -0
- package/src/Form/Form.web.tsx +50 -0
- package/src/Form/FormContext.ts +12 -0
- package/src/Form/fields/FormCheckbox.tsx +27 -0
- package/src/Form/fields/FormField.tsx +11 -0
- package/src/Form/fields/FormRadioGroup.tsx +24 -0
- package/src/Form/fields/FormSelect.tsx +27 -0
- package/src/Form/fields/FormSlider.tsx +26 -0
- package/src/Form/fields/FormSwitch.tsx +26 -0
- package/src/Form/fields/FormTextArea.tsx +27 -0
- package/src/Form/fields/FormTextInput.tsx +55 -0
- package/src/Form/index.native.ts +35 -0
- package/src/Form/index.ts +35 -0
- package/src/Form/index.web.ts +35 -0
- package/src/Form/types.ts +105 -0
- package/src/Form/useForm.ts +279 -0
- package/src/Form/useFormField.ts +21 -0
- package/src/Popover/Popover.native.tsx +1 -1
- package/src/RadioButton/RadioButton.styles.tsx +28 -0
- package/src/RadioButton/RadioGroup.native.tsx +28 -3
- package/src/RadioButton/RadioGroup.web.tsx +37 -1
- package/src/RadioButton/types.ts +16 -0
- package/src/Screen/Screen.native.tsx +12 -3
- package/src/Select/Select.native.tsx +10 -6
- package/src/Select/Select.styles.tsx +8 -8
- package/src/Select/Select.web.tsx +11 -7
- package/src/Select/types.ts +4 -2
- package/src/Slider/Slider.native.tsx +122 -0
- package/src/Slider/Slider.styles.tsx +48 -11
- package/src/Slider/Slider.web.tsx +54 -5
- package/src/Slider/types.ts +15 -0
- package/src/Switch/Switch.native.tsx +48 -20
- package/src/Switch/Switch.styles.tsx +28 -0
- package/src/Switch/Switch.web.tsx +55 -16
- package/src/Switch/types.ts +10 -0
- package/src/TextInput/TextInput.native.tsx +123 -40
- package/src/TextInput/TextInput.styles.tsx +47 -9
- package/src/TextInput/TextInput.web.tsx +163 -51
- package/src/TextInput/types.ts +16 -1
- package/src/hooks/useSmartPosition.native.ts +1 -1
- package/src/index.native.ts +19 -0
- package/src/index.ts +19 -0
- package/src/internal/BoundedModalContent.native.tsx +1 -1
- package/src/internal/SafeAreaDebugOverlay.native.tsx +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { useReducer, useRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import type { UseFormOptions, UseFormReturn, FormValues, FormErrors } from './types';
|
|
3
|
+
|
|
4
|
+
// Action types for the reducer
|
|
5
|
+
type FormAction<T extends FormValues> =
|
|
6
|
+
| { type: 'SET_VALUE'; name: keyof T; value: T[keyof T] }
|
|
7
|
+
| { type: 'SET_VALUES'; values: Partial<T> }
|
|
8
|
+
| { type: 'SET_TOUCHED'; name: keyof T }
|
|
9
|
+
| { type: 'SET_ERROR'; name: keyof T; error: string | undefined }
|
|
10
|
+
| { type: 'SET_ERRORS'; errors: FormErrors<T> }
|
|
11
|
+
| { type: 'SET_SUBMITTING'; isSubmitting: boolean }
|
|
12
|
+
| { type: 'INCREMENT_SUBMIT_COUNT' }
|
|
13
|
+
| { type: 'RESET'; values: T };
|
|
14
|
+
|
|
15
|
+
// Form state shape
|
|
16
|
+
interface FormState<T extends FormValues> {
|
|
17
|
+
values: T;
|
|
18
|
+
errors: FormErrors<T>;
|
|
19
|
+
touched: Partial<Record<keyof T, boolean>>;
|
|
20
|
+
isSubmitting: boolean;
|
|
21
|
+
submitCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reducer function
|
|
25
|
+
function formReducer<T extends FormValues>(
|
|
26
|
+
state: FormState<T>,
|
|
27
|
+
action: FormAction<T>
|
|
28
|
+
): FormState<T> {
|
|
29
|
+
switch (action.type) {
|
|
30
|
+
case 'SET_VALUE':
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
values: {
|
|
34
|
+
...state.values,
|
|
35
|
+
[action.name]: action.value,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
case 'SET_VALUES':
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
values: {
|
|
42
|
+
...state.values,
|
|
43
|
+
...action.values,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
case 'SET_TOUCHED':
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
touched: {
|
|
50
|
+
...state.touched,
|
|
51
|
+
[action.name]: true,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
case 'SET_ERROR':
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
errors: {
|
|
58
|
+
...state.errors,
|
|
59
|
+
[action.name]: action.error,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
case 'SET_ERRORS':
|
|
63
|
+
return {
|
|
64
|
+
...state,
|
|
65
|
+
errors: action.errors,
|
|
66
|
+
};
|
|
67
|
+
case 'SET_SUBMITTING':
|
|
68
|
+
return {
|
|
69
|
+
...state,
|
|
70
|
+
isSubmitting: action.isSubmitting,
|
|
71
|
+
};
|
|
72
|
+
case 'INCREMENT_SUBMIT_COUNT':
|
|
73
|
+
return {
|
|
74
|
+
...state,
|
|
75
|
+
submitCount: state.submitCount + 1,
|
|
76
|
+
};
|
|
77
|
+
case 'RESET':
|
|
78
|
+
return {
|
|
79
|
+
values: action.values,
|
|
80
|
+
errors: {},
|
|
81
|
+
touched: {},
|
|
82
|
+
isSubmitting: false,
|
|
83
|
+
submitCount: 0,
|
|
84
|
+
};
|
|
85
|
+
default:
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Field registration info
|
|
91
|
+
interface FieldRegistration {
|
|
92
|
+
ref: React.RefObject<any>;
|
|
93
|
+
order: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useForm<T extends FormValues = FormValues>(
|
|
97
|
+
options: UseFormOptions<T>
|
|
98
|
+
): UseFormReturn<T> {
|
|
99
|
+
const { initialValues, validate, validateOn = 'onBlur', onSubmit, disabled = false } = options;
|
|
100
|
+
|
|
101
|
+
// Store initial values in a ref for dirty detection and reset
|
|
102
|
+
const initialValuesRef = useRef<T>(initialValues);
|
|
103
|
+
|
|
104
|
+
// Auto-incrementing order counter for field registration
|
|
105
|
+
const orderCounterRef = useRef<number>(0);
|
|
106
|
+
|
|
107
|
+
// Field registration map
|
|
108
|
+
const fieldsRef = useRef<Map<keyof T, FieldRegistration>>(new Map());
|
|
109
|
+
|
|
110
|
+
// Initialize reducer with initial state
|
|
111
|
+
const [state, dispatch] = useReducer(formReducer<T>, {
|
|
112
|
+
values: initialValues,
|
|
113
|
+
errors: {},
|
|
114
|
+
touched: {},
|
|
115
|
+
isSubmitting: false,
|
|
116
|
+
submitCount: 0,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Validate a single field
|
|
120
|
+
const validateField = useCallback((name: keyof T): string | undefined => {
|
|
121
|
+
if (!validate) return undefined;
|
|
122
|
+
const result = validate(state.values);
|
|
123
|
+
if (!result) return undefined;
|
|
124
|
+
return result[name];
|
|
125
|
+
}, [validate, state.values]);
|
|
126
|
+
|
|
127
|
+
// Validate all fields
|
|
128
|
+
const validateAll = useCallback((): boolean => {
|
|
129
|
+
if (!validate) return true;
|
|
130
|
+
const errors = validate(state.values);
|
|
131
|
+
if (errors && Object.keys(errors).length > 0) {
|
|
132
|
+
dispatch({ type: 'SET_ERRORS', errors });
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
dispatch({ type: 'SET_ERRORS', errors: {} });
|
|
136
|
+
return true;
|
|
137
|
+
}, [validate, state.values]);
|
|
138
|
+
|
|
139
|
+
// Get a single value
|
|
140
|
+
const getValue = useCallback((name: keyof T): T[keyof T] => {
|
|
141
|
+
return state.values[name];
|
|
142
|
+
}, [state.values]);
|
|
143
|
+
|
|
144
|
+
// Set a single value
|
|
145
|
+
const setValue = useCallback((name: keyof T, value: T[keyof T]) => {
|
|
146
|
+
dispatch({ type: 'SET_VALUE', name, value });
|
|
147
|
+
|
|
148
|
+
// Validate on change if configured
|
|
149
|
+
if (validateOn === 'onChange' && validate) {
|
|
150
|
+
const result = validate({ ...state.values, [name]: value } as T);
|
|
151
|
+
const error = result ? result[name] : undefined;
|
|
152
|
+
dispatch({ type: 'SET_ERROR', name, error });
|
|
153
|
+
}
|
|
154
|
+
}, [validateOn, validate, state.values]);
|
|
155
|
+
|
|
156
|
+
// Set touched state for a field
|
|
157
|
+
const setTouched = useCallback((name: keyof T) => {
|
|
158
|
+
dispatch({ type: 'SET_TOUCHED', name });
|
|
159
|
+
|
|
160
|
+
// Validate on blur if configured
|
|
161
|
+
if (validateOn === 'onBlur' && validate) {
|
|
162
|
+
const result = validate(state.values);
|
|
163
|
+
const error = result ? result[name] : undefined;
|
|
164
|
+
dispatch({ type: 'SET_ERROR', name, error });
|
|
165
|
+
}
|
|
166
|
+
}, [validateOn, validate, state.values]);
|
|
167
|
+
|
|
168
|
+
// Get error for a field
|
|
169
|
+
const getError = useCallback((name: keyof T): string | undefined => {
|
|
170
|
+
return state.errors[name];
|
|
171
|
+
}, [state.errors]);
|
|
172
|
+
|
|
173
|
+
// Set error for a field
|
|
174
|
+
const setError = useCallback((name: keyof T, error: string | undefined) => {
|
|
175
|
+
dispatch({ type: 'SET_ERROR', name, error });
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
// Handle form submission
|
|
179
|
+
const handleSubmit = useCallback(async () => {
|
|
180
|
+
dispatch({ type: 'INCREMENT_SUBMIT_COUNT' });
|
|
181
|
+
|
|
182
|
+
const isValid = validateAll();
|
|
183
|
+
if (!isValid) return;
|
|
184
|
+
|
|
185
|
+
dispatch({ type: 'SET_SUBMITTING', isSubmitting: true });
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await onSubmit(state.values);
|
|
189
|
+
} finally {
|
|
190
|
+
dispatch({ type: 'SET_SUBMITTING', isSubmitting: false });
|
|
191
|
+
}
|
|
192
|
+
}, [validateAll, onSubmit, state.values]);
|
|
193
|
+
|
|
194
|
+
// Reset the form
|
|
195
|
+
const reset = useCallback((nextValues?: Partial<T>) => {
|
|
196
|
+
const resetValues = {
|
|
197
|
+
...initialValuesRef.current,
|
|
198
|
+
...nextValues,
|
|
199
|
+
} as T;
|
|
200
|
+
dispatch({ type: 'RESET', values: resetValues });
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// Register a field for keyboard flow
|
|
204
|
+
const registerField = useCallback((name: keyof T, ref: React.RefObject<any>, order?: number) => {
|
|
205
|
+
const fieldOrder = order ?? orderCounterRef.current++;
|
|
206
|
+
fieldsRef.current.set(name, { ref, order: fieldOrder });
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
// Unregister a field
|
|
210
|
+
const unregisterField = useCallback((name: keyof T) => {
|
|
211
|
+
fieldsRef.current.delete(name);
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
// Focus the next field in order
|
|
215
|
+
const focusNextField = useCallback((currentName: keyof T) => {
|
|
216
|
+
const fields = Array.from(fieldsRef.current.entries())
|
|
217
|
+
.sort((a, b) => a[1].order - b[1].order);
|
|
218
|
+
|
|
219
|
+
const currentIndex = fields.findIndex(([name]) => name === currentName);
|
|
220
|
+
if (currentIndex === -1 || currentIndex === fields.length - 1) return;
|
|
221
|
+
|
|
222
|
+
const nextField = fields[currentIndex + 1];
|
|
223
|
+
if (nextField && nextField[1].ref.current?.focus) {
|
|
224
|
+
nextField[1].ref.current.focus();
|
|
225
|
+
}
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
// Check if this is the last text field
|
|
229
|
+
const isLastTextField = useCallback((name: keyof T): boolean => {
|
|
230
|
+
const fields = Array.from(fieldsRef.current.entries())
|
|
231
|
+
.sort((a, b) => a[1].order - b[1].order);
|
|
232
|
+
|
|
233
|
+
if (fields.length === 0) return true;
|
|
234
|
+
|
|
235
|
+
const lastField = fields[fields.length - 1];
|
|
236
|
+
return lastField[0] === name;
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
// Compute isDirty by comparing current values to initial values
|
|
240
|
+
const isDirty = useMemo(() => {
|
|
241
|
+
const initial = initialValuesRef.current;
|
|
242
|
+
for (const key in state.values) {
|
|
243
|
+
if (state.values[key] !== initial[key]) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}, [state.values]);
|
|
249
|
+
|
|
250
|
+
// Compute isValid by checking if there are any errors
|
|
251
|
+
const isValid = useMemo(() => {
|
|
252
|
+
return Object.keys(state.errors).length === 0;
|
|
253
|
+
}, [state.errors]);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
values: state.values,
|
|
257
|
+
errors: state.errors,
|
|
258
|
+
touched: state.touched,
|
|
259
|
+
isSubmitting: state.isSubmitting,
|
|
260
|
+
isDirty,
|
|
261
|
+
isValid,
|
|
262
|
+
submitCount: state.submitCount,
|
|
263
|
+
disabled,
|
|
264
|
+
|
|
265
|
+
getValue,
|
|
266
|
+
setValue,
|
|
267
|
+
setTouched,
|
|
268
|
+
getError,
|
|
269
|
+
setError,
|
|
270
|
+
|
|
271
|
+
handleSubmit,
|
|
272
|
+
reset,
|
|
273
|
+
|
|
274
|
+
registerField,
|
|
275
|
+
unregisterField,
|
|
276
|
+
focusNextField,
|
|
277
|
+
isLastTextField,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useFormContext } from './FormContext';
|
|
2
|
+
import type { FieldRenderProps } from './types';
|
|
3
|
+
|
|
4
|
+
export function useFormField(name: string): FieldRenderProps {
|
|
5
|
+
const { form } = useFormContext();
|
|
6
|
+
|
|
7
|
+
const value = form.getValue(name);
|
|
8
|
+
const touched = Boolean(form.touched[name]);
|
|
9
|
+
const submitAttempted = form.submitCount > 0;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
value,
|
|
14
|
+
onChange: (val) => form.setValue(name, val as any),
|
|
15
|
+
onBlur: () => form.setTouched(name),
|
|
16
|
+
error: (touched || submitAttempted) ? form.getError(name) : undefined,
|
|
17
|
+
touched,
|
|
18
|
+
dirty: Boolean(form.values[name] !== undefined),
|
|
19
|
+
disabled: form.disabled,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, forwardRef, useMemo } from 'react';
|
|
2
2
|
import { Modal, View, TouchableWithoutFeedback, BackHandler, Dimensions } from 'react-native';
|
|
3
|
-
import { useSafeAreaInsets } from '
|
|
3
|
+
import { useSafeAreaInsets } from '@idealyst/theme';
|
|
4
4
|
import { PopoverProps } from './types';
|
|
5
5
|
import { popoverStyles } from './Popover.styles';
|
|
6
6
|
import { calculateSmartPosition } from '../utils/positionUtils.native';
|
|
@@ -125,4 +125,32 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
|
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
127
|
}),
|
|
128
|
+
|
|
129
|
+
groupWrapper: (_props: RadioButtonDynamicProps) => ({
|
|
130
|
+
display: 'flex' as const,
|
|
131
|
+
flexDirection: 'column' as const,
|
|
132
|
+
gap: 4,
|
|
133
|
+
}),
|
|
134
|
+
|
|
135
|
+
groupLabel: (_props: RadioButtonDynamicProps) => ({
|
|
136
|
+
fontSize: 14,
|
|
137
|
+
fontWeight: '500' as const,
|
|
138
|
+
color: theme.colors.text.primary,
|
|
139
|
+
variants: {
|
|
140
|
+
disabled: {
|
|
141
|
+
true: { opacity: 0.5 },
|
|
142
|
+
false: { opacity: 1 },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
groupHelperText: (_props: RadioButtonDynamicProps) => ({
|
|
148
|
+
fontSize: 12,
|
|
149
|
+
variants: {
|
|
150
|
+
hasError: {
|
|
151
|
+
true: { color: theme.intents.danger.primary },
|
|
152
|
+
false: { color: theme.colors.text.secondary },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
128
156
|
}));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { forwardRef, useMemo } from 'react';
|
|
2
|
-
import { View } from 'react-native';
|
|
2
|
+
import { View, Text } from 'react-native';
|
|
3
3
|
import { radioButtonStyles } from './RadioButton.styles';
|
|
4
4
|
import type { RadioGroupProps } from './types';
|
|
5
5
|
import { getNativeAccessibilityProps } from '../utils/accessibility';
|
|
@@ -19,6 +19,9 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
|
|
|
19
19
|
disabled = false,
|
|
20
20
|
orientation: _orientation = 'vertical',
|
|
21
21
|
children,
|
|
22
|
+
error,
|
|
23
|
+
helperText,
|
|
24
|
+
label,
|
|
22
25
|
style,
|
|
23
26
|
testID,
|
|
24
27
|
id,
|
|
@@ -29,6 +32,8 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
|
|
|
29
32
|
accessibilityHidden,
|
|
30
33
|
accessibilityRole,
|
|
31
34
|
}, ref) => {
|
|
35
|
+
const hasError = Boolean(error);
|
|
36
|
+
const showFooter = Boolean(error || helperText);
|
|
32
37
|
|
|
33
38
|
// Generate native accessibility props
|
|
34
39
|
const nativeA11yProps = useMemo(() => {
|
|
@@ -41,10 +46,13 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
|
|
|
41
46
|
});
|
|
42
47
|
}, [accessibilityLabel, accessibilityHint, accessibilityDisabled, disabled, accessibilityHidden, accessibilityRole]);
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
const wrapperStyle = (radioButtonStyles.groupWrapper as any)({});
|
|
50
|
+
const labelStyle = (radioButtonStyles.groupLabel as any)({ disabled });
|
|
51
|
+
const helperTextStyle = (radioButtonStyles.groupHelperText as any)({ hasError });
|
|
52
|
+
|
|
53
|
+
const content = (
|
|
45
54
|
<RadioGroupContext.Provider value={{ value, onValueChange, disabled }}>
|
|
46
55
|
<View
|
|
47
|
-
ref={ref as any}
|
|
48
56
|
nativeID={id}
|
|
49
57
|
style={[
|
|
50
58
|
radioButtonStyles.groupContainer,
|
|
@@ -57,6 +65,23 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
|
|
|
57
65
|
</View>
|
|
58
66
|
</RadioGroupContext.Provider>
|
|
59
67
|
);
|
|
68
|
+
|
|
69
|
+
if (!label && !showFooter) {
|
|
70
|
+
return content;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<View ref={ref as any} style={wrapperStyle}>
|
|
75
|
+
{label && <Text style={labelStyle}>{label}</Text>}
|
|
76
|
+
{content}
|
|
77
|
+
{showFooter && (
|
|
78
|
+
<View style={{ flex: 1 }}>
|
|
79
|
+
{error && <Text style={helperTextStyle}>{error}</Text>}
|
|
80
|
+
{!error && helperText && <Text style={helperTextStyle}>{helperText}</Text>}
|
|
81
|
+
</View>
|
|
82
|
+
)}
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
60
85
|
});
|
|
61
86
|
|
|
62
87
|
RadioGroup.displayName = 'RadioGroup';
|
|
@@ -18,6 +18,9 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
18
18
|
disabled = false,
|
|
19
19
|
orientation = 'vertical',
|
|
20
20
|
children,
|
|
21
|
+
error,
|
|
22
|
+
helperText,
|
|
23
|
+
label,
|
|
21
24
|
style,
|
|
22
25
|
testID,
|
|
23
26
|
id,
|
|
@@ -30,6 +33,8 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
30
33
|
}) => {
|
|
31
34
|
// Generate unique ID for accessibility
|
|
32
35
|
const groupId = useMemo(() => id || generateAccessibilityId('radiogroup'), [id]);
|
|
36
|
+
const hasError = Boolean(error);
|
|
37
|
+
const showFooter = Boolean(error || helperText);
|
|
33
38
|
|
|
34
39
|
// Generate ARIA props
|
|
35
40
|
const ariaProps = useMemo(() => {
|
|
@@ -45,6 +50,8 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
45
50
|
// Apply variants
|
|
46
51
|
radioButtonStyles.useVariants({
|
|
47
52
|
orientation,
|
|
53
|
+
disabled,
|
|
54
|
+
hasError,
|
|
48
55
|
});
|
|
49
56
|
|
|
50
57
|
const groupProps = getWebProps([
|
|
@@ -52,7 +59,11 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
52
59
|
style as any,
|
|
53
60
|
]);
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
const wrapperProps = getWebProps([(radioButtonStyles.groupWrapper as any)({})]);
|
|
63
|
+
const labelProps = getWebProps([(radioButtonStyles.groupLabel as any)({ disabled })]);
|
|
64
|
+
const helperTextProps = getWebProps([(radioButtonStyles.groupHelperText as any)({ hasError })]);
|
|
65
|
+
|
|
66
|
+
const content = (
|
|
56
67
|
<RadioGroupContext.Provider value={{ value, onValueChange, disabled }}>
|
|
57
68
|
<div
|
|
58
69
|
{...groupProps}
|
|
@@ -67,6 +78,31 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
|
|
|
67
78
|
</div>
|
|
68
79
|
</RadioGroupContext.Provider>
|
|
69
80
|
);
|
|
81
|
+
|
|
82
|
+
if (!label && !showFooter) {
|
|
83
|
+
return content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div {...wrapperProps}>
|
|
88
|
+
{label && <span {...labelProps}>{label}</span>}
|
|
89
|
+
{content}
|
|
90
|
+
{showFooter && (
|
|
91
|
+
<div style={{ flex: 1 }}>
|
|
92
|
+
{error && (
|
|
93
|
+
<span {...helperTextProps} role="alert">
|
|
94
|
+
{error}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
{!error && helperText && (
|
|
98
|
+
<span {...helperTextProps}>
|
|
99
|
+
{helperText}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
70
106
|
};
|
|
71
107
|
|
|
72
108
|
export default RadioGroup;
|
package/src/RadioButton/types.ts
CHANGED
|
@@ -26,6 +26,22 @@ export interface RadioGroupProps extends BaseProps, AccessibilityProps {
|
|
|
26
26
|
disabled?: boolean;
|
|
27
27
|
orientation?: 'horizontal' | 'vertical';
|
|
28
28
|
children: ReactNode;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Error message to display below the radio group. When set, shows error styling.
|
|
32
|
+
*/
|
|
33
|
+
error?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Helper text to display below the radio group. Hidden when error is set.
|
|
37
|
+
*/
|
|
38
|
+
helperText?: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Label text to display above the radio group
|
|
42
|
+
*/
|
|
43
|
+
label?: string;
|
|
44
|
+
|
|
29
45
|
style?: StyleProp<ViewStyle>;
|
|
30
46
|
testID?: string;
|
|
31
47
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { forwardRef, useEffect } from 'react';
|
|
2
2
|
import { View as RNView, ScrollView as RNScrollView, Keyboard, Platform } from 'react-native';
|
|
3
3
|
import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated';
|
|
4
|
-
import { useSafeAreaInsets } from '
|
|
4
|
+
import { useSafeAreaInsets } from '@idealyst/theme';
|
|
5
5
|
import { ScreenProps } from './types';
|
|
6
6
|
import { screenStyles } from './Screen.styles';
|
|
7
7
|
import type { IdealystElement } from '../utils/refTypes';
|
|
@@ -129,10 +129,19 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
|
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
const contentInsetStyle = contentInset ? {
|
|
133
|
+
paddingTop: safeAreaStyle.paddingTop + (contentInset.top ?? 0),
|
|
134
|
+
paddingBottom: safeAreaStyle.paddingBottom + (contentInset.bottom ?? 0),
|
|
135
|
+
paddingLeft: safeAreaStyle.paddingLeft + (contentInset.left ?? 0),
|
|
136
|
+
paddingRight: safeAreaStyle.paddingRight + (contentInset.right ?? 0),
|
|
137
|
+
} : safeAreaStyle;
|
|
138
|
+
|
|
132
139
|
return (
|
|
133
140
|
<Animated.View style={[{ flex: 1 }, avoidKeyboard && animatedKeyboardStyle]}>
|
|
134
|
-
<RNView ref={ref as any} nativeID={id} style={[screenStyle,
|
|
135
|
-
{
|
|
141
|
+
<RNView ref={ref as any} nativeID={id} style={[screenStyle, style]} testID={testID} onLayout={onLayout}>
|
|
142
|
+
<RNView style={[contentInsetStyle, { flex: 1 }]}>
|
|
143
|
+
{children}
|
|
144
|
+
</RNView>
|
|
136
145
|
</RNView>
|
|
137
146
|
</Animated.View>
|
|
138
147
|
);
|
|
@@ -24,7 +24,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
24
24
|
onChange,
|
|
25
25
|
placeholder = 'Select an option',
|
|
26
26
|
disabled = false,
|
|
27
|
-
error
|
|
27
|
+
error,
|
|
28
28
|
helperText,
|
|
29
29
|
label,
|
|
30
30
|
type = 'outlined',
|
|
@@ -43,6 +43,10 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
43
43
|
accessibilityLabel,
|
|
44
44
|
id,
|
|
45
45
|
}, ref) => {
|
|
46
|
+
// Derive hasError boolean from error prop
|
|
47
|
+
const hasError = Boolean(error);
|
|
48
|
+
// Get error message if error is a string
|
|
49
|
+
const errorMessage = typeof error === 'string' ? error : undefined;
|
|
46
50
|
const [isOpen, setIsOpen] = useState(false);
|
|
47
51
|
const [searchTerm, setSearchTerm] = useState('');
|
|
48
52
|
const scaleAnim = useRef(new Animated.Value(0)).current;
|
|
@@ -80,7 +84,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
80
84
|
type,
|
|
81
85
|
size,
|
|
82
86
|
disabled,
|
|
83
|
-
|
|
87
|
+
hasError,
|
|
84
88
|
focused: isOpen,
|
|
85
89
|
margin,
|
|
86
90
|
marginVertical,
|
|
@@ -90,7 +94,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
90
94
|
// Get dynamic styles - call as functions for theme reactivity
|
|
91
95
|
const containerStyle = (selectStyles.container as any)({});
|
|
92
96
|
const labelStyle = (selectStyles.label as any)({});
|
|
93
|
-
const triggerStyle = (selectStyles.trigger as any)({ type, intent, disabled,
|
|
97
|
+
const triggerStyle = (selectStyles.trigger as any)({ type, intent, disabled, hasError, focused: isOpen });
|
|
94
98
|
const triggerContentStyle = (selectStyles.triggerContent as any)({});
|
|
95
99
|
const triggerTextStyle = (selectStyles.triggerText as any)({});
|
|
96
100
|
const placeholderStyle = (selectStyles.placeholder as any)({});
|
|
@@ -105,7 +109,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
105
109
|
const optionIconStyle = (selectStyles.optionIcon as any)({});
|
|
106
110
|
const optionTextStyle = (selectStyles.optionText as any)({});
|
|
107
111
|
const optionTextDisabledStyle = (selectStyles.optionTextDisabled as any)({});
|
|
108
|
-
const helperTextStyle = (selectStyles.helperText as any)({
|
|
112
|
+
const helperTextStyle = (selectStyles.helperText as any)({ hasError });
|
|
109
113
|
const overlayStyle = (selectStyles.overlay as any)({});
|
|
110
114
|
|
|
111
115
|
const handleTriggerPress = () => {
|
|
@@ -357,9 +361,9 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
|
|
|
357
361
|
{/* Only render dropdown modal if not using iOS ActionSheet */}
|
|
358
362
|
{!(Platform.OS === 'ios' && presentationMode === 'actionSheet') && renderDropdown()}
|
|
359
363
|
|
|
360
|
-
{helperText && (
|
|
364
|
+
{(errorMessage || helperText) && (
|
|
361
365
|
<Text style={helperTextStyle}>
|
|
362
|
-
{helperText}
|
|
366
|
+
{errorMessage || helperText}
|
|
363
367
|
</Text>
|
|
364
368
|
)}
|
|
365
369
|
</View>
|
|
@@ -19,7 +19,7 @@ export type SelectDynamicProps = {
|
|
|
19
19
|
intent?: Intent;
|
|
20
20
|
type?: SelectType;
|
|
21
21
|
disabled?: boolean;
|
|
22
|
-
|
|
22
|
+
hasError?: boolean;
|
|
23
23
|
focused?: boolean;
|
|
24
24
|
margin?: ViewStyleSize;
|
|
25
25
|
marginVertical?: ViewStyleSize;
|
|
@@ -56,10 +56,10 @@ export const selectStyles = defineStyle('Select', (theme: Theme) => ({
|
|
|
56
56
|
marginBottom: 4,
|
|
57
57
|
}),
|
|
58
58
|
|
|
59
|
-
trigger: ({ type = 'outlined', intent = 'neutral', disabled = false,
|
|
60
|
-
// Determine border color based on state priority:
|
|
59
|
+
trigger: ({ type = 'outlined', intent = 'neutral', disabled = false, hasError = false, focused = false }: SelectDynamicProps) => {
|
|
60
|
+
// Determine border color based on state priority: hasError > focused > default
|
|
61
61
|
const getBorderColor = () => {
|
|
62
|
-
if (
|
|
62
|
+
if (hasError) return theme.intents.danger.primary;
|
|
63
63
|
if (focused) return theme.intents[intent]?.primary ?? theme.intents.primary.primary;
|
|
64
64
|
return type === 'filled' ? 'transparent' : theme.colors.border.primary;
|
|
65
65
|
};
|
|
@@ -86,9 +86,9 @@ export const selectStyles = defineStyle('Select', (theme: Theme) => ({
|
|
|
86
86
|
boxSizing: 'border-box',
|
|
87
87
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
88
88
|
border: `1px solid ${borderColor}`,
|
|
89
|
-
boxShadow: focused && !
|
|
89
|
+
boxShadow: focused && !hasError ? `0 0 0 2px ${theme.intents[intent]?.primary ?? theme.intents.primary.primary}33` : 'none',
|
|
90
90
|
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
|
|
91
|
-
_hover: disabled ? {} : { borderColor: focused ||
|
|
91
|
+
_hover: disabled ? {} : { borderColor: focused || hasError ? borderColor : theme.colors.border.secondary },
|
|
92
92
|
_active: disabled ? {} : { opacity: 0.9 },
|
|
93
93
|
_focus: { outline: 'none' },
|
|
94
94
|
},
|
|
@@ -274,10 +274,10 @@ export const selectStyles = defineStyle('Select', (theme: Theme) => ({
|
|
|
274
274
|
color: theme.colors.text.secondary,
|
|
275
275
|
}),
|
|
276
276
|
|
|
277
|
-
helperText: ({
|
|
277
|
+
helperText: ({ hasError = false }: SelectDynamicProps) => ({
|
|
278
278
|
fontSize: 12,
|
|
279
279
|
marginTop: 4,
|
|
280
|
-
color:
|
|
280
|
+
color: hasError ? theme.intents.danger.primary : theme.colors.text.secondary,
|
|
281
281
|
}),
|
|
282
282
|
|
|
283
283
|
overlay: (_props: SelectDynamicProps) => ({
|