@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.
Files changed (47) hide show
  1. package/package.json +4 -4
  2. package/src/Dialog/Dialog.native.tsx +1 -1
  3. package/src/Form/Form.native.tsx +38 -0
  4. package/src/Form/Form.styles.tsx +14 -0
  5. package/src/Form/Form.web.tsx +50 -0
  6. package/src/Form/FormContext.ts +12 -0
  7. package/src/Form/fields/FormCheckbox.tsx +27 -0
  8. package/src/Form/fields/FormField.tsx +11 -0
  9. package/src/Form/fields/FormRadioGroup.tsx +24 -0
  10. package/src/Form/fields/FormSelect.tsx +27 -0
  11. package/src/Form/fields/FormSlider.tsx +26 -0
  12. package/src/Form/fields/FormSwitch.tsx +26 -0
  13. package/src/Form/fields/FormTextArea.tsx +27 -0
  14. package/src/Form/fields/FormTextInput.tsx +55 -0
  15. package/src/Form/index.native.ts +35 -0
  16. package/src/Form/index.ts +35 -0
  17. package/src/Form/index.web.ts +35 -0
  18. package/src/Form/types.ts +105 -0
  19. package/src/Form/useForm.ts +279 -0
  20. package/src/Form/useFormField.ts +21 -0
  21. package/src/Popover/Popover.native.tsx +1 -1
  22. package/src/RadioButton/RadioButton.styles.tsx +28 -0
  23. package/src/RadioButton/RadioGroup.native.tsx +28 -3
  24. package/src/RadioButton/RadioGroup.web.tsx +37 -1
  25. package/src/RadioButton/types.ts +16 -0
  26. package/src/Screen/Screen.native.tsx +12 -3
  27. package/src/Select/Select.native.tsx +10 -6
  28. package/src/Select/Select.styles.tsx +8 -8
  29. package/src/Select/Select.web.tsx +11 -7
  30. package/src/Select/types.ts +4 -2
  31. package/src/Slider/Slider.native.tsx +122 -0
  32. package/src/Slider/Slider.styles.tsx +48 -11
  33. package/src/Slider/Slider.web.tsx +54 -5
  34. package/src/Slider/types.ts +15 -0
  35. package/src/Switch/Switch.native.tsx +48 -20
  36. package/src/Switch/Switch.styles.tsx +28 -0
  37. package/src/Switch/Switch.web.tsx +55 -16
  38. package/src/Switch/types.ts +10 -0
  39. package/src/TextInput/TextInput.native.tsx +123 -40
  40. package/src/TextInput/TextInput.styles.tsx +47 -9
  41. package/src/TextInput/TextInput.web.tsx +163 -51
  42. package/src/TextInput/types.ts +16 -1
  43. package/src/hooks/useSmartPosition.native.ts +1 -1
  44. package/src/index.native.ts +19 -0
  45. package/src/index.ts +19 -0
  46. package/src/internal/BoundedModalContent.native.tsx +1 -1
  47. package/src/internal/SafeAreaDebugOverlay.native.tsx +1 -1
@@ -17,6 +17,8 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
17
17
  checked = false,
18
18
  onChange,
19
19
  disabled = false,
20
+ error,
21
+ helperText,
20
22
  label,
21
23
  labelPosition = 'right',
22
24
  intent = 'primary',
@@ -40,6 +42,10 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
40
42
  accessibilityDescribedBy,
41
43
  accessibilityChecked,
