Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ButtonUnstyled] Allow receiving focus when disabled #32090

Merged
merged 12 commits into from
Apr 11, 2022
52 changes: 52 additions & 0 deletions docs/data/base/components/button/UnstyledButtonsDisabledFocus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import Stack from '@mui/material/Stack';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';
import { styled } from '@mui/system';

const blue = {
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
};

const CustomButton = styled(ButtonUnstyled)`
font-family: IBM Plex Sans, sans-serif;
font-weight: bold;
font-size: 0.875rem;
background-color: ${blue[500]};
padding: 12px 24px;
border-radius: 8px;
color: white;
transition: all 150ms ease;
cursor: pointer;
border: none;

&:hover {
background-color: ${blue[600]};
}

&.${buttonUnstyledClasses.active} {
background-color: ${blue[700]};
}

&.${buttonUnstyledClasses.focusVisible} {
box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5);
outline: none;
}

&.${buttonUnstyledClasses.disabled} {
opacity: 0.5;
cursor: not-allowed;
}
`;

export default function UnstyledButtonsDisabledFocus() {
return (
<Stack spacing={2} direction="row">
<CustomButton disabled>allowFocusWhenDisabled = false</CustomButton>
<CustomButton disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
</Stack>
);
}
52 changes: 52 additions & 0 deletions docs/data/base/components/button/UnstyledButtonsDisabledFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import Stack from '@mui/material/Stack';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';
import { styled } from '@mui/system';

const blue = {
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
};

const CustomButton = styled(ButtonUnstyled)`
font-family: IBM Plex Sans, sans-serif;
font-weight: bold;
font-size: 0.875rem;
background-color: ${blue[500]};
padding: 12px 24px;
border-radius: 8px;
color: white;
transition: all 150ms ease;
cursor: pointer;
border: none;

&:hover {
background-color: ${blue[600]};
}

&.${buttonUnstyledClasses.active} {
background-color: ${blue[700]};
}

&.${buttonUnstyledClasses.focusVisible} {
box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5);
outline: none;
}

&.${buttonUnstyledClasses.disabled} {
opacity: 0.5;
cursor: not-allowed;
}
`;

