Skip to content

Commit da62e1c

Browse files
committed
feat(checkbox): modernize for md3 spec compliance
Rewrites the Checkbox renderer to match the Material Design 3 spec (https://m3.material.io/components/checkbox/specs): - 18dp container with 2dp outline (unselected) / 0dp outline + theme primary fill (selected), inside a 40dp state-layer tap target. - State-layer overlay renders hover (8%), focus (10%) and pressed (10%) layers in the color the spec defines for each (selected pressed flips to onSurface; error always wins). - Focus indicator: 3dp ring at theme.colors.secondary with the 2dp outer-offset from md.sys.state.focusIndicator. Gated on :focus-visible via the useFocusVisible hook added in #4952. - Animations approximate Compose Material3 Checkbox.kt: 100ms fill transition and 150ms checkmark draw, sequenced short-leg then long-leg to suggest the stroke fraction. Indeterminate uses a scaleX-animated dash. - No new peer-deps: the checkmark is built from two rotated rectangles (View-based), not an SVG path. utils.ts: - New getSelectionVisualState helper returns the full color + opacity + outline-width picture for a given state combo. - Legacy getSelectionControlColor kept as a compatibility export for RadioButtonAndroid (radio button modernization is out of scope for this PR). 9 snapshots auto-updated to reflect the new render tree.
1 parent 8720eac commit da62e1c

10 files changed

Lines changed: 1795 additions & 801 deletions

File tree

src/components/Checkbox/Checkbox.tsx

Lines changed: 328 additions & 90 deletions
Large diffs are not rendered by default.

src/components/Checkbox/tokens.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* MD3 Checkbox spec dimensions and opacities.
3+
* @see https://m3.material.io/components/checkbox/specs
4+
*
5+
* Mirrors the `SwitchTokens` pattern from `src/components/Switch/tokens.ts`
6+
* so other selection-control modernizations (e.g. RadioButton) can adopt
7+
* the same shape.
8+
*/
9+
const sizes = {
10+
containerSize: 18,
11+
containerRadius: 2,
12+
outlineWidth: 2,
13+
stateLayerSize: 40,
14+
} as const;
15+
16+
export const CheckboxTokens = { ...sizes };

src/components/Checkbox/utils.ts

Lines changed: 140 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,183 @@
11
import type { ColorValue } from 'react-native';
22

33
import { tokens } from '../../theme/tokens';
4+
import type { ColorRole } from '../../theme/types';
5+
import { getStateLayer } from '../../theme/utils/state';
46
import type { InternalTheme } from '../../types';
57

8+
// MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs
9+
610
const stateOpacity = tokens.md.sys.state.opacity;
711

8-
const getCheckedColor = ({
9-
theme,
10-
customColor,
11-
error,
12-
}: {
12+
const CheckboxTokens = {
13+
containerColor: 'primary' as const,
14+
disabledContainerColor: 'onSurface' as const,
15+
errorContainerColor: 'error' as const,
16+
outlineColor: 'onSurfaceVariant' as const,
17+
disabledOutlineColor: 'onSurface' as const,
18+
errorOutlineColor: 'error' as const,
19+
iconColor: 'onPrimary' as const,
20+
disabledIconColor: 'surface' as const,
21+
errorIconColor: 'onError' as const,
22+
selectedStateLayerColor: 'primary' as const,
23+
unselectedStateLayerColor: 'onSurface' as const,
24+
errorStateLayerColor: 'error' as const,
25+
} as const;
26+
27+
type StaticState = {
1328
theme: InternalTheme;
14-
customColor?: ColorValue;
29+
selected: boolean;
30+
disabled?: boolean;
1531
error?: boolean;
16-
}) => {
32+
customColor?: ColorValue;
33+
customUncheckedColor?: ColorValue;
34+
};
35+
36+
type SelectionState = StaticState & {
37+
hovered?: boolean;
38+
pressed?: boolean;
39+
};
40+
41+
type SelectionVisualState = {
42+
containerColor: ColorValue;
43+
outlineColor: ColorValue;
44+
containerOpacity: number;
45+
iconColor: ColorValue;
46+
stateLayerColor: ColorValue;
47+
stateLayerOpacity: number;
48+
};
49+
50+
const getContainerColor = ({
51+
theme,
52+
disabled,
53+
error,
54+
customColor,
55+
}: StaticState): ColorValue => {
56+
if (disabled) {
57+
return theme.colors[CheckboxTokens.disabledContainerColor];
58+
}
1759
if (customColor) {
1860
return customColor;
1961
}
20-
2162
if (error) {
22-
return theme.colors.error;
63+
return theme.colors[CheckboxTokens.errorContainerColor];
2364
}
24-
25-
return theme.colors.primary;
65+
return theme.colors[CheckboxTokens.containerColor];
2666
};
2767

28-
const getUncheckedColor = ({
68+
const getOutlineColor = ({
2969
theme,
30-
customUncheckedColor,
70+
disabled,
3171
error,
32-
}: {
33-
theme: InternalTheme;
34-
customUncheckedColor?: ColorValue;
35-
error?: boolean;
36-
}) => {
72+
customUncheckedColor,
73+
}: StaticState): ColorValue => {
74+
if (disabled) {
75+
return theme.colors[CheckboxTokens.disabledOutlineColor];
76+
}
3777
if (customUncheckedColor) {
3878
return customUncheckedColor;
3979
}
40-
4180
if (error) {
42-
return theme.colors.error;
81+
return theme.colors[CheckboxTokens.errorOutlineColor];
4382
}
44-
45-
return theme.colors.onSurfaceVariant;
83+
return theme.colors[CheckboxTokens.outlineColor];
4684
};
4785

48-
const getControlColor = ({
86+
const getIconColor = ({
4987
theme,
50-
checked,
88+
selected,
5189
disabled,
52-
checkedColor,
53-
uncheckedColor,
54-
}: {
55-
theme: InternalTheme;
56-
checked: boolean;
57-
checkedColor: ColorValue;
58-
uncheckedColor: ColorValue;
59-
disabled?: boolean;
60-
}) => {
90+
error,
91+
}: StaticState): ColorValue => {
92+
if (!selected) {
93+
return 'transparent';
94+
}
6195
if (disabled) {
62-
return theme.colors.onSurface;
96+
return theme.colors[CheckboxTokens.disabledIconColor];
6397
}
64-
65-
if (checked) {
66-
return checkedColor;
98+
if (error) {
99+
return theme.colors[CheckboxTokens.errorIconColor];
67100
}
68-
return uncheckedColor;
101+
return theme.colors[CheckboxTokens.iconColor];
102+
};
103+
104+
// The MD3 spec renders the focused state as an outline ring only (no
105+
// state-layer fill), so `focused` is intentionally not handled here.
106+
const resolveStateLayer = ({
107+
theme,
108+
selected,
109+
hovered,
110+
pressed,
111+
error,
112+
}: Omit<SelectionState, 'customColor' | 'customUncheckedColor' | 'disabled'>): {
113+
color: ColorValue;
114+
opacity: number;
115+
} => {
116+
const state = pressed ? 'pressed' : hovered ? 'hovered' : null;
117+
if (!state) return { color: 'transparent', opacity: 0 };
118+
119+
// Pressed flips selected/unselected colors per the MD3 spec.
120+
const role: ColorRole = error
121+
? CheckboxTokens.errorStateLayerColor
122+
: (pressed ? !selected : selected)
123+
? CheckboxTokens.selectedStateLayerColor
124+
: CheckboxTokens.unselectedStateLayerColor;
125+
return getStateLayer(theme, role, state);
69126
};
70127

71-
export const getSelectionControlColor = ({
128+
/**
129+
* Resolve the full color + opacity picture for the Checkbox renderer.
130+
*
131+
* Returns flat values so the renderer can pass them straight to its
132+
* `Animated.View` styles without re-deriving anything.
133+
*/
134+
export const getSelectionVisualState = ({
72135
theme,
136+
selected,
73137
disabled,
74-
checked,
138+
hovered,
139+
pressed,
140+
error,
75141
customColor,
76142
customUncheckedColor,
77-
error,
78-
}: {
79-
theme: InternalTheme;
80-
checked: boolean;
81-
disabled?: boolean;
82-
customColor?: ColorValue;
83-
customUncheckedColor?: ColorValue;
84-
error?: boolean;
85-
}) => {
86-
const checkedColor = getCheckedColor({ theme, customColor, error });
87-
const uncheckedColor = getUncheckedColor({
143+
}: SelectionState): SelectionVisualState => {
144+
const containerColor = getContainerColor({
88145
theme,
146+
selected,
147+
disabled,
148+
error,
149+
customColor,
89150
customUncheckedColor,
151+
});
152+
const outlineColor = getOutlineColor({
153+
theme,
154+
selected,
155+
disabled,
156+
error,
157+
customColor,
158+
customUncheckedColor,
159+
});
160+
const iconColor = getIconColor({
161+
theme,
162+
selected,
163+
disabled,
164+
error,
165+
customColor,
166+
customUncheckedColor,
167+
});
168+
const stateLayer = resolveStateLayer({
169+
theme,
170+
selected,
171+
hovered: hovered && !disabled,
172+
pressed: pressed && !disabled,
90173
error,
91174
});
92-
const selectionControlOpacity = disabled
93-
? stateOpacity.disabled
94-
: stateOpacity.enabled;
95-
96175
return {
97-
selectionControlColor: getControlColor({
98-
theme,
99-
disabled,
100-
checked,
101-
checkedColor,
102-
uncheckedColor,
103-
}),
104-
selectionControlOpacity,
176+
containerColor,
177+
outlineColor,
178+
containerOpacity: disabled ? stateOpacity.disabled : stateOpacity.enabled,
179+
iconColor,
180+
stateLayerColor: stateLayer.color,
181+
stateLayerOpacity: stateLayer.opacity,
105182
};
106183
};

src/components/RadioButton/RadioButtonAndroid.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import * as React from 'react';
22
import { Animated, StyleSheet, View } from 'react-native';
33

44
import { RadioButtonContext, RadioButtonContextType } from './RadioButtonGroup';
5-
import { handlePress, isChecked } from './utils';
5+
import { getSelectionControlColor, handlePress, isChecked } from './utils';
66
import { useInternalTheme } from '../../core/theming';
77
import type { $RemoveChildren, ThemeProp } from '../../types';
8-
import { getSelectionControlColor } from '../Checkbox/utils';
98
import TouchableRipple from '../TouchableRipple/TouchableRipple';
109

1110
export type Props = $RemoveChildren<typeof TouchableRipple> & {

src/components/RadioButton/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,44 @@ export const getSelectionControlIOSColor = ({
9393
checkedColorOpacity,
9494
};
9595
};
96+
97+
/**
98+
* Color resolver for `RadioButtonAndroid`.
99+
*
100+
* Previously shared with the pre-MD3 Checkbox (and lived in
101+
* `Checkbox/utils.ts`); moved here when Checkbox modernized to its own
102+
* `getSelectionVisualState` helper.
103+
*/
104+
export const getSelectionControlColor = ({
105+
theme,
106+
disabled,
107+
checked,
108+
customColor,
109+
customUncheckedColor,
110+
error,
111+
}: {
112+
theme: InternalTheme;
113+
checked: boolean;
114+
disabled?: boolean;
115+
customColor?: ColorValue;
116+
customUncheckedColor?: ColorValue;
117+
error?: boolean;
118+
}): { selectionControlColor: ColorValue; selectionControlOpacity: number } => {
119+
const opacity = disabled ? stateOpacity.disabled : stateOpacity.enabled;
120+
const checkedColor = customColor
121+
? customColor
122+
: error
123+
? theme.colors.error
124+
: theme.colors.primary;
125+
const uncheckedColor = customUncheckedColor
126+
? customUncheckedColor
127+
: error
128+
? theme.colors.error
129+
: theme.colors.onSurfaceVariant;
130+
const color = disabled
131+
? theme.colors.onSurface
132+
: checked
133+
? checkedColor
134+
: uncheckedColor;
135+
return { selectionControlColor: color, selectionControlOpacity: opacity };
136+
};

src/components/__tests__/Checkbox/CheckboxItem.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ it('should have `accessibilityState={ checked: false }` when `status="unchecked"
4242
expect(elements).toHaveLength(2);
4343
});
4444

45-
it('should have `accessibilityState={ checked: false }` when `status="indeterminate"', () => {
45+
it('should have `accessibilityState={ checked: "mixed" }` when `status="indeterminate"`', () => {
4646
const { getAllByA11yState } = render(
4747
<Checkbox.Item status="indeterminate" label="Indeterminate Button" />
4848
);
4949

50-
const elements = getAllByA11yState({ checked: false });
51-
expect(elements).toHaveLength(2);
50+
// The inner Checkbox exposes `checked: "mixed"` (per W3C ARIA spec for
51+
// tri-state controls), while the outer row Pressable exposes
52+
// `checked: false`.
53+
expect(getAllByA11yState({ checked: 'mixed' })).toHaveLength(1);
54+
expect(getAllByA11yState({ checked: false })).toHaveLength(1);
5255
});
5356

5457
it('disables the row when the prop disabled is true', () => {

0 commit comments

Comments
 (0)