From 5f30983bfa16195237fde55a78d5e43b151a29fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 30 Aug 2021 13:11:59 +0200 Subject: [PATCH] [Button] Create ButtonUnstyled and useButton (#27600) --- docs/pages/api-docs/button-unstyled.js | 23 ++ docs/pages/api-docs/button-unstyled.json | 25 ++ .../buttons/UnstyledButtonCustom.js | 108 ++++++++ .../buttons/UnstyledButtonCustom.tsx | 110 +++++++++ .../buttons/UnstyledButtonsSimple.js | 52 ++++ .../buttons/UnstyledButtonsSimple.tsx | 53 ++++ .../components/buttons/UnstyledButtonsSpan.js | 52 ++++ .../buttons/UnstyledButtonsSpan.tsx | 53 ++++ .../src/pages/components/buttons/UseButton.js | 73 ++++++ .../pages/components/buttons/UseButton.tsx | 74 ++++++ docs/src/pages/components/buttons/buttons.md | 45 +++- docs/src/pagesApi.js | 1 + .../button-unstyled/button-unstyled-de.json | 7 + .../button-unstyled/button-unstyled-es.json | 7 + .../button-unstyled/button-unstyled-fr.json | 7 + .../button-unstyled/button-unstyled-ja.json | 7 + .../button-unstyled/button-unstyled-pt.json | 7 + .../button-unstyled/button-unstyled-ru.json | 7 + .../button-unstyled/button-unstyled-zh.json | 7 + .../button-unstyled/button-unstyled.json | 10 + .../ButtonUnstyled/ButtonUnstyled.test.tsx | 22 ++ .../src/ButtonUnstyled/ButtonUnstyled.tsx | 163 ++++++++++++ .../src/ButtonUnstyled/ButtonUnstyledProps.ts | 37 +++ .../src/ButtonUnstyled/UseButtonProps.ts | 35 +++ .../ButtonUnstyled/buttonUnstyledClasses.ts | 24 ++ .../src/ButtonUnstyled/index.ts | 9 + .../src/ButtonUnstyled/useButton.test.tsx | 134 ++++++++++ .../src/ButtonUnstyled/useButton.ts | 203 +++++++++++++++ packages/material-ui-unstyled/src/index.d.ts | 3 + packages/material-ui-unstyled/src/index.js | 3 + .../src/utils/extractEventHandlers.test.ts | 32 +++ .../src/utils/extractEventHandlers.ts | 21 ++ .../material-ui-unstyled/src/utils/index.ts | 4 +- .../material-ui/src/ButtonBase/ButtonBase.js | 231 ++---------------- .../src/ButtonBase/ButtonBase.test.js | 4 +- test/utils/describeConformanceUnstyled.tsx | 17 +- 36 files changed, 1450 insertions(+), 220 deletions(-) create mode 100644 docs/pages/api-docs/button-unstyled.js create mode 100644 docs/pages/api-docs/button-unstyled.json create mode 100644 docs/src/pages/components/buttons/UnstyledButtonCustom.js create mode 100644 docs/src/pages/components/buttons/UnstyledButtonCustom.tsx create mode 100644 docs/src/pages/components/buttons/UnstyledButtonsSimple.js create mode 100644 docs/src/pages/components/buttons/UnstyledButtonsSimple.tsx create mode 100644 docs/src/pages/components/buttons/UnstyledButtonsSpan.js create mode 100644 docs/src/pages/components/buttons/UnstyledButtonsSpan.tsx create mode 100644 docs/src/pages/components/buttons/UseButton.js create mode 100644 docs/src/pages/components/buttons/UseButton.tsx create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-de.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-es.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-fr.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-ja.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-pt.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-ru.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled-zh.json create mode 100644 docs/translations/api-docs/button-unstyled/button-unstyled.json create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.test.tsx create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.tsx create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyledProps.ts create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/index.ts create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx create mode 100644 packages/material-ui-unstyled/src/ButtonUnstyled/useButton.ts create mode 100644 packages/material-ui-unstyled/src/utils/extractEventHandlers.test.ts create mode 100644 packages/material-ui-unstyled/src/utils/extractEventHandlers.ts diff --git a/docs/pages/api-docs/button-unstyled.js b/docs/pages/api-docs/button-unstyled.js new file mode 100644 index 00000000000000..605eb23e3441eb --- /dev/null +++ b/docs/pages/api-docs/button-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './button-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/button-unstyled', + false, + /button-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/button-unstyled.json b/docs/pages/api-docs/button-unstyled.json new file mode 100644 index 00000000000000..308cfeb4288305 --- /dev/null +++ b/docs/pages/api-docs/button-unstyled.json @@ -0,0 +1,25 @@ +{ + "props": { + "action": { + "type": { + "name": "union", + "description": "func
| { current?: { focusVisible: func } }" + } + }, + "component": { "type": { "name": "elementType" }, "default": "'button'" }, + "components": { + "type": { "name": "shape", "description": "{ Root?: elementType }" }, + "default": "{}" + }, + "disabled": { "type": { "name": "bool" } } + }, + "name": "ButtonUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.tsx", + "inheritance": null, + "demos": "", + "styledComponent": true, + "cssComponent": false +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonCustom.js b/docs/src/pages/components/buttons/UnstyledButtonCustom.js new file mode 100644 index 00000000000000..7b2ed9fb3c6323 --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonCustom.js @@ -0,0 +1,108 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import ButtonUnstyled, { + buttonUnstyledClasses, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { ThemeProvider, createTheme } from '@material-ui/core/styles'; +import { styled, alpha } from '@material-ui/system'; + +const ButtonRoot = React.forwardRef(function ButtonRoot(props, ref) { + const { children, ...other } = props; + + return ( + + + + +
{children}
+
+
+ ); +}); + +ButtonRoot.propTypes = { + children: PropTypes.node, +}; + +const CustomButtonRoot = styled(ButtonRoot)( + ({ theme }) => ` + overflow: visible; + cursor: pointer; + + & polygon { + fill: transparent; + transition: all 800ms ease; + pointer-events: none; + } + + & .bg { + stroke: ${theme.palette.primary.main}; + stroke-width: 0.5; + filter: drop-shadow(0 4px 20px rgba(0, 0, 0, 0.1)); + } + + & .borderEffect { + stroke: ${theme.palette.primary.main}; + stroke-width: 2; + stroke-dasharray: 150 600; + stroke-dashoffset: 150; + } + + &:hover, + &.${buttonUnstyledClasses.focusVisible} { + .borderEffect { + stroke-dashoffset: -600; + } + + .bg { + fill: ${alpha(theme.palette.primary.main, theme.palette.action.hoverOpacity)}; + } + } + + &:focus, + &.${buttonUnstyledClasses.focusVisible} { + outline: none; + } + + &.${buttonUnstyledClasses.active} { + & .bg { + fill: ${alpha( + theme.palette.primary.main, + theme.palette.action.activatedOpacity, + )}; + transition: fill 300ms ease-out; + } + } + + & foreignObject { + pointer-events: none; + + & .content { + font-family: Helvetica, Inter, Arial, sans-serif; + font-size: 14px; + font-weight: 200; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: ${theme.palette.primary.main}; + text-transform: uppercase; + } + + & svg { + margin: 0 5px; + } + }`, +); + +const SvgButton = React.forwardRef(function SvgButton(props, ref) { + return ; +}); + +export default function UnstyledButtonCustom() { + return ( + + Button + + ); +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonCustom.tsx b/docs/src/pages/components/buttons/UnstyledButtonCustom.tsx new file mode 100644 index 00000000000000..27ad7605c17bc2 --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonCustom.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import ButtonUnstyled, { + ButtonUnstyledProps, + buttonUnstyledClasses, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { Theme, ThemeProvider, createTheme } from '@material-ui/core/styles'; +import { styled, alpha } from '@material-ui/system'; + +const ButtonRoot = React.forwardRef(function ButtonRoot( + props: React.PropsWithChildren<{}>, + ref: React.ForwardedRef, +) { + const { children, ...other } = props; + + return ( + + + + +
{children}
+
+
+ ); +}); + +const CustomButtonRoot = styled(ButtonRoot)( + ({ theme }: { theme: Theme }) => ` + overflow: visible; + cursor: pointer; + + & polygon { + fill: transparent; + transition: all 800ms ease; + pointer-events: none; + } + + & .bg { + stroke: ${theme.palette.primary.main}; + stroke-width: 0.5; + filter: drop-shadow(0 4px 20px rgba(0, 0, 0, 0.1)); + } + + & .borderEffect { + stroke: ${theme.palette.primary.main}; + stroke-width: 2; + stroke-dasharray: 150 600; + stroke-dashoffset: 150; + } + + &:hover, + &.${buttonUnstyledClasses.focusVisible} { + .borderEffect { + stroke-dashoffset: -600; + } + + .bg { + fill: ${alpha(theme.palette.primary.main, theme.palette.action.hoverOpacity)}; + } + } + + &:focus, + &.${buttonUnstyledClasses.focusVisible} { + outline: none; + } + + &.${buttonUnstyledClasses.active} { + & .bg { + fill: ${alpha( + theme.palette.primary.main, + theme.palette.action.activatedOpacity, + )}; + transition: fill 300ms ease-out; + } + } + + & foreignObject { + pointer-events: none; + + & .content { + font-family: Helvetica, Inter, Arial, sans-serif; + font-size: 14px; + font-weight: 200; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: ${theme.palette.primary.main}; + text-transform: uppercase; + } + + & svg { + margin: 0 5px; + } + }`, +); + +const SvgButton = React.forwardRef(function SvgButton( + props: ButtonUnstyledProps, + ref: React.ForwardedRef, +) { + return ; +}); + +export default function UnstyledButtonCustom() { + return ( + + Button + + ); +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonsSimple.js b/docs/src/pages/components/buttons/UnstyledButtonsSimple.js new file mode 100644 index 00000000000000..1ec111b8f335f5 --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonsSimple.js @@ -0,0 +1,52 @@ +import * as React from 'react'; +import Stack from '@material-ui/core/Stack'; +import ButtonUnstyled, { + buttonUnstyledClasses, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('button')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.${buttonUnstyledClasses.active} { + background-color: #004386; + } + + &.${buttonUnstyledClasses.focusVisible} { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.${buttonUnstyledClasses.disabled} { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +function CustomButton(props) { + return ; +} + +export default function UnstyledButton() { + return ( + + Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonsSimple.tsx b/docs/src/pages/components/buttons/UnstyledButtonsSimple.tsx new file mode 100644 index 00000000000000..c9ad71bc8325ea --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonsSimple.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import Stack from '@material-ui/core/Stack'; +import ButtonUnstyled, { + buttonUnstyledClasses, + ButtonUnstyledProps, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('button')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.${buttonUnstyledClasses.active} { + background-color: #004386; + } + + &.${buttonUnstyledClasses.focusVisible} { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.${buttonUnstyledClasses.disabled} { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +function CustomButton(props: ButtonUnstyledProps) { + return ; +} + +export default function UnstyledButton() { + return ( + + Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonsSpan.js b/docs/src/pages/components/buttons/UnstyledButtonsSpan.js new file mode 100644 index 00000000000000..57ec7316e393de --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonsSpan.js @@ -0,0 +1,52 @@ +import * as React from 'react'; +import Stack from '@material-ui/core/Stack'; +import ButtonUnstyled, { + buttonUnstyledClasses, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('span')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.${buttonUnstyledClasses.active} { + background-color: #004386; + } + + &.${buttonUnstyledClasses.focusVisible} { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.${buttonUnstyledClasses.disabled} { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +function CustomButton(props) { + return ; +} + +export default function UnstyledButton() { + return ( + + Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/UnstyledButtonsSpan.tsx b/docs/src/pages/components/buttons/UnstyledButtonsSpan.tsx new file mode 100644 index 00000000000000..ed73fcaa9eb1ba --- /dev/null +++ b/docs/src/pages/components/buttons/UnstyledButtonsSpan.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import Stack from '@material-ui/core/Stack'; +import ButtonUnstyled, { + ButtonUnstyledProps, + buttonUnstyledClasses, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('span')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.${buttonUnstyledClasses.active} { + background-color: #004386; + } + + &.${buttonUnstyledClasses.focusVisible} { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.${buttonUnstyledClasses.disabled} { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +function CustomButton(props: ButtonUnstyledProps) { + return ; +} + +export default function UnstyledButton() { + return ( + + Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/UseButton.js b/docs/src/pages/components/buttons/UseButton.js new file mode 100644 index 00000000000000..3bc11b9d64dde5 --- /dev/null +++ b/docs/src/pages/components/buttons/UseButton.js @@ -0,0 +1,73 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import Stack from '@material-ui/core/Stack'; +import { useButton } from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('button')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.active { + background-color: #004386; + } + + &.focusVisible { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +const CustomButton = React.forwardRef(function CustomButton(props, ref) { + const { children } = props; + const { active, disabled, focusVisible, getRootProps } = useButton({ + ...props, + ref, + component: CustomButtonRoot, + }); + + const classes = { + active, + disabled, + focusVisible, + }; + + return ( + + {children} + + ); +}); + +CustomButton.propTypes = { + children: PropTypes.node, +}; + +export default function UseButton() { + return ( + + console.log('click!')}>Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/UseButton.tsx b/docs/src/pages/components/buttons/UseButton.tsx new file mode 100644 index 00000000000000..27eec35e71f0d7 --- /dev/null +++ b/docs/src/pages/components/buttons/UseButton.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Stack from '@material-ui/core/Stack'; +import { + ButtonUnstyledProps, + useButton, +} from '@material-ui/unstyled/ButtonUnstyled'; +import { styled } from '@material-ui/system'; + +const CustomButtonRoot = styled('button')(` + background-color: #007fff; + padding: 15px 20px; + border-radius: 10px; + color: #fff; + font-weight: 600; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + transition: all 200ms ease; + cursor: pointer; + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 0 rgba(0, 127, 255, 0); + border: none; + + &:hover { + background-color: #0059b2; + } + + &.active { + background-color: #004386; + } + + &.focusVisible { + box-shadow: 0 4px 20px 0 rgba(61, 71, 82, 0.1), 0 0 0 5px rgba(0, 127, 255, 0.5); + outline: none; + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: 0 0 0 0 rgba(0, 127, 255, 0); + } +`); + +const CustomButton = React.forwardRef(function CustomButton( + props: ButtonUnstyledProps, + ref: React.ForwardedRef, +) { + const { children } = props; + const { active, disabled, focusVisible, getRootProps } = useButton({ + ...props, + ref, + component: CustomButtonRoot, + }); + + const classes = { + active, + disabled, + focusVisible, + }; + + return ( + + {children} + + ); +}); + +export default function UseButton() { + return ( + + console.log('click!')}>Button + Disabled + + ); +} diff --git a/docs/src/pages/components/buttons/buttons.md b/docs/src/pages/components/buttons/buttons.md index aa7064fc39f5d2..6791114fb76470 100644 --- a/docs/src/pages/components/buttons/buttons.md +++ b/docs/src/pages/components/buttons/buttons.md @@ -1,6 +1,6 @@ --- title: React Button component -components: Button, IconButton, ButtonBase, LoadingButton +components: Button, IconButton, ButtonBase, LoadingButton, ButtonUnstyled materialDesign: https://material.io/components/buttons githubLabel: 'component: Button' waiAria: https://www.w3.org/TR/wai-aria-practices/#button @@ -179,3 +179,46 @@ However: ``` This has the advantage of supporting any element, for instance, a link `` element. + +## Unstyled button + +The button also comes with an unstyled version. It's ideal for doing heavy customizations and minimizing bundle size. + +### Unstyled component + +```js +import ButtonUnstyled from '@material-ui/unstyled/ButtonUnstyled'; +``` + +{{"demo": "pages/components/buttons/UnstyledButtonsSimple.js"}} + +#### Customizing the root element + +By default, the `ButtonUnstyled` renders a native `button` element. +You are free to override this by setting the `component` or `components.Root` prop. +If a non-interactive element (such as a span) is provided this way, the `ButtonUnstyled` will take care of adding accessibility attributes. + +{{"demo": "pages/components/buttons/UnstyledButtonsSpan.js"}} + +Compare the attributes on the span with the button from the previous demo + +#### Complex customization + +You are not limited to using HTML elements for the button structure. +SVG elements, even with complex structure, are equally acceptable. + +{{"demo": "pages/components/buttons/UnstyledButtonCustom.js"}} + +### useButton hook + +```js +import { useButton } from '@material-ui/unstyled/ButtonUnstyled'; +``` + +If you need to use Button's functionality in another component, you can use the `useButton` hook. +It returns props to be placed on a custom button element and fields representing the internal state of the button. + +The `useButton` hook requires the ref of the element it'll be used on. +Additionally, you need to provide the `component` prop (unless you intend to use the plain `button`). + +{{"demo": "pages/components/buttons/UseButton.js"}} diff --git a/docs/src/pagesApi.js b/docs/src/pagesApi.js index 8f4cd760d371c2..aee6f9c8c6ad2d 100644 --- a/docs/src/pagesApi.js +++ b/docs/src/pagesApi.js @@ -19,6 +19,7 @@ module.exports = [ { pathname: '/api-docs/button' }, { pathname: '/api-docs/button-base' }, { pathname: '/api-docs/button-group' }, + { pathname: '/api-docs/button-unstyled' }, { pathname: '/api-docs/calendar-picker' }, { pathname: '/api-docs/calendar-picker-skeleton' }, { pathname: '/api-docs/card' }, diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-de.json b/docs/translations/api-docs/button-unstyled/button-unstyled-de.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-de.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-es.json b/docs/translations/api-docs/button-unstyled/button-unstyled-es.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-es.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-fr.json b/docs/translations/api-docs/button-unstyled/button-unstyled-fr.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-fr.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-ja.json b/docs/translations/api-docs/button-unstyled/button-unstyled-ja.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-ja.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-pt.json b/docs/translations/api-docs/button-unstyled/button-unstyled-pt.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-pt.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-ru.json b/docs/translations/api-docs/button-unstyled/button-unstyled-ru.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-ru.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled-zh.json b/docs/translations/api-docs/button-unstyled/button-unstyled-zh.json new file mode 100644 index 00000000000000..e18c23e66efd05 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled-zh.json @@ -0,0 +1,7 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/button-unstyled/button-unstyled.json b/docs/translations/api-docs/button-unstyled/button-unstyled.json new file mode 100644 index 00000000000000..fedcbc2a906ad9 --- /dev/null +++ b/docs/translations/api-docs/button-unstyled/button-unstyled.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "The foundation for building custom-styled buttons.", + "propDescriptions": { + "action": "A ref for imperative actions. It currently only supports focusVisible() action.", + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the Button. Either a string to use a HTML element or a component.", + "disabled": "If true, the component is disabled." + }, + "classDescriptions": {} +} diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.test.tsx b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.test.tsx new file mode 100644 index 00000000000000..ffcbbe7d824d9d --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.test.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { createMount, createClientRender, describeConformanceUnstyled } from 'test/utils'; +import ButtonUnstyled, { buttonUnstyledClasses } from '@material-ui/unstyled/ButtonUnstyled'; + +describe('', () => { + const mount = createMount(); + const render = createClientRender(); + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'button', + render, + mount, + refInstanceof: window.HTMLButtonElement, + testComponentPropWith: 'span', + muiName: 'MuiButton', + slots: { + root: { + expectedClassName: buttonUnstyledClasses.root, + }, + }, + })); +}); diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.tsx b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.tsx new file mode 100644 index 00000000000000..2022742f376033 --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyled.tsx @@ -0,0 +1,163 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { unstable_useForkRef as useForkRef } from '@material-ui/utils'; +import composeClasses from '../composeClasses'; +import { getButtonUnstyledUtilityClass } from './buttonUnstyledClasses'; +import ButtonUnstyledProps, { + ButtonUnstyledOwnProps, + ButtonUnstyledTypeMap, +} from './ButtonUnstyledProps'; +import useButton from './useButton'; +import appendOwnerState from '../utils/appendOwnerState'; + +export interface ButtonUnstyledOwnerState extends ButtonUnstyledOwnProps { + focusVisible: boolean; + active: boolean; +} + +const useUtilityClasses = (ownerState: ButtonUnstyledOwnerState) => { + const { active, disabled, focusVisible } = ownerState; + + const slots = { + root: ['root', disabled && 'disabled', focusVisible && 'focusVisible', active && 'active'], + }; + + return composeClasses(slots, getButtonUnstyledUtilityClass, {}); +}; +/** + * The foundation for building custom-styled buttons. + * + * Demos: + * + * - [Buttons](https://material-ui.com/components/buttons/) + * + * API: + * + * - [ButtonUnstyled API](https://material-ui.com/api/button-unstyled/) + */ +const ButtonUnstyled = React.forwardRef(function ButtonUnstyled< + D extends React.ElementType = ButtonUnstyledTypeMap['defaultComponent'], +>(props: ButtonUnstyledProps, ref: React.ForwardedRef) { + const { + className, + component, + components = {}, + componentsProps = {}, + children, + disabled, + action, + onBlur, + onClick, + onFocus, + onFocusVisible, + onKeyDown, + onKeyUp, + onMouseLeave, + ...other + } = props; + + const buttonRef = React.useRef(); + const handleRef = useForkRef(buttonRef, ref); + + const { active, focusVisible, setFocusVisible, getRootProps } = useButton({ + ...props, + ref: handleRef, + }); + + React.useImperativeHandle( + action, + () => ({ + focusVisible: () => { + setFocusVisible(true); + buttonRef?.current?.focus(); + }, + }), + [setFocusVisible], + ); + + const ownerState = { + ...props, + active, + focusVisible, + }; + + const ButtonRoot: React.ElementType = component ?? components.Root ?? 'button'; + const buttonRootProps = appendOwnerState( + ButtonRoot, + { ...other, ...componentsProps.root }, + ownerState, + ); + + const classes = useUtilityClasses(ownerState); + + return ( + + {children} + + ); +}); + +ButtonUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | 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: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + current: PropTypes.shape({ + focusVisible: PropTypes.func.isRequired, + }), + }), + ]), + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to `components.Root`. If both are provided, the `component` is used. + * @default 'button' + */ + component: PropTypes.elementType, + /** + * The components used for each slot inside the Button. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Root: PropTypes.elementType, + }), + /** + * @ignore + */ + componentsProps: PropTypes.object, + /** + * If `true`, the component is disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * @ignore + */ + onClick: PropTypes.func, + /** + * @ignore + */ + onFocusVisible: PropTypes.func, +} as any; + +export default ButtonUnstyled; diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyledProps.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyledProps.ts new file mode 100644 index 00000000000000..ab2aef51f507f6 --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/ButtonUnstyledProps.ts @@ -0,0 +1,37 @@ +import { OverrideProps } from '@material-ui/types'; +import UseButtonProps from './UseButtonProps'; + +export interface ButtonUnstyledActions { + focusVisible(): void; +} + +export interface ButtonUnstyledOwnProps extends Omit { + /** + * A ref for imperative actions. It currently only supports `focusVisible()` action. + */ + action?: React.Ref; + children?: React.ReactNode; + className?: string; + componentsProps?: { + root?: Record; + }; +} + +type ButtonUnstyledProps< + D extends React.ElementType = ButtonUnstyledTypeMap['defaultComponent'], + P = {}, +> = OverrideProps, D> & { + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to `components.Root`. If both are provided, the `component` is used. + */ + component?: D; +}; + +export interface ButtonUnstyledTypeMap

{ + props: P & ButtonUnstyledOwnProps; + defaultComponent: D; +} + +export default ButtonUnstyledProps; diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts new file mode 100644 index 00000000000000..a9c7457c472217 --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; + +export default interface UseButtonProps { + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to `components.Root`. If both are provided, the `component` is used. + * @default 'button' + */ + component?: React.ElementType; + /** + * The components used for each slot inside the Button. + * Either a string to use a HTML element or a component. + * @default {} + */ + components?: { + Root?: React.ElementType; + }; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + href?: string; + onClick?: React.MouseEventHandler; + onFocusVisible?: React.FocusEventHandler; + ref: React.Ref; + tabIndex?: NonNullable['tabIndex']>; + to?: string; + /** + * Type attribute applied when the `component` is `button`. + * @default 'button' + */ + type?: React.ButtonHTMLAttributes['type']; +} diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts new file mode 100644 index 00000000000000..bc4ca040ecbde0 --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts @@ -0,0 +1,24 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface ButtonUnstyledClasses { + root: string; + active: string; + disabled: string; + focusVisible: string; +} + +export type ButtonUnstyledClassKey = keyof ButtonUnstyledClasses; + +export function getButtonUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('ButtonUnstyled', slot); +} + +const buttonUnstyledClasses: ButtonUnstyledClasses = generateUtilityClasses('ButtonUnstyled', [ + 'root', + 'active', + 'disabled', + 'focusVisible', +]); + +export default buttonUnstyledClasses; diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts new file mode 100644 index 00000000000000..0d652d5917c80d --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts @@ -0,0 +1,9 @@ +export { default } from './ButtonUnstyled'; +export { + default as buttonUnstyledClasses, + getButtonUnstyledUtilityClass, +} from './buttonUnstyledClasses'; +export type { default as ButtonUnstyledProps } from './ButtonUnstyledProps'; +export * from './ButtonUnstyledProps'; +export { default as useButton } from './useButton'; +export type { default as UseButtonProps } from './UseButtonProps'; diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx new file mode 100644 index 00000000000000..e1bd3165d79ad5 --- /dev/null +++ b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { useButton } from '@material-ui/unstyled/ButtonUnstyled'; +import { createClientRender, fireEvent } from 'test/utils'; +import { expect } from 'chai'; +import { spy } from 'sinon'; + +describe('useButton', () => { + const render = createClientRender(); + + describe('state: active', () => { + describe('when using a button element', () => { + it('is set when triggered by mouse', () => { + const TestComponent = () => { + const buttonRef = React.useRef(null); + const { active, getRootProps } = useButton({ ref: buttonRef }); + + return