diff --git a/change/@fluentui-react-switch-disabledFocusable.json b/change/@fluentui-react-switch-disabledFocusable.json new file mode 100644 index 0000000000000..702b96bfafeda --- /dev/null +++ b/change/@fluentui-react-switch-disabledFocusable.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add disabledFocusable prop to Switch component", + "packageName": "@fluentui/react-switch", + "email": "copilot@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-switch/library/etc/react-switch.api.md b/packages/react-components/react-switch/library/etc/react-switch.api.md index 2829567138398..c88091eca1ca1 100644 --- a/packages/react-components/react-switch/library/etc/react-switch.api.md +++ b/packages/react-components/react-switch/library/etc/react-switch.api.md @@ -33,6 +33,7 @@ export type SwitchOnChangeData = { // @public export type SwitchProps = Omit, 'input'>, 'checked' | 'defaultChecked' | 'onChange' | 'size'> & { checked?: boolean; + disabledFocusable?: boolean; defaultChecked?: boolean; labelPosition?: 'above' | 'after' | 'before'; size?: 'small' | 'medium'; @@ -48,7 +49,7 @@ export type SwitchSlots = { }; // @public -export type SwitchState = ComponentState & Required>; +export type SwitchState = ComponentState & Required>; // @public export const useSwitch_unstable: (props: SwitchProps, ref: React_2.Ref) => SwitchState; diff --git a/packages/react-components/react-switch/library/src/components/Switch/Switch.test.tsx b/packages/react-components/react-switch/library/src/components/Switch/Switch.test.tsx index f2a13c7f70af4..2c4e24de4e2eb 100644 --- a/packages/react-components/react-switch/library/src/components/Switch/Switch.test.tsx +++ b/packages/react-components/react-switch/library/src/components/Switch/Switch.test.tsx @@ -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( + <> + + + , + ); + 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(); + 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, SwitchOnChangeData]>(); const { getByRole } = render(); diff --git a/packages/react-components/react-switch/library/src/components/Switch/Switch.types.ts b/packages/react-components/react-switch/library/src/components/Switch/Switch.types.ts index f36ec83c1806f..fdb43e94a79ff 100644 --- a/packages/react-components/react-switch/library/src/components/Switch/Switch.types.ts +++ b/packages/react-components/react-switch/library/src/components/Switch/Switch.types.ts @@ -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. * @@ -87,7 +95,8 @@ export type SwitchBaseProps = Omit; /** * State used in rendering Switch */ -export type SwitchState = ComponentState & Required>; +export type SwitchState = ComponentState & + Required>; /** * Switch base state, excluding design-related state like size diff --git a/packages/react-components/react-switch/library/src/components/Switch/useSwitch.tsx b/packages/react-components/react-switch/library/src/components/Switch/useSwitch.tsx index e3ad1dfa4b5b8..99bfa74a73228 100644 --- a/packages/react-components/react-switch/library/src/components/Switch/useSwitch.tsx +++ b/packages/react-components/react-switch/library/src/components/Switch/useSwitch.tsx @@ -38,12 +38,20 @@ export const useSwitchBase_unstable = (props: SwitchBaseProps, ref?: React.Ref, 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); @@ -57,15 +65,36 @@ export const useSwitchBase_unstable = (props: SwitchBaseProps, ref?: React.Ref 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 }, diff --git a/packages/react-components/react-switch/library/src/components/Switch/useSwitchStyles.styles.ts b/packages/react-components/react-switch/library/src/components/Switch/useSwitchStyles.styles.ts index d32acf6abe308..6fc02fb187816 100644 --- a/packages/react-components/react-switch/library/src/components/Switch/useSwitchStyles.styles.ts +++ b/packages/react-components/react-switch/library/src/components/Switch/useSwitchStyles.styles.ts @@ -115,7 +115,7 @@ const useInputBaseClassName = makeResetStyles({ }, // Disabled (both checked and unchecked) - ':disabled': { + ':disabled, &[aria-disabled="true"]': { cursor: 'default', [`& ~ .${switchClassNames.indicator}`]: { @@ -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, @@ -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, @@ -178,14 +178,14 @@ 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, @@ -193,7 +193,7 @@ const useInputBaseClassName = makeResetStyles({ }, '@media (forced-colors: active)': { - ':disabled': { + ':disabled, &[aria-disabled="true"]': { [`& ~ .${switchClassNames.indicator}`]: { color: 'GrayText', borderColor: 'GrayText', @@ -209,7 +209,7 @@ const useInputBaseClassName = makeResetStyles({ ':hover:active': { color: 'CanvasText', }, - ':enabled:checked': { + ':enabled:checked:not([aria-disabled="true"])': { ':hover': { [`& ~ .${switchClassNames.indicator}`]: { backgroundColor: 'Highlight', diff --git a/packages/react-components/react-switch/stories/src/Switch/SwitchDisabled.stories.tsx b/packages/react-components/react-switch/stories/src/Switch/SwitchDisabled.stories.tsx index 46fd56a2c2d27..e384c5c364dca 100644 --- a/packages/react-components/react-switch/stories/src/Switch/SwitchDisabled.stories.tsx +++ b/packages/react-components/react-switch/stories/src/Switch/SwitchDisabled.stories.tsx @@ -11,6 +11,8 @@ export const Disabled = (): JSXElement => (
+ +
);