From 9f31eab8854b9da9129e1faa7fd76898b3e175cd Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 26 Feb 2026 23:45:22 +0800 Subject: [PATCH 01/16] Extract a hook from ButtonBase --- .../AccordionSummary/AccordionSummary.d.ts | 2 +- .../src/AccordionSummary/AccordionSummary.js | 3 +- .../BottomNavigationAction.d.ts | 6 +- .../BottomNavigationAction.js | 3 +- .../src/Breadcrumbs/BreadcrumbCollapsed.js | 3 +- packages/mui-material/src/Button/Button.js | 3 +- .../mui-material/src/Button/Button.test.js | 14 + .../src/ButtonBase/ButtonBase.d.ts | 9 +- .../mui-material/src/ButtonBase/ButtonBase.js | 136 ++++----- .../src/ButtonBase/ButtonBase.test.js | 144 +++++++++- .../src/ButtonBase/useButtonBase.test.tsx | 258 ++++++++++++++++++ .../src/ButtonBase/useButtonBase.ts | 169 ++++++++++++ .../src/CardActionArea/CardActionArea.d.ts | 6 +- .../src/CardActionArea/CardActionArea.js | 3 +- packages/mui-material/src/Chip/Chip.js | 3 +- packages/mui-material/src/Chip/Chip.test.js | 10 + packages/mui-material/src/Fab/Fab.js | 3 +- .../mui-material/src/IconButton/IconButton.js | 3 +- .../src/ListItemButton/ListItemButton.js | 9 +- .../mui-material/src/MenuItem/MenuItem.js | 3 +- .../src/PaginationItem/PaginationItem.js | 3 +- .../mui-material/src/StepButton/StepButton.js | 3 +- packages/mui-material/src/Tab/Tab.js | 3 +- .../src/TabScrollButton/TabScrollButton.d.ts | 2 +- .../src/TabScrollButton/TabScrollButton.js | 5 +- .../src/TableSortLabel/TableSortLabel.js | 3 +- .../src/ToggleButton/ToggleButton.js | 3 +- .../mui-material/src/internal/SwitchBase.d.ts | 7 +- .../mui-material/src/internal/SwitchBase.js | 3 +- 29 files changed, 703 insertions(+), 119 deletions(-) create mode 100644 packages/mui-material/src/ButtonBase/useButtonBase.test.tsx create mode 100644 packages/mui-material/src/ButtonBase/useButtonBase.ts diff --git a/packages/mui-material/src/AccordionSummary/AccordionSummary.d.ts b/packages/mui-material/src/AccordionSummary/AccordionSummary.d.ts index eea853d93ec1a2..46e1505a29839a 100644 --- a/packages/mui-material/src/AccordionSummary/AccordionSummary.d.ts +++ b/packages/mui-material/src/AccordionSummary/AccordionSummary.d.ts @@ -36,7 +36,7 @@ export type AccordionSummarySlotsAndSlotProps = CreateSlotsAndSlotProps< * By default, the available props are based on the [ButtonBase](https://mui.com/material-ui/api/button-base/#props) component. */ root: SlotProps< - React.ElementType, + React.ElementType>, AccordionSummaryRootSlotPropsOverrides, AccordionSummaryOwnerState >; diff --git a/packages/mui-material/src/AccordionSummary/AccordionSummary.js b/packages/mui-material/src/AccordionSummary/AccordionSummary.js index fa1ba0e1e34571..9ab47ea291d47f 100644 --- a/packages/mui-material/src/AccordionSummary/AccordionSummary.js +++ b/packages/mui-material/src/AccordionSummary/AccordionSummary.js @@ -118,6 +118,7 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref slotProps, ...other } = props; + const { nativeButton, ...buttonBaseProps } = other; const { disabled = false, disableGutters, expanded, toggle } = React.useContext(AccordionContext); const handleChange = (event) => { @@ -150,7 +151,7 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref elementType: AccordionSummaryRoot, externalForwardedProps: { ...externalForwardedProps, - ...other, + ...buttonBaseProps, }, ownerState, additionalProps: { diff --git a/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.d.ts b/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.d.ts index 4271a3f36511cf..0936f10d1d121e 100644 --- a/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.d.ts +++ b/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.d.ts @@ -31,7 +31,11 @@ export type BottomNavigationActionSlotsAndSlotProps = CreateSlotsAndSlotProps< * Props forwarded to the root slot. * By default, the available props are based on the ButtonBase element. */ - root: SlotProps, {}, BottomNavigationActionOwnerState>; + root: SlotProps< + React.ElementType>, + {}, + BottomNavigationActionOwnerState + >; /** * Props forwarded to the label slot. * By default, the available props are based on the span element. diff --git a/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.js b/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.js index 9c8709a21ff13b..d725cadf2811d3 100644 --- a/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.js +++ b/packages/mui-material/src/BottomNavigationAction/BottomNavigationAction.js @@ -104,6 +104,7 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction( slotProps = {}, ...other } = props; + const { nativeButton, ...buttonBaseProps } = other; const ownerState = props; const classes = useUtilityClasses(ownerState); @@ -127,7 +128,7 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction( elementType: BottomNavigationActionRoot, externalForwardedProps: { ...externalForwardedProps, - ...other, + ...buttonBaseProps, }, shouldForwardComponentProp: true, ownerState, diff --git a/packages/mui-material/src/Breadcrumbs/BreadcrumbCollapsed.js b/packages/mui-material/src/Breadcrumbs/BreadcrumbCollapsed.js index 681040d92916e8..8d5d06fa29a4e7 100644 --- a/packages/mui-material/src/Breadcrumbs/BreadcrumbCollapsed.js +++ b/packages/mui-material/src/Breadcrumbs/BreadcrumbCollapsed.js @@ -41,11 +41,12 @@ const BreadcrumbCollapsedIcon = styled(MoreHorizIcon)({ */ function BreadcrumbCollapsed(props) { const { slots = {}, slotProps = {}, ...otherProps } = props; + const { nativeButton, ...buttonBaseProps } = otherProps; const ownerState = props; return (
  • - + {startIcon} diff --git a/packages/mui-material/src/Button/Button.test.js b/packages/mui-material/src/Button/Button.test.js index dc6619dc230068..b56dcbc85533f0 100644 --- a/packages/mui-material/src/Button/Button.test.js +++ b/packages/mui-material/src/Button/Button.test.js @@ -51,6 +51,20 @@ describe(', + ); + + expect(screen.getByRole('button')).to.have.tagName('DIV'); + expect(errorSpy.mock.calls.length).to.equal(0); + errorSpy.mockRestore(); + }); + it('startIcon and endIcon should have icon class', () => { render( + + ); + } + + render(); + + const child = screen.getByTestId('child'); + act(() => { + child.focus(); + }); + + fireEvent.keyDown(child, { key: 'Enter' }); + fireEvent.keyUp(child, { key: ' ' }); + + expect(handleClick.callCount).to.equal(0); + }); + + it('does not fire when disabled', () => { + const handleClick = spy(); + + render(); + + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + fireEvent.keyUp(el, { key: ' ' }); + + expect(handleClick.callCount).to.equal(0); + }); + + it('does not synthesize clicks for native buttons', () => { + const handleClick = spy(); + + render(); + + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + fireEvent.keyUp(el, { key: ' ' }); + + expect(handleClick.callCount).to.equal(0); + }); + + it('resolves keyboard behavior from the resolved host', () => { + const handleClick = spy(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + , + ); + + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + + expect(handleClick.callCount).to.equal(0); + errorSpy.mockRestore(); + }); + }); + + describe('dev warnings', () => { + it('warns when nativeButton=true but the resolved host is not a button', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + const allArgs = errorSpy.mock.calls.map((call) => call[0]); + expect(allArgs.length).to.be.greaterThanOrEqual(1); + expect( + allArgs.some((msg) => + msg.includes('A component that acts as a button expected a native ); + + 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 ; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + const allArgs = errorSpy.mock.calls.map((call) => call[0]); + expect(screen.getByRole('button')).to.have.tagName('SPAN'); + expect(allArgs.length).to.be.greaterThanOrEqual(1); + expect(allArgs.some((msg) => msg.includes('resolved to a non-button host'))).to.equal(true); + errorSpy.mockRestore(); + }); + + it('does not warn for custom non-button components when nativeButton={false}', () => { + const StyledSpan = React.forwardRef(function StyledSpan(props, ref) { + return ; + }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); render( - , ); - expect(screen.getByRole('button')).to.have.tagName('DIV'); + expect(screen.getByRole('button')).to.have.tagName('SPAN'); expect(errorSpy.mock.calls.length).to.equal(0); errorSpy.mockRestore(); }); diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.d.ts b/packages/mui-material/src/ButtonBase/ButtonBase.d.ts index 12924b96c6fdfb..ffacec0c1f7bfd 100644 --- a/packages/mui-material/src/ButtonBase/ButtonBase.d.ts +++ b/packages/mui-material/src/ButtonBase/ButtonBase.d.ts @@ -107,8 +107,7 @@ export interface ButtonBaseTypeMap< * can make extension quite tricky */ export interface ExtendButtonBaseTypeMap { - props: TypeMap['props'] & - Omit; + props: TypeMap['props'] & Omit; defaultComponent: TypeMap['defaultComponent']; } diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.js b/packages/mui-material/src/ButtonBase/ButtonBase.js index 8ead2cf39d9746..fc6b3b48b68a22 100644 --- a/packages/mui-material/src/ButtonBase/ButtonBase.js +++ b/packages/mui-material/src/ButtonBase/ButtonBase.js @@ -111,7 +111,9 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { let ComponentProp = component; - if (ComponentProp === 'button' && (other.href || other.to)) { + const isLink = !!(other.href || other.to); + + if (ComponentProp === 'button' && isLink) { ComponentProp = LinkComponent; } @@ -122,9 +124,16 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { if (disabled && focusVisible) { setFocusVisible(false); } - const { eventHandlers, rootRef: buttonRef } = useButtonBase({ + const { + eventHandlers, + buttonProps, + rootRef: buttonRef, + } = useButtonBase({ nativeButton, disabled, + type, + hasFormAction: !!other.formAction, + tabIndex, onBeforeKeyDown: (event) => { // Check if key is already down to avoid repeats being counted as multiple activations if (focusRipple && !event.repeat && focusVisible && event.key === ' ') { @@ -147,6 +156,35 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { onKeyUp, }); + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + const root = buttonRef.current; + if (root == null) { + return; + } + if (typeof ComponentProp === 'string') { + return; + } + if (isLink) { + return; + } + + if (nativeButtonProp === undefined) { + const tagName = root.tagName.toLowerCase(); + if (tagName !== 'button') { + console.error( + [ + 'MUI: A component that acts as a button resolved to a non-button host,', + `but \`nativeButton={false}\` was not specified and the resolved root is <${tagName}>.`, + 'When using a custom `component`, set `nativeButton={false}` explicitly or render a ); const allArgs = errorSpy.mock.calls.map((call) => call[0]); - expect(screen.getByRole('button')).to.have.tagName('SPAN'); + expect(screen.getByText('Hello World')).to.have.tagName('SPAN'); expect(allArgs.length).to.be.greaterThanOrEqual(1); expect(allArgs.some((msg) => msg.includes('resolved to a non-button host'))).to.equal(true); errorSpy.mockRestore(); }); + it('does not warn for custom button components when nativeButton is omitted', () => { + const CustomButton = React.forwardRef(function CustomButton(props, ref) { + return ); + + 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 ; diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.d.ts b/packages/mui-material/src/ButtonBase/ButtonBase.d.ts index ffacec0c1f7bfd..5ec90cba521b3d 100644 --- a/packages/mui-material/src/ButtonBase/ButtonBase.d.ts +++ b/packages/mui-material/src/ButtonBase/ButtonBase.d.ts @@ -66,8 +66,10 @@ export interface ButtonBaseOwnProps { LinkComponent?: React.ElementType | undefined; /** * If `true`, the component is expected to resolve to a native `, () => ({ classes, inheritComponent: ButtonBase, @@ -69,10 +94,8 @@ describe('); - const allArgs = errorSpy.mock.calls.map((call) => call[0]); expect(screen.getByText('Hello World')).to.have.tagName('SPAN'); - expect(allArgs.length).to.be.greaterThanOrEqual(1); - expect(allArgs.some((msg) => msg.includes('resolved to a non-button host'))).to.equal(true); + expectWarningWithFragments(errorSpy, ['nativebutton={false}', 'non-, + ); + + 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( + + ); + } + + render(); + + const child = screen.getByTestId('child'); + act(() => { + child.focus(); + }); + + fireEvent.keyDown(child, { key: 'Enter' }); + fireEvent.keyUp(child, { key: ' ' }); + + expect(handleClick).not.toHaveBeenCalled(); }); - fireEvent.keyDown(el, { key: 'Enter' }); - expect(handleClick.callCount).to.equal(1); + it('does not call key handlers or fire when disabled', () => { + const handleClick = vi.fn(); + const handleKeyDown = vi.fn(); + const handleKeyUp = vi.fn(); + + render( + , + ); + + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + fireEvent.keyUp(el, { key: ' ' }); + + expect(handleKeyDown).not.toHaveBeenCalled(); + expect(handleKeyUp).not.toHaveBeenCalled(); + expect(handleClick).not.toHaveBeenCalled(); + }); }); - it('Space on keyDown prevents default and keyUp activates click for pseudo-buttons', () => { - const handleClick = spy(); - const handleKeyDown = spy(); + describe('native button', () => { + it('does not synthesize clicks', () => { + const handleClick = vi.fn(); - render( - , - ); + render(); - const el = screen.getByTestId('root'); - act(() => { - el.focus(); + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + fireEvent.keyUp(el, { key: ' ' }); + + expect(handleClick).not.toHaveBeenCalled(); }); - fireEvent.keyDown(el, { key: ' ' }); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - expect(handleClick.callCount).to.equal(0); + it('resolves keyboard behavior from the resolved host', () => { + const handleClick = vi.fn(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); - fireEvent.keyUp(el, { key: ' ' }); - expect(handleClick.callCount).to.equal(1); + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + + expect(handleClick).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('does not call key handlers for disabled focusable buttons', () => { + const handleKeyDown = vi.fn(); + const handleKeyUp = vi.fn(); + + render( + , + ); + + const el = screen.getByTestId('root'); + act(() => { + el.focus(); + }); + + fireEvent.keyDown(el, { key: 'Enter' }); + fireEvent.keyUp(el, { key: ' ' }); + + expect(handleKeyDown).not.toHaveBeenCalled(); + expect(handleKeyUp).not.toHaveBeenCalled(); + }); }); + }); + + describe('param: focusableWhenDisabled', () => { + it('allows disabled native buttons to receive focus', async () => { + const { user } = render(); + + const button = screen.getByRole('button'); - it('does not fire when the event comes from a child element', () => { - const handleClick = spy(); + expect(button).not.toHaveFocus(); + await user.tab(); + expect(button).toHaveFocus(); + }); - function TestWithChild(props: UseButtonBaseParameters) { - const { eventHandlers, rootRef } = useButtonBase(props); + it('only calls focus and blur for disabled focusable non-native buttons', async () => { + const handleClick = vi.fn(); + const handleKeyDown = vi.fn(); + const handleKeyUp = vi.fn(); + const handleFocus = vi.fn(); + const handleBlur = vi.fn(); + + function FocusableNonNativeButton( + props: UseButtonBaseParameters & { + onClick?: React.MouseEventHandler | undefined; + onKeyDown?: React.KeyboardEventHandler | undefined; + onKeyUp?: React.KeyboardEventHandler | undefined; + onFocus?: React.FocusEventHandler | undefined; + onBlur?: React.FocusEventHandler | undefined; + }, + ) { + const { onClick, onKeyDown, onKeyUp, onFocus, onBlur, ...params } = props; + const { getButtonProps, rootRef } = useButtonBase(params); return ( -
    } {...eventHandlers}> - -
    +
    } + {...getButtonProps({ onBlur, onClick, onFocus, onKeyDown, onKeyUp })} + /> ); } - render(); + const { user } = render( + , + ); - const child = screen.getByTestId('child'); - act(() => { - child.focus(); - }); + const button = screen.getByRole('button'); - fireEvent.keyDown(child, { key: 'Enter' }); - fireEvent.keyUp(child, { key: ' ' }); + expect(button).not.toHaveFocus(); + expect(handleFocus).not.toHaveBeenCalled(); - expect(handleClick.callCount).to.equal(0); - }); + await user.tab(); + expect(button).toHaveFocus(); + expect(handleFocus).toHaveBeenCalledTimes(1); + handleKeyDown.mockClear(); + handleKeyUp.mockClear(); - it('does not fire when disabled', () => { - const handleClick = spy(); + fireEvent.keyDown(button, { key: 'Enter' }); + expect(handleKeyDown).not.toHaveBeenCalled(); + expect(handleClick).not.toHaveBeenCalled(); - render(); + fireEvent.keyDown(button, { key: ' ' }); + expect(handleKeyDown).not.toHaveBeenCalled(); - const el = screen.getByTestId('root'); - act(() => { - el.focus(); - }); + fireEvent.keyUp(button, { key: ' ' }); + expect(handleKeyUp).not.toHaveBeenCalled(); + expect(handleClick).not.toHaveBeenCalled(); - fireEvent.keyDown(el, { key: 'Enter' }); - fireEvent.keyUp(el, { key: ' ' }); + await user.click(button); + expect(handleClick).not.toHaveBeenCalled(); - expect(handleClick.callCount).to.equal(0); + expect(handleBlur).not.toHaveBeenCalled(); + await user.tab(); + expect(handleBlur).toHaveBeenCalledTimes(1); + expect(button).not.toHaveFocus(); }); + }); - it('does not synthesize clicks for native buttons', () => { - const handleClick = spy(); + describe('getButtonProps', () => { + describe('native button', () => { + it('returns type="button" and disabled', () => { + render(); - render(); + const el = screen.getByTestId('root'); + expect(el.getAttribute('type')).toBe('button'); + expect(el.getAttribute('role')).toBeNull(); + expect((el as HTMLButtonElement).disabled).toBe(false); + }); + + it('returns disabled=true when disabled', () => { + render(); - const el = screen.getByTestId('root'); - act(() => { - el.focus(); + const el = screen.getByTestId('root'); + expect(el.getAttribute('type')).toBe('button'); + expect((el as HTMLButtonElement).disabled).toBe(true); + expect(el.getAttribute('tabindex')).toBe('-1'); }); - fireEvent.keyDown(el, { key: 'Enter' }); - fireEvent.keyUp(el, { key: ' ' }); + it('does not default type when hasFormAction is true', () => { + render(); - expect(handleClick.callCount).to.equal(0); + const el = screen.getByTestId('root'); + expect(el.getAttribute('type')).toBeNull(); + }); + + it('returns aria-disabled instead of disabled for focusable disabled buttons', () => { + render(); + + const el = screen.getByTestId('root'); + expect(el.getAttribute('type')).toBe('button'); + expect(el.hasAttribute('disabled')).toBe(false); + expect(el.getAttribute('aria-disabled')).toBe('true'); + expect(el.getAttribute('tabindex')).toBe('0'); + }); }); - it('resolves keyboard behavior from the resolved host', () => { - const handleClick = spy(); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + describe('non-native button', () => { + it('returns role="button" and aria-disabled', () => { + render(); - render( - , - ); + const el = screen.getByTestId('root'); + expect(el.getAttribute('role')).toBe('button'); + expect(el.getAttribute('aria-disabled')).toBe('true'); + expect(el.getAttribute('type')).toBeNull(); + expect(el.getAttribute('tabindex')).toBe('-1'); + }); - const el = screen.getByTestId('root'); - act(() => { - el.focus(); + it('returns tabIndex=0 when enabled', () => { + render(); + + const el = screen.getByTestId('root'); + expect(el.getAttribute('role')).toBe('button'); + expect(el.getAttribute('aria-disabled')).toBeNull(); + expect(el.getAttribute('tabindex')).toBe('0'); }); - fireEvent.keyDown(el, { key: 'Enter' }); + it('respects custom tabIndex', () => { + render(); - expect(handleClick.callCount).to.equal(0); - errorSpy.mockRestore(); + const el = screen.getByTestId('root'); + expect(el.getAttribute('tabindex')).toBe('5'); + }); + + it('overrides custom tabIndex to -1 when disabled', () => { + render(); + + const el = screen.getByTestId('root'); + expect(el.getAttribute('tabindex')).toBe('-1'); + }); }); }); - describe('buttonProps', () => { - it('returns type="button" and disabled for native buttons', () => { - render(); + describe('warnings', () => { + function getWarningMessages(errorSpy: ReturnType) { + return errorSpy.mock.calls.map((call: [unknown, ...unknown[]]) => + String(call[0]).replace(/\s+/g, ' ').trim().toLowerCase(), + ); + } + + function expectWarningWithFragments( + errorSpy: ReturnType, + fragments: string[], + ) { + const messages = getWarningMessages(errorSpy); + + expect(messages.length).toBeGreaterThanOrEqual(1); + expect( + messages.some((message: string) => + fragments.every((fragment: string) => message.includes(fragment.toLowerCase())), + ), + ).toBe(true); + } + + it('warns when nativeButton is omitted and a custom component resolves to a button host', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); - const el = screen.getByTestId('root'); - expect(el).to.have.attribute('type', 'button'); - expect(el).to.not.have.attribute('role'); - expect(el).to.have.property('disabled', false); + expectWarningWithFragments(errorSpy, ['nativebutton={true}', 'native +``` + ## Customization Here are some examples of customizing the component. From ce75c06de99430180c189dd0db2f90d32c81acb7 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 18 Mar 2026 12:51:30 +0800 Subject: [PATCH 07/16] fixes --- docs/pages/material-ui/api/button.json | 1 - docs/translations/api-docs/button-base/button-base.json | 2 +- docs/translations/api-docs/button/button.json | 3 --- .../api-docs/pagination-item/pagination-item.json | 2 +- packages/mui-material/src/Button/Button.js | 7 ------- packages/mui-material/src/ButtonBase/ButtonBase.d.ts | 2 +- packages/mui-material/src/ButtonBase/ButtonBase.js | 2 +- .../mui-material/src/PaginationItem/PaginationItem.d.ts | 6 ++---- packages/mui-material/src/PaginationItem/PaginationItem.js | 6 ++---- 9 files changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/pages/material-ui/api/button.json b/docs/pages/material-ui/api/button.json index 4301dee20d9f60..9873c89dcc0792 100644 --- a/docs/pages/material-ui/api/button.json +++ b/docs/pages/material-ui/api/button.json @@ -29,7 +29,6 @@ }, "default": "'center'" }, - "nativeButton": { "type": { "name": "bool" } }, "size": { "type": { "name": "union", diff --git a/docs/translations/api-docs/button-base/button-base.json b/docs/translations/api-docs/button-base/button-base.json index 2e5b4cf0a842c5..e673010acc12d3 100644 --- a/docs/translations/api-docs/button-base/button-base.json +++ b/docs/translations/api-docs/button-base/button-base.json @@ -30,7 +30,7 @@ "description": "The component used to render a link when the href prop is provided." }, "nativeButton": { - "description": "Whether the custom component should render a native <button> element when rendering a React componentwith the component or slots prop." + "description": "Whether the custom component should render a native <button> element when rendering a React component with the component or slots prop." }, "onFocusVisible": { "description": "Callback fired when the component is focused with a keyboard. We trigger a onFocus callback too." diff --git a/docs/translations/api-docs/button/button.json b/docs/translations/api-docs/button/button.json index c1393ba89b43af..76efbc84729490 100644 --- a/docs/translations/api-docs/button/button.json +++ b/docs/translations/api-docs/button/button.json @@ -33,9 +33,6 @@ "loadingPosition": { "description": "The loading indicator can be positioned on the start, end, or the center of the button." }, - "nativeButton": { - "description": "Whether the custom component should render a native <button> element when rendering a React componentwith the component or slots prop." - }, "size": { "description": "The size of the component. small is equivalent to the dense button styling." }, diff --git a/docs/translations/api-docs/pagination-item/pagination-item.json b/docs/translations/api-docs/pagination-item/pagination-item.json index 8a603716ea822d..f7c7cf6936cb18 100644 --- a/docs/translations/api-docs/pagination-item/pagination-item.json +++ b/docs/translations/api-docs/pagination-item/pagination-item.json @@ -13,7 +13,7 @@ }, "disabled": { "description": "If true, the component is disabled." }, "nativeButton": { - "description": "If true, the component is expected to resolve to a native <button> element. When omitted, custom components inherit the default button semantics of the current wrapper. Set to true when a custom component resolves to a native <button>, or false when it resolves to a non-button host." + "description": "Whether the custom component should render a native <button> element when rendering a React component with the component or slots prop." }, "page": { "description": "The current page number." }, "selected": { "description": "If true the pagination item is selected." }, diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 92f5cbba7175a5..1df4db7b714c7f 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -526,7 +526,6 @@ const Button = React.forwardRef(function Button(inProps, ref) { loading = null, loadingIndicator: loadingIndicatorProp, loadingPosition = 'center', - nativeButton: nativeButtonProp, size = 'medium', startIcon: startIconProp, type, @@ -603,7 +602,6 @@ const Button = React.forwardRef(function Button(inProps, ref) { focusVisibleClassName={clsx(classes.focusVisible, focusVisibleClassName)} ref={ref} defaultNativeButton - nativeButton={nativeButtonProp} type={type} id={loading ? loadingId : idProp} {...other} @@ -713,11 +711,6 @@ Button.propTypes /* remove-proptypes */ = { * @default 'center' */ loadingPosition: PropTypes.oneOf(['center', 'end', 'start']), - /** - * Whether the custom component should render a native ` +``` + +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: ``, `, + ); + + expect(screen.getByRole('button')).to.have.tagName('BUTTON'); + expectWarningWithFragments(errorSpy, ['nativebutton', 'false', 'non-