Skip to content
Draft
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
7 changes: 7 additions & 0 deletions change/@fluentui-react-switch-disabledFocusable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add disabledFocusable prop to Switch component",
"packageName": "@fluentui/react-switch",
"email": "copilot@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type SwitchOnChangeData = {
// @public
export type SwitchProps = Omit<ComponentProps<Partial<SwitchSlots>, 'input'>, 'checked' | 'defaultChecked' | 'onChange' | 'size'> & {
checked?: boolean;
disabledFocusable?: boolean;
defaultChecked?: boolean;
labelPosition?: 'above' | 'after' | 'before';
size?: 'small' | 'medium';
Expand All @@ -48,7 +49,7 @@ export type SwitchSlots = {
};

// @public
export type SwitchState = ComponentState<SwitchSlots> & Required<Pick<SwitchProps, 'labelPosition' | 'size'>>;
export type SwitchState = ComponentState<SwitchSlots> & Required<Pick<SwitchProps, 'disabledFocusable' | 'labelPosition' | 'size'>>;

// @public
export const useSwitch_unstable: (props: SwitchProps, ref: React_2.Ref<HTMLInputElement>) => SwitchState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,33 @@ describe('Switch', () => {
expect(checked.checked).toBe(true);
});

it('does not trigger a change in the checked state if it is disabledFocusable', () => {
const { getAllByRole } = render(
<>
<Switch defaultChecked={false} disabledFocusable />
<Switch defaultChecked={true} disabledFocusable />
</>,
);
const [unchecked, checked] = getAllByRole('switch') as HTMLInputElement[];

expect(unchecked.checked).toBe(false);
userEvent.click(unchecked);
expect(unchecked.checked).toBe(false);

expect(checked.checked).toBe(true);
userEvent.click(checked);
expect(checked.checked).toBe(true);
});

it('can be focused when disabledFocusable has been passed', () => {
const { getByRole } = render(<Switch disabledFocusable />);
const input = getByRole('switch');

expect(document.activeElement).not.toEqual(input);
userEvent.tab();
expect(document.activeElement).toEqual(input);
});

it('calls onChange with the correct value', () => {
const onChange = jest.fn<void, [React.ChangeEvent<HTMLInputElement>, SwitchOnChangeData]>();
const { getByRole } = render(<Switch onChange={onChange} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export type SwitchProps = Omit<
*/
checked?: boolean;

/**
* When set, allows the Switch to be focusable even when it has been disabled. This is used in scenarios where it is
* important to keep a consistent tab order for screen reader and keyboard users.
*
* @default false
*/
disabledFocusable?: boolean;

/**
* Defines whether the Switch is initially in a checked state or not when rendered.
*
Expand Down Expand Up @@ -87,7 +95,8 @@ export type SwitchBaseProps = Omit<SwitchProps, 'size'>;
/**
* State used in rendering Switch
*/
export type SwitchState = ComponentState<SwitchSlots> & Required<Pick<SwitchProps, 'labelPosition' | 'size'>>;
export type SwitchState = ComponentState<SwitchSlots> &
Required<Pick<SwitchProps, 'disabledFocusable' | 'labelPosition' | 'size'>>;

/**
* Switch base state, excluding design-related state like size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,20 @@ export const useSwitchBase_unstable = (props: SwitchBaseProps, ref?: React.Ref<H
// Merge props from surrounding <Field>, if any
props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true });

const { checked, defaultChecked, disabled, labelPosition = 'after', onChange, required } = props;
const {
checked,
defaultChecked,
disabled,
disabledFocusable = false,
labelPosition = 'after',
onChange,
required,
} = props;

const nativeProps = getPartitionedNativeProps({
props,
primarySlotTagName: 'input',
excludedPropNames: ['checked', 'defaultChecked', 'onChange'],
excludedPropNames: ['checked', 'defaultChecked', 'onChange', 'disabledFocusable'],
});

const id = useId('switch-', nativeProps.primary.id);
Expand All @@ -57,15 +65,36 @@ export const useSwitchBase_unstable = (props: SwitchBaseProps, ref?: React.Ref<H
elementType: 'div',
});
const input = slot.always(props.input, {
defaultProps: { checked, defaultChecked, id, ref, role: 'switch', type: 'checkbox', ...nativeProps.primary },
defaultProps: {
checked,
defaultChecked,
id,
ref,
role: 'switch',
type: 'checkbox',
...nativeProps.primary,
disabled: disabled && !disabledFocusable,
...(disabledFocusable && { 'aria-disabled': true }),
},
elementType: 'input',
});
input.onChange = mergeCallbacks(input.onChange, ev => onChange?.(ev, { checked: ev.currentTarget.checked }));
input.onClick = mergeCallbacks(input.onClick, ev => {
if (disabledFocusable) {
ev.preventDefault();
}
});
input.onKeyDown = mergeCallbacks(input.onKeyDown, ev => {
if (disabledFocusable && (ev.key === ' ' || ev.key === 'Enter')) {
ev.preventDefault();
}
});
const label = slot.optional(props.label, {
defaultProps: { disabled, htmlFor: id, required, size: 'medium' },
defaultProps: { disabled: disabled || disabledFocusable, htmlFor: id, required, size: 'medium' },
elementType: Label,
});
return {
disabledFocusable,
labelPosition,
components: { root: 'div', indicator: 'div', input: 'input', label: Label },

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const useInputBaseClassName = makeResetStyles({
},

// Disabled (both checked and unchecked)
':disabled': {
':disabled, &[aria-disabled="true"]': {
cursor: 'default',

[`& ~ .${switchClassNames.indicator}`]: {
Expand All @@ -129,7 +129,7 @@ const useInputBaseClassName = makeResetStyles({
},

// Enabled and unchecked
':enabled:not(:checked)': {
':enabled:not(:checked):not([aria-disabled="true"])': {
[`& ~ .${switchClassNames.indicator}`]: {
color: tokens.colorNeutralStrokeAccessible,
borderColor: tokens.colorNeutralStrokeAccessible,
Expand All @@ -155,7 +155,7 @@ const useInputBaseClassName = makeResetStyles({
},

// Enabled and checked
':enabled:checked': {
':enabled:checked:not([aria-disabled="true"])': {
[`& ~ .${switchClassNames.indicator}`]: {
backgroundColor: tokens.colorCompoundBrandBackground,
color: tokens.colorNeutralForegroundInverted,
Expand All @@ -178,22 +178,22 @@ const useInputBaseClassName = makeResetStyles({
},

// Disabled and unchecked
':disabled:not(:checked)': {
':disabled:not(:checked), &[aria-disabled="true"]:not(:checked)': {
[`& ~ .${switchClassNames.indicator}`]: {
borderColor: tokens.colorNeutralStrokeDisabled,
},
},

// Disabled and checked
':disabled:checked': {
':disabled:checked, &[aria-disabled="true"]:checked': {
[`& ~ .${switchClassNames.indicator}`]: {
backgroundColor: tokens.colorNeutralBackgroundDisabled,
borderColor: tokens.colorTransparentStrokeDisabled,
},
},

'@media (forced-colors: active)': {
':disabled': {
':disabled, &[aria-disabled="true"]': {
[`& ~ .${switchClassNames.indicator}`]: {
color: 'GrayText',
borderColor: 'GrayText',
Expand All @@ -209,7 +209,7 @@ const useInputBaseClassName = makeResetStyles({
':hover:active': {
color: 'CanvasText',
},
':enabled:checked': {
':enabled:checked:not([aria-disabled="true"])': {
':hover': {
[`& ~ .${switchClassNames.indicator}`]: {
backgroundColor: 'Highlight',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const Disabled = (): JSXElement => (
<div style={wrapperStyle}>
<Switch disabled label="Unchecked and disabled" />
<Switch checked disabled label="Checked and disabled" />
<Switch disabledFocusable label="Unchecked and disabled focusable" />
<Switch checked disabledFocusable label="Checked and disabled focusable" />
</div>
);

Expand Down