@idealyst/components 1.2.106 → 1.2.107

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.
Files changed (42) hide show
  1. package/package.json +4 -4
  2. package/src/Form/Form.native.tsx +38 -0
  3. package/src/Form/Form.styles.tsx +14 -0
  4. package/src/Form/Form.web.tsx +50 -0
  5. package/src/Form/FormContext.ts +12 -0
  6. package/src/Form/fields/FormCheckbox.tsx +27 -0
  7. package/src/Form/fields/FormField.tsx +11 -0
  8. package/src/Form/fields/FormRadioGroup.tsx +24 -0
  9. package/src/Form/fields/FormSelect.tsx +27 -0
  10. package/src/Form/fields/FormSlider.tsx +26 -0
  11. package/src/Form/fields/FormSwitch.tsx +26 -0
  12. package/src/Form/fields/FormTextArea.tsx +27 -0
  13. package/src/Form/fields/FormTextInput.tsx +55 -0
  14. package/src/Form/index.native.ts +35 -0
  15. package/src/Form/index.ts +35 -0
  16. package/src/Form/index.web.ts +35 -0
  17. package/src/Form/types.ts +105 -0
  18. package/src/Form/useForm.ts +279 -0
  19. package/src/Form/useFormField.ts +21 -0
  20. package/src/RadioButton/RadioButton.styles.tsx +28 -0
  21. package/src/RadioButton/RadioGroup.native.tsx +28 -3
  22. package/src/RadioButton/RadioGroup.web.tsx +37 -1
  23. package/src/RadioButton/types.ts +16 -0
  24. package/src/Screen/Screen.native.tsx +11 -2
  25. package/src/Select/Select.native.tsx +10 -6
  26. package/src/Select/Select.styles.tsx +8 -8
  27. package/src/Select/Select.web.tsx +11 -7
  28. package/src/Select/types.ts +4 -2
  29. package/src/Slider/Slider.native.tsx +122 -0
  30. package/src/Slider/Slider.styles.tsx +48 -11
  31. package/src/Slider/Slider.web.tsx +54 -5
  32. package/src/Slider/types.ts +15 -0
  33. package/src/Switch/Switch.native.tsx +48 -20
  34. package/src/Switch/Switch.styles.tsx +28 -0
  35. package/src/Switch/Switch.web.tsx +55 -16
  36. package/src/Switch/types.ts +10 -0
  37. package/src/TextInput/TextInput.native.tsx +123 -40
  38. package/src/TextInput/TextInput.styles.tsx +47 -9
  39. package/src/TextInput/TextInput.web.tsx +163 -51
  40. package/src/TextInput/types.ts +16 -1
  41. package/src/index.native.ts +19 -0
  42. package/src/index.ts +19 -0
@@ -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
+ }
@@ -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
- return (
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
- return (
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;
@@ -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
  }
@@ -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, safeAreaStyle, style]} testID={testID} onLayout={onLayout}>
135
- {children}
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 = false,
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
- error,
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, error, focused: isOpen });
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)({ error });
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
- error?: boolean;
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, error = false, focused = false }: SelectDynamicProps) => {
60
- // Determine border color based on state priority: error > focused > default
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 (error) return theme.intents.danger.primary;
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 && !error ? `0 0 0 2px ${theme.intents[intent]?.primary ?? theme.intents.primary.primary}33` : 'none',
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 || error ? borderColor : theme.colors.border.secondary },
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: ({ error = false }: SelectDynamicProps) => ({
277
+ helperText: ({ hasError = false }: SelectDynamicProps) => ({
278
278
  fontSize: 12,
279
279
  marginTop: 4,
280
- color: error ? theme.intents.danger.primary : theme.colors.text.secondary,
280
+ color: hasError ? theme.intents.danger.primary : theme.colors.text.secondary,
281
281
  }),
282
282
 
283
283
  overlay: (_props: SelectDynamicProps) => ({
@@ -18,7 +18,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
18
18
  onChange,
19
19
  placeholder = 'Select an option',
20
20
  disabled = false,
21
- error = false,
21
+ error,
22
22
  helperText,
23
23
  label,
24
24
  type = 'outlined',
@@ -36,6 +36,10 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
36
36
  accessibilityLabel,
37
37
  id,
38
38
  }, ref) => {
39
+ // Derive hasError boolean from error prop
40
+ const hasError = Boolean(error);
41
+ // Get error message if error is a string
42
+ const errorMessage = typeof error === 'string' ? error : undefined;
39
43
  const [isOpen, setIsOpen] = useState(false);
40
44
  const [searchTerm, setSearchTerm] = useState('');
41
45
  const [focusedIndex, setFocusedIndex] = useState(-1);
@@ -66,7 +70,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
66
70
  type,
67
71
  size,
68
72
  disabled,
69
- error,
73
+ hasError,
70
74
  focused: isOpen,
71
75
  margin,
72
76
  marginVertical,
@@ -182,7 +186,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
182
186
  // Get dynamic styles - call as functions for theme reactivity
183
187
  const containerStyle = (selectStyles.container as any)({});
184
188
  const labelStyle = (selectStyles.label as any)({});
185
- const triggerStyle = (selectStyles.trigger as any)({ type, intent, disabled, error, focused: isOpen });
189
+ const triggerStyle = (selectStyles.trigger as any)({ type, intent, disabled, hasError, focused: isOpen });
186
190
  const triggerContentStyle = (selectStyles.triggerContent as any)({});
187
191
  const triggerTextStyle = (selectStyles.triggerText as any)({});
188
192
  const placeholderStyle = (selectStyles.placeholder as any)({});
@@ -200,7 +204,7 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
200
204
  const optionIconStyle = (selectStyles.optionIcon as any)({});
201
205
  const optionTextStyle = (selectStyles.optionText as any)({});
202
206
  const optionTextDisabledStyle = (selectStyles.optionTextDisabled as any)({});
203
- const helperTextStyle = (selectStyles.helperText as any)({ error });
207
+ const helperTextStyle = (selectStyles.helperText as any)({ hasError });
204
208
 
205
209
  const containerWebProps = getWebProps([containerStyle, style as any]);
206
210
  const triggerWebProps = getWebProps([triggerStyle]);
@@ -336,9 +340,9 @@ const Select = forwardRef<IdealystElement, SelectProps>(({
336
340
  </div>
337
341
  </PositionedPortal>
338
342
 
339
- {helperText && (
340
- <div {...getWebProps([helperTextStyle])}>
341
- {helperText}
343
+ {(errorMessage || helperText) && (
344
+ <div {...getWebProps([helperTextStyle])} role={errorMessage ? 'alert' : undefined}>
345
+ {errorMessage || helperText}
342
346
  </div>
343
347
  )}
344
348
  </div>