Skip to content

Commit b3ee095

Browse files
authored
fix: ToggleButton provides an opt-in isAccessible variant for accessible checked colors (microsoft#35837)
1 parent 2095d48 commit b3ee095

File tree

9 files changed

+171
-14
lines changed

9 files changed

+171
-14
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "fix: ToggleButton provides an opt-in isAccessible variant for accessible checked colors",
4+
"packageName": "@fluentui/react-button",
5+
"email": "sarah.higley@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-button/library/etc/react-button.api.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,11 @@ export const toggleButtonClassNames: SlotClassNames<ButtonSlots>;
127127
export type ToggleButtonProps = ButtonProps & {
128128
defaultChecked?: boolean;
129129
checked?: boolean;
130+
isAccessible?: boolean;
130131
};
131132

132133
// @public (undocumented)
133-
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked'>>;
134+
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;
134135

135136
// @public
136137
export const useButton_unstable: (props: ButtonProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => ButtonState;
@@ -166,7 +167,7 @@ export const useToggleButton_unstable: (props: ToggleButtonProps, ref: React_2.R
166167
export const useToggleButtonStyles_unstable: (state: ToggleButtonState) => ToggleButtonState;
167168

168169
// @public (undocumented)
169-
export function useToggleState<TToggleButtonProps extends Pick<ToggleButtonProps, 'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable'>, TButtonState extends Pick<ButtonState, 'root'>, TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root'>>(props: TToggleButtonProps, state: TButtonState): TToggleButtonState;
170+
export function useToggleState<TToggleButtonProps extends Pick<ToggleButtonProps, 'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable' | 'isAccessible'>, TButtonState extends Pick<ButtonState, 'root'>, TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root' | 'isAccessible'>>(props: TToggleButtonProps, state: TButtonState): TToggleButtonState;
170171

171172
// (No @packageDocumentation comment for this package)
172173

packages/react-components/react-button/library/src/components/ToggleButton/ToggleButton.types.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@ export type ToggleButtonProps = ButtonProps & {
1717
* @default false
1818
*/
1919
checked?: boolean;
20+
21+
/**
22+
* Defines whether the `ToggleButton` should use the alternate selected styles that have adequate contrast with the rest style
23+
*
24+
* @default false
25+
*/
26+
isAccessible?: boolean;
2027
};
2128

22-
export type ToggleButtonBaseProps = ButtonBaseProps & Pick<ToggleButtonProps, 'defaultChecked' | 'checked'>;
29+
export type ToggleButtonBaseProps = ButtonBaseProps &
30+
Pick<ToggleButtonProps, 'defaultChecked' | 'checked' | 'isAccessible'>;
2331

24-
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked'>>;
32+
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;
2533

26-
export type ToggleButtonBaseState = ButtonBaseState & Required<Pick<ToggleButtonProps, 'checked'>>;
34+
export type ToggleButtonBaseState = ButtonBaseState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;

packages/react-components/react-button/library/src/components/ToggleButton/useToggleButton.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const useToggleButton_unstable = (
1515
props: ToggleButtonProps,
1616
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
1717
): ToggleButtonState => {
18-
const { checked = false, defaultChecked = false, ...buttonProps } = props;
18+
const { checked = false, defaultChecked = false, isAccessible = false, ...buttonProps } = props;
1919
const buttonState = useButton_unstable(buttonProps, ref);
2020

2121
return useToggleState(props, buttonState);
@@ -31,7 +31,7 @@ export const useToggleButtonBase_unstable = (
3131
props: ToggleButtonProps,
3232
ref?: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
3333
): ToggleButtonBaseState => {
34-
const { checked = false, defaultChecked = false, ...buttonProps } = props;
34+
const { checked = false, defaultChecked = false, isAccessible = false, ...buttonProps } = props;
3535
const buttonState = useButtonBase_unstable(buttonProps, ref);
3636

3737
return useToggleState(props, buttonState);

packages/react-components/react-button/library/src/components/ToggleButton/useToggleButtonStyles.styles.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,62 @@ const useRootCheckedStyles = makeStyles({
144144
},
145145
});
146146

147+
const useCheckedAccessibleStyles = makeStyles({
148+
// Base styles
149+
base: {
150+
backgroundColor: tokens.colorBrandBackground,
151+
...shorthands.borderColor('transparent'),
152+
color: tokens.colorNeutralForegroundOnBrand,
153+
154+
':hover': {
155+
backgroundColor: tokens.colorBrandBackgroundHover,
156+
...shorthands.borderColor('transparent'),
157+
color: tokens.colorNeutralForegroundOnBrand,
158+
},
159+
160+
':hover:active,:active:focus-visible': {
161+
backgroundColor: tokens.colorBrandBackgroundPressed,
162+
...shorthands.borderColor('transparent'),
163+
color: tokens.colorNeutralForegroundOnBrand,
164+
},
165+
},
166+
167+
// Appearance variations
168+
outline: {
169+
// There's no longer a reason to thicken the outline variant's border
170+
...shorthands.borderWidth(tokens.strokeWidthThin),
171+
},
172+
173+
primary: {
174+
// primary has an inner stroke for the checked style
175+
outline: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralForegroundOnBrand}`,
176+
outlineOffset: `calc(${tokens.strokeWidthThicker} * -1)`,
177+
178+
// need to not have the default focus style that removes the outline
179+
...createCustomFocusIndicatorStyle({
180+
outline: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralForegroundOnBrand}`,
181+
outlineOffset: `calc(${tokens.strokeWidthThickest} * -1)`,
182+
}),
183+
},
184+
185+
subtle: {
186+
// override subtle-appearance-specific icon color on hover
187+
':hover': {
188+
[`& .${toggleButtonClassNames.icon}`]: {
189+
color: tokens.colorNeutralForegroundOnBrand,
190+
},
191+
},
192+
},
193+
194+
transparent: {
195+
/* No styles */
196+
},
197+
198+
secondary: {
199+
/* No styles */
200+
},
201+
});
202+
147203
const useRootDisabledStyles = makeStyles({
148204
// Base styles
149205
base: {
@@ -213,7 +269,7 @@ const useRootDisabledStyles = makeStyles({
213269
});
214270

215271
const useIconCheckedStyles = makeStyles({
216-
// Appearance variations
272+
// Appearance variations with isAccessible=false
217273
subtleOrTransparent: {
218274
color: tokens.colorNeutralForeground2BrandSelected,
219275
},
@@ -253,11 +309,12 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
253309
'use no memo';
254310

255311
const rootCheckedStyles = useRootCheckedStyles();
312+
const accessibleCheckedStyles = useCheckedAccessibleStyles();
256313
const rootDisabledStyles = useRootDisabledStyles();
257314
const iconCheckedStyles = useIconCheckedStyles();
258315
const primaryHighContrastStyles = usePrimaryHighContrastStyles();
259316

260-
const { appearance, checked, disabled, disabledFocusable } = state;
317+
const { appearance, checked, disabled, disabledFocusable, isAccessible } = state;
261318

262319
state.root.className = mergeClasses(
263320
toggleButtonClassNames.root,
@@ -271,6 +328,10 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
271328
checked && rootCheckedStyles.highContrast,
272329
appearance && checked && rootCheckedStyles[appearance],
273330

331+
// Opt-in accessible checked styles
332+
isAccessible && checked && accessibleCheckedStyles.base,
333+
isAccessible && appearance && checked && accessibleCheckedStyles[appearance],
334+
274335
// Disabled styles
275336
(disabled || disabledFocusable) && rootDisabledStyles.base,
276337
appearance && (disabled || disabledFocusable) && rootDisabledStyles[appearance],
@@ -282,7 +343,10 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
282343
if (state.icon) {
283344
state.icon.className = mergeClasses(
284345
toggleButtonClassNames.icon,
285-
checked && (appearance === 'subtle' || appearance === 'transparent') && iconCheckedStyles.subtleOrTransparent,
346+
checked &&
347+
!isAccessible &&
348+
(appearance === 'subtle' || appearance === 'transparent') &&
349+
iconCheckedStyles.subtleOrTransparent,
286350
iconCheckedStyles.highContrast,
287351
state.icon.className,
288352
);

packages/react-components/react-button/library/src/utils/useToggleState.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import type { ButtonState } from '../Button';
66
import type { ToggleButtonProps, ToggleButtonState } from '../ToggleButton';
77

88
export function useToggleState<
9-
TToggleButtonProps extends Pick<ToggleButtonProps, 'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable'>,
9+
TToggleButtonProps extends Pick<
10+
ToggleButtonProps,
11+
'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable' | 'isAccessible'
12+
>,
1013
TButtonState extends Pick<ButtonState, 'root'>,
11-
TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root'>,
14+
TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root' | 'isAccessible'>,
1215
>(props: TToggleButtonProps, state: TButtonState): TToggleButtonState {
13-
const { checked, defaultChecked, disabled, disabledFocusable } = props;
16+
const { checked, defaultChecked, disabled, disabledFocusable, isAccessible = false } = props;
1417
const { onClick, role } = state.root;
1518

1619
const [checkedValue, setCheckedValue] = useControllableState({
@@ -39,6 +42,8 @@ export function useToggleState<
3942

4043
checked: checkedValue,
4144

45+
isAccessible,
46+
4247
root: {
4348
...state.root,
4449
[isCheckboxTypeRole ? 'aria-checked' : 'aria-pressed']: checkedValue,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Accessibility
2+
3+
!! WARNING !!
4+
5+
The default colors of the checked state of a ToggleButton do not meet accessibility requirements for [not using color alone to indicate state](https://w3c.github.io/wcag/understanding/use-of-color.html).
6+
7+
In order to ensure a ToggleButton is accessible, use one of the following two strategies:
8+
9+
1. Include distinct icons for checked & unchecked states. This could be an empty space vs. check icon, or a filled vs. unfilled icon.
10+
2. Use the boolean `isAccessible` prop to opt-in to an accessible, contrasting color change for the checked state.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as React from 'react';
2+
import type { JSXElement } from '@fluentui/react-components';
3+
import { makeStyles, ToggleButton } from '@fluentui/react-components';
4+
5+
const useStyles = makeStyles({
6+
wrapper: {
7+
columnGap: '15px',
8+
display: 'flex',
9+
minWidth: 'min-content',
10+
},
11+
});
12+
13+
export const AccessibleAppearance = (): JSXElement => {
14+
const [checked1, setChecked1] = React.useState(false);
15+
const [checked2, setChecked2] = React.useState(false);
16+
const styles = useStyles();
17+
18+
const toggleChecked = React.useCallback(
19+
(buttonIndex: number) => {
20+
switch (buttonIndex) {
21+
case 1:
22+
setChecked1(!checked1);
23+
break;
24+
case 2:
25+
setChecked2(!checked2);
26+
break;
27+
}
28+
},
29+
[checked1, checked2],
30+
);
31+
32+
return (
33+
<div className={styles.wrapper}>
34+
<ToggleButton checked={checked1} onClick={() => toggleChecked(1)} isAccessible>
35+
Default
36+
</ToggleButton>
37+
<ToggleButton appearance="primary" checked={checked2} onClick={() => toggleChecked(2)} isAccessible>
38+
Primary
39+
</ToggleButton>
40+
<ToggleButton appearance="outline" onClick={() => toggleChecked(3)} isAccessible>
41+
Outline
42+
</ToggleButton>
43+
<ToggleButton appearance="subtle" isAccessible>
44+
Subtle
45+
</ToggleButton>
46+
<ToggleButton appearance="transparent" isAccessible>
47+
Transparent
48+
</ToggleButton>
49+
</div>
50+
);
51+
};
52+
53+
AccessibleAppearance.parameters = {
54+
docs: {
55+
description: {
56+
story:
57+
'Appearance variants with isAccessible set, showing more contrasting colors when checked. The primary variant uses the same colors, but with an inset stroke for the checked state.\n\nThis approach is available for when the icon is not used to differentiate checked vs. unchecked states.',
58+
},
59+
},
60+
};

packages/react-components/react-button/stories/src/ToggleButton/index.stories.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { Meta } from '@storybook/react-webpack5';
22
import { ToggleButton } from '@fluentui/react-components';
33
import descriptionMd from './ToggleButtonDescription.md';
44
import bestPracticesMd from '../Button/ButtonBestPractices.md';
5+
import accessibilityMd from './ToggleButtonAccessibility.md';
56

67
export { Default } from './ToggleButtonDefault.stories';
78
export { Shape } from './ToggleButtonShape.stories';
89
export { Appearance } from './ToggleButtonAppearance.stories';
10+
export { AccessibleAppearance } from './ToggleButtonAppearanceAccessible.stories';
911
export { Icon } from './ToggleButtonIcon.stories';
1012
export { Size } from './ToggleButtonSize.stories';
1113
export { Disabled } from './ToggleButtonDisabled.stories';
@@ -18,7 +20,7 @@ export default {
1820
parameters: {
1921
docs: {
2022
description: {
21-
component: [descriptionMd, bestPracticesMd].join('\n'),
23+
component: [descriptionMd, bestPracticesMd, accessibilityMd].join('\n'),
2224
},
2325
},
2426
},

0 commit comments

Comments
 (0)