Skip to content

Commit b24a02e

Browse files
committed
feat: add Button selected toggle state (MD3 expressive)
Add a `selected?: boolean` prop. When `true`, the button flips its `shape` (round ↔ square) so the selected/unselected pair contrasts, and for `outlined`/`text` modes adopts a filled tonal-selected appearance (`secondaryContainer` background, `onSecondaryContainer` label, no border). `accessibilityState.selected` is set so screen readers announce the toggle state. Other modes keep their colors and only flip the shape. The `selected` flag is threaded through `getButtonColors` and its sub-helpers.
1 parent 2845d5b commit b24a02e

3 files changed

Lines changed: 130 additions & 8 deletions

File tree

src/components/Button/Button.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ export type Props = $Omit<
7777
* `borderRadius` in `style`.
7878
*/
7979
shape?: ButtonShape;
80+
/**
81+
* Whether this button is in the selected state (Material Design 3
82+
* expressive toggle). When `true`:
83+
*
84+
* - The `shape` is flipped: `'round'` becomes `'square'` and vice versa.
85+
* - For `outlined` and `text` modes, the button adopts a filled
86+
* `secondaryContainer` appearance (matches `contained-tonal`).
87+
* - `accessibilityState.selected` is set so screen readers announce the
88+
* toggle state.
89+
*
90+
* Other modes only flip the shape.
91+
*/
92+
selected?: boolean;
8093
/**
8194
* @deprecated Deprecated in v5.x - use `buttonColor` or `textColor` instead.
8295
* Custom text color for flat button, or background color for contained button.
@@ -230,6 +243,7 @@ const Button = (
230243
mode = 'text',
231244
size,
232245
shape,
246+
selected,
233247
dark,
234248
loading,
235249
icon,
@@ -355,8 +369,17 @@ const Button = (
355369
return radiusStyles;
356370
}, [style]);
357371

358-
const borderRadius = shape
359-
? getButtonShapeRadius({ size, shape })
372+
// When the button is `selected`, flip the requested shape so the
373+
// unselected/selected pair contrasts visually (round ↔ square).
374+
const effectiveShape: ButtonShape | undefined = shape
375+
? selected
376+
? shape === 'round'
377+
? 'square'
378+
: 'round'
379+
: shape
380+
: undefined;
381+
const borderRadius = effectiveShape
382+
? getButtonShapeRadius({ size, shape: effectiveShape })
360383
: theme.shapes.corner.largeIncreased;
361384

362385
const {
@@ -375,8 +398,9 @@ const Button = (
375398
mode,
376399
disabled,
377400
dark,
401+
selected,
378402
}),
379-
[customButtonColor, customTextColor, theme, mode, disabled, dark]
403+
[customButtonColor, customTextColor, theme, mode, disabled, dark, selected]
380404
);
381405

382406
const rippleColor = React.useMemo(
@@ -484,7 +508,7 @@ const Button = (
484508
accessibilityLabel={accessibilityLabel}
485509
accessibilityHint={accessibilityHint}
486510
accessibilityRole={accessibilityRole}
487-
accessibilityState={{ disabled }}
511+
accessibilityState={{ disabled, selected }}
488512
accessible={accessible}
489513
hitSlop={hitSlop}
490514
disabled={disabled}

src/components/Button/utils.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ type BaseProps = {
152152
isMode: (mode: ButtonMode) => boolean;
153153
theme: InternalTheme;
154154
disabled?: boolean;
155+
selected?: boolean;
155156
};
156157

157158
const isDark = ({
@@ -177,6 +178,7 @@ const getButtonBackgroundColor = ({
177178
theme,
178179
disabled,
179180
customButtonColor,
181+
selected,
180182
}: BaseProps & {
181183
customButtonColor?: string;
182184
}) => {
@@ -192,6 +194,12 @@ const getButtonBackgroundColor = ({
192194
return colors.onSurface;
193195
}
194196

197+
// Selected toggle (only outlined/text adopt a filled "tonal-selected" look;
198+
// contained / contained-tonal / elevated already render filled).
199+
if (selected && (isMode('outlined') || isMode('text'))) {
200+
return colors.secondaryContainer;
201+
}
202+
195203
if (isMode('elevated')) {
196204
return colors.surfaceContainerLow;
197205
}
@@ -214,6 +222,7 @@ const getButtonTextColor = ({
214222
customTextColor,
215223
backgroundColor,
216224
dark,
225+
selected,
217226
}: BaseProps & {
218227
customTextColor?: string;
219228
backgroundColor: string;
@@ -228,6 +237,11 @@ const getButtonTextColor = ({
228237
return theme.colors.onSurface;
229238
}
230239

240+
// Selected toggle for outlined/text mirrors the contained-tonal label color.
241+
if (selected && (isMode('outlined') || isMode('text'))) {
242+
return colors.onSecondaryContainer;
243+
}
244+
231245
if (typeof dark === 'boolean') {
232246
if (
233247
isMode('contained') ||
@@ -253,15 +267,26 @@ const getButtonTextColor = ({
253267
return colors.primary;
254268
};
255269

256-
const getButtonBorderColor = ({ isMode, theme }: BaseProps) => {
270+
const getButtonBorderColor = ({ isMode, theme, selected }: BaseProps) => {
271+
// A selected outlined toggle drops its outline (the filled background takes
272+
// over as the visual affordance).
273+
if (selected && isMode('outlined')) {
274+
return 'transparent';
275+
}
257276
if (isMode('outlined')) {
258277
return theme.colors.outlineVariant;
259278
}
260279

261280
return 'transparent';
262281
};
263282

264-
const getButtonBorderWidth = ({ isMode }: Omit<BaseProps, 'disabled'>) => {
283+
const getButtonBorderWidth = ({
284+
isMode,
285+
selected,
286+
}: Omit<BaseProps, 'disabled' | 'theme'>) => {
287+
if (selected && isMode('outlined')) {
288+
return 0;
289+
}
265290
if (isMode('outlined')) {
266291
return 1;
267292
}
@@ -276,13 +301,15 @@ export const getButtonColors = ({
276301
customTextColor,
277302
disabled,
278303
dark,
304+
selected,
279305
}: {
280306
theme: InternalTheme;
281307
mode: ButtonMode;
282308
customButtonColor?: string;
283309
customTextColor?: string;
284310
disabled?: boolean;
285311
dark?: boolean;
312+
selected?: boolean;
286313
}) => {
287314
const isMode = (modeToCompare: ButtonMode) => {
288315
return mode === modeToCompare;
@@ -293,6 +320,7 @@ export const getButtonColors = ({
293320
theme,
294321
disabled,
295322
customButtonColor,
323+
selected,
296324
});
297325

298326
const textColor = getButtonTextColor({
@@ -302,11 +330,12 @@ export const getButtonColors = ({
302330
customTextColor,
303331
backgroundColor,
304332
dark,
333+
selected,
305334
});
306335

307-
const borderColor = getButtonBorderColor({ isMode, theme });
336+
const borderColor = getButtonBorderColor({ isMode, theme, selected });
308337

309-
const borderWidth = getButtonBorderWidth({ isMode, theme });
338+
const borderWidth = getButtonBorderWidth({ isMode, selected });
310339

311340
const textOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled;
312341

src/components/__tests__/Button.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,75 @@ describe('shape prop', () => {
935935
});
936936
});
937937

938+
describe('selected prop', () => {
939+
it('sets accessibilityState.selected', () => {
940+
const { getByTestId } = render(
941+
<Button testID="button" selected onPress={() => {}} label="X" />
942+
);
943+
944+
expect(getByTestId('button').props.accessibilityState).toMatchObject({
945+
selected: true,
946+
});
947+
});
948+
949+
it('flips a round button into the square radius when selected', () => {
950+
const { getByTestId } = render(
951+
<Button testID="button" size="large" shape="round" selected label="X" />
952+
);
953+
954+
expect(getByTestId('button-container')).toHaveStyle({ borderRadius: 28 });
955+
});
956+
957+
it('flips a square button into the round radius when selected', () => {
958+
const { getByTestId } = render(
959+
<Button testID="button" shape="square" selected label="X" />
960+
);
961+
962+
expect(getByTestId('button-container')).toHaveStyle({ borderRadius: 9999 });
963+
});
964+
965+
it('gives an outlined button the tonal-selected appearance', () => {
966+
expect(
967+
getButtonColors({
968+
theme: getTheme(),
969+
mode: 'outlined',
970+
selected: true,
971+
})
972+
).toMatchObject({
973+
backgroundColor: getTheme().colors.secondaryContainer,
974+
textColor: getTheme().colors.onSecondaryContainer,
975+
borderColor: 'transparent',
976+
borderWidth: 0,
977+
});
978+
});
979+
980+
it('gives a text-mode button the tonal-selected appearance', () => {
981+
expect(
982+
getButtonColors({
983+
theme: getTheme(),
984+
mode: 'text',
985+
selected: true,
986+
})
987+
).toMatchObject({
988+
backgroundColor: getTheme().colors.secondaryContainer,
989+
textColor: getTheme().colors.onSecondaryContainer,
990+
});
991+
});
992+
993+
it('does not change contained colors when selected', () => {
994+
expect(
995+
getButtonColors({
996+
theme: getTheme(),
997+
mode: 'contained',
998+
selected: true,
999+
})
1000+
).toMatchObject({
1001+
backgroundColor: getTheme().colors.primary,
1002+
textColor: getTheme().colors.onPrimary,
1003+
});
1004+
});
1005+
});
1006+
9381007
it('animated value changes correctly', () => {
9391008
const value = new Animated.Value(1);
9401009
const { getByTestId } = render(

0 commit comments

Comments
 (0)