Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix: ToggleButton provides an opt-in isAccessible variant for accessible checked colors",
"packageName": "@fluentui/react-button",
"email": "sarah.higley@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ export const toggleButtonClassNames: SlotClassNames<ButtonSlots>;
export type ToggleButtonProps = ButtonProps & {
defaultChecked?: boolean;
checked?: boolean;
isAccessible?: boolean;
};

// @public (undocumented)
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked'>>;
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;

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

// @public (undocumented)
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;
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;

// (No @packageDocumentation comment for this package)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ export type ToggleButtonProps = ButtonProps & {
* @default false
*/
checked?: boolean;

/**
* Defines whether the `ToggleButton` should use the alternate selected styles that have adequate contrast with the rest style
*
* @default false
*/
isAccessible?: boolean;
};

export type ToggleButtonBaseProps = ButtonBaseProps & Pick<ToggleButtonProps, 'defaultChecked' | 'checked'>;
export type ToggleButtonBaseProps = ButtonBaseProps &
Pick<ToggleButtonProps, 'defaultChecked' | 'checked' | 'isAccessible'>;

export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked'>>;
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;

export type ToggleButtonBaseState = ButtonBaseState & Required<Pick<ToggleButtonProps, 'checked'>>;
export type ToggleButtonBaseState = ButtonBaseState & Required<Pick<ToggleButtonProps, 'checked' | 'isAccessible'>>;
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,56 @@ const useRootCheckedStyles = makeStyles({
},
});

