@idealyst/components 1.2.105 → 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
@@ -6,6 +6,7 @@ import { TextInputProps } from './types';
6
6
  import { textInputStyles } from './TextInput.styles';
7
7
  import { getNativeFormAccessibilityProps } from '../utils/accessibility';
8
8
  import type { IdealystElement } from '../utils/refTypes';
9
+ import Text from '../Text';
9
10
 
10
11
  // Inner TextInput component that can be memoized to prevent re-renders
11
12
  // for Android secure text entry
@@ -68,6 +69,9 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
68
69
  size = 'md',
69
70
  type = 'outlined',
70
71
  hasError = false,
72
+ error,
73
+ helperText,
74
+ label,
71
75
  // Spacing variants from FormInputStyleProps
72
76
  margin,
73
77
  marginVertical,
@@ -91,6 +95,12 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
91
95
  const [isFocused, setIsFocused] = useState(false);
92
96
  const [isPasswordVisible, setIsPasswordVisible] = useState(false);
93
97
 
98
+ // Derive hasError from error prop or hasError boolean
99
+ const computedHasError = Boolean(error) || hasError;
100
+
101
+ // Determine if we need a wrapper (when label, error, or helperText is present)
102
+ const needsWrapper = Boolean(label) || Boolean(error) || Boolean(helperText);
103
+
94
104
  // Track if this is a secure field that needs Android workaround
95
105
  const isSecureField = inputMode === 'password' || secureTextEntry;
96
106
  const needsAndroidSecureWorkaround = Platform.OS === 'android' && isSecureField && !isPasswordVisible;
@@ -160,12 +170,13 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
160
170
 
161
171
  // Generate native accessibility props
162
172
  const nativeA11yProps = useMemo(() => {
163
- // Derive invalid state from hasError or explicit accessibilityInvalid
164
- const isInvalid = accessibilityInvalid ?? hasError;
173
+ // Derive invalid state from computedHasError or explicit accessibilityInvalid
174
+ const isInvalid = accessibilityInvalid ?? computedHasError;
175
+ const computedLabel = accessibilityLabel ?? label ?? placeholder;
165
176
 
166
177
  return getNativeFormAccessibilityProps({
167
- accessibilityLabel,
168
- accessibilityHint,
178
+ accessibilityLabel: computedLabel,
179
+ accessibilityHint: accessibilityHint ?? (error || helperText),
169
180
  accessibilityDisabled: accessibilityDisabled ?? disabled,
170
181
  accessibilityHidden,
171
182
  accessibilityRole: accessibilityRole ?? 'textbox',
@@ -174,14 +185,18 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
174
185
  });
175
186
  }, [
176
187
  accessibilityLabel,
188
+ label,
189
+ placeholder,
177
190
  accessibilityHint,
191
+ error,
192
+ helperText,
178
193
  accessibilityDisabled,
179
194
  disabled,
180
195
  accessibilityHidden,
181
196
  accessibilityRole,
182
197
  accessibilityRequired,
183
198
  accessibilityInvalid,
184
- hasError,
199
+ computedHasError,
185
200
  ]);
186
201
 
187
202
  // Determine the textContentType for iOS AutoFill
@@ -224,7 +239,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
224
239
  size,
225
240
  type,
226
241
  focused: isFocused,
227
- hasError,
242
+ hasError: computedHasError,
228
243
  disabled,
229
244
  margin,
230
245
  marginVertical,
@@ -232,11 +247,19 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
232
247
  });
233
248
 
234
249
  // Compute dynamic styles - call as functions for theme reactivity
235
- const containerStyle = (textInputStyles.container as any)({ type, focused: isFocused, hasError, disabled });
250
+ const containerStyle = (textInputStyles.container as any)({ type, focused: isFocused, hasError: computedHasError, disabled });
236
251
  const leftIconContainerStyle = (textInputStyles.leftIconContainer as any)({});
237
252
  const rightIconContainerStyle = (textInputStyles.rightIconContainer as any)({});
238
253
  const passwordToggleStyle = (textInputStyles.passwordToggle as any)({});
239
254
 
255
+ // Wrapper, label, and footer styles
256
+ const wrapperStyle = (textInputStyles.wrapper as any)({});
257
+ const labelStyle = (textInputStyles.label as any)({ disabled });
258
+ const footerStyle = (textInputStyles.footer as any)({});
259
+ const helperTextStyle = (textInputStyles.helperText as any)({ hasError: computedHasError });
260
+
261
+ const showFooter = Boolean(error) || Boolean(helperText);
262
+
240
263
  // Helper to render left icon
