From daed91d4b7d5b92056a7448269a09a731c91b73f Mon Sep 17 00:00:00 2001 From: Kushagra Bansal Date: Tue, 11 Oct 2022 02:55:54 +0530 Subject: [PATCH] [Joy] Add button loading functionality (#34658) --- .../joy/components/button/ButtonLoading.js | 17 ++++ .../joy/components/button/ButtonLoading.tsx | 17 ++++ .../button/ButtonLoading.tsx.preview | 6 ++ .../button/ButtonLoadingPosition.js | 22 +++++ .../button/ButtonLoadingPosition.tsx | 22 +++++ .../button/ButtonLoadingPosition.tsx.preview | 11 +++ docs/data/joy/components/button/button.md | 18 ++++ packages/mui-joy/src/Button/Button.spec.tsx | 13 +++ packages/mui-joy/src/Button/Button.test.js | 96 +++++++++++++++++++ packages/mui-joy/src/Button/Button.tsx | 91 ++++++++++++++++-- packages/mui-joy/src/Button/ButtonProps.ts | 17 ++++ packages/mui-joy/src/Button/buttonClasses.ts | 6 ++ 12 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 docs/data/joy/components/button/ButtonLoading.js create mode 100644 docs/data/joy/components/button/ButtonLoading.tsx create mode 100644 docs/data/joy/components/button/ButtonLoading.tsx.preview create mode 100644 docs/data/joy/components/button/ButtonLoadingPosition.js create mode 100644 docs/data/joy/components/button/ButtonLoadingPosition.tsx create mode 100644 docs/data/joy/components/button/ButtonLoadingPosition.tsx.preview diff --git a/docs/data/joy/components/button/ButtonLoading.js b/docs/data/joy/components/button/ButtonLoading.js new file mode 100644 index 00000000000000..231860dc44b56c --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoading.js @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Stack from '@mui/joy/Stack'; +import SendIcon from '@mui/icons-material/Send'; +import Button from '@mui/joy/Button'; + +export default function ButtonLoading() { + return ( + + + + + ); +} diff --git a/docs/data/joy/components/button/ButtonLoading.tsx b/docs/data/joy/components/button/ButtonLoading.tsx new file mode 100644 index 00000000000000..231860dc44b56c --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoading.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Stack from '@mui/joy/Stack'; +import SendIcon from '@mui/icons-material/Send'; +import Button from '@mui/joy/Button'; + +export default function ButtonLoading() { + return ( + + + + + ); +} diff --git a/docs/data/joy/components/button/ButtonLoading.tsx.preview b/docs/data/joy/components/button/ButtonLoading.tsx.preview new file mode 100644 index 00000000000000..ca37d5ae89ed8c --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoading.tsx.preview @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/docs/data/joy/components/button/ButtonLoadingPosition.js b/docs/data/joy/components/button/ButtonLoadingPosition.js new file mode 100644 index 00000000000000..4c851a0de942f7 --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoadingPosition.js @@ -0,0 +1,22 @@ +import * as React from 'react'; +import Stack from '@mui/joy/Stack'; +import SendIcon from '@mui/icons-material/Send'; +import Button from '@mui/joy/Button'; + +export default function ButtonLoadingPosition() { + return ( + + + + + ); +} diff --git a/docs/data/joy/components/button/ButtonLoadingPosition.tsx b/docs/data/joy/components/button/ButtonLoadingPosition.tsx new file mode 100644 index 00000000000000..4c851a0de942f7 --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoadingPosition.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import Stack from '@mui/joy/Stack'; +import SendIcon from '@mui/icons-material/Send'; +import Button from '@mui/joy/Button'; + +export default function ButtonLoadingPosition() { + return ( + + + + + ); +} diff --git a/docs/data/joy/components/button/ButtonLoadingPosition.tsx.preview b/docs/data/joy/components/button/ButtonLoadingPosition.tsx.preview new file mode 100644 index 00000000000000..711e7cf236fcf7 --- /dev/null +++ b/docs/data/joy/components/button/ButtonLoadingPosition.tsx.preview @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/docs/data/joy/components/button/button.md b/docs/data/joy/components/button/button.md index 4744eed5b33def..cb2a509bd5473a 100644 --- a/docs/data/joy/components/button/button.md +++ b/docs/data/joy/components/button/button.md @@ -70,6 +70,24 @@ Use the `startDecorator` and/or `endDecorator` props to add supporting decorator {{"demo": "ButtonIcons.js"}} +### Loading + +Enable `loading` prop to show button's loading state. The button will be `disabled` when it is in the loading state. + +The default loading indicator uses the [`CircularProgress`](/joy-ui/react-circular-progress/) component which can be customized using the `loadingIndicator` prop. + +{{"demo": "ButtonLoading.js"}} + +### Loading position + +The `loadingPosition` prop supports 3 values: + +- `center` (default): The loading indicator element is wrapped inside the button's `loadingIndicatorCenter` slot to create a proper style. +- `start`: The loading indicator replaces the **start** decorator's content when the button is in loading state. +- `end`: The loading indicator replaces the **end** decorator's content when the button is in loading state. + +{{"demo": "ButtonLoadingPosition.js"}} + ### Icon button Use the `IconButton` component if you want width and height to be the same while not having a label. diff --git a/packages/mui-joy/src/Button/Button.spec.tsx b/packages/mui-joy/src/Button/Button.spec.tsx index 275d22749aae21..91477466e8f66f 100644 --- a/packages/mui-joy/src/Button/Button.spec.tsx +++ b/packages/mui-joy/src/Button/Button.spec.tsx @@ -84,3 +84,16 @@ function Icon() { ; + +; +; +; +; diff --git a/packages/mui-joy/src/Button/Button.test.js b/packages/mui-joy/src/Button/Button.test.js index 65b7c3a3b35e07..ceda31f74e3a43 100644 --- a/packages/mui-joy/src/Button/Button.test.js +++ b/packages/mui-joy/src/Button/Button.test.js @@ -93,4 +93,100 @@ describe('Joy ); + + const progressbar = getByRole('progressbar'); + expect(progressbar).toBeVisible(); + }); + }); + + describe('prop: loadingIndicator', () => { + it('is not rendered by default', () => { + const { getByRole } = render(); + + expect(getByRole('button')).to.have.text('Test'); + }); + + it('is rendered properly when `loading` and children should not be visible', function test() { + if (!/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + const { container, getByRole } = render( + , + ); + + expect(container.querySelector(`.${classes.loadingIndicatorCenter}`)).to.have.text( + 'loading..', + ); + expect(getByRole('button')).toHaveComputedStyle({ color: 'transparent' }); + }); + }); + + describe('prop: loadingPosition', () => { + it('center is rendered by default', () => { + const { getByRole } = render(); + const loader = getByRole('progressbar'); + + expect(loader.parentElement).to.have.class(classes.loadingIndicatorCenter); + }); + + it('there should be only one loading indicator', () => { + const { getAllByRole } = render( + , + ); + const loaders = getAllByRole('progressbar'); + + expect(loaders).to.have.length(1); + }); + + it('loading indicator with `position="start"` replaces the `startDecorator` content', () => { + const { getByRole } = render( + , + ); + const loader = getByRole('progressbar'); + const button = getByRole('button'); + + expect(loader).toBeVisible(); + expect(button).to.have.text('loading..Test'); + }); + + it('loading indicator with `position="end"` replaces the `startDecorator` content', () => { + const { getByRole } = render( + , + ); + const loader = getByRole('progressbar'); + const button = getByRole('button'); + + expect(loader).toBeVisible(); + expect(button).to.have.text('Testloading..'); + }); + }); }); diff --git a/packages/mui-joy/src/Button/Button.tsx b/packages/mui-joy/src/Button/Button.tsx index 45103d8514dd57..8287f230441b30 100644 --- a/packages/mui-joy/src/Button/Button.tsx +++ b/packages/mui-joy/src/Button/Button.tsx @@ -5,12 +5,21 @@ import composeClasses from '@mui/base/composeClasses'; import { useSlotProps } from '@mui/base/utils'; import { unstable_capitalize as capitalize, unstable_useForkRef as useForkRef } from '@mui/utils'; import { styled, useThemeProps } from '../styles'; +import CircularProgress from '../CircularProgress'; import buttonClasses, { getButtonUtilityClass } from './buttonClasses'; import { ButtonOwnerState, ButtonTypeMap, ExtendButton } from './ButtonProps'; const useUtilityClasses = (ownerState: ButtonOwnerState) => { - const { color, disabled, focusVisible, focusVisibleClassName, fullWidth, size, variant } = - ownerState; + const { + color, + disabled, + focusVisible, + focusVisibleClassName, + fullWidth, + size, + variant, + loading, + } = ownerState; const slots = { root: [ @@ -21,9 +30,11 @@ const useUtilityClasses = (ownerState: ButtonOwnerState) => { variant && `variant${capitalize(variant)}`, color && `color${capitalize(color)}`, size && `size${capitalize(size)}`, + loading && 'loading', ], startDecorator: ['startDecorator'], endDecorator: ['endDecorator'], + loadingIndicatorCenter: ['loadingIndicatorCenter'], }; const composedClasses = composeClasses(slots, getButtonUtilityClass, {}); @@ -57,6 +68,21 @@ const ButtonEndDecorator = styled('span', { marginLeft: 'var(--Button-gap)', }); +const ButtonLoadingCenter = styled('span', { + name: 'JoyButton', + slot: 'LoadingCenter', + overridesResolver: (props, styles) => styles.loadingIndicatorCenter, +})<{ ownerState: ButtonOwnerState }>(({ theme, ownerState }) => ({ + display: 'inherit', + position: 'absolute', + left: '50%', + transform: 'translateX(-50%)', + color: theme.variants[ownerState.variant!]?.[ownerState.color!]?.color, + ...(ownerState.disabled && { + color: theme.variants[`${ownerState.variant!}Disabled`]?.[ownerState.color!]?.color, + }), +})); + export const ButtonRoot = styled('button', { name: 'JoyButton', slot: 'Root', @@ -118,6 +144,13 @@ export const ButtonRoot = styled('button', { { [`&.${buttonClasses.disabled}`]: theme.variants[`${ownerState.variant!}Disabled`]?.[ownerState.color!], + ...(ownerState.loadingPosition === 'center' && { + [`&.${buttonClasses.loading}`]: { + transition: + 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', + color: 'transparent', + }, + }), }, ]; }); @@ -139,6 +172,10 @@ const Button = React.forwardRef(function Button(inProps, ref) { fullWidth = false, startDecorator, endDecorator, + loading = false, + loadingPosition = 'center', + loadingIndicator: loadingIndicatorProp, + disabled, ...other } = props; @@ -147,9 +184,14 @@ const Button = React.forwardRef(function Button(inProps, ref) { const { focusVisible, setFocusVisible, getRootProps } = useButton({ ...props, + disabled: disabled || loading, ref: handleRef, }); + const loadingIndicator = loadingIndicatorProp ?? ( + + ); + React.useImperativeHandle( action, () => ({ @@ -169,6 +211,9 @@ const Button = React.forwardRef(function Button(inProps, ref) { variant, size, focusVisible, + loading, + loadingPosition, + disabled: disabled || loading, }; const classes = useUtilityClasses(ownerState); @@ -199,15 +244,32 @@ const Button = React.forwardRef(function Button(inProps, ref) { className: classes.endDecorator, }); + const loadingIndicatorCenterProps = useSlotProps({ + elementType: ButtonLoadingCenter, + externalSlotProps: componentsProps.loadingIndicatorCenter, + ownerState, + className: classes.loadingIndicatorCenter, + }); + return ( - {startDecorator && ( - {startDecorator} + {(startDecorator || (loading && loadingPosition === 'start')) && ( + + {loading && loadingPosition === 'start' ? loadingIndicator : startDecorator} + )} {children} - {endDecorator && ( - {endDecorator} + {loading && loadingPosition === 'center' && ( + + {loadingIndicator} + + )} + + {(endDecorator || (loading && loadingPosition === 'end')) && ( + + {loading && loadingPosition === 'end' ? loadingIndicator : endDecorator} + )} ); @@ -252,6 +314,7 @@ Button.propTypes /* remove-proptypes */ = { */ componentsProps: PropTypes.shape({ endDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + loadingIndicatorCenter: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), startDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }), @@ -273,6 +336,22 @@ Button.propTypes /* remove-proptypes */ = { * @default false */ fullWidth: PropTypes.bool, + /** + * If `true`, the loading indicator is shown. + * @default false + */ + loading: PropTypes.bool, + /** + * The node should contain an element with `role="progressbar"` with an accessible name. + * By default we render a `CircularProgress` that is labelled by the button itself. + * @default + */ + loadingIndicator: PropTypes.node, + /** + * The loading indicator can be positioned on the start, end, or the center of the button. + * @default 'center' + */ + loadingPosition: PropTypes.oneOf(['center', 'end', 'start']), /** * The size of the component. */ diff --git a/packages/mui-joy/src/Button/ButtonProps.ts b/packages/mui-joy/src/Button/ButtonProps.ts index 76ec8a6186575e..0f3947005549c9 100644 --- a/packages/mui-joy/src/Button/ButtonProps.ts +++ b/packages/mui-joy/src/Button/ButtonProps.ts @@ -20,6 +20,7 @@ interface ComponentsProps { root?: SlotComponentProps<'button', { sx?: SxProps }, ButtonOwnerState>; startDecorator?: SlotComponentProps<'span', { sx?: SxProps }, ButtonOwnerState>; endDecorator?: SlotComponentProps<'span', { sx?: SxProps }, ButtonOwnerState>; + loadingIndicatorCenter?: SlotComponentProps<'span', { sx?: SxProps }, ButtonOwnerState>; } export interface ButtonTypeMap

{ @@ -84,6 +85,22 @@ export interface ButtonTypeMap

{ * @default 'solid' */ variant?: OverridableStringUnion; + /** + * If `true`, the loading indicator is shown. + * @default false + */ + loading?: boolean; + /** + * The node should contain an element with `role="progressbar"` with an accessible name. + * By default we render a `CircularProgress` that is labelled by the button itself. + * @default + */ + loadingIndicator?: React.ReactNode; + /** + * The loading indicator can be positioned on the start, end, or the center of the button. + * @default 'center' + */ + loadingPosition?: 'start' | 'end' | 'center'; }; defaultComponent: D; } diff --git a/packages/mui-joy/src/Button/buttonClasses.ts b/packages/mui-joy/src/Button/buttonClasses.ts index fe84b0fce52a72..405baa4fe22bb6 100644 --- a/packages/mui-joy/src/Button/buttonClasses.ts +++ b/packages/mui-joy/src/Button/buttonClasses.ts @@ -39,6 +39,10 @@ export interface ButtonClasses { startDecorator: string; /** Styles applied to the endDecorator element if supplied. */ endDecorator: string; + /** Styles applied to the root element if `loading={true}`. */ + loading: string; + /** Styles applied to the loadingIndicatorCenter element. */ + loadingIndicatorCenter: string; } export type ButtonClassKey = keyof ButtonClasses; @@ -67,6 +71,8 @@ const buttonClasses: ButtonClasses = generateUtilityClasses('JoyButton', [ 'fullWidth', 'startDecorator', 'endDecorator', + 'loading', + 'loadingIndicatorCenter', ]); export default buttonClasses;