const useCheckedAccessibleStyles = makeStyles({
// Base styles
base: {
backgroundColor: tokens.colorBrandBackground,
...shorthands.borderColor('transparent'),
color: tokens.colorNeutralForegroundOnBrand,

':hover': {
backgroundColor: tokens.colorBrandBackgroundHover,
...shorthands.borderColor('transparent'),
color: tokens.colorNeutralForegroundOnBrand,
},

':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorBrandBackgroundPressed,
...shorthands.borderColor('transparent'),
color: tokens.colorNeutralForegroundOnBrand,
},
},

// Appearance variations
outline: {
// There's no longer a reason to thicken the outline variant's border
...shorthands.borderWidth(tokens.strokeWidthThin),
},

primary: {
// primary has an inner stroke for the checked style
outline: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralForegroundOnBrand}`,
outlineOffset: `calc(${tokens.strokeWidthThicker} * -1)`,
},

subtle: {
// override subtle-appearance-specific icon color on hover
':hover': {
[`& .${toggleButtonClassNames.icon}`]: {
color: tokens.colorNeutralForegroundOnBrand,
},
},
},

transparent: {
/* No styles */
},

secondary: {
/* No styles */
},
});

const useRootDisabledStyles = makeStyles({
// Base styles
base: {
Expand Down Expand Up @@ -213,7 +263,7 @@ const useRootDisabledStyles = makeStyles({
});

const useIconCheckedStyles = makeStyles({
// Appearance variations
// Appearance variations with isAccessible=false
subtleOrTransparent: {
color: tokens.colorNeutralForeground2BrandSelected,
},
Expand Down Expand Up @@ -253,11 +303,12 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
'use no memo';

const rootCheckedStyles = useRootCheckedStyles();
const accessibleCheckedStyles = useCheckedAccessibleStyles();
const rootDisabledStyles = useRootDisabledStyles();
const iconCheckedStyles = useIconCheckedStyles();
const primaryHighContrastStyles = usePrimaryHighContrastStyles();

const { appearance, checked, disabled, disabledFocusable } = state;
const { appearance, checked, disabled, disabledFocusable, isAccessible } = state;

state.root.className = mergeClasses(
toggleButtonClassNames.root,
Expand All @@ -271,6 +322,10 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
checked && rootCheckedStyles.highContrast,
appearance && checked && rootCheckedStyles[appearance],

// Opt-in accessible checked styles
isAccessible && checked && accessibleCheckedStyles.base,
isAccessible && appearance && checked && accessibleCheckedStyles[appearance],

// Disabled styles
(disabled || disabledFocusable) && rootDisabledStyles.base,
appearance && (disabled || disabledFocusable) && rootDisabledStyles[appearance],
Expand All @@ -282,7 +337,10 @@ export const useToggleButtonStyles_unstable = (state: ToggleButtonState): Toggle
if (state.icon) {
state.icon.className = mergeClasses(
toggleButtonClassNames.icon,
checked && (appearance === 'subtle' || appearance === 'transparent') && iconCheckedStyles.subtleOrTransparent,
checked &&
!isAccessible &&
(appearance === 'subtle' || appearance === 'transparent') &&
iconCheckedStyles.subtleOrTransparent,
iconCheckedStyles.highContrast,
state.icon.className,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import type { ButtonState } from '../Button';
import type { ToggleButtonProps, ToggleButtonState } from '../ToggleButton';

export function useToggleState<
TToggleButtonProps extends Pick<ToggleButtonProps, 'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable'>,
TToggleButtonProps extends Pick<
ToggleButtonProps,
'checked' | 'defaultChecked' | 'disabled' | 'disabledFocusable' | 'isAccessible'
>,
TButtonState extends Pick<ButtonState, 'root'>,
TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root'>,
TToggleButtonState extends Pick<ToggleButtonState, 'checked' | 'root' | 'isAccessible'>,
>(props: TToggleButtonProps, state: TButtonState): TToggleButtonState {
const { checked, defaultChecked, disabled, disabledFocusable } = props;
const { checked, defaultChecked, disabled, disabledFocusable, isAccessible = false } = props;
const { onClick, role } = state.root;

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

checked: checkedValue,

isAccessible,

root: {
...state.root,
[isCheckboxTypeRole ? 'aria-checked' : 'aria-pressed']: checkedValue,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Accessibility

!! WARNING !!

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).

In order to ensure a ToggleButton is accessible, use one of the following two strategies:

1. Include distinct icons for checked & unchecked states. This could be an empty space vs. check icon, or a filled vs. unfilled icon.
2. Use the boolean `isAccessible` prop to opt-in to an accessible, contrasting color change for the checked state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';
import { makeStyles, ToggleButton } from '@fluentui/react-components';

const useStyles = makeStyles({
wrapper: {
columnGap: '15px',
display: 'flex',
minWidth: 'min-content',
},
});

export const AccessibleAppearance = (): JSXElement => {
const [checked1, setChecked1] = React.useState(false);
const [checked2, setChecked2] = React.useState(false);
const styles = useStyles();

const toggleChecked = React.useCallback(
(buttonIndex: number) => {
switch (buttonIndex) {
case 1:
setChecked1(!checked1);
break;
case 2:
setChecked2(!checked2);
break;
}
},
[checked1, checked2],
);

return (
<div className={styles.wrapper}>
<ToggleButton checked={checked1} onClick={() => toggleChecked(1)} isAccessible>
Default
</ToggleButton>
<ToggleButton appearance="primary" checked={checked2} onClick={() => toggleChecked(2)} isAccessible>
Primary
</ToggleButton>
<ToggleButton appearance="outline" onClick={() => toggleChecked(3)} isAccessible>
Outline
</ToggleButton>
<ToggleButton appearance="subtle" isAccessible>
Subtle
</ToggleButton>
<ToggleButton appearance="transparent" isAccessible>
Transparent
</ToggleButton>
</div>
);
};

AccessibleAppearance.parameters = {
docs: {
description: {
story:
'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.',
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Meta } from '@storybook/react-webpack5';
import { ToggleButton } from '@fluentui/react-components';
import descriptionMd from './ToggleButtonDescription.md';
import bestPracticesMd from '../Button/ButtonBestPractices.md';
import accessibilityMd from './ToggleButtonAccessibility.md';

export { Default } from './ToggleButtonDefault.stories';
export { Shape } from './ToggleButtonShape.stories';
export { Appearance } from './ToggleButtonAppearance.stories';
export { AccessibleAppearance } from './ToggleButtonAppearanceAccessible.stories';
export { Icon } from './ToggleButtonIcon.stories';
export { Size } from './ToggleButtonSize.stories';
export { Disabled } from './ToggleButtonDisabled.stories';
Expand All @@ -18,7 +20,7 @@ export default {
parameters: {
docs: {
description: {
component: [descriptionMd, bestPracticesMd].join('\n'),
component: [descriptionMd, bestPracticesMd, accessibilityMd].join('\n'),
},
},
},
Expand Down