241
264
  const renderLeftIcon = () => {
242
265
  if (!leftIcon) return null;
@@ -275,44 +298,104 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
275
298
  return null;
276
299
  };
277
300
 
301
+ // If no wrapper needed, return flat input container
302
+ if (!needsWrapper) {
303
+ return (
304
+ <View style={[containerStyle, style]} testID={testID} nativeID={id}>
305
+ {/* Left Icon */}
306
+ {leftIcon && (
307
+ <View style={leftIconContainerStyle}>
308
+ {renderLeftIcon()}
309
+ </View>
310
+ )}
311
+
312
+ {/* Input */}
313
+ <InnerRNTextInput
314
+ inputRef={ref}
315
+ value={value}
316
+ onChangeText={handleChangeText}
317
+ isAndroidSecure={needsAndroidSecureWorkaround}
318
+ inputStyle={inputStyle}
319
+ textInputProps={textInputProps}
320
+ />
321
+
322
+ {/* Right Icon or Password Toggle */}
323
+ {shouldShowPasswordToggle ? (
324
+ <TouchableOpacity
325
+ style={passwordToggleStyle}
326
+ onPress={togglePasswordVisibility}
327
+ disabled={disabled}
328
+ accessibilityLabel={isPasswordVisible ? 'Hide password' : 'Show password'}
329
+ >
330
+ <MaterialDesignIcons
331
+ name={isPasswordVisible ? 'eye-off' : 'eye'}
332
+ size={iconSize}
333
+ color={iconColor}
334
+ />
335
+ </TouchableOpacity>
336
+ ) : rightIcon ? (
337
+ <View style={rightIconContainerStyle}>
338
+ {renderRightIcon()}
339
+ </View>
340
+ ) : null}
341
+ </View>
342
+ );
343
+ }
344
+
345
+ // With wrapper for label/error/helperText
278
346
  return (
279
- <View style={[containerStyle, style]} testID={testID} nativeID={id}>
280
- {/* Left Icon */}
281
- {leftIcon && (
282
- <View style={leftIconContainerStyle}>
283
- {renderLeftIcon()}
284
- </View>
347
+ <View style={[wrapperStyle, style]} testID={testID} nativeID={id}>
348
+ {label && (
349
+ <Text style={labelStyle}>{label}</Text>
285
350
  )}
286
351
 
287
- {/* Input */}
288
- <InnerRNTextInput
289
- inputRef={ref}
290
- value={value}
291
- onChangeText={handleChangeText}
292
- isAndroidSecure={needsAndroidSecureWorkaround}
293
- inputStyle={inputStyle}
294
- textInputProps={textInputProps}
295
- />
352
+ <View style={containerStyle}>
353
+ {/* Left Icon */}
354
+ {leftIcon && (
355
+ <View style={leftIconContainerStyle}>
356
+ {renderLeftIcon()}
357
+ </View>
358
+ )}
359
+
360
+ {/* Input */}
361
+ <InnerRNTextInput
362
+ inputRef={ref}
363
+ value={value}
364
+ onChangeText={handleChangeText}
365
+ isAndroidSecure={needsAndroidSecureWorkaround}
366
+ inputStyle={inputStyle}
367
+ textInputProps={textInputProps}
368
+ />
296
369
 
297
- {/* Right Icon or Password Toggle */}
298
- {shouldShowPasswordToggle ? (
299
- <TouchableOpacity
300
- style={passwordToggleStyle}
301
- onPress={togglePasswordVisibility}
302
- disabled={disabled}
303
- accessibilityLabel={isPasswordVisible ? 'Hide password' : 'Show password'}
304
- >
305
- <MaterialDesignIcons
306
- name={isPasswordVisible ? 'eye-off' : 'eye'}
307
- size={iconSize}
308
- color={iconColor}
309
- />
310
- </TouchableOpacity>
311
- ) : rightIcon ? (
312
- <View style={rightIconContainerStyle}>
313
- {renderRightIcon()}
370
+ {/* Right Icon or Password Toggle */}
371
+ {shouldShowPasswordToggle ? (
372
+ <TouchableOpacity
373
+ style={passwordToggleStyle}
374
+ onPress={togglePasswordVisibility}
375
+ disabled={disabled}
376
+ accessibilityLabel={isPasswordVisible ? 'Hide password' : 'Show password'}
377
+ >
378
+ <MaterialDesignIcons
379
+ name={isPasswordVisible ? 'eye-off' : 'eye'}
380
+ size={iconSize}
381
+ color={iconColor}
382
+ />
383
+ </TouchableOpacity>
384
+ ) : rightIcon ? (
385
+ <View style={rightIconContainerStyle}>
386
+ {renderRightIcon()}
387
+ </View>
388
+ ) : null}
389
+ </View>
390
+
391
+ {showFooter && (
392
+ <View style={footerStyle}>
393
+ <View style={{ flex: 1 }}>
394
+ {error && <Text style={helperTextStyle}>{error}</Text>}
395
+ {!error && helperText && <Text style={helperTextStyle}>{helperText}</Text>}
396
+ </View>
314
397
  </View>
315
- ) : null}
398
+ )}
316
399
  </View>
317
400
  );
318
401
  });
