Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/data/material/components/buttons/buttons.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ The `loading` value should always be `null` or `boolean`. The pattern below is n

:::

## Rendering non-native buttons

The `nativeButton` prop can be used to allow buttons to remain keyboard accessible when passing a React component to the [`component`](/material-ui/guides/composition/#passing-other-react-components) prop that renders a non-interactive element like a `<div>`.
Copy link
Member

Choose a reason for hiding this comment

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

For this, this information was not enough to understand fully what's going on. Maybe we need to have another example with the other case, when nativeButton is true and custom component is actually a button. Also, a an example when the prop is true and custom component returns not a button, we mention the warning.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we need to have another example with the other case, when nativeButton is true and custom component is actually a button.

I think this is pretty rare in practice (e.g. making a MenuItem render a <button> elem) and not worth documenting, the dev warning should be self explanatory enough ~

an example when the prop is true and custom component returns not a button

Same here, the dev warnings should be enough for the user to correct the issue without having to go to the docs; only button -> span/div is worth documenting here as it's the more common use-case among the various dev warning combinations


```jsx
const CustomButton = React.forwardRef(function CustomButton(props, ref) {
return <div ref={ref} {...props} />;
})

<Button component={CustomButton} nativeButton={false}>
OK
</Button>
```

## Customization

Here are some examples of customizing the component.
Expand Down
26 changes: 25 additions & 1 deletion docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,31 @@ in the ButtonBase keyboard handlers. This is actually the expected behavior.

#### Event handlers on disabled non-native buttons

When ButtonBase renders a non-native element like a `<span>`, keyboard event handlers will no longer run when the component is disabled.
When ButtonBase renders a non-native element like a `<span>`, keyboard and click event handlers will no longer run when the component is disabled.

#### Replacing native button elements with non-interactive elements

The `nativeButton` prop is available on `<ButtonBase>` and all button-like components to ensure that they are rendered with the correct HTML attributes before hydration, for example during server-side rendering.

This should be specified when passing a React component to the `component` prop of a button-like component that either replaces the default rendered element:

- From a native `<button>` to a non-interactive element like a `<div>`, or
- From a non-button like a `<div>` to a native `<button>`

```jsx
const CustomButton = React.forwardRef(function CustomButton(props, ref) {
return <div ref={ref} {...props} />;
})

<Button component={CustomButton} nativeButton={false}>
OK
</Button>
```

A warning will be shown in development mode if the `nativeButton` prop is incorrectly omitted, or if the resolved element
does not match the value of the prop.

The prop can be used for: `<ButtonBase>`, `<Button>`, `<Fab>`, `<IconButton>`, `<ListItemButton>`, `<MenuItem>`, `<StepButton>`, `<Tab>`, `<ToggleButton>`, `<AccordionSummary>`, `<BottomNavigationAction>`, `<CardActionArea>`, `<TableSortLabel>` and `<PaginationItem>`.

### Autocomplete

Expand Down
1 change: 1 addition & 0 deletions docs/pages/material-ui/api/button-base.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"focusRipple": { "type": { "name": "bool" }, "default": "false" },
"focusVisibleClassName": { "type": { "name": "string" } },
"LinkComponent": { "type": { "name": "elementType" }, "default": "'a'" },
"nativeButton": { "type": { "name": "bool" } },
"onFocusVisible": { "type": { "name": "func" } },
"sx": {
"type": {
Expand Down
1 change: 1 addition & 0 deletions docs/pages/material-ui/api/chip.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"disabled": { "type": { "name": "bool" }, "default": "false" },
"icon": { "type": { "name": "element" } },
"label": { "type": { "name": "node" } },
"nativeButton": { "type": { "name": "bool" } },
"onDelete": { "type": { "name": "func" } },
"size": {
"type": {
Expand Down
1 change: 1 addition & 0 deletions docs/pages/material-ui/api/pagination-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"component": { "type": { "name": "elementType" } },
"disabled": { "type": { "name": "bool" }, "default": "false" },
"nativeButton": { "type": { "name": "bool" } },
"page": { "type": { "name": "node" } },
"selected": { "type": { "name": "bool" }, "default": "false" },
"shape": {
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs/button-base/button-base.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"LinkComponent": {
"description": "The component used to render a link when the <code>href</code> prop is provided."
},
"nativeButton": {
"description": "Whether the custom component is expected to render a native <code>&lt;button&gt;</code> element when passing a React component to the <code>component</code> or <code>slots</code> prop."
},
"onFocusVisible": {
"description": "Callback fired when the component is focused with a keyboard. We trigger a <code>onFocus</code> callback too."
},
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs/chip/chip.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"disabled": { "description": "If <code>true</code>, the component is disabled." },
"icon": { "description": "Icon element." },
"label": { "description": "The content of the component." },
"nativeButton": {
"description": "If <code>true</code>, the component is expected to resolve to a native <code>&lt;button&gt;</code> element. When omitted, custom components inherit the default button semantics of the current wrapper. Set to <code>true</code> when a custom component resolves to a native <code>&lt;button&gt;</code>, or <code>false</code> when it resolves to a non-button host."
},
"onDelete": {
"description": "Callback fired when the delete icon is clicked. If set, the delete icon will be shown."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"description": "The component used for the root node. Either a string to use a HTML element or a component."
},
"disabled": { "description": "If <code>true</code>, the component is disabled." },
"nativeButton": {
"description": "Whether the custom component should render a native <code>&lt;button&gt;</code> element when rendering a React component with the <code>component</code> or <code>slots</code> prop."
},
"page": { "description": "The current page number." },
"selected": { "description": "If <code>true</code> the pagination item is selected." },
"shape": { "description": "The shape of the pagination item." },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref
additionalProps: {
focusRipple: false,
disableRipple: true,
internalNativeButton: true,
disabled,
'aria-expanded': expanded,
focusVisibleClassName: clsx(classes.focusVisible, focusVisibleClassName),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,27 @@ describe('<AccordionSummary />', () => {

expect(handleFocusVisible.callCount).to.equal(1);
});

describe('prop: nativeButton', () => {
it('forwards nativeButton={false} through useSlot to ButtonBase', () => {
const CustomSpan = React.forwardRef((props, ref) => <span ref={ref} {...props} />);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<Accordion>
<AccordionSummary component={CustomSpan} nativeButton={false} />
</Accordion>,
);

const summary = screen.getByRole('button');
expect(summary).to.have.tagName('SPAN');
expect(summary).to.have.attribute('aria-expanded', 'false');
expect(summary).not.to.have.attribute('type');

// Proves nativeButton={false} was forwarded — without it, ButtonBase
// would warn about a non-button host with nativeButton omitted.
expect(errorSpy.mock.calls.length).to.equal(0);
errorSpy.mockRestore();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction(
ref,
className: clsx(classes.root, className),
additionalProps: {
internalNativeButton: true,
focusRipple: true,
},
getSlotProps: (handlers) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,20 @@ describe('<BottomNavigationAction />', () => {
expect(handleClick.callCount).to.equal(1);
});
});

it('forwards nativeButton={false} through useSlot to ButtonBase', () => {
const CustomSpan = React.forwardRef((props, ref) => <span ref={ref} {...props} />);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<BottomNavigationAction component={CustomSpan} nativeButton={false} />);

const action = screen.getByRole('button');
expect(action).to.have.tagName('SPAN');
expect(action).not.to.have.attribute('type');

// Proves nativeButton={false} was forwarded — without it, ButtonBase
// would warn about a non-button host with nativeButton omitted.
expect(errorSpy.mock.calls.length).to.equal(0);
errorSpy.mockRestore();
});
});
3 changes: 2 additions & 1 deletion packages/mui-material/src/Breadcrumbs/BreadcrumbCollapsed.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ const BreadcrumbCollapsedIcon = styled(MoreHorizIcon)({
*/
function BreadcrumbCollapsed(props) {
const { slots = {}, slotProps = {}, ...otherProps } = props;
const { nativeButton, ...buttonBaseProps } = otherProps;
const ownerState = props;

return (
<li>
<BreadcrumbCollapsedButton focusRipple {...otherProps} ownerState={ownerState}>
<BreadcrumbCollapsedButton focusRipple {...buttonBaseProps} ownerState={ownerState}>
<BreadcrumbCollapsedIcon
as={slots.CollapsedIcon}
ownerState={ownerState}
Expand Down
1 change: 1 addition & 0 deletions packages/mui-material/src/Button/Button.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ const Button = React.forwardRef(function Button(inProps, ref) {
focusRipple={!disableFocusRipple}
focusVisibleClassName={clsx(classes.focusVisible, focusVisibleClassName)}
ref={ref}
internalNativeButton
type={type}
id={loading ? loadingId : idProp}
{...other}
Expand Down
107 changes: 107 additions & 0 deletions packages/mui-material/src/Button/Button.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ import * as ripple from '../../test/ripple';
describe('<Button />', () => {
const { render, renderToString } = createRenderer();

/**
* @param {{ mock: { calls: unknown[][] } }} errorSpy
* @returns {string[]}
*/
function getWarningMessages(errorSpy) {
return errorSpy.mock.calls.map((call) =>
String(call[0]).replace(/\s+/g, ' ').trim().toLowerCase(),
);
}

/**
* @param {{ mock: { calls: unknown[][] } }} errorSpy
* @param {string[]} fragments
*/
function expectWarningWithFragments(errorSpy, fragments) {
const messages = getWarningMessages(errorSpy);

expect(messages.length).to.be.greaterThanOrEqual(1);
expect(
messages.some((message) =>
fragments.every((fragment) => message.includes(fragment.toLowerCase())),
),
).to.equal(true);
}

describeConformance(<Button startIcon="icon">Conformance?</Button>, () => ({
classes,
inheritComponent: ButtonBase,
Expand All @@ -43,6 +68,88 @@ describe('<Button />', () => {
expect(button).not.to.have.class(classes.sizeLarge);
});

it('does not warn for intrinsic non-button components when nativeButton is omitted', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<Button component="span">Hello World</Button>);

expect(screen.getByRole('button')).to.have.tagName('SPAN');
expect(errorSpy.mock.calls.length).to.equal(0);
errorSpy.mockRestore();
});

it('warns for custom non-button components when nativeButton is omitted', () => {
const StyledSpan = React.forwardRef(function StyledSpan(props, ref) {
return <span ref={ref} {...props} />;
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<Button component={StyledSpan}>Hello World</Button>);

expect(screen.getByText('Hello World')).to.have.tagName('SPAN');
expectWarningWithFragments(errorSpy, ['nativebutton={false}', 'non-<button>']);
errorSpy.mockRestore();
});

it('does not warn for custom button components when nativeButton is omitted', () => {
const CustomButton = React.forwardRef(function CustomButton(props, ref) {
return <button ref={ref} {...props} />;
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<Button component={CustomButton}>Hello World</Button>);

expect(screen.getByRole('button')).to.have.tagName('BUTTON');
expect(errorSpy.mock.calls.length).to.equal(0);
errorSpy.mockRestore();
});

it('does not warn for custom non-button components when nativeButton={false}', () => {
const StyledSpan = React.forwardRef(function StyledSpan(props, ref) {
return <span ref={ref} {...props} />;
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<Button component={StyledSpan} nativeButton={false}>
Hello World
</Button>,
);

expect(screen.getByRole('button')).to.have.tagName('SPAN');
expect(errorSpy.mock.calls.length).to.equal(0);
errorSpy.mockRestore();
});

it('warns when nativeButton={false} is used with a custom component that renders a button', () => {
const CustomButton = React.forwardRef(function CustomButton(props, ref) {
return <button ref={ref} {...props} />;
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<Button component={CustomButton} nativeButton={false}>
Hello World
</Button>,
);

expect(screen.getByRole('button')).to.have.tagName('BUTTON');
expectWarningWithFragments(errorSpy, ['nativebutton', 'false', 'non-<button>']);
errorSpy.mockRestore();
});

it('does not forward focusableWhenDisabled to ButtonBase', () => {
render(
<Button disabled focusableWhenDisabled>
Hello World
</Button>,
);

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

it('startIcon and endIcon should have icon class', () => {
render(
<Button startIcon={<span>start icon</span>} endIcon={<span>end icon</span>}>
Expand Down
5 changes: 5 additions & 0 deletions packages/mui-material/src/ButtonBase/ButtonBase.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export interface ButtonBaseOwnProps {
* @default 'a'
*/
LinkComponent?: React.ElementType | undefined;
/**
* Whether the custom component is expected to render a native `<button>` element
* when passing a React component to the `component` or `slots` prop.
*/
nativeButton?: boolean | undefined;
/**
* Callback fired when the component is focused with a keyboard.
* We trigger a `onFocus` callback too.
Expand Down
Loading
Loading