Skip to content

Commit 91a9fab

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 91a9fab

11 files changed

Lines changed: 1796 additions & 895 deletions

File tree

example/src/Examples/CheckboxExample.tsx

Lines changed: 201 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,229 @@
11
import * as React from 'react';
22
import { StyleSheet, View } from 'react-native';
33

4-
import { Checkbox, Palette, Text, TouchableRipple } from 'react-native-paper';
4+
import { Checkbox, Text, useTheme } from 'react-native-paper';
55

66
import ScreenWrapper from '../ScreenWrapper';
77

8-
const CheckboxExample = () => {
9-
const [checkedNormal, setCheckedNormal] = React.useState<boolean>(true);
10-
const [checkedCustom, setCheckedCustom] = React.useState<boolean>(true);
11-
const [indeterminate, setIndeterminate] = React.useState<boolean>(true);
8+
type Status = 'unchecked' | 'checked' | 'indeterminate';
9+
10+
const STATUSES: Status[] = ['unchecked', 'checked', 'indeterminate'];
11+
12+
type Row = {
13+
label: string;
14+
render: (status: Status) => React.ReactNode;
15+
};
1216

17+
const ROWS: Row[] = [
18+
{
19+
label: 'Default',
20+
render: (status) => <Checkbox status={status} onPress={() => {}} />,
21+
},
22+
{
23+
label: 'Disabled',
24+
render: (status) => <Checkbox status={status} disabled />,
25+
},
26+
{
27+
label: 'Custom color (tertiary)',
28+
render: (status) => <CustomColorCheckbox status={status} />,
29+
},
30+
{
31+
label: 'Error',
32+
render: (status) => <Checkbox status={status} error onPress={() => {}} />,
33+
},
34+
];
35+
36+
const CustomColorCheckbox = ({ status }: { status: Status }) => {
37+
const theme = useTheme();
1338
return (
14-
<ScreenWrapper style={styles.container}>
15-
<TouchableRipple onPress={() => setCheckedNormal(!checkedNormal)}>
16-
<View style={styles.row}>
17-
<Text>Normal</Text>
18-
<View pointerEvents="none">
19-
<Checkbox status={checkedNormal ? 'checked' : 'unchecked'} />
20-
</View>
21-
</View>
22-
</TouchableRipple>
39+
<Checkbox
40+
status={status}
41+
color={theme.colors.tertiary}
42+
uncheckedColor={theme.colors.tertiary}
43+
onPress={() => {}}
44+
/>
45+
);
46+
};
2347

24-
<TouchableRipple onPress={() => setCheckedCustom(!checkedCustom)}>
25-
<View style={styles.row}>
26-
<Text>Custom</Text>
27-
<View pointerEvents="none">
28-
<Checkbox
29-
color={Palette.error70}
30-
status={checkedCustom ? 'checked' : 'unchecked'}
31-
/>
32-
</View>
48+
const Interactive = () => {
49+
const theme = useTheme();
50+
const [aChecked, setAChecked] = React.useState(false);
51+
const [bIndeterminate, setBIndeterminate] = React.useState(false);
52+
return (
53+
<View style={styles.interactive}>
54+
<Text style={[styles.rowLabel, { color: theme.colors.onSurfaceVariant }]}>
55+
Tap to toggle
56+
</Text>
57+
<View style={styles.interactiveRow}>
58+
<View style={styles.cell}>
59+
<Checkbox
60+
status={aChecked ? 'checked' : 'unchecked'}
61+
onPress={() => setAChecked((v) => !v)}
62+
/>
3363
</View>
34-
</TouchableRipple>
35-
36-
<TouchableRipple onPress={() => setIndeterminate(!indeterminate)}>
37-
<View style={styles.row}>
38-
<Text>Indeterminate</Text>
39-
<View pointerEvents="none">
40-
<Checkbox status={indeterminate ? 'indeterminate' : 'unchecked'} />
41-
</View>
64+
<Text
65+
style={[
66+
styles.interactiveLabel,
67+
{ color: theme.colors.onSurfaceVariant },
68+
]}
69+
>
70+
unchecked ↔ checked
71+
</Text>
72+
</View>
73+
<View style={[styles.interactiveRow, styles.interactiveRowSpacing]}>
74+
<View style={styles.cell}>
75+
<Checkbox
76+
status={bIndeterminate ? 'indeterminate' : 'unchecked'}
77+
onPress={() => setBIndeterminate((v) => !v)}
78+
/>
4279
</View>
43-
</TouchableRipple>
80+
<Text
81+
style={[
82+
styles.interactiveLabel,
83+
{ color: theme.colors.onSurfaceVariant },
84+
]}
85+
>
86+
unchecked ↔ indeterminate
87+
</Text>
88+
</View>
89+
</View>
90+
);
91+
};
4492

45-
<View style={styles.row}>
46-
<Text>Checked (Disabled)</Text>
47-
<Checkbox status="checked" disabled />
93+
const IndeterminateParent = () => {
94+
const theme = useTheme();
95+
const [child1, setChild1] = React.useState(true);
96+
const [child2, setChild2] = React.useState(false);
97+
const [child3, setChild3] = React.useState(true);
98+
const allChecked = child1 && child2 && child3;
99+
const noneChecked = !child1 && !child2 && !child3;
100+
const parentStatus: Status = allChecked
101+
? 'checked'
102+
: noneChecked
103+
? 'unchecked'
104+
: 'indeterminate';
105+
const toggleAll = () => {
106+
const next = !allChecked;
107+
setChild1(next);
108+
setChild2(next);
109+
setChild3(next);
110+
};
111+
return (
112+
<View style={styles.row}>
113+
<Text style={[styles.rowLabel, { color: theme.colors.onSurfaceVariant }]}>
114+
Parent / children (indeterminate)
115+
</Text>
116+
<View style={styles.parentRow}>
117+
<Checkbox status={parentStatus} onPress={toggleAll} />
118+
<Text
119+
style={[styles.interactiveLabel, { color: theme.colors.onSurface }]}
120+
>
121+
Select all
122+
</Text>
123+
</View>
124+
<View style={styles.childRow}>
125+
<Checkbox
126+
status={child1 ? 'checked' : 'unchecked'}
127+
onPress={() => setChild1((v) => !v)}
128+
/>
129+
<Text
130+
style={[styles.interactiveLabel, { color: theme.colors.onSurface }]}
131+
>
132+
Apples
133+
</Text>
48134
</View>
49-
<View style={styles.row}>
50-
<Text>Unchecked (Disabled)</Text>
51-
<Checkbox status="unchecked" disabled />
135+
<View style={styles.childRow}>
136+
<Checkbox
137+
status={child2 ? 'checked' : 'unchecked'}
138+
onPress={() => setChild2((v) => !v)}
139+
/>
140+
<Text
141+
style={[styles.interactiveLabel, { color: theme.colors.onSurface }]}
142+
>
143+
Bananas
144+
</Text>
52145
</View>
53-
<View style={styles.row}>
54-
<Text>Indeterminate (Disabled)</Text>
55-
<Checkbox status="indeterminate" disabled />
146+
<View style={styles.childRow}>
147+
<Checkbox
148+
status={child3 ? 'checked' : 'unchecked'}
149+
onPress={() => setChild3((v) => !v)}
150+
/>
151+
<Text
152+
style={[styles.interactiveLabel, { color: theme.colors.onSurface }]}
153+
>
154+
Cherries
155+
</Text>
56156
</View>
157+
</View>
158+
);
159+
};
160+
161+
const CheckboxExample = () => {
162+
const theme = useTheme();
163+
return (
164+
<ScreenWrapper contentContainerStyle={styles.scrollContent}>
165+
<Interactive />
166+
{ROWS.map((row) => (
167+
<View key={row.label} style={styles.row}>
168+
<Text
169+
style={[styles.rowLabel, { color: theme.colors.onSurfaceVariant }]}
170+
>
171+
{row.label}
172+
</Text>
173+
<View style={styles.cells}>
174+
{STATUSES.map((status) => (
175+
<View key={status} style={styles.cell}>
176+
{row.render(status)}
177+
</View>
178+
))}
179+
</View>
180+
</View>
181+
))}
182+
<IndeterminateParent />
57183
</ScreenWrapper>
58184
);
59185
};
60186

61187
CheckboxExample.title = 'Checkbox';
62188

63189
const styles = StyleSheet.create({
64-
container: {
65-
paddingVertical: 8,
190+
scrollContent: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 32 },
191+
row: { marginBottom: 20 },
192+
rowLabel: {
193+
fontSize: 14,
194+
fontWeight: '500',
195+
marginBottom: 6,
196+
opacity: 0.7,
197+
},
198+
cells: {
199+
flexDirection: 'row',
200+
alignItems: 'center',
201+
},
202+
cell: {
203+
width: 60,
204+
alignItems: 'center',
205+
},
206+
interactive: { marginBottom: 24 },
207+
interactiveRow: {
208+
flexDirection: 'row',
209+
alignItems: 'center',
210+
gap: 12,
211+
},
212+
interactiveRowSpacing: { marginTop: 8 },
213+
interactiveLabel: {
214+
fontSize: 14,
215+
opacity: 0.7,
216+
},
217+
parentRow: {
218+
flexDirection: 'row',
219+
alignItems: 'center',
220+
gap: 12,
66221
},
67-
row: {
222+
childRow: {
68223
flexDirection: 'row',
69224
alignItems: 'center',
70-
justifyContent: 'space-between',
71-
paddingVertical: 8,
72-
paddingHorizontal: 16,
225+
gap: 12,
226+
paddingLeft: 32,
73227
},
74228
});
75229

0 commit comments

Comments
 (0)