@@ -75,15 +75,6 @@ export const textInputStyles = defineStyle('TextInput', (theme: Theme) => ({
75
75
  height: theme.sizes.$input.height,
76
76
  paddingHorizontal: theme.sizes.$input.paddingHorizontal,
77
77
  },
78
- margin: {
79
- margin: theme.sizes.$view.padding,
80
- },
81
- marginVertical: {
82
- marginVertical: theme.sizes.$view.padding,
83
- },
84
- marginHorizontal: {
85
- marginHorizontal: theme.sizes.$view.padding,
86
- },
87
78
  },
88
79
  _web: {
89
80
  boxSizing: 'border-box',
@@ -208,6 +199,53 @@ export const textInputStyles = defineStyle('TextInput', (theme: Theme) => ({
208
199
  },
209
200
  },
210
201
  }),
202
+
203
+ wrapper: (_props: TextInputDynamicProps) => ({
204
+ display: 'flex' as const,
205
+ flexDirection: 'column' as const,
206
+ gap: 4,
207
+ variants: {
208
+ margin: {
209
+ margin: theme.sizes.$view.padding,
210
+ },
211
+ marginVertical: {
212
+ marginVertical: theme.sizes.$view.padding,
213
+ },
214
+ marginHorizontal: {
215
+ marginHorizontal: theme.sizes.$view.padding,
216
+ },
217
+ },
218
+ }),
219
+
220
+ label: (_props: TextInputDynamicProps) => ({
221
+ fontSize: 14,
222
+ fontWeight: '500' as const,
223
+ color: theme.colors.text.primary,
224
+ variants: {
225
+ disabled: {
226
+ true: { opacity: 0.5 },
227
+ false: { opacity: 1 },
228
+ },
229
+ },
230
+ }),
231
+
232
+ helperText: (_props: TextInputDynamicProps) => ({
233
+ fontSize: 12,
234
+ variants: {
235
+ hasError: {
236
+ true: { color: theme.intents.danger.primary },
237
+ false: { color: theme.colors.text.secondary },
238
+ },
239
+ },
240
+ }),
241
+
242
+ footer: (_props: TextInputDynamicProps) => ({
243
+ display: 'flex' as const,
244
+ flexDirection: 'row' as const,
245
+ justifyContent: 'space-between' as const,
246
+ alignItems: 'center' as const,
247
+ gap: 4,
248
+ }),
211
249
  }));
212
250
 
213
251
  // Legacy export for backwards compatibility
@@ -6,7 +6,7 @@ import { isIconName } from '../Icon/icon-resolver';
6
6
  import useMergeRefs from '../hooks/useMergeRefs';
7
7
  import { textInputStyles } from './TextInput.styles';
8
8
  import { TextInputProps } from './types';
9
- import { getWebFormAriaProps } from '../utils/accessibility';
9
+ import { getWebFormAriaProps, combineIds, generateAccessibilityId } from '../utils/accessibility';
10
10
  import type { IdealystElement } from '../utils/refTypes';
11
11
  import { flattenStyle } from '../utils/flattenStyle';
12
12
 
@@ -31,6 +31,9 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
31
31
  size = 'md',
32
32
  type = 'outlined',
33
33
  hasError = false,
34
+ error,
35
+ helperText,
36
+ label,
34
37
  // Spacing variants from FormInputStyleProps
35
38
  margin,
36
39
  marginVertical,
@@ -65,6 +68,18 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
65
68
  const isPasswordField = inputMode === 'password' || secureTextEntry;
66
69
  const shouldShowPasswordToggle = isPasswordField && (showPasswordToggle !== false);
67
70
 
