From c45a995ef0fa2a5d5c9c8d0d7a926f11b1d6517c Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Fri, 4 Aug 2023 12:02:33 -0400 Subject: [PATCH 01/15] Copy ButtonBase from material --- .../src/ButtonBase/ButtonBase.d.ts | 135 ++ .../src/ButtonBase/ButtonBase.js | 540 +++++++ .../src/ButtonBase/ButtonBase.test.js | 1246 +++++++++++++++++ .../src/ButtonBase/buttonBaseClasses.ts | 25 + .../src/ButtonBase/index.d.ts | 8 + .../mui-material-next/src/ButtonBase/index.js | 8 + 6 files changed, 1962 insertions(+) create mode 100644 packages/mui-material-next/src/ButtonBase/ButtonBase.d.ts create mode 100644 packages/mui-material-next/src/ButtonBase/ButtonBase.js create mode 100644 packages/mui-material-next/src/ButtonBase/ButtonBase.test.js create mode 100644 packages/mui-material-next/src/ButtonBase/buttonBaseClasses.ts create mode 100644 packages/mui-material-next/src/ButtonBase/index.d.ts create mode 100644 packages/mui-material-next/src/ButtonBase/index.js diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.d.ts b/packages/mui-material-next/src/ButtonBase/ButtonBase.d.ts new file mode 100644 index 00000000000000..6a6c256d87a48d --- /dev/null +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.d.ts @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { TouchRippleActions, TouchRippleProps } from './TouchRipple'; +import { OverrideProps, OverridableComponent, OverridableTypeMap } from '../OverridableComponent'; +import { ButtonBaseClasses } from './buttonBaseClasses'; + +export interface ButtonBaseTypeMap< + AdditionalProps = {}, + DefaultComponent extends React.ElementType = 'button', +> { + props: AdditionalProps & { + /** + * A ref for imperative actions. + * It currently only supports `focusVisible()` action. + */ + action?: React.Ref; + /** + * If `true`, the ripples are centered. + * They won't start at the cursor interaction position. + * @default false + */ + centerRipple?: boolean; + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the ripple effect is disabled. + * + * ⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure + * to highlight the element by applying separate styles with the `.Mui-focusVisible` class. + * @default false + */ + disableRipple?: boolean; + /** + * If `true`, the touch ripple effect is disabled. + * @default false + */ + disableTouchRipple?: boolean; + /** + * If `true`, the base button will have a keyboard focus ripple. + * @default false + */ + focusRipple?: boolean; + /** + * This prop can help identify which element has keyboard focus. + * The class name will be applied when the element gains the focus through keyboard interaction. + * It's a polyfill for the [CSS :focus-visible selector](https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo). + * The rationale for using this feature [is explained here](https://github.com/WICG/focus-visible/blob/HEAD/explainer.md). + * A [polyfill can be used](https://github.com/WICG/focus-visible) to apply a `focus-visible` class to other components + * if needed. + */ + focusVisibleClassName?: string; + /** + * The component used to render a link when the `href` prop is provided. + * @default 'a' + */ + LinkComponent?: React.ElementType; + /** + * Callback fired when the component is focused with a keyboard. + * We trigger a `onFocus` callback too. + */ + onFocusVisible?: React.FocusEventHandler; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * @default 0 + */ + tabIndex?: NonNullable['tabIndex']>; + /** + * Props applied to the `TouchRipple` element. + */ + TouchRippleProps?: Partial; + /** + * A ref that points to the `TouchRipple` element. + */ + touchRippleRef?: React.Ref; + }; + defaultComponent: DefaultComponent; +} + +/** + * utility to create component types that inherit props from ButtonBase. + * This component has an additional overload if the `href` prop is set which + * can make extension quite tricky + */ +export interface ExtendButtonBaseTypeMap { + props: TypeMap['props'] & Omit; + defaultComponent: TypeMap['defaultComponent']; +} + +export type ExtendButtonBase = (( + props: { href: string } & OverrideProps, 'a'>, +) => JSX.Element) & + OverridableComponent>; + +/** + * `ButtonBase` contains as few styles as possible. + * It aims to be a simple building block for creating a button. + * It contains a load of style reset and some focus/ripple logic. + * + * Demos: + * + * - [Button](https://mui.com/material-ui/react-button/) + * + * API: + * + * - [ButtonBase API](https://mui.com/material-ui/api/button-base/) + */ +declare const ButtonBase: ExtendButtonBase; + +export type ButtonBaseProps< + RootComponent extends React.ElementType = ButtonBaseTypeMap['defaultComponent'], + AdditionalProps = {}, +> = OverrideProps, RootComponent> & { + component?: React.ElementType; +}; + +export interface ButtonBaseActions { + focusVisible(): void; +} + +export default ButtonBase; diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.js b/packages/mui-material-next/src/ButtonBase/ButtonBase.js new file mode 100644 index 00000000000000..414a9d640303c3 --- /dev/null +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.js @@ -0,0 +1,540 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { elementTypeAcceptingRef, refType } from '@mui/utils'; +import composeClasses from '@mui/base/composeClasses'; +import styled from '../styles/styled'; +import useThemeProps from '../styles/useThemeProps'; +import useForkRef from '../utils/useForkRef'; +import useEventCallback from '../utils/useEventCallback'; +import useIsFocusVisible from '../utils/useIsFocusVisible'; +import TouchRipple from './TouchRipple'; +import buttonBaseClasses, { getButtonBaseUtilityClass } from './buttonBaseClasses'; + +const useUtilityClasses = (ownerState) => { + const { disabled, focusVisible, focusVisibleClassName, classes } = ownerState; + + const slots = { + root: ['root', disabled && 'disabled', focusVisible && 'focusVisible'], + }; + + const composedClasses = composeClasses(slots, getButtonBaseUtilityClass, classes); + + if (focusVisible && focusVisibleClassName) { + composedClasses.root += ` ${focusVisibleClassName}`; + } + + return composedClasses; +}; + +export const ButtonBaseRoot = styled('button', { + name: 'MuiButtonBase', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + boxSizing: 'border-box', + WebkitTapHighlightColor: 'transparent', + backgroundColor: 'transparent', // Reset default value + // We disable the focus ring for mouse, touch and keyboard users. + outline: 0, + border: 0, + margin: 0, // Remove the margin in Safari + borderRadius: 0, + padding: 0, // Remove the padding in Firefox + cursor: 'pointer', + userSelect: 'none', + verticalAlign: 'middle', + MozAppearance: 'none', // Reset + WebkitAppearance: 'none', // Reset + textDecoration: 'none', + // So we take precedent over the style of a native element. + color: 'inherit', + '&::-moz-focus-inner': { + borderStyle: 'none', // Remove Firefox dotted outline. + }, + [`&.${buttonBaseClasses.disabled}`]: { + pointerEvents: 'none', // Disable link interactions + cursor: 'default', + }, + '@media print': { + colorAdjust: 'exact', + }, +}); + +/** + * `ButtonBase` contains as few styles as possible. + * It aims to be a simple building block for creating a button. + * It contains a load of style reset and some focus/ripple logic. + */ +const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'MuiButtonBase' }); + const { + action, + centerRipple = false, + children, + className, + component = 'button', + disabled = false, + disableRipple = false, + disableTouchRipple = false, + focusRipple = false, + focusVisibleClassName, + LinkComponent = 'a', + onBlur, + onClick, + onContextMenu, + onDragLeave, + onFocus, + onFocusVisible, + onKeyDown, + onKeyUp, + onMouseDown, + onMouseLeave, + onMouseUp, + onTouchEnd, + onTouchMove, + onTouchStart, + tabIndex = 0, + TouchRippleProps, + touchRippleRef, + type, + ...other + } = props; + + const buttonRef = React.useRef(null); + + const rippleRef = React.useRef(null); + const handleRippleRef = useForkRef(rippleRef, touchRippleRef); + + const { + isFocusVisibleRef, + onFocus: handleFocusVisible, + onBlur: handleBlurVisible, + ref: focusVisibleRef, + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = React.useState(false); + if (disabled && focusVisible) { + setFocusVisible(false); + } + + React.useImperativeHandle( + action, + () => ({ + focusVisible: () => { + setFocusVisible(true); + buttonRef.current.focus(); + }, + }), + [], + ); + + const [mountedState, setMountedState] = React.useState(false); + + React.useEffect(() => { + setMountedState(true); + }, []); + + const enableTouchRipple = mountedState && !disableRipple && !disabled; + + React.useEffect(() => { + if (focusVisible && focusRipple && !disableRipple && mountedState) { + rippleRef.current.pulsate(); + } + }, [disableRipple, focusRipple, focusVisible, mountedState]); + + function useRippleHandler(rippleAction, eventCallback, skipRippleAction = disableTouchRipple) { + return useEventCallback((event) => { + if (eventCallback) { + eventCallback(event); + } + + const ignore = skipRippleAction; + if (!ignore && rippleRef.current) { + rippleRef.current[rippleAction](event); + } + + return true; + }); + } + + const handleMouseDown = useRippleHandler('start', onMouseDown); + const handleContextMenu = useRippleHandler('stop', onContextMenu); + const handleDragLeave = useRippleHandler('stop', onDragLeave); + const handleMouseUp = useRippleHandler('stop', onMouseUp); + const handleMouseLeave = useRippleHandler('stop', (event) => { + if (focusVisible) { + event.preventDefault(); + } + if (onMouseLeave) { + onMouseLeave(event); + } + }); + const handleTouchStart = useRippleHandler('start', onTouchStart); + const handleTouchEnd = useRippleHandler('stop', onTouchEnd); + const handleTouchMove = useRippleHandler('stop', onTouchMove); + + const handleBlur = useRippleHandler( + 'stop', + (event) => { + handleBlurVisible(event); + if (isFocusVisibleRef.current === false) { + setFocusVisible(false); + } + if (onBlur) { + onBlur(event); + } + }, + false, + ); + + const handleFocus = useEventCallback((event) => { + // Fix for https://github.com/facebook/react/issues/7769 + if (!buttonRef.current) { + buttonRef.current = event.currentTarget; + } + + handleFocusVisible(event); + if (isFocusVisibleRef.current === true) { + setFocusVisible(true); + + if (onFocusVisible) { + onFocusVisible(event); + } + } + + if (onFocus) { + onFocus(event); + } + }); + + const isNonNativeButton = () => { + const button = buttonRef.current; + return component && component !== 'button' && !(button.tagName === 'A' && button.href); + }; + + /** + * IE11 shim for https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat + */ + const keydownRef = React.useRef(false); + const handleKeyDown = useEventCallback((event) => { + // Check if key is already down to avoid repeats being counted as multiple activations + if ( + focusRipple && + !keydownRef.current && + focusVisible && + rippleRef.current && + event.key === ' ' + ) { + keydownRef.current = true; + rippleRef.current.stop(event, () => { + rippleRef.current.start(event); + }); + } + + if (event.target === event.currentTarget && isNonNativeButton() && event.key === ' ') { + event.preventDefault(); + } + + if (onKeyDown) { + onKeyDown(event); + } + + // Keyboard accessibility for non interactive elements + if ( + event.target === event.currentTarget && + isNonNativeButton() && + event.key === 'Enter' && + !disabled + ) { + event.preventDefault(); + if (onClick) { + onClick(event); + } + } + }); + + const handleKeyUp = useEventCallback((event) => { + // calling preventDefault in keyUp on a + + the button + + + ); + } + + const { container, getByTestId } = render(); + + fireEvent.click(getByTestId('trigger')); + expect(container.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); + }); + + it('should stop the ripple on blur if disableTouchRipple is set', () => { + const buttonActions = React.createRef(); + + const { getByRole } = render( + , + ); + + const button = getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + + act(() => { + button.blur(); + }); + + expect(button.querySelectorAll('.ripple-visible .child-leaving')).to.have.lengthOf(1); + }); + }); + }); + + describe('prop: centerRipple', () => { + it('centers the TouchRipple', () => { + const { container, getByRole } = render( + + Hello + , + ); + // @ts-ignore + stub(container.querySelector('.touch-ripple'), 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 100, + bottom: 10, + left: 20, + top: 20, + })); + fireEvent.mouseDown(getByRole('button'), { clientX: 10, clientY: 10 }); + const rippleRipple = container.querySelector('.touch-ripple-ripple'); + expect(rippleRipple).not.to.equal(null); + // @ts-ignore + const rippleStyle = window.getComputedStyle(rippleRipple); + expect(rippleStyle).to.have.property('height', '101px'); + expect(rippleStyle).to.have.property('width', '101px'); + }); + + it('is disabled by default', () => { + const { container, getByRole } = render( + + Hello + , + ); + // @ts-ignore + stub(container.querySelector('.touch-ripple'), 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 100, + bottom: 10, + left: 20, + top: 20, + })); + fireEvent.mouseDown(getByRole('button'), { clientX: 10, clientY: 10 }); + const rippleRipple = container.querySelector('.touch-ripple-ripple'); + expect(rippleRipple).not.to.equal(null); + // @ts-ignore + const rippleStyle = window.getComputedStyle(rippleRipple); + expect(rippleStyle).not.to.have.property('height', '101px'); + expect(rippleStyle).not.to.have.property('width', '101px'); + }); + }); + + describe('focusRipple', () => { + it('should pulsate the ripple when focusVisible', () => { + const { getByRole } = render( + , + ); + const button = getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + + expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); + }); + + it('should not stop the ripple when the mouse leaves', () => { + const { getByRole } = render( + , + ); + const button = getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + fireEvent.mouseLeave(button); + + expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); + }); + + it('should stop pulsate and start a ripple when the space button is pressed', () => { + const { getByRole } = render( + , + ); + const button = getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + fireEvent.keyDown(button, { key: ' ' }); + + expect(button.querySelectorAll('.ripple-pulsate .child-leaving')).to.have.lengthOf(1); + expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(0); + }); + + it('should stop and re-pulsate when space bar is released', () => { + const { getByRole } = render( + , + ); + const button = getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + fireEvent.keyDown(button, { key: ' ' }); + fireEvent.keyUp(button, { key: ' ' }); + + expect(button.querySelectorAll('.ripple-pulsate .child-leaving')).to.have.lengthOf(1); + expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(2); + expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(3); + }); + + it('should stop on blur and set focusVisible to false', () => { + const { getByRole } = render( + , + ); + const button = getByRole('button'); + simulatePointerDevice(); + focusVisible(button); + + act(() => { + button.blur(); + }); + + expect(button.querySelectorAll('.ripple-visible .child-leaving')).to.have.lengthOf(1); + }); + }); + + describe('prop: disabled', () => { + it('should have a negative tabIndex', () => { + const { getByText } = render(Hello); + expect(getByText('Hello')).to.have.property('tabIndex', -1); + }); + + it('should forward it to native buttons', () => { + const { getByText } = render( + + Hello + , + ); + expect(getByText('Hello')).to.have.property('disabled', true); + }); + + it('should reset the focused state', () => { + const { getByText, setProps } = render(Hello); + const button = getByText('Hello'); + simulatePointerDevice(); + + focusVisible(button); + + expect(button).to.have.class(classes.focusVisible); + + setProps({ disabled: true }); + + expect(button).not.to.have.class(classes.focusVisible); + }); + + it('should not use aria-disabled with button host', () => { + const { getByRole } = render(Hello); + const button = getByRole('button'); + + expect(button).to.have.attribute('disabled'); + expect(button).not.to.have.attribute('aria-disabled'); + }); + + it('should use aria-disabled for other components', () => { + const { getByRole, setProps } = render( + + Hello + , + ); + const button = getByRole('button'); + + expect(button).not.to.have.attribute('disabled'); + expect(button).to.have.attribute('aria-disabled', 'true'); + + setProps({ disabled: false }); + expect(button).not.to.have.attribute('aria-disabled'); + }); + }); + + describe('prop: component', () => { + it('should allow to use a link component', () => { + /** + * @type {React.ForwardRefExoticComponent>} + */ + const Link = React.forwardRef((props, ref) => ( +
+ )); + const { getByTestId } = render(Hello); + + expect(getByTestId('link')).to.have.attribute('role', 'button'); + }); + }); + + describe('event: focus', () => { + it('when disabled should be called onFocus', () => { + const onFocusSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + + act(() => { + getByRole('button').focus(); + }); + + expect(onFocusSpy.callCount).to.equal(1); + }); + + it('has a focus-visible polyfill', () => { + const { getByText } = render(Hello); + const button = getByText('Hello'); + simulatePointerDevice(); + + expect(button).not.to.have.class(classes.focusVisible); + + act(() => { + button.focus(); + }); + + if (programmaticFocusTriggersFocusVisible()) { + expect(button).to.have.class(classes.focusVisible); + } else { + expect(button).not.to.have.class(classes.focusVisible); + } + + focusVisible(button); + + expect(button).to.have.class(classes.focusVisible); + }); + + it('removes focus-visible if focus is re-targetted', () => { + /** + * @type {string[]} + */ + const eventLog = []; + function Test() { + /** + * @type {React.Ref} + */ + const focusRetargetRef = React.useRef(null); + return ( +
{ + const { current: focusRetarget } = focusRetargetRef; + if (focusRetarget === null) { + throw new TypeError('Nothing to focus. Test cannot work.'); + } + focusRetarget.focus(); + }} + > + + eventLog.push('blur')} + onFocus={() => eventLog.push('focus')} + onFocusVisible={() => eventLog.push('focus-visible')} + > + Hello + +
+ ); + } + const { getByText } = render(); + const buttonBase = getByText('Hello'); + const focusRetarget = getByText('you cannot escape me'); + simulatePointerDevice(); + + focusVisible(buttonBase); + + expect(focusRetarget).toHaveFocus(); + expect(eventLog).to.deep.equal(['focus-visible', 'focus', 'blur']); + expect(buttonBase).not.to.have.class(classes.focusVisible); + }); + + it('onFocusVisibleHandler() should propagate call to onFocusVisible prop', () => { + const onFocusVisibleSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + simulatePointerDevice(); + + focusVisible(getByRole('button')); + + expect(onFocusVisibleSpy.calledOnce).to.equal(true); + expect(onFocusVisibleSpy.firstCall.args).to.have.lengthOf(1); + }); + + it('can be autoFocused', () => { + // as of react@16.8.6 autoFocus causes focus to be emitted before refs + // so we need to check if we're resilient against it + const { getByText } = render(Hello); + + expect(getByText('Hello')).toHaveFocus(); + }); + }); + + describe('event: keydown', () => { + it('ripples on repeated keydowns', () => { + const { container, getByText } = render( + + Hello + , + ); + + const button = getByText('Hello'); + + act(() => { + button.focus(); + fireEvent.keyDown(button, { key: 'Enter' }); + }); + + expect(container.querySelectorAll('.ripple-visible')).to.have.lengthOf(1); + + // technically the second keydown should be fire with repeat: true + // but that isn't implemented in IE11 so we shouldn't mock it here either + fireEvent.keyDown(button, { key: 'Enter' }); + + expect(container.querySelectorAll('.ripple-visible')).to.have.lengthOf(1); + }); + + describe('prop: onKeyDown', () => { + it('call it when keydown events are dispatched', () => { + const onKeyDownSpy = spy(); + const { getByText } = render( + + Hello + , + ); + + fireEvent.keyDown(getByText('Hello')); + + expect(onKeyDownSpy.callCount).to.equal(1); + }); + }); + + describe('prop: disableTouchRipple', () => { + it('creates no ripples on click', () => { + const { getByText } = render( + + Hello + , + ); + const button = getByText('Hello'); + + fireEvent.click(button); + + expect(button).not.to.have.class('ripple-visible'); + }); + }); + + describe('prop: disableRipple', () => { + it('removes the TouchRipple', () => { + const { getByText } = render( + + Hello + , + ); + + expect(getByText('Hello').querySelector('.touch-ripple')).to.equal(null); + }); + }); + + describe('keyboard accessibility for non interactive elements', () => { + it('does not call onClick when a spacebar is pressed on the element but prevents the default', () => { + const onKeyDown = spy(); + const onClickSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + const button = getByRole('button'); + + act(() => { + button.focus(); + fireEvent.keyDown(button, { + key: ' ', + }); + }); + + expect(onClickSpy.callCount).to.equal(0); + expect(onKeyDown.callCount).to.equal(1); + expect(onKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); + + it('does call onClick when a spacebar is released on the element', () => { + const onClickSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + const button = getByRole('button'); + + act(() => { + button.focus(); + fireEvent.keyUp(button, { + key: ' ', + }); + }); + + expect(onClickSpy.callCount).to.equal(1); + expect(onClickSpy.firstCall.args[0]).to.have.property('defaultPrevented', false); + }); + + it('does not call onClick when a spacebar is released and the default is prevented', () => { + const onClickSpy = spy(); + const { getByRole } = render( + event.preventDefault() + } + component="div" + > + Hello + , + ); + const button = getByRole('button'); + + act(() => { + button.focus(); + fireEvent.keyUp(button, { + key: ' ', + }); + }); + + expect(onClickSpy.callCount).to.equal(0); + }); + + it('calls onClick when Enter is pressed on the element', () => { + const onClickSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + const button = getByRole('button'); + + act(() => { + button.focus(); + fireEvent.keyDown(button, { + key: 'Enter', + }); + }); + + expect(onClickSpy.calledOnce).to.equal(true); + expect(onClickSpy.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); + + it('does not call onClick if Enter was pressed on a child', () => { + const onClickSpy = spy(); + const onKeyDownSpy = spy(); + render( + + + , + ); + + fireEvent.keyDown(screen.getByRole('textbox'), { + key: 'Enter', + }); + + expect(onKeyDownSpy.callCount).to.equal(1); + expect(onClickSpy.callCount).to.equal(0); + }); + + it('does not call onClick if Space was released on a child', () => { + const onClickSpy = spy(); + const onKeyUpSpy = spy(); + render( + + + , + ); + + fireEvent.keyUp(screen.getByRole('textbox'), { + key: ' ', + }); + + expect(onKeyUpSpy.callCount).to.equal(1); + expect(onClickSpy.callCount).to.equal(0); + }); + + it('prevents default with an anchor and empty href', () => { + const onClickSpy = spy(); + const { getByRole } = render( + + Hello + , + ); + const button = getByRole('button'); + + act(() => { + button.focus(); + fireEvent.keyDown(button, { key: 'Enter' }); + }); + + expect(onClickSpy.calledOnce).to.equal(true); + expect(onClickSpy.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); + + it('should ignore anchors with href', () => { + const onClick = spy(); + const onKeyDown = spy(); + const { getByText } = render( + + Hello + , + ); + const button = getByText('Hello'); + + act(() => { + button.focus(); + fireEvent.keyDown(button, { + key: 'Enter', + }); + }); + + expect(onClick.callCount).to.equal(0); + expect(onKeyDown.callCount).to.equal(1); + expect(onKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', false); + }); + }); + }); + + describe('prop: action', () => { + it('should be able to focus visible the button', () => { + /** + * @type {React.RefObject} + */ + const buttonActionsRef = React.createRef(); + const { getByText } = render( + + Hello + , + ); + + // @ts-ignore + expect(typeof buttonActionsRef.current.focusVisible).to.equal('function'); + + act(() => { + // @ts-ignore + buttonActionsRef.current.focusVisible(); + }); + + expect(getByText('Hello')).toHaveFocus(); + expect(getByText('Hello')).to.match('.focusVisible'); + }); + }); + + describe('warnings', () => { + beforeEach(() => { + PropTypes.resetWarningCache(); + }); + + it('warns on invalid `component` prop: ref forward', function test() { + // Only run the test on node. On the browser the thrown error is not caught + if (!/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + /** + * + * @param {import('react').HTMLAttributes} props + */ + function Component(props) { + return + )); + + // cant match the error message here because flakiness with mocha watchmode + + expect(() => { + render(); + }).toErrorDev('Please make sure the children prop is rendered in this custom component.'); + }); + }); + + describe('prop: type', () => { + it('is `button` by default', () => { + render(); + + expect(screen.getByRole('button')).to.have.property('type', 'button'); + }); + + it('can be changed to other button types', () => { + render(); + + expect(screen.getByRole('button')).to.have.property('type', 'submit'); + }); + + it('allows non-standard values', () => { + // @ts-expect-error `@types/react` only lists standard values + render(); + + expect(screen.getByRole('button')).to.have.attribute('type', 'fictional-type'); + // By spec non-supported types result in the default type for `
); - - expect(mouseDownSpy.callCount).to.equal(0); - - fireEvent.mouseDown(getByRole('button')); - - expect(mouseDownSpy.callCount).to.equal(1); - }); - }); }); diff --git a/packages/mui-material-next/src/Button/Button.tsx b/packages/mui-material-next/src/Button/Button.tsx index 4810f77207df68..835cb35a3f41a0 100644 --- a/packages/mui-material-next/src/Button/Button.tsx +++ b/packages/mui-material-next/src/Button/Button.tsx @@ -1,43 +1,22 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { - elementTypeAcceptingRef, - refType, - unstable_capitalize as capitalize, - unstable_useForkRef as useForkRef, -} from '@mui/utils'; -import { useButton } from '@mui/base/useButton'; -import { EventHandlers, useSlotProps } from '@mui/base/utils'; +import { elementTypeAcceptingRef, unstable_capitalize as capitalize } from '@mui/utils'; +import { useSlotProps } from '@mui/base/utils'; import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses'; -import { useThemeProps, alpha } from '@mui/system'; -import TouchRipple from './TouchRipple'; -import { TouchRippleActions } from './TouchRipple.types'; -import useTouchRipple from './useTouchRipple'; +import { useThemeProps, alpha, shouldForwardProp } from '@mui/system'; import { MD3ColorSchemeTokens, styled } from '../styles'; -import buttonClasses, { getButtonUtilityClass } from './buttonClasses'; +import { getButtonUtilityClass } from './buttonClasses'; +import buttonBaseClasses from '../ButtonBase/buttonBaseClasses'; import { ButtonProps, ExtendButton, ButtonTypeMap, ButtonOwnerState } from './Button.types'; +import ButtonBase from '../ButtonBase'; -const useUtilityClasses = (styleProps: ButtonOwnerState) => { - const { - classes, - color, - disabled, - active, - disableElevation, - focusVisible, - focusVisibleClassName, - fullWidth, - size, - variant, - } = styleProps; +const useUtilityClasses = (ownerState: ButtonOwnerState) => { + const { classes, color, disableElevation, fullWidth, size, variant } = ownerState; const slots = { root: [ 'root', - disabled && 'disabled', - focusVisible && 'focusVisible', - active && 'active', variant, `color${capitalize(color ?? '')}`, `size${capitalize(size ?? '')}`, @@ -51,11 +30,10 @@ const useUtilityClasses = (styleProps: ButtonOwnerState) => { const composedClasses = composeClasses(slots, getButtonUtilityClass, classes); - if (focusVisible && focusVisibleClassName) { - composedClasses.root += ` ${focusVisibleClassName}`; - } - - return composedClasses; + return { + ...classes, // forward the focused, disabled, etc. classes to the ButtonBase + ...composedClasses, + }; }; const commonIconStyles = ({ size }: ButtonOwnerState) => ({ @@ -77,9 +55,10 @@ const commonIconStyles = ({ size }: ButtonOwnerState) => ({ }), }); -export const ButtonRoot = styled('button', { +export const ButtonRoot = styled(ButtonBase, { name: 'MuiButton', slot: 'Root', + shouldForwardProp: (prop) => shouldForwardProp(prop), overridesResolver: (props, styles) => { const { ownerState } = props; @@ -282,29 +261,6 @@ export const ButtonRoot = styled('button', { '--md-comp-button-pressed-icon-color': labelTextColor[ownerState.variant ?? 'text'], // same as default '--md-comp-button-focused-icon-color': labelTextColor[ownerState.variant ?? 'text'], // same as default '--md-comp-button-disabled-icon-color': disabledLabelTextColor, - // Normalized styles for buttons - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - position: 'relative', - boxSizing: 'border-box', - WebkitTapHighlightColor: 'transparent', - // We disable the focus ring for mouse, touch and keyboard users. - outline: 0, - border: 0, - margin: `var(--Button-margin, 0)`, // Remove the margin in Safari by default - cursor: 'pointer', - userSelect: 'none', - verticalAlign: 'middle', - MozAppearance: 'none', // Reset - WebkitAppearance: 'none', // Reset - textDecoration: 'none', - '&::-moz-focus-inner': { - borderStyle: 'none', // Remove Firefox dotted outline. - }, - '@media print': { - colorAdjust: 'exact', - }, padding: '10px 24px', minWidth: 64, letterSpacing, @@ -350,19 +306,19 @@ export const ButtonRoot = styled('button', { backgroundColor: hoveredContainerColor[ownerState.variant ?? 'text'], boxShadow: hoveredContainerElevation[ownerState.variant ?? 'text'], }, - [`&.${buttonClasses.active}`]: { + [`&.${buttonBaseClasses.active}`]: { '--md-comp-button-icon-color': 'var(--md-comp-button-pressed-icon-color)', ...((ownerState.disableRipple || ownerState.disableTouchRipple) && { backgroundColor: pressedContainerColor[ownerState.variant ?? 'text'], }), boxShadow: pressedContainerElevation[ownerState.variant ?? 'text'], }, - [`&.${buttonClasses.focusVisible}`]: { + [`&.${buttonBaseClasses.focusVisible}`]: { '--md-comp-button-icon-color': 'var(--md-comp-button-focused-icon-color)', backgroundColor: focusedContainerColor[ownerState.variant ?? 'text'], boxShadow: focusedContainerElevation[ownerState.variant ?? 'text'], }, - [`&.${buttonClasses.disabled}`]: { + [`&.${buttonBaseClasses.disabled}`]: { // Allows developer to specify the disabled icon color var '--md-comp-button-icon-color': 'var(--md-comp-button-disabled-icon-color)', pointerEvents: 'none', // Disable link interactions @@ -414,108 +370,25 @@ const Button = React.forwardRef(function Button< >(inProps: ButtonProps, ref: React.ForwardedRef) { const props = useThemeProps({ props: inProps, name: 'MuiButton' }); const { - action, - centerRipple = false, children, - className, classes: classesProp, color = 'primary', - component = 'button', - disabled = false, - focusableWhenDisabled = false, disableElevation = false, - disableRipple = false, - disableTouchRipple = false, endIcon: endIconProp, - focusVisibleClassName, fullWidth = false, - LinkComponent = 'a', - onBlur, - onContextMenu, - onDragLeave, - onFocusVisible, - onMouseDown, - onMouseLeave, - onMouseUp, - onTouchEnd, - onTouchMove, - onTouchStart, size = 'medium', startIcon: startIconProp, - tabIndex = 0, - TouchRippleProps, - type, variant = 'text', ...other } = props; - const buttonRef = React.useRef(null); - const handleRef = useForkRef(buttonRef, ref); - - const rippleRef = React.useRef(null); - let ComponentProp = component; - - if (ComponentProp === 'button' && (other.href || other.to)) { - ComponentProp = LinkComponent; - } - - const { focusVisible, active, setFocusVisible, getRootProps } = useButton({ - disabled, - focusableWhenDisabled, - href: props.href, - onFocusVisible, - tabIndex, - // @ts-ignore - to: props.to, - type, - rootRef: handleRef, - }); - - React.useImperativeHandle( - action, - () => ({ - focusVisible: () => { - setFocusVisible(true); - buttonRef.current!.focus(); - }, - }), - [setFocusVisible], - ); - - const { enableTouchRipple, getRippleHandlers } = useTouchRipple({ - disabled, - disableRipple, - disableTouchRipple, - rippleRef, - }); - - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { - if (enableTouchRipple && !rippleRef.current) { - console.error( - [ - 'MUI: The `component` prop provided to Button is invalid.', - 'Please make sure the children prop is rendered in this custom component.', - ].join('\n'), - ); - } - }, [enableTouchRipple]); - } - const ownerState = { ...props, classes: classesProp, color, - component, - disabled, disableElevation, - active, - focusVisible, fullWidth, size, - tabIndex, - type, variant, }; @@ -523,18 +396,13 @@ const Button = React.forwardRef(function Button< const rootProps = useSlotProps({ elementType: ButtonRoot, - getSlotProps: (otherHandlers: EventHandlers) => - getRootProps({ - ...otherHandlers, - ...getRippleHandlers(props), - }), externalForwardedProps: other, externalSlotProps: {}, additionalProps: { - as: ComponentProp, + classes, + ref, }, ownerState, - className: [classes.root, className], }); const startIcon = startIconProp && ( @@ -554,10 +422,6 @@ const Button = React.forwardRef(function Button< {startIcon} {children} {endIcon} - {enableTouchRipple ? ( - /* TouchRipple is only needed client-side, x2 boost on the server. */ - - ) : null} ); }) as ExtendButton; @@ -567,17 +431,6 @@ Button.propTypes /* remove-proptypes */ = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit TypeScript types and run "yarn proptypes" | // ---------------------------------------------------------------------- - /** - * A ref for imperative actions. - * It currently only supports `focusVisible()` action. - */ - action: refType, - /** - * If `true`, the ripples are centered. - * They won't start at the cursor interaction position. - * @default false - */ - centerRipple: PropTypes.bool, /** * The content of the component. */ @@ -586,10 +439,6 @@ Button.propTypes /* remove-proptypes */ = { * Override or extend the styles applied to the component. */ classes: PropTypes.object, - /** - * @ignore - */ - className: PropTypes.string, /** * The color of the component. * It supports both default and custom theme colors, which can be added as shown in the @@ -600,16 +449,6 @@ Button.propTypes /* remove-proptypes */ = { PropTypes.oneOf(['primary', 'secondary', 'tertiary']), PropTypes.string, ]), - /** - * The component used for the root node. - * Either a string to use a HTML element or a component. - */ - component: elementTypeAcceptingRef, - /** - * If `true`, the component is disabled. - * @default false - */ - disabled: PropTypes.bool, /** * If `true`, no elevation is used. * @default false @@ -620,74 +459,15 @@ Button.propTypes /* remove-proptypes */ = { * @default false */ disableRipple: PropTypes.bool, - /** - * If `true`, the touch ripple effect is disabled. - * @default false - */ - disableTouchRipple: PropTypes.bool, /** * Element placed after the children. */ endIcon: PropTypes.node, - /** - * @ignore - */ - focusVisibleClassName: PropTypes.string, /** * If `true`, the button will take up the full width of its container. * @default false */ fullWidth: PropTypes.bool, - /** - * The URL to link to when the button is clicked. - * If defined, an `a` element will be used as the root node. - */ - href: PropTypes.string, - /** - * The component used to render a link when the `href` prop is provided. - * @default 'a' - */ - LinkComponent: PropTypes.elementType, - /** - * @ignore - */ - onBlur: PropTypes.func, - /** - * @ignore - */ - onContextMenu: PropTypes.func, - /** - * @ignore - */ - onDragLeave: PropTypes.func, - /** - * @ignore - */ - onFocusVisible: PropTypes.func, - /** - * @ignore - */ - onMouseDown: PropTypes.func, - /** - * @ignore - */ - onMouseLeave: PropTypes.func, - /** - * @ignore - */ - onMouseUp: PropTypes.func, - /** - * @ignore - */ - onTouchEnd: PropTypes.func, - /** - * @ignore - */ - onTouchMove: PropTypes.func, - /** - * @ignore - */ - onTouchStart: PropTypes.func, /** * The size of the component. * `small` is equivalent to the dense button styling. @@ -701,18 +481,6 @@ Button.propTypes /* remove-proptypes */ = { * Element placed before the children. */ startIcon: PropTypes.node, - /** - * @default 0 - */ - tabIndex: PropTypes.number, - /** - * Props applied to the `TouchRipple` element. - */ - TouchRippleProps: PropTypes.object, - /** - * @ignore - */ - type: PropTypes.oneOfType([PropTypes.oneOf(['button', 'reset', 'submit']), PropTypes.string]), /** * The variant to use. * @default 'text' diff --git a/packages/mui-material-next/src/Button/Button.types.ts b/packages/mui-material-next/src/Button/Button.types.ts index 4a712d0bc009da..ace489b6303c3e 100644 --- a/packages/mui-material-next/src/Button/Button.types.ts +++ b/packages/mui-material-next/src/Button/Button.types.ts @@ -5,7 +5,6 @@ import { OverridableComponent, OverridableTypeMap, } from '@mui/types'; -import { TouchRippleProps } from './TouchRipple.types'; import { SxProps } from '../styles/Theme.types'; import { ButtonClasses } from './buttonClasses'; @@ -24,36 +23,6 @@ export type ButtonTypeMap< DefaultComponent extends React.ElementType = 'button', > = { props: AdditionalProps & { - /** - * A ref for imperative actions. - * It currently only supports `focusVisible()` action. - */ - action?: React.Ref; - /** - * If `true`, the ripples are centered. - * They won't start at the cursor interaction position. - * @default false - */ - centerRipple?: boolean; - /** - * This prop can help identify which element has keyboard focus. - * The class name will be applied when the element gains the focus through keyboard interaction. - * It's a polyfill for the [CSS :focus-visible selector](https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo). - * The rationale for using this feature [is explained here](https://github.com/WICG/focus-visible/blob/HEAD/explainer.md). - * A [polyfill can be used](https://github.com/WICG/focus-visible) to apply a `focus-visible` class to other components - * if needed. - */ - focusVisibleClassName?: string; - /** - * The component used to render a link when the `href` prop is provided. - * @default 'a' - */ - LinkComponent?: React.ElementType; - /* - * Callback fired when the component is focused with a keyboard. - * We trigger a `onFocus` callback too. - */ - onFocusVisible?: React.FocusEventHandler; /** * The content of the component. */ @@ -69,11 +38,6 @@ export type ButtonTypeMap< * @default 'primary' */ color?: OverridableStringUnion<'primary' | 'secondary' | 'tertiary', ButtonPropsColorOverrides>; - /** - * If `true`, the component is disabled. - * @default false - */ - disabled?: boolean; /** * If `true`, no elevation is used. * @default false @@ -98,11 +62,6 @@ export type ButtonTypeMap< * @default false */ fullWidth?: boolean; - /** - * The URL to link to when the button is clicked. - * If defined, an `a` element will be used as the root node. - */ - href?: string; /** * The size of the component. * `small` is equivalent to the dense button styling. @@ -121,10 +80,6 @@ export type ButtonTypeMap< * @default 0 */ tabIndex?: NonNullable['tabIndex']>; - /** - * Props applied to the `TouchRipple` element. - */ - TouchRippleProps?: Partial; /** * The variant to use. * @default 'text' @@ -137,16 +92,7 @@ export type ButtonTypeMap< defaultComponent: DefaultComponent; }; -export interface ButtonOwnerState extends ButtonProps { - /** - * If `true`, the button's focus is visible. - */ - focusVisible?: boolean; - /** - * If `true`, the button is active. - */ - active?: boolean; -} +export interface ButtonOwnerState extends ButtonProps {} /** * A utility to create component types that inherit props from the Button. diff --git a/packages/mui-material-next/src/Button/buttonClasses.ts b/packages/mui-material-next/src/Button/buttonClasses.ts index 46aee0f25c386b..ac219fe771e49f 100644 --- a/packages/mui-material-next/src/Button/buttonClasses.ts +++ b/packages/mui-material-next/src/Button/buttonClasses.ts @@ -1,5 +1,7 @@ -import generateUtilityClass from '@mui/material/generateUtilityClass'; -import generateUtilityClasses from '@mui/material/generateUtilityClasses'; +import { + unstable_generateUtilityClasses as generateUtilityClasses, + unstable_generateUtilityClass as generateUtilityClass, +} from '@mui/utils'; export interface ButtonClasses { /** Styles applied to the root element. */ @@ -16,12 +18,6 @@ export interface ButtonClasses { elevated: string; /** Styles applied to the root element if `disableElevation={true}`. */ disableElevation: string; - /** State class applied to the ButtonBase root element if the button is keyboard focused. */ - focusVisible: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; - /** State class applied to the root element if the element is active. */ - active: string; /** Styles applied to the root element if `color="primary"`. */ colorPrimary: string; /** Styles applied to the root element if `color="secondary"`. */ @@ -65,9 +61,6 @@ const buttonClasses: ButtonClasses = generateUtilityClasses('MuiButton', [ 'colorSecondary', 'colorTertiary', 'disableElevation', - 'focusVisible', - 'disabled', - 'active', 'colorInherit', 'sizeSmall', 'sizeMedium', diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.js b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx similarity index 79% rename from packages/mui-material-next/src/ButtonBase/ButtonBase.test.js rename to packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index 29b8ab76204b34..90dd4cbd6c0f76 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.js +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -13,10 +13,13 @@ import { programmaticFocusTriggersFocusVisible, } from 'test/utils'; import PropTypes from 'prop-types'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import ButtonBase, { buttonBaseClasses as classes } from '@mui/material/ButtonBase'; +import { MuiCancellableEventHandler } from '@mui/base/utils/MuiCancellableEvent'; +import { CssVarsProvider, extendTheme } from '@mui/material-next/styles'; +import ButtonBase, { buttonBaseClasses as classes } from '@mui/material-next/ButtonBase'; +import { ButtonBaseActions } from './ButtonBase.types'; +import { TouchRippleActions } from './TouchRipple.types'; -describe('', () => { +describe.only('', () => { const { render } = createRenderer(); // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14156632/ @@ -33,7 +36,23 @@ describe('', () => { } }); + let originalMatchmedia: typeof window.matchMedia; + + beforeEach(() => { + originalMatchmedia = window.matchMedia; + window.matchMedia = () => + ({ + addListener: () => {}, + removeListener: () => {}, + } as unknown as MediaQueryList); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; + }); + describeConformance(, () => ({ + ThemeProvider: CssVarsProvider, + createTheme: extendTheme, classes, inheritComponent: 'button', render, @@ -91,11 +110,11 @@ describe('', () => { }); it('should use custom LinkComponent when provided in the theme', () => { - const CustomLink = React.forwardRef((props, ref) => { + const CustomLink = React.forwardRef((props, ref: React.ForwardedRef) => { // eslint-disable-next-line jsx-a11y/anchor-has-content return ; }); - const theme = createTheme({ + const theme = extendTheme({ components: { MuiButtonBase: { defaultProps: { @@ -106,9 +125,9 @@ describe('', () => { }); const { container, getByTestId } = render( - + Hello - , + , ); const button = container.firstChild; expect(getByTestId('customLink')).not.to.equal(null); @@ -125,13 +144,12 @@ describe('', () => { }); it('should not add role="button" if custom component and href are used', () => { - const CustomLink = React.forwardRef((props, ref) => { + const CustomLink = React.forwardRef((props, ref: React.ForwardedRef) => { // eslint-disable-next-line jsx-a11y/anchor-has-content return ; }); const { container } = render( - // @ts-expect-error missing types in CustomLink Hello , @@ -144,7 +162,7 @@ describe('', () => { }); it('should not add role="button" if custom component and to are used', () => { - const CustomLink = React.forwardRef((props, ref) => { + const CustomLink = React.forwardRef((props, ref: React.ForwardedRef) => { // @ts-expect-error missing types in CustomLink const { to, ...other } = props; // eslint-disable-next-line jsx-a11y/anchor-has-content @@ -253,7 +271,7 @@ describe('', () => { , @@ -263,7 +281,7 @@ describe('', () => { focusVisible(button); - expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(0); + expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(0); }); it('should start the ripple when the mouse is pressed', () => { @@ -464,84 +482,6 @@ describe('', () => { button.querySelectorAll('.ripple-visible .child:not(.child-leaving)'), ).to.have.lengthOf(0); }); - - it('should not crash when changes enableRipple from false to true', () => { - function App() { - /** @type {React.MutableRefObject} */ - const buttonRef = React.useRef(null); - const [enableRipple, setRipple] = React.useState(false); - - React.useEffect(() => { - if (buttonRef.current) { - buttonRef.current.focusVisible(); - } else { - throw new Error('buttonRef.current must be available'); - } - }, []); - - return ( -
- - - the button - -
- ); - } - - const { container, getByTestId } = render(); - - fireEvent.click(getByTestId('trigger')); - expect(container.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); - }); - - it('should stop the ripple on blur if disableTouchRipple is set', () => { - const buttonActions = React.createRef(); - - const { getByRole } = render( - , - ); - - const button = getByRole('button'); - - simulatePointerDevice(); - focusVisible(button); - - act(() => { - button.blur(); - }); - - expect(button.querySelectorAll('.ripple-visible .child-leaving')).to.have.lengthOf(1); - }); }); }); @@ -598,122 +538,10 @@ describe('', () => { }); }); - describe('focusRipple', () => { - it('should pulsate the ripple when focusVisible', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - - simulatePointerDevice(); - focusVisible(button); - - expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); - }); - - it('should not stop the ripple when the mouse leaves', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - - simulatePointerDevice(); - focusVisible(button); - fireEvent.mouseLeave(button); - - expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(1); - }); - - it('should stop pulsate and start a ripple when the space button is pressed', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - - simulatePointerDevice(); - focusVisible(button); - fireEvent.keyDown(button, { key: ' ' }); - - expect(button.querySelectorAll('.ripple-pulsate .child-leaving')).to.have.lengthOf(1); - expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(0); - }); - - it('should stop and re-pulsate when space bar is released', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - - simulatePointerDevice(); - focusVisible(button); - fireEvent.keyDown(button, { key: ' ' }); - fireEvent.keyUp(button, { key: ' ' }); - - expect(button.querySelectorAll('.ripple-pulsate .child-leaving')).to.have.lengthOf(1); - expect(button.querySelectorAll('.ripple-pulsate')).to.have.lengthOf(2); - expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(3); - }); - - it('should stop on blur and set focusVisible to false', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - simulatePointerDevice(); - focusVisible(button); - - act(() => { - button.blur(); - }); - - expect(button.querySelectorAll('.ripple-visible .child-leaving')).to.have.lengthOf(1); - }); - }); - describe('prop: disabled', () => { it('should have a negative tabIndex', () => { const { getByText } = render(Hello); - expect(getByText('Hello')).to.have.property('tabIndex', -1); + expect(getByText('Hello')).to.have.property('disabled'); }); it('should forward it to native buttons', () => { @@ -765,10 +593,7 @@ describe('', () => { describe('prop: component', () => { it('should allow to use a link component', () => { - /** - * @type {React.ForwardRefExoticComponent>} - */ - const Link = React.forwardRef((props, ref) => ( + const Link = React.forwardRef((props, ref: React.ForwardedRef) => (
)); const { getByTestId } = render(Hello); @@ -816,15 +641,9 @@ describe('', () => { }); it('removes focus-visible if focus is re-targetted', () => { - /** - * @type {string[]} - */ - const eventLog = []; + const eventLog: string[] = []; function Test() { - /** - * @type {React.Ref} - */ - const focusRetargetRef = React.useRef(null); + const focusRetargetRef = React.useRef(null); return (
{ @@ -885,29 +704,6 @@ describe('', () => { }); describe('event: keydown', () => { - it('ripples on repeated keydowns', () => { - const { container, getByText } = render( - - Hello - , - ); - - const button = getByText('Hello'); - - act(() => { - button.focus(); - fireEvent.keyDown(button, { key: 'Enter' }); - }); - - expect(container.querySelectorAll('.ripple-visible')).to.have.lengthOf(1); - - // technically the second keydown should be fire with repeat: true - // but that isn't implemented in IE11 so we shouldn't mock it here either - fireEvent.keyDown(button, { key: 'Enter' }); - - expect(container.querySelectorAll('.ripple-visible')).to.have.lengthOf(1); - }); - describe('prop: onKeyDown', () => { it('call it when keydown events are dispatched', () => { const onKeyDownSpy = spy(); @@ -1002,17 +798,11 @@ describe('', () => { it('does not call onClick when a spacebar is released and the default is prevented', () => { const onClickSpy = spy(); + const onKeyUp: MuiCancellableEventHandler = (event) => { + event.defaultMuiPrevented = true; + }; const { getByRole } = render( - event.preventDefault() - } - component="div" - > + Hello , ); @@ -1129,7 +919,7 @@ describe('', () => { /** * @type {React.RefObject} */ - const buttonActionsRef = React.createRef(); + const buttonActionsRef = React.createRef(); const { getByText } = render( Hello @@ -1160,17 +950,12 @@ describe('', () => { this.skip(); } - /** - * - * @param {import('react').HTMLAttributes} props - */ - function Component(props) { + function Component(props: React.HTMLAttributes) { return @@ -1210,7 +995,6 @@ describe('', () => { }); it('allows non-standard values', () => { - // @ts-expect-error `@types/react` only lists standard values render(); expect(screen.getByRole('button')).to.have.attribute('type', 'fictional-type'); @@ -1226,10 +1010,9 @@ describe('', () => { }); it('is forwarded to custom components', () => { - /** - * @type {React.ForwardRefExoticComponent>} - */ - const CustomButton = React.forwardRef((props, ref) => +``` + +### Prevent default on `key-up` and `key-down` events + +If you need to prevent default on a `key-up` and/or `key-down` event, then besides calling `preventDefault` you'll need to set `event.defaultMuiPrevented` to `true` as follows: + +```diff + const onKeyDown = (event) => { + event.preventDefault(); ++ event.defaultMuiPrevented = true; + }; + + const onKeyUp = (event) => { + event.preventDefault(); ++ event.defaultMuiPrevented = true; + }; + + +``` + ## Slider ### Thumb and Value Label slots must accept refs From 0e5abe25e0d89aad55167bfcf26f5d7be7f8a186 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 10 Aug 2023 16:36:01 -0400 Subject: [PATCH 08/15] Update migration --- packages/mui-material-next/migration.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/mui-material-next/migration.md b/packages/mui-material-next/migration.md index 208f215be5567b..e553891c6bb381 100644 --- a/packages/mui-material-next/migration.md +++ b/packages/mui-material-next/migration.md @@ -106,18 +106,6 @@ The breaking changes in this section apply to the following components: So the examples below are interchangeable for these components. -### Focus on disabled state - -When the root component is disabled, it no longer receives `tabIndex = -1`, which means it won't be focusable. -If you need your button to be focusable when disabled, you can use the `focusableWhenDisabled` prop: - -```diff -- -``` - ### Prevent default on `key-up` and `key-down` events If you need to prevent default on a `key-up` and/or `key-down` event, then besides calling `preventDefault` you'll need to set `event.defaultMuiPrevented` to `true` as follows: @@ -138,6 +126,8 @@ If you need to prevent default on a `key-up` and/or `key-down` event, then besid ``` +This is so the default is also prevented when the `ButtonBase` root is not a native button, for example, when the root element used is a `span`. + ## Slider ### Thumb and Value Label slots must accept refs From 2d0f593a932a524b3163444011ec3f8d7a5c5031 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Mon, 14 Aug 2023 15:06:11 -0400 Subject: [PATCH 09/15] Remove focusRipple prop --- packages/mui-material-next/migration.md | 4 ++ .../src/ButtonBase/ButtonBase.test.tsx | 41 ++++++++++++++++--- .../src/ButtonBase/ButtonBase.tsx | 9 +--- .../src/ButtonBase/ButtonBase.types.ts | 5 --- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/mui-material-next/migration.md b/packages/mui-material-next/migration.md index e553891c6bb381..2061908f298510 100644 --- a/packages/mui-material-next/migration.md +++ b/packages/mui-material-next/migration.md @@ -106,6 +106,10 @@ The breaking changes in this section apply to the following components: So the examples below are interchangeable for these components. +### Removed focusRipple + +The `focusRipple` prop was removed as ripples are absent in Material You's focused states. + ### Prevent default on `key-up` and `key-down` events If you need to prevent default on a `key-up` and/or `key-down` event, then besides calling `preventDefault` you'll need to set `event.defaultMuiPrevented` to `true` as follows: diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index d88c2a4993bb02..2a560aa4ca66f5 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -758,14 +758,37 @@ describe('', () => { }); describe('prop: disableRipple', () => { - it('removes the TouchRipple', () => { - const { getByText } = render( - - Hello + const touchRippleTestId = 'touch-ripple-test-id'; + + it('is enabled by default', () => { + const { getAllByTestId } = render( + + Hello World + , + ); + + expect(getAllByTestId(touchRippleTestId).length).to.equal(1); + }); + + it('removes the TouchRipple when disableRipple is true', () => { + const { queryByTestId } = render( + + Hello World , ); - expect(getByText('Hello').querySelector('.touch-ripple')).to.equal(null); + expect(queryByTestId(touchRippleTestId)).to.equal(null); }); }); @@ -1067,4 +1090,12 @@ describe('', () => { expect(button).to.have.class(classes.active); }); }); + + describe('prop: tabIndex', () => { + it('should apply user value of tabIndex', () => { + const { getByRole } = render(); + + expect(getByRole('button')).to.have.property('tabIndex', 5); + }); + }); }); diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.tsx index 391db6e9bab584..de46813447ad93 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.tsx @@ -101,7 +101,6 @@ const ButtonBase = React.forwardRef(function ButtonBase< disabled = false, disableRipple = false, disableTouchRipple = false, - focusRipple = false, focusVisibleClassName, focusableWhenDisabled = false, LinkComponent = 'a', @@ -155,8 +154,6 @@ const ButtonBase = React.forwardRef(function ButtonBase< disabled, disableRipple, disableTouchRipple, - focusRipple, - tabIndex, focusVisible, active, }; @@ -166,8 +163,6 @@ const ButtonBase = React.forwardRef(function ButtonBase< const rootProps = useSlotProps({ elementType: ButtonBaseRoot, getSlotProps: (otherHandlers: EventHandlers) => ({ - // tabIndex should be handled by useButton after https://github.com/mui/material-ui/issues/38368 is fixed - tabIndex, ...getRootProps({ ...otherHandlers, ...getRippleHandlers(props), @@ -257,10 +252,10 @@ ButtonBase.propTypes /* remove-proptypes */ = { */ disableTouchRipple: PropTypes.bool, /** - * If `true`, the base button will have a keyboard focus ripple. + * If `true`, allows a disabled button to receive focus. * @default false */ - focusRipple: PropTypes.bool, + focusableWhenDisabled: PropTypes.bool, /** * This prop can help identify which element has keyboard focus. * The class name will be applied when the element gains the focus through keyboard interaction. diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.types.ts b/packages/mui-material-next/src/ButtonBase/ButtonBase.types.ts index d766fbc336ff9d..1ed0777f27ecff 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.types.ts +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.types.ts @@ -52,11 +52,6 @@ export interface ButtonBaseTypeMap< * @default false */ focusableWhenDisabled?: boolean; - /** - * If `true`, the base button will have a keyboard focus ripple. - * @default false - */ - focusRipple?: boolean; /** * This prop can help identify which element has keyboard focus. * The class name will be applied when the element gains the focus through keyboard interaction. From 2011a92574d4d49053835aeef700450a73cbd675 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Mon, 14 Aug 2023 15:27:42 -0400 Subject: [PATCH 10/15] Remove duplicated button disabled style --- packages/mui-material-next/src/Button/Button.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/mui-material-next/src/Button/Button.tsx b/packages/mui-material-next/src/Button/Button.tsx index c54f122b5dba80..65888394fa55aa 100644 --- a/packages/mui-material-next/src/Button/Button.tsx +++ b/packages/mui-material-next/src/Button/Button.tsx @@ -321,8 +321,6 @@ export const ButtonRoot = styled(ButtonBase, { [`&.${buttonBaseClasses.disabled}`]: { // Allows developer to specify the disabled icon color var '--md-comp-button-icon-color': 'var(--md-comp-button-disabled-icon-color)', - pointerEvents: 'none', // Disable link interactions - cursor: 'default', color: disabledLabelTextColor, backgroundColor: disabledContainerColor[ownerState.variant ?? 'text'], boxShadow: tokens.sys.elevation[0], From 361ada90222c9fd3f589ab0d8a9faf3ddb342d57 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Wed, 16 Aug 2023 15:35:38 -0400 Subject: [PATCH 11/15] Improve test name Co-authored-by: Albert Yu Signed-off-by: Diego Andai --- packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index 2a560aa4ca66f5..28fed4a11048fb 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -553,7 +553,7 @@ describe('', () => { expect(getByText('Hello')).to.have.property('disabled', true); }); - it('should reset the focused state', () => { + it('should reset the focused state upon being disabled', () => { const { getByText, setProps } = render(Hello); const button = getByText('Hello'); simulatePointerDevice(); From 3f288ac6637e899316ec37a42436505cf7efa858 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Wed, 16 Aug 2023 16:03:23 -0400 Subject: [PATCH 12/15] Use userEvent in focus tests --- .../src/ButtonBase/ButtonBase.test.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index 28fed4a11048fb..133032c548f89d 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy, stub } from 'sinon'; +import userEvent from '@testing-library/user-event'; import { describeConformance, act, @@ -19,6 +20,10 @@ import ButtonBase, { buttonBaseClasses as classes } from '@mui/material-next/But import { ButtonBaseActions } from './ButtonBase.types'; import { TouchRippleActions } from './TouchRipple.types'; +// TODO v6: initialize @testing-library/user-event using userEvent.setup() instead of directly calling methods e.g. userEvent.click() for all related tests in this file +// currently the setup() method uses the ClipboardEvent constructor which is incompatible with our lowest supported version of iOS Safari (12.2) https://github.com/mui/material-ui/blob/master/.browserslistrc#L44 +// userEvent.setup() requires Safari 14 or up to work + describe('', () => { const { render } = createRenderer(); @@ -619,17 +624,41 @@ describe('', () => { }); describe('event: focus', () => { - it('when disabled should be called onFocus', () => { + it('should call onFocus', async () => { const onFocusSpy = spy(); - const { getByRole } = render( + render( + + Hello + , + ); + + await userEvent.keyboard('[Tab]'); + + expect(onFocusSpy.callCount).to.equal(1); + }); + + it('when disabled should not call onFocus', async () => { + const onFocusSpy = spy(); + render( Hello , ); - act(() => { - getByRole('button').focus(); - }); + await userEvent.keyboard('[Tab]'); + + expect(onFocusSpy.callCount).to.equal(0); + }); + + it('when disabled and focusableWhenDisabled should call onFocus', async () => { + const onFocusSpy = spy(); + render( + + Hello + , + ); + + await userEvent.keyboard('[Tab]'); expect(onFocusSpy.callCount).to.equal(1); }); From 7867d5f459d141df6396db14dd7c42a05fdf00c3 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Wed, 16 Aug 2023 16:10:27 -0400 Subject: [PATCH 13/15] Remove duplicated test --- .../src/ButtonBase/ButtonBase.test.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index 133032c548f89d..51713dee47e3a3 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -334,30 +334,6 @@ describe('', () => { ).to.have.lengthOf(0); }); - it('should start the ripple when the mouse is pressed 2', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - fireEvent.mouseDown(button); - fireEvent.mouseUp(button); - - fireEvent.mouseDown(button); - - expect(button.querySelectorAll('.ripple-visible .child-leaving')).to.have.lengthOf(1); - expect( - button.querySelectorAll('.ripple-visible .child:not(.child-leaving)'), - ).to.have.lengthOf(1); - }); - it('should stop the ripple when the button blurs', () => { const { getByRole } = render( Date: Thu, 17 Aug 2023 09:03:33 -0400 Subject: [PATCH 14/15] Improve migration copy Co-authored-by: Albert Yu Signed-off-by: Diego Andai --- packages/mui-material-next/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-material-next/migration.md b/packages/mui-material-next/migration.md index 2061908f298510..7a2ba5d4cef21a 100644 --- a/packages/mui-material-next/migration.md +++ b/packages/mui-material-next/migration.md @@ -130,7 +130,7 @@ If you need to prevent default on a `key-up` and/or `key-down` event, then besid ``` -This is so the default is also prevented when the `ButtonBase` root is not a native button, for example, when the root element used is a `span`. +This is to ensure that default is prevented when the `ButtonBase` root is not a native button, for example, when the root element used is a `span`. ## Slider From ed2632d1aa95ee66f9d0abf15851744f964b8ebb Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Mon, 21 Aug 2023 16:13:16 -0400 Subject: [PATCH 15/15] Remove focus ripple test --- .../src/ButtonBase/ButtonBase.test.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx index 51713dee47e3a3..ab5d5da6070aba 100644 --- a/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx +++ b/packages/mui-material-next/src/ButtonBase/ButtonBase.test.tsx @@ -271,24 +271,6 @@ describe('', () => { describe('ripple', () => { describe('interactions', () => { - it('should not have a focus ripple by default', () => { - const { getByRole } = render( - , - ); - const button = getByRole('button'); - simulatePointerDevice(); - - focusVisible(button); - - expect(button.querySelectorAll('.ripple-visible')).to.have.lengthOf(0); - }); - it('should start the ripple when the mouse is pressed', () => { const { getByRole } = render(