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 (
+
+ } variant="solid">
+ Send
+
+
+ Fetch data
+
+
+ );
+}
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 (
+
+ } variant="solid">
+ Send
+
+
+ Fetch data
+
+
+ );
+}
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 @@
+ } variant="solid">
+ Send
+
+
+ Fetch data
+
\ 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 (
+
+
+ Fetch data
+
+ }
+ variant="solid"
+ >
+ Send
+
+
+ );
+}
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 (
+
+
+ Fetch data
+
+ }
+ variant="solid"
+ >
+ Send
+
+
+ );
+}
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 @@
+
+ Fetch data
+
+ }
+ variant="solid"
+>
+ Send
+
\ 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() {
} color="success">
Checkout
;
+
+
+ disabled
+ ;
+
+ Fetch data
+ ;
+ } loading loadingPosition="end">
+ Send
+;
+ }>
+ Save
+;
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 ', () => {
expect(button).to.have.class(classes.root);
expect(endDecorator).not.to.have.class(classes.startDecorator);
});
+
+ describe('prop: loading', () => {
+ it('disables the button', () => {
+ const { getByRole } = render( );
+
+ const button = getByRole('button');
+ expect(button).to.have.property('disabled', true);
+ });
+
+ it('renders a progressbar', () => {
+ const { getByRole } = render(Submit );
+
+ const progressbar = getByRole('progressbar');
+ expect(progressbar).toBeVisible();
+ });
+ });
+
+ describe('prop: loadingIndicator', () => {
+ it('is not rendered by default', () => {
+ const { getByRole } = render(Test );
+
+ 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(
+
+ Test
+ ,
+ );
+
+ 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(Test );
+ const loader = getByRole('progressbar');
+
+ expect(loader.parentElement).to.have.class(classes.loadingIndicatorCenter);
+ });
+
+ it('there should be only one loading indicator', () => {
+ const { getAllByRole } = render(
+
+ Test
+ ,
+ );
+ const loaders = getAllByRole('progressbar');
+
+ expect(loaders).to.have.length(1);
+ });
+
+ it('loading indicator with `position="start"` replaces the `startDecorator` content', () => {
+ const { getByRole } = render(
+ icon}
+ loadingPosition="start"
+ loadingIndicator={loading.. }
+ >
+ Test
+ ,
+ );
+ 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(
+ icon}
+ loadingPosition="end"
+ loadingIndicator={loading.. }
+ >
+ Test
+ ,
+ );
+ 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;