71
+ // Derive hasError from error prop or hasError boolean
72
+ const computedHasError = Boolean(error) || hasError;
73
+
74
+ // Determine if we need a wrapper (when label, error, or helperText is present)
75
+ const needsWrapper = Boolean(label) || Boolean(error) || Boolean(helperText);
76
+
77
+ // Generate unique IDs for accessibility
78
+ const inputId = useMemo(() => id || generateAccessibilityId('textinput'), [id]);
79
+ const errorId = useMemo(() => `${inputId}-error`, [inputId]);
80
+ const helperId = useMemo(() => `${inputId}-helper`, [inputId]);
81
+ const labelId = useMemo(() => label ? `${inputId}-label` : undefined, [inputId, label]);
82
+
68
83
  // Get theme for icon sizes and colors
69
84
  const { theme } = useUnistyles();
70
85
  const iconSize = theme.sizes.input[size].iconSize;
@@ -138,7 +153,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
138
153
  size,
139
154
  type,
140
155
  focused: isFocused,
141
- hasError,
156
+ hasError: computedHasError,
142
157
  disabled,
143
158
  margin,
144
159
  marginVertical,
@@ -146,19 +161,34 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
146
161
  });
147
162
 
148
163
  // Get web props for all styled elements (all styles are dynamic functions)
149
- const dynamicContainerStyle = (textInputStyles.container as any)({ type, focused: isFocused, hasError, disabled });
150
- const {ref: containerStyleRef, ...containerProps} = getWebProps([dynamicContainerStyle, flattenStyle(style)]);
164
+ const dynamicContainerStyle = (textInputStyles.container as any)({ type, focused: isFocused, hasError: computedHasError, disabled });
165
+ const {ref: containerStyleRef, ...containerProps} = getWebProps([dynamicContainerStyle, !needsWrapper && flattenStyle(style)].filter(Boolean));
151
166
  const leftIconContainerProps = getWebProps([(textInputStyles.leftIconContainer as any)({})]);
152
167
  const rightIconContainerProps = getWebProps([(textInputStyles.rightIconContainer as any)({})]);
153
168
  const passwordToggleProps = getWebProps([(textInputStyles.passwordToggle as any)({})]);
154
169
 
170
+ // Wrapper, label, and footer styles
171
+ const wrapperStyleComputed = (textInputStyles.wrapper as any)({});
172
+ const labelStyleComputed = (textInputStyles.label as any)({ disabled });
173
+ const footerStyleComputed = (textInputStyles.footer as any)({});
174
+ const helperTextStyleComputed = (textInputStyles.helperText as any)({ hasError: computedHasError });
175
+
176
+ const wrapperProps = getWebProps([wrapperStyleComputed, flattenStyle(style)]);
177
+ const labelProps = getWebProps([labelStyleComputed]);
178
+ const footerProps = getWebProps([footerStyleComputed]);
179
+ const helperTextProps = getWebProps([helperTextStyleComputed]);
180
+
155
181
  // Get input props
156
182
  const inputWebProps = getWebProps([(textInputStyles.input as any)({})]);
157
183
 
158
184
  // Generate accessibility props
159
185
  const ariaProps = useMemo(() => {
160
- // Derive invalid state from hasError or explicit accessibilityInvalid
161
- const isInvalid = accessibilityInvalid ?? hasError;
186
+ // Derive invalid state from computedHasError or explicit accessibilityInvalid
187
+ const isInvalid = accessibilityInvalid ?? computedHasError;
188
+ const describedByIds = combineIds(
189
+ accessibilityDescribedBy,
190
+ error ? errorId : helperText ? helperId : undefined
191
+ );
162
192
 
163
193
  return getWebFormAriaProps({
164
194
  accessibilityLabel,
@@ -166,8 +196,8 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
166
196
  accessibilityDisabled: accessibilityDisabled ?? disabled,
167
197
  accessibilityHidden,
168
198
  accessibilityRole: accessibilityRole ?? 'textbox',
169
- accessibilityLabelledBy,
170
- accessibilityDescribedBy,
199
+ accessibilityLabelledBy: accessibilityLabelledBy ?? labelId,
200
+ accessibilityDescribedBy: describedByIds,
171
201
  accessibilityControls,
172
202
  accessibilityExpanded,
173
203
  accessibilityPressed,
@@ -175,7 +205,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
175
205
  accessibilityHasPopup,
176
206
  accessibilityRequired,
177
207
  accessibilityInvalid: isInvalid,
178
- accessibilityErrorMessage,
208
+ accessibilityErrorMessage: accessibilityErrorMessage ?? (error ? errorId : undefined),
179
209
  accessibilityAutoComplete,
180
210
  });
181
211
  }, [
@@ -186,7 +216,12 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
186
216
  accessibilityHidden,
187
217
  accessibilityRole,
188
218
  accessibilityLabelledBy,
219
+ labelId,
189
220
  accessibilityDescribedBy,
221
+ error,
222
+ errorId,
223
+ helperText,
224
+ helperId,
190
225
  accessibilityControls,
191
226
  accessibilityExpanded,
192
227
  accessibilityPressed,
@@ -194,7 +229,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
194
229
  accessibilityHasPopup,
195
230
  accessibilityRequired,
196
231
  accessibilityInvalid,
197
- hasError,
232
+ computedHasError,
198
233
  accessibilityErrorMessage,
199
234
  accessibilityAutoComplete,
200
235
  ]);
