diff --git a/docs/data/joy/components/circular-progress/CircularProgressButton.js b/docs/data/joy/components/circular-progress/CircularProgressButton.js new file mode 100644 index 00000000000000..e8f7e196e6ce19 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressButton.js @@ -0,0 +1,28 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Link from '@mui/joy/Link'; +import IconButton from '@mui/joy/IconButton'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressSizes() { + return ( + + + + + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + } + sx={{ p: 1 }} + > + Submitting... + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressButton.tsx b/docs/data/joy/components/circular-progress/CircularProgressButton.tsx new file mode 100644 index 00000000000000..e8f7e196e6ce19 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressButton.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Link from '@mui/joy/Link'; +import IconButton from '@mui/joy/IconButton'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressSizes() { + return ( + + + + + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + } + sx={{ p: 1 }} + > + Submitting... + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressButton.tsx.preview b/docs/data/joy/components/circular-progress/CircularProgressButton.tsx.preview new file mode 100644 index 00000000000000..6f3dcd5ff8f424 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressButton.tsx.preview @@ -0,0 +1,15 @@ + + + + +{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} +} + sx={{ p: 1 }} +> + Submitting... + \ No newline at end of file diff --git a/docs/data/joy/components/circular-progress/CircularProgressChildren.js b/docs/data/joy/components/circular-progress/CircularProgressChildren.js new file mode 100644 index 00000000000000..d941ef3a49576e --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressChildren.js @@ -0,0 +1,21 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; +import ReportIcon from '@mui/icons-material/Report'; +import WarningIcon from '@mui/icons-material/Warning'; + +export default function CircularProgressChildren() { + return ( + + + + + + 2 / 3 + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx b/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx new file mode 100644 index 00000000000000..d941ef3a49576e --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; +import ReportIcon from '@mui/icons-material/Report'; +import WarningIcon from '@mui/icons-material/Warning'; + +export default function CircularProgressChildren() { + return ( + + + + + + 2 / 3 + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx.preview b/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx.preview new file mode 100644 index 00000000000000..da094e98fbd7fb --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressChildren.tsx.preview @@ -0,0 +1,9 @@ + + + + + 2 / 3 + + + + \ No newline at end of file diff --git a/docs/data/joy/components/circular-progress/CircularProgressColors.js b/docs/data/joy/components/circular-progress/CircularProgressColors.js new file mode 100644 index 00000000000000..8754f8dd5c6f0f --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressColors.js @@ -0,0 +1,66 @@ +import Typography from '@mui/joy/Typography'; +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; + +import Radio from '@mui/joy/Radio'; +import RadioGroup from '@mui/joy/RadioGroup'; +import Sheet from '@mui/joy/Sheet'; + +export default function CircularProgressColors() { + const [variant, setVariant] = React.useState('solid'); + return ( + + + + + + + + + + + + Variant: + + setVariant(event.target.value)} + > + + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressColors.tsx b/docs/data/joy/components/circular-progress/CircularProgressColors.tsx new file mode 100644 index 00000000000000..c28c5d113d3a29 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressColors.tsx @@ -0,0 +1,66 @@ +import Typography from '@mui/joy/Typography'; +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; +import { VariantProp } from '@mui/joy'; +import Radio from '@mui/joy/Radio'; +import RadioGroup from '@mui/joy/RadioGroup'; +import Sheet from '@mui/joy/Sheet'; + +export default function CircularProgressColors() { + const [variant, setVariant] = React.useState('solid'); + return ( + + + + + + + + + + + + Variant: + + setVariant(event.target.value as VariantProp)} + > + + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressDeterminate.js b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.js new file mode 100644 index 00000000000000..ec645d020be21a --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.js @@ -0,0 +1,27 @@ +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; +import * as React from 'react'; + +export default function CircularProgressDeterminate() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => { + setProgress((prevProgress) => (prevProgress >= 100 ? 0 : prevProgress + 10)); + }, 800); + + return () => { + clearInterval(timer); + }; + }, []); + + return ( + + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx new file mode 100644 index 00000000000000..ec645d020be21a --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx @@ -0,0 +1,27 @@ +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; +import * as React from 'react'; + +export default function CircularProgressDeterminate() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => { + setProgress((prevProgress) => (prevProgress >= 100 ? 0 : prevProgress + 10)); + }, 800); + + return () => { + clearInterval(timer); + }; + }, []); + + return ( + + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx.preview b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx.preview new file mode 100644 index 00000000000000..ef177ff7f5a3c5 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressDeterminate.tsx.preview @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/data/joy/components/circular-progress/CircularProgressSizes.js b/docs/data/joy/components/circular-progress/CircularProgressSizes.js new file mode 100644 index 00000000000000..df9d18d6e1e357 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressSizes.js @@ -0,0 +1,13 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressSizes() { + return ( + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx b/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx new file mode 100644 index 00000000000000..df9d18d6e1e357 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressSizes() { + return ( + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx.preview b/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx.preview new file mode 100644 index 00000000000000..8647eb18a867ad --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressSizes.tsx.preview @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/data/joy/components/circular-progress/CircularProgressThickness.js b/docs/data/joy/components/circular-progress/CircularProgressThickness.js new file mode 100644 index 00000000000000..dec58d30f6088f --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressThickness.js @@ -0,0 +1,6 @@ +import * as React from 'react'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressThickness() { + return ; +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressUsage.js b/docs/data/joy/components/circular-progress/CircularProgressUsage.js new file mode 100644 index 00000000000000..0e432734b93048 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressUsage.js @@ -0,0 +1,41 @@ +import * as React from 'react'; +import JoyUsageDemo from 'docs/src/modules/components/JoyUsageDemo'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressUsage() { + return ( + } + /> + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressVariables.js b/docs/data/joy/components/circular-progress/CircularProgressVariables.js new file mode 100644 index 00000000000000..fa1fa1663ad2d2 --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressVariables.js @@ -0,0 +1,26 @@ +import * as React from 'react'; +import JoyVariablesDemo from 'docs/src/modules/components/JoyVariablesDemo'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressVariables() { + return ( + } + /> + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressVariants.js b/docs/data/joy/components/circular-progress/CircularProgressVariants.js new file mode 100644 index 00000000000000..d418a0ef52160b --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressVariants.js @@ -0,0 +1,14 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressVariants() { + return ( + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx b/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx new file mode 100644 index 00000000000000..d418a0ef52160b --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function CircularProgressVariants() { + return ( + + + + + + + ); +} diff --git a/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx.preview b/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx.preview new file mode 100644 index 00000000000000..f96ecf19cb9c7b --- /dev/null +++ b/docs/data/joy/components/circular-progress/CircularProgressVariants.tsx.preview @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/data/joy/components/circular-progress/circular-progress.md b/docs/data/joy/components/circular-progress/circular-progress.md new file mode 100644 index 00000000000000..90471d0d25467b --- /dev/null +++ b/docs/data/joy/components/circular-progress/circular-progress.md @@ -0,0 +1,95 @@ +--- +product: joy-ui +title: React Circular Progress component +githubLabel: 'component: CircularProgress' +--- + +# Circular Progress + +

Circular Progress indicators, commonly known as spinners, express an unspecified wait time or display the length of a process.

+ +## Introduction + +Progress indicators inform users about the status of ongoing processes, such as loading an app, submitting a form, or saving updates. + +The `CircularProgress` is indeterminate by default, indicating an unspecified wait time. +To actually have it represent how long an operation will take, use the [determinate](#determinate) mode. + +The animations of the components rely on CSS as much as possible to work even before the JavaScript is loaded. + +{{"demo": "CircularProgressUsage.js", "hideToolbar": true}} + +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +## Component + +After [installation](/joy-ui/getting-started/installation/), you can start building with this component using the following basic elements: + +```jsx +import CircularProgress from '@mui/joy/CircularProgress'; + +export default function MyApp() { + return ; +} +``` + +### Variants + +The circular progress component supports the four global variants: `solid`, `soft` (default), `outlined`, and `plain`. + +{{"demo": "CircularProgressVariants.js"}} + +### Colors + +Every palette included in the theme is available via the `color` prop. +Play around combining different colors. + +{{"demo": "CircularProgressColors.js"}} + +### Sizes + +The circular progress component comes with three sizes out of the box: `sm`, `md` (default), and `lg`. + +{{"demo": "CircularProgressSizes.js"}} + +:::success +To learn how to add more sizes to the component, check out [Themed components—Extend sizes](/joy-ui/customization/themed-components/#extend-sizes). +::: + +### Determinate + +You can use the `determinate` prop if you want to indicate a specified wait time. + +{{"demo": "CircularProgressDeterminate.js"}} + +### Thickness + +Provides a number to `thickness` prop to control the circle's stroke width. + +{{"demo": "CircularProgressThickness.js"}} + +### Children + +The circular progress component places the provided children in the center by default. + +{{"demo": "CircularProgressChildren.js"}} + +:::info +For plain texts and icons, the dimension is relative to the circular progress's CSS variable (`--CircularProgress-size`). +::: + +### With a button + +`CircularProgress` can be used as a decorator to show loading on a button. + +The size of the circular progress is controlled by a button, an icon button, or a link unless the `size` prop is explicitly specified on the progress. + +{{"demo": "CircularProgressButton.js"}} + +## CSS variables + +Play around with all the CSS variables available on the component to see how the design changes. + +You can use those to customize the component on both the `sx` prop and the theme. + +{{"demo": "CircularProgressVariables.js", "hideToolbar": true}} diff --git a/docs/data/joy/pages.ts b/docs/data/joy/pages.ts index 62b6eb0eb3d40b..428d5ad53d4f05 100644 --- a/docs/data/joy/pages.ts +++ b/docs/data/joy/pages.ts @@ -54,7 +54,11 @@ const pages = [ { pathname: '/joy-ui/components/feedback', subheader: 'feedback', - children: [{ pathname: '/joy-ui/react-alert' }, { pathname: '/joy-ui/react-modal' }], + children: [ + { pathname: '/joy-ui/react-alert' }, + { pathname: '/joy-ui/react-circular-progress' }, + { pathname: '/joy-ui/react-modal' }, + ], }, { pathname: '/joy-ui/components/surfaces', diff --git a/docs/pages/joy-ui/react-circular-progress.js b/docs/pages/joy-ui/react-circular-progress.js new file mode 100644 index 00000000000000..e89faaf5b3f2f6 --- /dev/null +++ b/docs/pages/joy-ui/react-circular-progress.js @@ -0,0 +1,11 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { + demos, + docs, + demoComponents, +} from 'docs/data/joy/components/circular-progress/circular-progress.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/src/modules/components/JoyUsageDemo.tsx b/docs/src/modules/components/JoyUsageDemo.tsx index f61dad78c05eab..835f2f306d45e8 100644 --- a/docs/src/modules/components/JoyUsageDemo.tsx +++ b/docs/src/modules/components/JoyUsageDemo.tsx @@ -117,7 +117,7 @@ interface JoyUsageDemoProps { * - `input`: render * - `radio`: render group of radios */ - knob?: 'switch' | 'color' | 'select' | 'input' | 'radio'; + knob?: 'switch' | 'color' | 'select' | 'input' | 'radio' | 'number'; /** * The options for these knobs: `select` and `radio` */ @@ -500,6 +500,35 @@ export default function JoyUsageDemo({ /> ); } + if (knob === 'number') { + return ( + + setProps((latestProps) => ({ + ...latestProps, + [propName]: Number.isNaN(event.target.valueAsNumber) + ? undefined + : event.target.valueAsNumber, + })) + } + sx={{ + textTransform: 'capitalize', + [`& .${inputClasses.root}`]: { + bgcolor: 'background.body', + }, + }} + /> + ); + } return null; })} diff --git a/docs/src/modules/components/JoyVariablesDemo.tsx b/docs/src/modules/components/JoyVariablesDemo.tsx index 863defa0319ccb..47d70a13053f07 100644 --- a/docs/src/modules/components/JoyVariablesDemo.tsx +++ b/docs/src/modules/components/JoyVariablesDemo.tsx @@ -91,6 +91,7 @@ export default function JoyVariablesDemo(props: { styles.startIcon, })<{ ownerState: ButtonOwnerState }>({ '--Icon-margin': '0 0 0 calc(var(--Button-gap) / -2)', + '--CircularProgress-margin': '0 0 0 calc(var(--Button-gap) / -2)', display: 'inherit', marginRight: 'var(--Button-gap)', }); @@ -51,6 +52,7 @@ const ButtonEndIcon = styled('span', { overridesResolver: (props, styles) => styles.endIcon, })<{ ownerState: ButtonOwnerState }>({ '--Icon-margin': '0 calc(var(--Button-gap) / -2) 0 0', + '--CircularProgress-margin': '0 calc(var(--Button-gap) / -2) 0 0', display: 'inherit', marginLeft: 'var(--Button-gap)', }); @@ -63,6 +65,7 @@ export const ButtonRoot = styled('button', { return [ { '--Icon-margin': 'initial', // reset the icon's margin. + '--CircularProgress-size': 'var(--Icon-fontSize)', ...(ownerState.size === 'sm' && { '--Icon-fontSize': '1.25rem', '--Button-gap': '0.375rem', diff --git a/packages/mui-joy/src/CircularProgress/CircularProgress.spec.tsx b/packages/mui-joy/src/CircularProgress/CircularProgress.spec.tsx new file mode 100644 index 00000000000000..52a281f7c6539d --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/CircularProgress.spec.tsx @@ -0,0 +1,34 @@ +import CircularProgress from '@mui/joy/CircularProgress'; +import * as React from 'react'; + +; + +; + +// `variant` +; +; +; +; + +// `color` +; +; +; +; +; +; + +// `size` +; +; +; + +// @ts-expect-error there is no variant `filled` +; + +// @ts-expect-error there is no color `secondary` +; + +// @ts-expect-error there is no size `xl2` +; diff --git a/packages/mui-joy/src/CircularProgress/CircularProgress.test.tsx b/packages/mui-joy/src/CircularProgress/CircularProgress.test.tsx new file mode 100644 index 00000000000000..c3f1a1ede5387c --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/CircularProgress.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, describeConformance } from 'test/utils'; +import { ThemeProvider } from '@mui/joy/styles'; +import CircularProgress, { circularProgressClasses as classes } from '@mui/joy/CircularProgress'; +import { unstable_capitalize as capitalize } from '@mui/utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + classes, + inheritComponent: 'span', + render, + ThemeProvider, + muiName: 'JoyCircularProgress', + refInstanceof: window.HTMLSpanElement, + testVariantProps: { determinate: true }, + testCustomVariant: true, + skip: ['classesRoot', 'componentsProp'], + })); + + describe('prop: variant', () => { + it('soft by default', () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class(classes.variantSoft); + }); + + (['plain', 'outlined', 'soft', 'solid'] as const).forEach((variant) => { + it(`should render ${variant}`, () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class( + classes[`variant${capitalize(variant)}` as keyof typeof classes], + ); + }); + }); + }); + + describe('prop: color', () => { + it('adds a primary class by default', () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class(classes.colorPrimary); + }); + + (['primary', 'success', 'info', 'danger', 'neutral', 'warning'] as const).forEach((color) => { + it(`should render ${color}`, () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class( + classes[`color${capitalize(color)}` as keyof typeof classes], + ); + }); + }); + }); + + describe('prop: size', () => { + it('md by default', () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class(classes.sizeMd); + }); + (['sm', 'md', 'lg'] as const).forEach((size) => { + it(`should render ${size}`, () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).to.have.class( + classes[`size${capitalize(size)}` as keyof typeof classes], + ); + }); + }); + }); +}); diff --git a/packages/mui-joy/src/CircularProgress/CircularProgress.tsx b/packages/mui-joy/src/CircularProgress/CircularProgress.tsx new file mode 100644 index 00000000000000..041ed45583a21e --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/CircularProgress.tsx @@ -0,0 +1,348 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { unstable_composeClasses as composeClasses } from '@mui/base'; +import { useSlotProps } from '@mui/base/utils'; +import { keyframes, css } from '@mui/system'; +import { OverridableComponent } from '@mui/types'; +import { unstable_capitalize as capitalize } from '@mui/utils'; +import styled from '../styles/styled'; +import useThemeProps from '../styles/useThemeProps'; +import { getCircularProgressUtilityClass } from './circularProgressClasses'; +import { + CircularProgressProps, + CircularProgressTypeMap, + CircularProgressOwnerState, +} from './CircularProgressProps'; + +const circulate = keyframes({ + '0%': { + // let the progress start at the top of the ring + transform: 'rotate(-90deg)', + }, + '100%': { + transform: 'rotate(270deg)', + }, +}); + +const useUtilityClasses = (ownerState: CircularProgressOwnerState) => { + const { determinate, color, variant, size } = ownerState; + + const slots = { + root: [ + 'root', + determinate && 'indeterminate', + color && `color${capitalize(color)}`, + variant && `variant${capitalize(variant)}`, + size && `size${capitalize(size)}`, + ], + svg: ['svg'], + track: ['track'], + progress: ['progress'], + }; + + return composeClasses(slots, getCircularProgressUtilityClass, {}); +}; + +const CircularProgressRoot = styled('span', { + name: 'JoyCircularProgress', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})<{ ownerState: CircularProgressOwnerState }>(({ ownerState, theme }) => { + const { color, backgroundColor, ...rest } = + theme.variants[ownerState.variant!]?.[ownerState.color!] || {}; + return { + // integration with icon + '--Icon-fontSize': 'calc(0.4 * var(--_root-size))', + // public variables + '--CircularProgress-track-color': backgroundColor, + '--CircularProgress-progress-color': color, + '--CircularProgress-percent': ownerState.value, // 0 - 100 + '--CircularProgress-linecap': 'round', + ...(ownerState.size === 'sm' && { + '--CircularProgress-track-thickness': '3px', + '--CircularProgress-progress-thickness': '3px', + '--_root-size': 'var(--CircularProgress-size, 24px)', // use --_root-size to let other components overrides via --CircularProgress-size + }), + ...(ownerState.instanceSize === 'sm' && { + '--CircularProgress-size': '24px', + }), + ...(ownerState.size === 'md' && { + '--CircularProgress-track-thickness': '6px', + '--CircularProgress-progress-thickness': '6px', + '--_root-size': 'var(--CircularProgress-size, 40px)', + }), + ...(ownerState.instanceSize === 'md' && { + '--CircularProgress-size': '40px', + }), + ...(ownerState.size === 'lg' && { + '--CircularProgress-track-thickness': '8px', + '--CircularProgress-progress-thickness': '8px', + '--_root-size': 'var(--CircularProgress-size, 64px)', + }), + ...(ownerState.instanceSize === 'lg' && { + '--CircularProgress-size': '64px', + }), + ...(ownerState.thickness && { + '--CircularProgress-track-thickness': `${ownerState.thickness}px`, + '--CircularProgress-progress-thickness': `${ownerState.thickness}px`, + }), + // internal variables + '--_thickness-diff': + 'calc(var(--CircularProgress-track-thickness) - var(--CircularProgress-progress-thickness))', + '--_inner-size': 'calc(var(--_root-size) - 2 * var(--variant-borderWidth))', + width: 'var(--_root-size)', + height: 'var(--_root-size)', + borderRadius: 'var(--_root-size)', + margin: 'var(--CircularProgress-margin)', + boxSizing: 'border-box', + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + color, + ...(ownerState.children && { + // only add font related properties when there is a child. + // so that when there is no child, the size can be controlled by the parent font-size e.g. Link + fontFamily: theme.vars.fontFamily.body, + fontWeight: theme.vars.fontWeight.md, + fontSize: 'calc(0.2 * var(--_root-size))', + }), + ...rest, + }; +}); + +const CircularProgressSvg = styled('svg', { + name: 'JoyCircularProgress', + slot: 'Svg', + overridesResolver: (props, styles) => styles.svg, +})<{ ownerState: CircularProgressOwnerState }>({ + width: 'inherit', + height: 'inherit', + display: 'inherit', + boxSizing: 'inherit', + position: 'absolute', + top: 'calc(-1 * var(--variant-borderWidth))', // centered align + left: 'calc(-1 * var(--variant-borderWidth))', // centered align +}); + +const CircularProgressTrack = styled('circle', { + name: 'JoyCircularProgress', + slot: 'track', + overridesResolver: (props, styles) => styles.track, +})<{ ownerState: CircularProgressOwnerState }>({ + cx: '50%', + cy: '50%', + r: 'calc(var(--_inner-size) / 2 - var(--CircularProgress-track-thickness) / 2 + min(0px, var(--_thickness-diff) / 2))', + fill: 'transparent', + strokeWidth: 'var(--CircularProgress-track-thickness)', + stroke: 'var(--CircularProgress-track-color)', +}); + +const CircularProgressProgress = styled('circle', { + name: 'JoyCircularProgress', + slot: 'progress', + overridesResolver: (props, styles) => styles.progress, +})<{ ownerState: CircularProgressOwnerState }>( + { + '--_progress-radius': + 'calc(var(--_inner-size) / 2 - var(--CircularProgress-progress-thickness) / 2 - max(0px, var(--_thickness-diff) / 2))', + '--_progress-length': 'calc(2 * 3.1415926535 * var(--_progress-radius))', // the circumference around the progress + cx: '50%', + cy: '50%', + r: 'var(--_progress-radius)', + fill: 'transparent', + strokeWidth: 'var(--CircularProgress-progress-thickness)', + stroke: 'var(--CircularProgress-progress-color)', + strokeLinecap: 'var(--CircularProgress-linecap, round)' as 'round', // can't use CSS variable directly, need to cast type. + strokeDasharray: 'var(--_progress-length)', + strokeDashoffset: + 'calc(var(--_progress-length) - var(--CircularProgress-percent) * var(--_progress-length) / 100)', + transformOrigin: 'center', + transform: 'rotate(-90deg)', // to initially appear at the top-center of the circle. + }, + ({ ownerState }) => + ownerState.determinate + ? { + transition: 'stroke-dashoffset 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', // copy from Material UI CircularProgress + } + : css` + animation: var( + --CircularProgress-circulation, + 0.5s linear 0s infinite normal none running + ) + ${circulate}; + `, +); + +/** + * ## ARIA + * + * If the progress bar is describing the loading progress of a particular region of a page, + * you should use `aria-describedby` to point to the progress bar, and set the `aria-busy` + * attribute to `true` on that region until it has finished loading. + */ +const CircularProgress = React.forwardRef(function CircularProgress(inProps, ref) { + const props = useThemeProps({ + props: inProps, + name: 'JoyCircularProgress', + }); + + const { + componentsProps = {}, + component = 'span', + children, + className, + color = 'primary', + size = 'md', + variant = 'soft', + thickness, + determinate = false, + value = determinate ? 0 : 25, // `25` is the 1/4 of the circle. + ...other + } = props; + + const ownerState = { + ...props, + color, + size, + variant, + thickness, + value, + determinate, + instanceSize: inProps.size, + }; + + const classes = useUtilityClasses(ownerState); + + const rootProps = useSlotProps({ + elementType: CircularProgressRoot, + externalSlotProps: componentsProps.root, + externalForwardedProps: other, + ownerState, + additionalProps: { + ref, + as: component, + role: 'progressbar', + }, + className: clsx(classes.root, className), + ...(value && + determinate && { + 'aria-valuenow': + typeof value === 'number' ? Math.round(value) : Math.round(Number(value || 0)), + }), + }); + + const svgProps = useSlotProps({ + elementType: CircularProgressSvg, + externalSlotProps: componentsProps.svg, + ownerState, + className: classes.svg, + }); + + const trackProps = useSlotProps({ + elementType: CircularProgressTrack, + externalSlotProps: componentsProps.track, + ownerState, + className: classes.track, + }); + + const progressProps = useSlotProps({ + elementType: CircularProgressProgress, + externalSlotProps: componentsProps.progress, + ownerState, + className: classes.progress, + }); + + return ( + + + + + + {children} + + ); +}) as OverridableComponent; + +CircularProgress.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'primary' + */ + color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['danger', 'info', 'neutral', 'primary', 'success', 'warning']), + PropTypes.string, + ]), + /** + * The component used for the root node. + * Either a string to use a HTML element or a component. + */ + component: PropTypes.elementType, + /** + * The props used for each slot inside the CircularProgress. + * @default {} + */ + componentsProps: PropTypes.shape({ + progress: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + svg: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + track: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The boolean to select a variant. + * Use indeterminate when there is no progress value. + * @default false + */ + determinate: PropTypes.bool, + /** + * The size of the component. + * It accepts theme values between 'sm' and 'lg'. + * @default 'md' + */ + size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['sm', 'md', 'lg']), + PropTypes.string, + ]), + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * The thickness of the circle. + */ + thickness: PropTypes.number, + /** + * The value of the progress indicator for the determinate variant. + * Value between 0 and 100. + * + * For indeterminate, @default 25 + */ + value: PropTypes.number, + /** + * The variant to use. + * @default 'soft' + */ + variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']), + PropTypes.string, + ]), +} as any; + +export default CircularProgress; diff --git a/packages/mui-joy/src/CircularProgress/CircularProgressProps.ts b/packages/mui-joy/src/CircularProgress/CircularProgressProps.ts new file mode 100644 index 00000000000000..968e87ac2113c5 --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/CircularProgressProps.ts @@ -0,0 +1,77 @@ +import { SlotComponentProps } from '@mui/base/utils'; +import { OverridableStringUnion, OverrideProps } from '@mui/types'; +import * as React from 'react'; +import { ColorPaletteProp, SxProps, VariantProp } from '../styles/types'; + +export type CircularProgressSlot = 'root' | 'svg' | 'track' | 'progress'; + +export interface CircularProgressPropsColorOverrides {} +export interface CircularProgressPropsSizeOverrides {} +export interface CircularProgressPropsVariantOverrides {} + +interface ComponentsProps { + root?: SlotComponentProps<'span', { sx?: SxProps }, CircularProgressOwnerState>; + svg?: SlotComponentProps<'svg', { sx?: SxProps }, CircularProgressOwnerState>; + track?: SlotComponentProps<'circle', { sx?: SxProps }, CircularProgressOwnerState>; + progress?: SlotComponentProps<'circle', { sx?: SxProps }, CircularProgressOwnerState>; +} + +export interface CircularProgressTypeMap

{ + props: P & { + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'primary' + */ + color?: OverridableStringUnion; + /** + * The props used for each slot inside the CircularProgress. + * @default {} + */ + componentsProps?: ComponentsProps; + /** + * The boolean to select a variant. + * Use indeterminate when there is no progress value. + * @default false + */ + determinate?: true | false; + /** + * The size of the component. + * It accepts theme values between 'sm' and 'lg'. + * @default 'md' + */ + size?: OverridableStringUnion<'sm' | 'md' | 'lg', CircularProgressPropsSizeOverrides>; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * The thickness of the circle. + */ + thickness?: number; + /** + * The value of the progress indicator for the determinate variant. + * Value between 0 and 100. + * + * For indeterminate, @default 25 + */ + value?: number; + /** + * The variant to use. + * @default 'soft' + */ + variant?: OverridableStringUnion; + }; + defaultComponent: D; +} + +export type CircularProgressProps< + D extends React.ElementType = CircularProgressTypeMap['defaultComponent'], + P = { component?: React.ElementType }, +> = OverrideProps, D>; + +export interface CircularProgressOwnerState extends CircularProgressProps { + /** + * @internal the explicit size on the instance: + */ + instanceSize: CircularProgressProps['size']; +} diff --git a/packages/mui-joy/src/CircularProgress/circularProgressClasses.ts b/packages/mui-joy/src/CircularProgress/circularProgressClasses.ts new file mode 100644 index 00000000000000..fa3065de409a7a --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/circularProgressClasses.ts @@ -0,0 +1,72 @@ +import { generateUtilityClass, generateUtilityClasses } from '../className'; + +export interface CircularProgressClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the root element if `determinate` is true. */ + determinate: string; + /** Styles applied to the svg element. */ + svg: string; + /** Styles applied to the `track` element. */ + track: string; + /** Styles applied to the `progress` element. */ + progress: string; + /** Styles applied to the root element if `color="primary"`. */ + colorPrimary: string; + /** Styles applied to the root element if `color="neutral"`. */ + colorNeutral: string; + /** Styles applied to the root element if `color="danger"`. */ + colorDanger: string; + /** Styles applied to the root element if `color="info"`. */ + colorInfo: string; + /** Styles applied to the root element if `color="success"`. */ + colorSuccess: string; + /** Styles applied to the root element if `color="warning"`. */ + colorWarning: string; + /** Styles applied to the root element if `size="sm"`. */ + sizeSm: string; + /** Styles applied to the root element if `size="md"`. */ + sizeMd: string; + /** Styles applied to the root element if `size="lg"`. */ + sizeLg: string; + /** Styles applied to the root element if `variant="plain"`. */ + variantPlain: string; + /** Styles applied to the root element if `variant="outlined"`. */ + variantOutlined: string; + /** Styles applied to the root element if `variant="soft"`. */ + variantSoft: string; + /** Styles applied to the root element if `variant="solid"`. */ + variantSolid: string; +} + +export type CircularProgressClassKey = keyof CircularProgressClasses; + +export function getCircularProgressUtilityClass(slot: string): string { + return generateUtilityClass('JoyCircularProgress', slot); +} + +const circularProgressClasses: CircularProgressClasses = generateUtilityClasses( + 'JoyCircularProgress', + [ + 'root', + 'determinate', + 'svg', + 'track', + 'progress', + 'colorPrimary', + 'colorNeutral', + 'colorDanger', + 'colorInfo', + 'colorSuccess', + 'colorWarning', + 'sizeSm', + 'sizeMd', + 'sizeLg', + 'variantPlain', + 'variantOutlined', + 'variantSoft', + 'variantSolid', + ], +); + +export default circularProgressClasses; diff --git a/packages/mui-joy/src/CircularProgress/index.ts b/packages/mui-joy/src/CircularProgress/index.ts new file mode 100644 index 00000000000000..44a57d7f65fa32 --- /dev/null +++ b/packages/mui-joy/src/CircularProgress/index.ts @@ -0,0 +1,4 @@ +export { default } from './CircularProgress'; +export * from './circularProgressClasses'; +export { default as circularProgressClasses } from './circularProgressClasses'; +export * from './CircularProgressProps'; diff --git a/packages/mui-joy/src/IconButton/IconButton.tsx b/packages/mui-joy/src/IconButton/IconButton.tsx index 68ebfbc7b7cd6c..6e809307157859 100644 --- a/packages/mui-joy/src/IconButton/IconButton.tsx +++ b/packages/mui-joy/src/IconButton/IconButton.tsx @@ -38,6 +38,7 @@ export const IconButtonRoot = styled('button', { })<{ ownerState: IconButtonOwnerState }>(({ theme, ownerState }) => [ { '--Icon-margin': 'initial', // reset the icon's margin. + '--CircularProgress-size': 'var(--Icon-fontSize)', ...(ownerState.size === 'sm' && { '--Icon-fontSize': 'calc(var(--IconButton-size, 2rem) / 1.6)', // 1.25rem by default minWidth: 'var(--IconButton-size, 2rem)', // use min-width instead of height to make the button resilient to its content diff --git a/packages/mui-joy/src/Link/Link.tsx b/packages/mui-joy/src/Link/Link.tsx index 97572853c0f4f7..d497c4320c7d32 100644 --- a/packages/mui-joy/src/Link/Link.tsx +++ b/packages/mui-joy/src/Link/Link.tsx @@ -61,6 +61,7 @@ const LinkRoot = styled('a', { return [ { '--Icon-fontSize': '1.25em', + '--CircularProgress-size': '1em', ...(ownerState.level && ownerState.level !== 'inherit' && theme.typography[ownerState.level]), ...(ownerState.level === 'inherit' && { fontSize: 'inherit', diff --git a/packages/mui-joy/src/index.ts b/packages/mui-joy/src/index.ts index 52f274426d1b2c..d1b615bd72462a 100644 --- a/packages/mui-joy/src/index.ts +++ b/packages/mui-joy/src/index.ts @@ -25,6 +25,9 @@ export * from './Breadcrumbs'; export { default as Button } from './Button'; export * from './Button'; +export { default as CircularProgress } from './CircularProgress'; +export * from './CircularProgress'; + export { default as Card } from './Card'; export * from './Card'; diff --git a/packages/mui-joy/src/styles/components.d.ts b/packages/mui-joy/src/styles/components.d.ts index 4ecc210e0a1fde..35bdbbdf2edce6 100644 --- a/packages/mui-joy/src/styles/components.d.ts +++ b/packages/mui-joy/src/styles/components.d.ts @@ -39,6 +39,11 @@ import { ChipDeleteOwnerState, ChipDeleteSlot, } from '../ChipDelete/ChipDeleteProps'; +import { + CircularProgressProps, + CircularProgressOwnerState, + CircularProgressSlot, +} from '../CircularProgress/CircularProgressProps'; import { ContainerProps, ContainerSlot } from '../Container/ContainerProps'; import { FormHelperTextProps, @@ -172,6 +177,10 @@ export interface Components { defaultProps?: Partial; styleOverrides?: OverridesStyleRules; }; + JoyCircularProgress?: { + defaultProps?: Partial; + styleOverrides?: OverridesStyleRules; + }; JoyCard?: { defaultProps?: Partial; styleOverrides?: OverridesStyleRules; diff --git a/packages/mui-joy/src/styles/extendTheme.spec.ts b/packages/mui-joy/src/styles/extendTheme.spec.ts index 5e037e1c204861..e4aa2cf50c5ebc 100644 --- a/packages/mui-joy/src/styles/extendTheme.spec.ts +++ b/packages/mui-joy/src/styles/extendTheme.spec.ts @@ -12,6 +12,7 @@ import { CardOverflowOwnerState } from '@mui/joy/CardOverflow'; import { CheckboxOwnerState } from '@mui/joy/Checkbox'; import { ChipOwnerState } from '@mui/joy/Chip'; import { ChipDeleteOwnerState } from '@mui/joy/ChipDelete'; +import { CircularProgressOwnerState } from '@mui/joy/CircularProgress'; import { ContainerProps } from '@mui/joy/Container'; import { FormHelperTextOwnerState } from '@mui/joy/FormHelperText'; import { FormLabelOwnerState } from '@mui/joy/FormLabel'; @@ -299,6 +300,38 @@ extendTheme({ }, }, }, + JoyCircularProgress: { + defaultProps: { + variant: 'solid', + color: 'primary', + }, + styleOverrides: { + root: ({ ownerState }) => { + expectType, typeof ownerState>( + ownerState, + ); + return {}; + }, + svg: ({ ownerState }) => { + expectType, typeof ownerState>( + ownerState, + ); + return {}; + }, + track: ({ ownerState }) => { + expectType, typeof ownerState>( + ownerState, + ); + return {}; + }, + progress: ({ ownerState }) => { + expectType, typeof ownerState>( + ownerState, + ); + return {}; + }, + }, + }, JoyContainer: { defaultProps: { disableGutters: true,