@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
@@ -62,9 +62,11 @@ export interface SelectProps extends FormInputStyleProps, FormAccessibilityProps
62
62
  disabled?: boolean;
63
63
 
64
64
  /**
65
- * Whether the select shows an error state
65
+ * Error state or error message. When a string is provided, it displays as error text.
66
+ * When boolean true, shows error styling without text.
67
+ * @deprecated Using boolean is deprecated. Prefer passing an error message string.
66
68
  */
67
- error?: boolean;
69
+ error?: string | boolean;
68
70
 
69
71
  /**
70
72
  * Helper text to display below the select
@@ -17,6 +17,9 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
17
17
  max = 100,
18
18
  step = 1,
19
19
  disabled = false,
20
+ error,
21
+ helperText,
22
+ label,
20
23
  showValue = false,
21
24
  showMinMax = false,
22
25
  marks = [],
@@ -43,6 +46,11 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
43
46
  accessibilityValueMax,
44
47
  accessibilityValueText,
45
48
  }, ref) => {
49
+ // Derive hasError from error prop
50
+ const hasError = Boolean(error);
51
+ // Determine if we need a wrapper (when label, error, or helperText is present)
52
+ const needsWrapper = Boolean(label) || Boolean(error) || Boolean(helperText);
53
+ const showFooter = Boolean(error) || Boolean(helperText);
46
54
  const [internalValue, setInternalValue] = useState(defaultValue);
47
55
  const [trackWidthState, setTrackWidthState] = useState(0);
48
56
  const trackWidth = useSharedValue(0);
@@ -55,11 +63,18 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
55
63
  sliderStyles.useVariants({
56
64
  size,
57
65
  disabled,
66
+ hasError,
58
67
  margin,
59
68
  marginVertical,
60
69
  marginHorizontal,
61
70
  });
62
71
 
72
+ // Wrapper, label, and footer styles
73
+ const wrapperStyle = sliderStyles.wrapper as any;
74
+ const labelStyle = sliderStyles.label as any;
75
+ const footerStyle = sliderStyles.footer as any;
76
+ const helperTextStyle = sliderStyles.helperText as any;
77
+
63
78
  const clampValue = (val: number) => {
64
79
  'worklet';
65
80
  const clampedValue = Math.min(Math.max(val, min), max);
@@ -211,6 +226,113 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
211
226
  const containerStyle = (sliderStyles.container as any);
212
227
  const trackStyle = (sliderStyles.track as any);
213
228
 
229
+ // The slider container element
230
+ const sliderContainer = (
231
+ <View style={[containerStyle, !needsWrapper && style]} testID={!needsWrapper ? testID : undefined} {...nativeA11yProps}>
232
+ {showValue && (
233
+ <View style={sliderStyles.valueLabel as any}>
234
+ <Text>{value}</Text>
235
+ </View>
236
+ )}
237
+
238
+ <View style={sliderStyles.sliderWrapper}>
239
+ <GestureDetector gesture={composedGesture}>
240
+ <View
241
+ style={trackStyle}
242
+ onLayout={(e: LayoutChangeEvent) => {
243
+ const width = e.nativeEvent.layout.width;
244
+ trackWidth.value = width;
245
+ setTrackWidthState(width);
246
+ }}
247
+ >
248
+ {/* Filled track */}
249
+ <Animated.View
250
+ style={[(sliderStyles.filledTrack as any), filledTrackAnimatedStyle]}
251
+ />
252
+
253
+ {/* Marks */}
254
+ {marks.length > 0 && trackWidthState > 0 && (
255
+ <View style={sliderStyles.marks as any}>
256
+ {marks.map((mark) => {
257
+ const markPercentage = ((mark.value - min) / (max - min)) * 100;
258
+ const markPosition = (markPercentage / 100) * trackWidthState;
259
+ return (
260
+ <View key={mark.value}>
261
+ <View
262
+ style={[
263
+ sliderStyles.mark as any,
264
+ { left: markPosition },
265
+ ]}
266
+ />
267
+ {mark.label && (
268
+ <View
269
+ style={[
270
+ sliderStyles.markLabel as any,
271
+ { left: markPosition },
272
+ ]}
273
+ >
274
+ <Text typography="caption">{mark.label}</Text>
275
+ </View>
276
+ )}
277
+ </View>
278
+ );
279
+ })}
280
+ </View>
281
+ )}
282
+
283
+ {/* Thumb */}
284
+ <Animated.View
285
+ style={[
286
+ (sliderStyles.thumb as any),
287
+ {
288
+ // Manual positioning/sizing for native layout
289
+ position: 'absolute',
290
+ top: '50%',
291
+ marginTop: -thumbSize / 2,
292
+ width: thumbSize,
293
+ height: thumbSize,
294
+ borderRadius: thumbSize / 2,
295
+ },
296
+ thumbAnimatedStyle,
297
+ ]}
298
+ >
299
+ {renderIcon()}
300
+ </Animated.View>
301
+ </View>
302
+ </GestureDetector>
303
+ </View>
304
+
305
+ {showMinMax && (
306
+ <View style={sliderStyles.minMaxLabels}>
307
+ <Text style={sliderStyles.minMaxLabel} typography="caption">{min}</Text>
308
+ <Text style={sliderStyles.minMaxLabel} typography="caption">{max}</Text>
309
+ </View>
310
+ )}
311
+ </View>
312
+ );
313
+
314
+ // If wrapper needed for label/error/helperText
315
+ if (needsWrapper) {
316
+ return (
317
+ <View ref={ref as any} nativeID={id} style={[wrapperStyle, style]} testID={testID}>
318
+ {label && (
319
+ <Text style={labelStyle}>{label}</Text>
320
+ )}
321
+
322
+ {sliderContainer}
323
+
324
+ {showFooter && (
325
+ <View style={footerStyle}>
326
+ <View style={{ flex: 1 }}>
327
+ {error && <Text style={helperTextStyle}>{error}</Text>}
328
+ {!error && helperText && <Text style={helperTextStyle}>{helperText}</Text>}
329
+ </View>
330
+ </View>
331
+ )}
332
+ </View>
333
+ );
334
+ }
335
+
214
336
  return (
215
337
  <View ref={ref as any} nativeID={id} style={[containerStyle, style]} testID={testID} {...nativeA11yProps}>
216
338
  {showValue && (
@@ -16,6 +16,7 @@ export type SliderVariants = {
16
16
  size: Size;
17
17
  intent: Intent;
18
18
  disabled: boolean;
19
+ hasError?: boolean;
19
20
  margin?: ViewStyleSize;
20
21
  marginVertical?: ViewStyleSize;
21
22
  marginHorizontal?: ViewStyleSize;
@@ -55,17 +56,6 @@ export const sliderStyles = defineStyle('Slider', (theme: Theme) => ({
55
56
  container: {
56
57
  gap: 4,
57
58
  paddingVertical: 8,
58
- variants: {
59
- margin: {
60
- margin: theme.sizes.$view.padding,
61
- },
62
- marginVertical: {
63
- marginVertical: theme.sizes.$view.padding,
64
- },
65
- marginHorizontal: {
66
- marginHorizontal: theme.sizes.$view.padding,
67
- },
68
- },
69
59
  },
70
60
 
71
61
  sliderWrapper: {
@@ -266,4 +256,51 @@ export const sliderStyles = defineStyle('Slider', (theme: Theme) => ({
266
256
  whiteSpace: 'nowrap',
267
257
  },
268
258
  },
259
+
260
+ wrapper: {
261
+ display: 'flex' as const,
262
+ flexDirection: 'column' as const,
263
+ gap: 4,
264
+ variants: {
265
+ margin: {
266
+ margin: theme.sizes.$view.padding,
267
+ },
268
+ marginVertical: {
269
+ marginVertical: theme.sizes.$view.padding,
270
+ },
271
+ marginHorizontal: {
272
+ marginHorizontal: theme.sizes.$view.padding,
273
+ },
274
+ },
275
+ },
276
+
277
+ label: {
278
+ fontSize: 14,
279
+ fontWeight: '500' as const,
280
+ color: theme.colors.text.primary,
281
+ variants: {
282
+ disabled: {
283
+ true: { opacity: 0.5 },
284
+ false: { opacity: 1 },
285
+ },
286
+ },
287
+ },
288
+
289
+ helperText: {
290
+ fontSize: 12,
291
+ variants: {
292
+ hasError: {
293
+ true: { color: theme.intents.danger.primary },
294
+ false: { color: theme.colors.text.secondary },
295
+ },
296
+ },
297
+ },
298
+
299
+ footer: {
300
+ display: 'flex' as const,
301
+ flexDirection: 'row' as const,
302
+ justifyContent: 'space-between' as const,
303
+ alignItems: 'center' as const,
304
+ gap: 4,
305
+ },
269
306
  }));
@@ -19,6 +19,9 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
19
19
  max = 100,
20
20
  step = 1,
21
21
  disabled = false,
22
+ error,
23
+ helperText,
24
+ label,
22
25
  showValue = false,
23
26
  showMinMax = false,
24
27
  marks = [],
@@ -45,6 +48,10 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
45
48
  accessibilityValueMax,
46
49
  accessibilityValueText,
47
50
  }, ref) => {
51
+ // Derive hasError from error prop
52
+ const hasError = Boolean(error);
53
+ // Determine if we need a wrapper (when label, error, or helperText is present)
54
+ const needsWrapper = Boolean(label) || Boolean(error) || Boolean(helperText);
48
55
  const [internalValue, setInternalValue] = useState(defaultValue);
49
56
  const [isDragging, setIsDragging] = useState(false);
50
57
  const trackRef = useRef<HTMLDivElement>(null);
@@ -58,13 +65,14 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
58
65
  size,
59
66
  intent,
60
67
  disabled,
68
+ hasError,
61
69
  margin,
62
70
  marginVertical,
63
71
  marginHorizontal,
64
72
  });
65
73
 
66
- const containerProps = getWebProps([sliderStyles.container as any, style as any]);
67
- const wrapperProps = getWebProps([sliderStyles.sliderWrapper as any]);
74
+ const containerProps = getWebProps([sliderStyles.container as any, !needsWrapper && style as any].filter(Boolean));
75
+ const sliderWrapperProps = getWebProps([sliderStyles.sliderWrapper as any]);
68
76
  const trackProps = getWebProps([sliderStyles.track as any]);
69
77
  const thumbIconProps = getWebProps([sliderStyles.thumbIcon as any]);
70
78
  const valueLabelProps = getWebProps([sliderStyles.valueLabel as any]);
@@ -72,6 +80,14 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
72
80
  const minMaxLabelProps = getWebProps([sliderStyles.minMaxLabel as any]);
73
81
  const marksProps = getWebProps([sliderStyles.marks as any]);
74
82
 
83
+ // Wrapper, label, and footer styles
84
+ const outerWrapperProps = getWebProps([sliderStyles.wrapper as any, style as any]);
85
+ const labelProps = getWebProps([sliderStyles.label as any]);
86
+ const footerProps = getWebProps([sliderStyles.footer as any]);
87
+ const helperTextProps = getWebProps([sliderStyles.helperText as any]);
88
+
89
+ const showFooter = Boolean(error) || Boolean(helperText);
90
+
75
91
  const clampValue = useCallback((val: number) => {
76
92
  const clampedValue = Math.min(Math.max(val, min), max);
77
93
  const steppedValue = Math.round(clampedValue / step) * step;
@@ -252,15 +268,16 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
252
268
 
253
269
  const mergedRef = useMergeRefs(ref, containerProps.ref);
254
270
 
255
- return (
256
- <div {...containerProps} ref={mergedRef} id={sliderId} data-testid={testID}>
271
+ // The slider container element
272
+ const sliderContainer = (
273
+ <div {...containerProps} ref={!needsWrapper ? mergedRef : undefined} id={!needsWrapper ? sliderId : undefined} data-testid={!needsWrapper ? testID : undefined}>
257
274
  {showValue && (
258
275
  <div {...valueLabelProps}>
259
276
  {value}
260
277
  </div>
261
278
  )}
262
279
 
263
- <div {...wrapperProps}>
280
+ <div {...sliderWrapperProps}>
264
281
  <div
265
282
  {...trackProps}
266
283
  {...ariaProps}
@@ -311,6 +328,38 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
311
328
  )}
312
329
  </div>
313
330
  );
331
+
332
+ // If wrapper needed for label/error/helperText
333
+ if (needsWrapper) {
334
+ return (
335
+ <div {...outerWrapperProps} id={sliderId} data-testid={testID}>
336
+ {label && (
337
+ <label {...labelProps}>{label}</label>
338
+ )}
339
+
340
+ {sliderContainer}
341
+
342
+ {showFooter && (
343
+ <div {...footerProps}>
344
+ <div style={{ flex: 1 }}>
345
+ {error && (
346
+ <span {...helperTextProps} role="alert">
347
+ {error}
348
+ </span>
349
+ )}
350
+ {!error && helperText && (
351
+ <span {...helperTextProps}>
352
+ {helperText}
353
+ </span>
354
+ )}
355
+ </div>
356
+ </div>
357
+ )}
358
+ </div>
359
+ );
360
+ }
361
+
362
+ return sliderContainer;
314
363
  });
315
364
 
316
365
  Slider.displayName = 'Slider';
@@ -22,6 +22,21 @@ export interface SliderProps extends FormInputStyleProps, RangeAccessibilityProp
22
22
  max?: number;
23
23
  step?: number;
24
24
  disabled?: boolean;
25
+
26
+ /**
27
+ * Error message to display below the slider. When set, shows error styling.
28
+ */
29
+ error?: string;
30
+
31
+ /**
32
+ * Helper text to display below the slider. Hidden when error is set.
33
+ */
34
+ helperText?: string;
35
+
36
+ /**
37
+ * Label text to display above the slider
38
+ */
39
+ label?: string;
25
40
  showValue?: boolean;
26
41
  showMinMax?: boolean;
27
42
  marks?: SliderMark[];
@@ -1,5 +1,5 @@
1
1
  import React, { forwardRef, useMemo } from 'react';
2
- import { Pressable } from 'react-native';
2
+ import { Pressable, View } from 'react-native';
3
3
  import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
4
4
  import { switchStyles } from './Switch.styles';
5
5
  import Text from '../Text';
@@ -11,6 +11,8 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
11
11
  checked = false,
12
12
  onChange,
13
13
  disabled = false,
14
+ error,
15
+ helperText,
14
16
  label,
15
17
  labelPosition = 'right',
16
18
  intent = 'primary',
@@ -32,10 +34,17 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
32
34
  accessibilityDescribedBy,
33
35
  accessibilityChecked,
34
36
  }, ref) => {
37
+ // Derive hasError from error prop
38
+ const hasError = Boolean(error);
39
+ // Determine if we need a wrapper (when error or helperText is present)
40
+ const needsWrapper = Boolean(error) || Boolean(helperText);
41
+ const showFooter = Boolean(error) || Boolean(helperText);
42
+
35
43
  switchStyles.useVariants({
36
44
  size,
37
45
  disabled,
38
46
  checked,
47
+ hasError,
39
48
  intent,
40
49
  position: labelPosition,
41
50
  margin,
@@ -43,6 +52,10 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
43
52
  marginHorizontal,
44
53
  });
45
54
 
55
+ // Wrapper and helperText styles
56
+ const wrapperStyle = (switchStyles.wrapper as any)({});
57
+ const helperTextStyle = (switchStyles.helperText as any)({ hasError });
58
+
46
59
  const progress = useSharedValue(checked ? 1 : 0);
47
60
 
48
61
  React.useEffect(() => {
@@ -123,12 +136,12 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
123
136
 
124
137
  const switchElement = (
125
138
  <Pressable
126
- ref={!label ? ref : undefined}
127
- nativeID={!label ? id : undefined}
139
+ ref={!label && !needsWrapper ? ref : undefined}
140
+ nativeID={!label && !needsWrapper ? id : undefined}
128
141
  onPress={handlePress}
129
142
  disabled={disabled}
130
143
  style={switchStyles.switchContainer as any}
131
- testID={testID}
144
+ testID={!label && !needsWrapper ? testID : undefined}
132
145
  {...nativeA11yProps}
133
146
  >
134
147
  <Animated.View style={switchStyles.switchTrack as any}>
@@ -154,27 +167,42 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
154
167
  </Pressable>
155
168
  );
156
169
 
157
- if (label) {
170
+ // The switch + label row
171
+ const switchWithLabel = label ? (
172
+ <Pressable
173
+ ref={!needsWrapper ? ref as any : undefined}
174
+ nativeID={!needsWrapper ? id : undefined}
175
+ onPress={handlePress}
176
+ disabled={disabled}
177
+ style={[switchStyles.container as any, !needsWrapper && style]}
178
+ testID={!needsWrapper ? testID : undefined}
179
+ >
180
+ {labelPosition === 'left' && (
181
+ <Text style={switchStyles.label as any}>{label}</Text>
182
+ )}
183
+ {switchElement}
184
+ {labelPosition === 'right' && (
185
+ <Text style={switchStyles.label as any}>{label}</Text>
186
+ )}
187
+ </Pressable>
188
+ ) : switchElement;
189
+
190
+ // If wrapper needed for error/helperText
191
+ if (needsWrapper) {
158
192
  return (
159
- <Pressable
160
- ref={ref as any}
161
- nativeID={id}
162
- onPress={handlePress}
163
- disabled={disabled}
164
- style={[switchStyles.container as any, style]}
165
- >
166
- {labelPosition === 'left' && (
167
- <Text style={switchStyles.label as any}>{label}</Text>
168
- )}
169
- {switchElement}
170
- {labelPosition === 'right' && (
171
- <Text style={switchStyles.label as any}>{label}</Text>
193
+ <View ref={ref as any} nativeID={id} style={[wrapperStyle, style]} testID={testID}>
194
+ {switchWithLabel}
195
+ {showFooter && (
196
+ <View style={{ flex: 1 }}>
197
+ {error && <Text style={helperTextStyle}>{error}</Text>}
198
+ {!error && helperText && <Text style={helperTextStyle}>{helperText}</Text>}
199
+ </View>
172
200
  )}
173
- </Pressable>
201
+ </View>
174
202
  );
175
203
  }
176
204
 
177
- return switchElement;
205
+ return switchWithLabel;
178
206
  });
179
207
 
180
208
  Switch.displayName = 'Switch';
@@ -19,6 +19,7 @@ export type SwitchDynamicProps = {
19
19
  intent?: Intent;
20
20
  checked?: boolean;
21
21
  disabled?: boolean;
22
+ hasError?: boolean;
22
23
  labelPosition?: LabelPosition;
23
24
  margin?: ViewStyleSize;
24
25
  marginVertical?: ViewStyleSize;
@@ -166,4 +167,31 @@ export const switchStyles = defineStyle('Switch', (theme: Theme) => ({
166
167
  },
167
168
  },
168
169
  }),
170
+
171
+ wrapper: (_props: SwitchDynamicProps) => ({
172
+ display: 'flex' as const,
173
+ flexDirection: 'column' as const,
174
+ gap: 4,
175
+ variants: {
176
+ margin: {
177
+ margin: theme.sizes.$view.padding,
178
+ },
179
+ marginVertical: {
180
+ marginVertical: theme.sizes.$view.padding,
181
+ },
182
+ marginHorizontal: {
183
+ marginHorizontal: theme.sizes.$view.padding,
184
+ },
185
+ },
186
+ }),
187
+
188
+ helperText: (_props: SwitchDynamicProps) => ({
189
+ fontSize: 12,
190
+ variants: {
191
+ hasError: {
192
+ true: { color: theme.intents.danger.primary },
193
+ false: { color: theme.colors.text.secondary },
194
+ },
195
+ },
196
+ }),
169
197
  }));
@@ -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
  */