@@ -257,57 +292,134 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
257
292
 
258
293
  const containerRef = useRef<HTMLDivElement>(null);
259
294
 
260
- const handleContainerPress = (e: React.MouseEvent<HTMLDivElement>) => {
295
+ const handleContainerPress = (e: React.MouseEvent<HTMLDivElement>) => {
261
296
  e.preventDefault();
262
297
  e.stopPropagation();
263
298
  containerRef.current?.focus();
264
- }
299
+ };
265
300
 
266
301
  const mergedContainerRef = useMergeRefs(containerRef, containerStyleRef);
267
302
 
303
+ const showFooter = Boolean(error) || Boolean(helperText);
304
+
305
+ // If no wrapper needed, return flat input container
306
+ if (!needsWrapper) {
307
+ return (
308
+ <div onClick={handleContainerPress} ref={mergedContainerRef} {...containerProps} id={id} data-testid={testID}>
309
+ {/* Left Icon */}
310
+ {leftIcon && (
311
+ <span {...leftIconContainerProps}>
312
+ {renderLeftIcon()}
313
+ </span>
314
+ )}
315
+
316
+ {/* Input */}
317
+ <input
318
+ {...inputWebProps}
319
+ {...ariaProps}
320
+ id={inputId}
321
+ ref={mergedInputRef}
322
+ type={getInputType()}
323
+ value={value}
324
+ onClick={handlePress}
325
+ onChange={handleChange}
326
+ onFocus={handleFocus}
327
+ onBlur={handleBlur}
328
+ onKeyDown={handleKeyDown}
329
+ placeholder={placeholder}
330
+ disabled={disabled}
331
+ autoCapitalize={autoCapitalize}
332
+ />
333
+
334
+ {/* Right Icon or Password Toggle */}
335
+ {shouldShowPasswordToggle ? (
336
+ <button
337
+ {...passwordToggleProps}
338
+ onClick={togglePasswordVisibility}
339
+ disabled={disabled}
340
+ aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
341
+ type="button"
342
+ tabIndex={-1}
343
+ >
344
+ {renderPasswordToggleIcon()}
345
+ </button>
346
+ ) : rightIcon ? (
347
+ <span {...rightIconContainerProps}>
348
+ {renderRightIcon()}
349
+ </span>
350
+ ) : null}
351
+ </div>
352
+ );
353
+ }
354
+
355
+ // With wrapper for label/error/helperText
268
356
  return (
269
- <div onClick={handleContainerPress} ref={mergedContainerRef} {...containerProps} id={id} data-testid={testID}>
270
- {/* Left Icon */}
271
- {leftIcon && (
272
- <span {...leftIconContainerProps}>
273
- {renderLeftIcon()}
274
- </span>
357
+ <div {...wrapperProps} id={id} data-testid={testID}>
358
+ {label && (
359
+ <label {...labelProps} id={labelId} htmlFor={inputId}>{label}</label>
275
360
  )}
276
361
 
277
- {/* Input */}
278
- <input
279
- {...inputWebProps}
280
- {...ariaProps}
281
- ref={mergedInputRef}
282
- type={getInputType()}
283
- value={value}
284
- onClick={handlePress}
285
- onChange={handleChange}
286
- onFocus={handleFocus}
287
- onBlur={handleBlur}
288
- onKeyDown={handleKeyDown}
289
- placeholder={placeholder}
290
- disabled={disabled}
291
- autoCapitalize={autoCapitalize}
292
- />
293
-
294
- {/* Right Icon or Password Toggle */}
295
- {shouldShowPasswordToggle ? (
296
- <button
297
- {...passwordToggleProps}
298
- onClick={togglePasswordVisibility}
362
+ <div onClick={handleContainerPress} ref={mergedContainerRef} {...containerProps}>
363
+ {/* Left Icon */}
364
+ {leftIcon && (
365
+ <span {...leftIconContainerProps}>
366
+ {renderLeftIcon()}
367
+ </span>
368
+ )}
369
+
370
+ {/* Input */}
371
+ <input
372
+ {...inputWebProps}
373
+ {...ariaProps}
374
+ id={inputId}
375
+ ref={mergedInputRef}
376
+ type={getInputType()}
377
+ value={value}
378
+ onClick={handlePress}
379
+ onChange={handleChange}
380
+ onFocus={handleFocus}
381
+ onBlur={handleBlur}
382
+ onKeyDown={handleKeyDown}
383
+ placeholder={placeholder}
299
384
  disabled={disabled}
300
- aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
301
- type="button"
302
- tabIndex={-1}
303
- >
304
- {renderPasswordToggleIcon()}
305
- </button>
306
- ) : rightIcon ? (
307
- <span {...rightIconContainerProps}>
308
- {renderRightIcon()}
309
- </span>
310
- ) : null}
385
+ autoCapitalize={autoCapitalize}
386
+ />
387
+
388
+ {/* Right Icon or Password Toggle */}
389
+ {shouldShowPasswordToggle ? (
390
+ <button
391
+ {...passwordToggleProps}
392
+ onClick={togglePasswordVisibility}
393
+ disabled={disabled}
394
+ aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
395
+ type="button"
396
+ tabIndex={-1}
397
+ >
398
+ {renderPasswordToggleIcon()}
399
+ </button>
400
+ ) : rightIcon ? (
401
+ <span {...rightIconContainerProps}>
402
+ {renderRightIcon()}
403
+ </span>
404
+ ) : null}
405
+ </div>
406
+
407
+ {showFooter && (
408
+ <div {...footerProps}>
409
+ <div style={{ flex: 1 }}>
410
+ {error && (
411
+ <span {...helperTextProps} id={errorId} role="alert">
412
+ {error}
413
+ </span>
414
+ )}
415
+ {!error && helperText && (
416
+ <span {...helperTextProps} id={helperId}>
417
+ {helperText}
418
+ </span>
419
+ )}
420
+ </div>
421
+ </div>
422
+ )}
311
423
  </div>
312
424
  );
313
425
  });