42
44
  }, ref) => {
45
+ // Derive hasError from error prop
46
+ const hasError = Boolean(error);
47
+ // Determine if we need a wrapper (when error or helperText is present)
48
+ const needsWrapper = Boolean(error) || Boolean(helperText);
43
49
  const handleClick = (e: React.MouseEvent) => {
44
50
  e.preventDefault();
45
51
  e.stopPropagation();
@@ -86,6 +92,7 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
86
92
  size,
87
93
  checked,
88
94
  disabled: disabled as boolean,
95
+ hasError,
89
96
  labelPosition,
90
97
  margin,
91
98
  intent,
@@ -98,6 +105,14 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
98
105
  const thumbIconProps = getWebProps([switchStyles.thumbIcon as any]);
99
106
  const labelProps = getWebProps([switchStyles.label as any]);
100
107
 
108
+ // Wrapper and helperText styles
109
+ const wrapperStyleComputed = (switchStyles.wrapper as any)({});
110
+ const helperTextStyleComputed = (switchStyles.helperText as any)({ hasError });
111
+ const wrapperProps = getWebProps([wrapperStyleComputed, flattenStyle(style)]);
112
+ const helperTextProps = getWebProps([helperTextStyleComputed]);
113
+
114
+ const showFooter = Boolean(error) || Boolean(helperText);
115
+
101
116
  // Helper to render icon
102
117
  const renderIcon = () => {
103
118
  const iconToRender = checked ? onIcon : offIcon;
@@ -122,7 +137,7 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
122
137
  const computedButtonProps = getWebProps([switchStyles.switchContainer as any]);
123
138
 
124
139
  // Computed container props (for when label exists)
125
- const computedContainerProps = getWebProps([switchStyles.container as any]);
140
+ const computedContainerProps = getWebProps([switchStyles.container as any, !needsWrapper && flattenStyle(style)].filter(Boolean));
126
141
 
127
142
  const mergedButtonRef = useMergeRefs(ref as React.Ref<HTMLButtonElement>, computedButtonProps.ref);
128
143
  const mergedContainerRef = useMergeRefs(ref as React.Ref<HTMLDivElement>, computedContainerProps.ref);
@@ -131,12 +146,12 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
131
146
  <button
132
147
  {...computedButtonProps}
133
148
  {...ariaProps}
134
- style={flattenStyle(style)}
135
- ref={mergedButtonRef}
149
+ style={!label && !needsWrapper ? flattenStyle(style) : undefined}
150
+ ref={!label && !needsWrapper ? mergedButtonRef : undefined}
136
151
  onClick={handleClick}
137
152
  disabled={disabled}
138
153
  id={switchId}
139
- data-testid={testID}
154
+ data-testid={!label && !needsWrapper ? testID : undefined}
140
155
  >
141
156
  <div {...trackProps}>
142
157
  <div {...thumbProps}>
@@ -146,24 +161,48 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
146
161
  </button>
147
162
  );
148
163
 
149
- if (label) {
164
+ // The switch + label row
165
+ const switchWithLabel = label ? (
166
+ <div
167
+ {...computedContainerProps}
168
+ ref={!needsWrapper ? mergedContainerRef : undefined}
169
+ id={!needsWrapper ? id : undefined}
170
+ data-testid={!needsWrapper ? testID : undefined}
171
+ >
172
+ {labelPosition === 'left' && (
173
+ <span {...labelProps}>{label}</span>
174
+ )}
175
+ {switchElement}
176
+ {labelPosition === 'right' && (
177
+ <span {...labelProps}>{label}</span>
178
+ )}
179
+ </div>
180
+ ) : switchElement;
181
+
182
+ // If wrapper needed for error/helperText
183
+ if (needsWrapper) {
150
184
  return (
151
- <div
152
- {...computedContainerProps}
153
- ref={mergedContainerRef}
154
- >
155
- {labelPosition === 'left' && (
156
- <span {...labelProps}>{label}</span>
157
- )}
158
- {switchElement}
159
- {labelPosition === 'right' && (
160
- <span {...labelProps}>{label}</span>
185
+ <div {...wrapperProps} id={id} data-testid={testID}>
186
+ {switchWithLabel}
187
+ {showFooter && (
188
+ <div style={{ flex: 1 }}>
189
+ {error && (
190
+ <span {...helperTextProps} role="alert">
191
+ {error}
192
+ </span>
193
+ )}
194
+ {!error && helperText && (
195
+ <span {...helperTextProps}>
196
+ {helperText}
197
+ </span>
198
+ )}
199
+ </div>
161
200
  )}
162
201
  </div>
163
202
  );
164
203
  }
165
204
 
166
- return switchElement;
205
+ return switchWithLabel;
167
206
  });
168
207
 
169
208
  Switch.displayName = 'Switch';
@@ -28,6 +28,16 @@ export interface SwitchProps extends FormInputStyleProps, SelectionAccessibility
28
28
  */
29
29
  disabled?: boolean;
30
30
 
31
+ /**
32
+ * Error message to display below the switch. When set, shows error styling.
33
+ */
34
+ error?: string;
35
+
36
+ /**
37
+ * Helper text to display below the switch. Hidden when error is set.
38
+ */
39
+ helperText?: string;
40
+
31
41
  /**
32
42
  * Label text to display next to the switch
33
43
  */
@@ -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