export default function UnstyledButtonsDisabledFocus() {
return (
<Stack spacing={2} direction="row">
<CustomButton disabled>allowFocusWhenDisabled = false</CustomButton>
<CustomButton disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<CustomButton disabled>allowFocusWhenDisabled = false</CustomButton>
<CustomButton disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react';
import Stack from '@mui/material/Stack';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';
import { styled } from '@mui/system';

const blue = {
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
};

const CustomButton = styled(ButtonUnstyled)`
font-family: IBM Plex Sans, sans-serif;
font-weight: bold;
font-size: 0.875rem;
background-color: ${blue[500]};
padding: 12px 24px;
border-radius: 8px;
color: white;
transition: all 150ms ease;
cursor: pointer;
border: none;

&:hover:not(.${buttonUnstyledClasses.disabled}) {
background-color: ${blue[600]};
}

&.${buttonUnstyledClasses.active} {
background-color: ${blue[700]};
}

&.${buttonUnstyledClasses.focusVisible} {
box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5);
outline: none;
}

&.${buttonUnstyledClasses.disabled} {
opacity: 0.5;
cursor: not-allowed;
}
`;

export default function UnstyledButtonsDisabledFocusCustom() {
return (
<Stack spacing={2} direction="row">
<CustomButton component="span" disabled>
allowFocusWhenDisabled = false
</CustomButton>
<CustomButton component="span" disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react';
import Stack from '@mui/material/Stack';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';
import { styled } from '@mui/system';

const blue = {
500: '#007FFF',
600: '#0072E5',
700: '#0059B2',
};

const CustomButton = styled(ButtonUnstyled)`
font-family: IBM Plex Sans, sans-serif;
font-weight: bold;
font-size: 0.875rem;
background-color: ${blue[500]};
padding: 12px 24px;
border-radius: 8px;
color: white;
transition: all 150ms ease;
cursor: pointer;
border: none;

&:hover:not(.${buttonUnstyledClasses.disabled}) {
background-color: ${blue[600]};
}

&.${buttonUnstyledClasses.active} {
background-color: ${blue[700]};
}

&.${buttonUnstyledClasses.focusVisible} {
box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5);
outline: none;
}

&.${buttonUnstyledClasses.disabled} {
opacity: 0.5;
cursor: not-allowed;
}
`;

export default function UnstyledButtonsDisabledFocusCustom() {
return (
<Stack spacing={2} direction="row">
<CustomButton component="span" disabled>
allowFocusWhenDisabled = false
</CustomButton>
<CustomButton component="span" disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<CustomButton component="span" disabled>
allowFocusWhenDisabled = false
</CustomButton>
<CustomButton component="span" disabled allowFocusWhenDisabled>
allowFocusWhenDisabled = true
</CustomButton>
15 changes: 15 additions & 0 deletions docs/data/base/components/button/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ In addition to HTML elements, you can also use SVGs with the `ButtonUnstyled` co

{{"demo": "UnstyledButtonCustom.js"}}

## Focus of disabled buttons

Similarly to the native `<button>`, the `ButtonUnstyled` component can't receive focus when it's disabled.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe expand on when this would be a good use-cases (for example MenuItem) and why. For example, in some cases, disabled elements that are required for completing a step (or are just generally too important to be missed) should be focusable with TAB key.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I explained how this may be important for accessibility and shared that we use this internally for the menu button. Does it look better for you now?

The `allowFocusWhenDisabled` prop lets you change this behavior.
When this prop is set, the underlying button does not set the `disabled` prop.
Instead, `aria-disabled` is used, what makes the button focusable.

{{"demo": "UnstyledButtonsDisabledFocus.js"}}

It works the same when the root slot is customized.
In this case, however, the `aria-disabled` attribute is used no matter the state of the `allowFocusWhenDisabled` prop.
The ability to receive focus is controlled internally by the `tabindex` attribute.

{{"demo": "UnstyledButtonsDisabledFocusCustom.js"}}

## useButton hook

```js
Expand Down
1 change: 1 addition & 0 deletions docs/pages/base/api/button-unstyled.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"description": "func<br>&#124;&nbsp;{ current?: { focusVisible: func } }"
}
},
"allowFocusWhenDisabled": { "type": { "name": "bool" } },
"component": { "type": { "name": "elementType" }, "default": "'button'" },
"components": {
"type": { "name": "shape", "description": "{ Root?: elementType }" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"componentDescription": "The foundation for building custom-styled buttons.",
"propDescriptions": {
"action": "A ref for imperative actions. It currently only supports <code>focusVisible()</code> action.",
"allowFocusWhenDisabled": "If <code>true</code>, allows a disabled button to receive focus.",
"component": "The component used for the Root slot. Either a string to use a HTML element or a component.",
"components": "The components used for each slot inside the Button. Either a string to use a HTML element or a component.",
"componentsProps": "The props used for each slot inside the Button.",
Expand Down
100 changes: 98 additions & 2 deletions packages/mui-base/src/ButtonUnstyled/ButtonUnstyled.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import * as React from 'react';
import { createMount, createRenderer, describeConformanceUnstyled } from 'test/utils';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';
import {
act,
createMount,
createRenderer,
describeConformanceUnstyled,
fireEvent,
} from 'test/utils';
import { expect } from 'chai';
import { spy } from 'sinon';
import ButtonUnstyled, { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled';

describe('<ButtonUnstyled />', () => {
const mount = createMount();
Expand Down Expand Up @@ -51,4 +58,93 @@ describe('<ButtonUnstyled />', () => {
expect(getByRole('button')).not.to.have.attribute('role');
});
});

describe('prop: allowFocusWhenDisabled', () => {
describe('as native button', () => {
it('has the aria-disabled instead of disabled attribute when disabled', () => {
const { getByRole } = render(<ButtonUnstyled allowFocusWhenDisabled disabled />);

const button = getByRole('button');
expect(button).to.have.attribute('aria-disabled');
expect(button).not.to.have.attribute('disabled');
});

it('can receive focus when allowFocusWhenDisabled is set', () => {
const { getByRole } = render(<ButtonUnstyled allowFocusWhenDisabled disabled />);

const button = getByRole('button');
act(() => {
button.focus();
});

expect(document.activeElement).to.equal(button);
});

it('does not respond to user actions when disabled and focused', () => {
const handleClick = spy();
const { getByRole } = render(
<ButtonUnstyled allowFocusWhenDisabled disabled onClick={handleClick} />,
);

const button = getByRole('button');
act(() => {
button.focus();
});

act(() => {
button.click();
fireEvent.keyDown(button, { key: 'Enter' });
fireEvent.keyUp(button, { key: ' ' });
});

expect(handleClick.callCount).to.equal(0);
});
});

describe('as non-button element', () => {
it('can receive focus when allowFocusWhenDisabled is set', () => {
const { getByRole } = render(
<ButtonUnstyled component="span" allowFocusWhenDisabled disabled />,
);

const button = getByRole('button');
act(() => {
button.focus();
});

expect(document.activeElement).to.equal(button);
});

it('has aria-disabled and tabIndex attributes set', () => {
const { getByRole } = render(
<ButtonUnstyled component="span" allowFocusWhenDisabled disabled />,
);

const button = getByRole('button');

expect(button).to.have.attribute('aria-disabled', 'true');
expect(button).to.have.attribute('tabindex', '0');
});

it('does not respond to user actions when disabled and focused', () => {
const handleClick = spy();
const { getByRole } = render(
<ButtonUnstyled component="span" allowFocusWhenDisabled disabled onClick={handleClick} />,
);

const button = getByRole('button');
act(() => {
button.focus();
});

act(() => {
button.click();
fireEvent.keyDown(button, { key: 'Enter' });
fireEvent.keyUp(button, { key: ' ' });
});

expect(handleClick.callCount).to.equal(0);
});
});
});
});
Loading