@@ -167,10 +167,25 @@ export interface TextInputProps extends FormInputStyleProps, FormAccessibilityPr
167
167
 
168
168
  /**
169
169
  * Whether the input has an error state
170
- * @deprecated Use intent="danger" instead
170
+ * @deprecated Use error prop or intent="danger" instead
171
171
  */
172
172
  hasError?: boolean;
173
173
 
174
+ /**
175
+ * Error message to display below the input. When set, the input shows error styling.
176
+ */
177
+ error?: string;
178
+
179
+ /**
180
+ * Helper text to display below the input. Hidden when error is set.
181
+ */
182
+ helperText?: string;
183
+
184
+ /**
185
+ * Label text to display above the input
186
+ */
187
+ label?: string;
188
+
174
189
  /**
175
190
  * Called when the user submits the input (presses Enter on web, or the return key on mobile).
176
191
  * Use this for form submission or moving to the next field.
@@ -119,6 +119,9 @@ export * from './Platform/types';
119
119
  export { Keyboard } from './Keyboard';
120
120
  export * from './Keyboard/types';
121
121
 
122
+ export { default as Form, useForm } from './Form';
123
+ export * from './Form/types';
124
+
122
125
  export type { ButtonProps } from './Button/types';
123
126
  export type { IconButtonProps } from './IconButton/types';
124
127
  export type { TextProps } from './Text/types';
@@ -165,6 +168,22 @@ export type {
165
168
  KeyboardSubscription,
166
169
  KeyboardStatic,
167
170
  } from './Keyboard/types';
171
+ export type {
172
+ FormProps,
173
+ UseFormOptions,
174
+ UseFormReturn,
175
+ FormValues,
176
+ FormErrors,
177
+ FormTextInputProps,
178
+ FormTextAreaProps,
179
+ FormSelectProps,
180
+ FormCheckboxProps,
181
+ FormRadioGroupProps,
182
+ FormSwitchProps,
183
+ FormSliderProps,
184
+ FormFieldProps,
185
+ FieldRenderProps,
186
+ } from './Form/types';
168
187
 
169
188
  // Event utilities
170
189
  export * from './utils/events';