diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index b6cfd9a64b..a035e951fa 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -27,6 +27,13 @@ export type Props = { * Custom color for checkbox. */ color?: string; + /** + * Whether the checkbox is in an error state. When true, the outline + * (unchecked) and container (checked / indeterminate) use + * `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor` + * overrides take precedence. + */ + error?: boolean; /** * @optional */ diff --git a/src/components/Checkbox/CheckboxAndroid.tsx b/src/components/Checkbox/CheckboxAndroid.tsx index 8a93cc6a70..d73bdf57c5 100644 --- a/src/components/Checkbox/CheckboxAndroid.tsx +++ b/src/components/Checkbox/CheckboxAndroid.tsx @@ -34,6 +34,13 @@ export type Props = $RemoveChildren & { * Custom color for checkbox. */ color?: ColorValue; + /** + * Whether the checkbox is in an error state. When true, the outline + * (unchecked) and container (checked / indeterminate) use + * `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor` + * overrides take precedence. + */ + error?: boolean; /** * @optional */ @@ -60,6 +67,7 @@ const CheckboxAndroid = ({ disabled, onPress, testID, + error, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -107,6 +115,7 @@ const CheckboxAndroid = ({ checked, customColor: rest.color, customUncheckedColor: rest.uncheckedColor, + error, }); const borderWidth = scaleAnim.interpolate({ diff --git a/src/components/Checkbox/CheckboxIOS.tsx b/src/components/Checkbox/CheckboxIOS.tsx index d4a8127180..5830359372 100644 --- a/src/components/Checkbox/CheckboxIOS.tsx +++ b/src/components/Checkbox/CheckboxIOS.tsx @@ -29,6 +29,12 @@ export type Props = $RemoveChildren & { * Custom color for checkbox. */ color?: ColorValue; + /** + * Whether the checkbox is in an error state. When true, the checked / + * indeterminate icon uses `theme.colors.error`. `disabled` and explicit + * `color` overrides take precedence. + */ + error?: boolean; /** * @optional */ @@ -52,6 +58,7 @@ const CheckboxIOS = ({ onPress, theme: themeOverrides, testID, + error, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); @@ -62,6 +69,7 @@ const CheckboxIOS = ({ theme, disabled, customColor: rest.color, + error, }); const icon = indeterminate ? 'minus' : 'check'; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index f47457151b..87362df00c 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -8,28 +8,40 @@ const { stateOpacity } = tokens.md.ref; const getAndroidCheckedColor = ({ theme, customColor, + error, }: { theme: InternalTheme; customColor?: ColorValue; + error?: boolean; }) => { if (customColor) { return customColor; } + if (error) { + return theme.colors.error; + } + return theme.colors.primary; }; const getAndroidUncheckedColor = ({ theme, customUncheckedColor, + error, }: { theme: InternalTheme; customUncheckedColor?: ColorValue; + error?: boolean; }) => { if (customUncheckedColor) { return customUncheckedColor; } + if (error) { + return theme.colors.error; + } + return theme.colors.onSurfaceVariant; }; @@ -62,17 +74,20 @@ export const getAndroidSelectionControlColor = ({ checked, customColor, customUncheckedColor, + error, }: { theme: InternalTheme; checked: boolean; disabled?: boolean; customColor?: ColorValue; customUncheckedColor?: ColorValue; + error?: boolean; }) => { - const checkedColor = getAndroidCheckedColor({ theme, customColor }); + const checkedColor = getAndroidCheckedColor({ theme, customColor, error }); const uncheckedColor = getAndroidUncheckedColor({ theme, customUncheckedColor, + error, }); const selectionControlOpacity = disabled ? stateOpacity.disabled @@ -94,10 +109,12 @@ const getIOSCheckedColor = ({ theme, disabled, customColor, + error, }: { theme: InternalTheme; customColor?: ColorValue; disabled?: boolean; + error?: boolean; }) => { if (disabled) { return theme.colors.primary; @@ -107,6 +124,10 @@ const getIOSCheckedColor = ({ return customColor; } + if (error) { + return theme.colors.error; + } + return theme.colors.primary; }; @@ -114,12 +135,19 @@ export const getSelectionControlIOSColor = ({ theme, disabled, customColor, + error, }: { theme: InternalTheme; disabled?: boolean; customColor?: ColorValue; + error?: boolean; }) => { - const checkedColor = getIOSCheckedColor({ theme, disabled, customColor }); + const checkedColor = getIOSCheckedColor({ + theme, + disabled, + customColor, + error, + }); const checkedColorOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx index a18dd2f78e..2aa20fd688 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -87,6 +87,82 @@ describe('getAndroidSelectionControlColor - checkbox color', () => { selectionControlColor: getTheme(false).colors.onSurfaceVariant, }); }); + + it('should return error color, checked, when error is true', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(), + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.error, + }); + }); + + it('should return error color, unchecked, when error is true', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(), + checked: false, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.error, + }); + }); + + it('should return error color, checked, dark mode, when error is true', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(true), + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme(true).colors.error, + }); + }); + + it('should return disabled color when both disabled and error are true (disabled wins)', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(), + disabled: true, + checked: true, + error: true, + }) + ).toMatchObject({ + selectionControlColor: getTheme().colors.onSurface, + selectionControlOpacity: stateOpacity.disabled, + }); + }); + + it('should return custom color when both customColor and error are true, checked (customColor wins)', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(), + checked: true, + customColor: 'purple', + error: true, + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); + + it('should return custom unchecked color when both customUncheckedColor and error are true, unchecked (customUncheckedColor wins)', () => { + expect( + getAndroidSelectionControlColor({ + theme: getTheme(), + checked: false, + customUncheckedColor: 'purple', + error: true, + }) + ).toMatchObject({ + selectionControlColor: 'purple', + }); + }); }); describe('getSelectionControlIOSColor - checked color', () => { @@ -122,4 +198,51 @@ describe('getSelectionControlIOSColor - checked color', () => { checkedColor: getTheme().colors.primary, }); }); + + it('should return error color when error is true', () => { + expect( + getSelectionControlIOSColor({ + theme: getTheme(), + error: true, + }) + ).toMatchObject({ + checkedColor: getTheme().colors.error, + }); + }); + + it('should return error color, dark mode, when error is true', () => { + expect( + getSelectionControlIOSColor({ + theme: getTheme(true), + error: true, + }) + ).toMatchObject({ + checkedColor: getTheme(true).colors.error, + }); + }); + + it('should return disabled color when both disabled and error are true (disabled wins)', () => { + expect( + getSelectionControlIOSColor({ + theme: getTheme(), + disabled: true, + error: true, + }) + ).toMatchObject({ + checkedColor: getTheme().colors.primary, + checkedColorOpacity: stateOpacity.disabled, + }); + }); + + it('should return custom color when both customColor and error are true (customColor wins)', () => { + expect( + getSelectionControlIOSColor({ + theme: getTheme(), + customColor: 'purple', + error: true, + }) + ).toMatchObject({ + checkedColor: 'purple', + }); + }); });