diff --git a/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.js b/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.js new file mode 100644 index 00000000000000..7b20091a88ef16 --- /dev/null +++ b/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.js @@ -0,0 +1,65 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Snackbar from '@mui/joy/Snackbar'; +import { keyframes } from '@mui/system'; + +const inAnimation = keyframes` + 0% { + transform: scale(0); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +`; + +const outAnimation = keyframes` + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(0); + opacity: 0; + } +`; + +export default function CustomAnimatedSnackbar() { + const [open, setOpen] = React.useState(false); + + const animationDuration = 600; + + const handleClick = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( +
+ + + I love this animation! + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.tsx b/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.tsx new file mode 100644 index 00000000000000..7b20091a88ef16 --- /dev/null +++ b/docs/data/joy/components/snackbar/CustomAnimatedSnackbar.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Snackbar from '@mui/joy/Snackbar'; +import { keyframes } from '@mui/system'; + +const inAnimation = keyframes` + 0% { + transform: scale(0); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +`; + +const outAnimation = keyframes` + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(0); + opacity: 0; + } +`; + +export default function CustomAnimatedSnackbar() { + const [open, setOpen] = React.useState(false); + + const animationDuration = 600; + + const handleClick = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( +
+ + + I love this animation! + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/PositionedSnackbar.js b/docs/data/joy/components/snackbar/PositionedSnackbar.js new file mode 100644 index 00000000000000..da5ad2379fbc40 --- /dev/null +++ b/docs/data/joy/components/snackbar/PositionedSnackbar.js @@ -0,0 +1,105 @@ +import * as React from 'react'; +import Grid from '@mui/joy/Grid'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Snackbar from '@mui/joy/Snackbar'; +import NorthWestIcon from '@mui/icons-material/NorthWest'; +import NorthEastIcon from '@mui/icons-material/NorthEast'; +import NorthIcon from '@mui/icons-material/North'; +import SouthIcon from '@mui/icons-material/South'; +import SouthEastIcon from '@mui/icons-material/SouthEast'; +import SouthWestIcon from '@mui/icons-material/SouthWest'; + +export default function PositionedSnackbar() { + const [state, setState] = React.useState({ + open: false, + vertical: 'top', + horizontal: 'center', + }); + const { vertical, horizontal, open } = state; + + const handleClick = (newState) => () => { + setState({ ...newState, open: true }); + }; + + const handleClose = () => { + setState({ ...state, open: false }); + }; + + const buttons = ( + + + + + + + + + + + + + + + + + + + + + + + ); + + return ( + + {buttons} + + I love snacks + + + ); +} diff --git a/docs/data/joy/components/snackbar/PositionedSnackbar.tsx b/docs/data/joy/components/snackbar/PositionedSnackbar.tsx new file mode 100644 index 00000000000000..12b7e695bdc0d1 --- /dev/null +++ b/docs/data/joy/components/snackbar/PositionedSnackbar.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import Grid from '@mui/joy/Grid'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Snackbar, { SnackbarOrigin } from '@mui/joy/Snackbar'; +import NorthWestIcon from '@mui/icons-material/NorthWest'; +import NorthEastIcon from '@mui/icons-material/NorthEast'; +import NorthIcon from '@mui/icons-material/North'; +import SouthIcon from '@mui/icons-material/South'; +import SouthEastIcon from '@mui/icons-material/SouthEast'; +import SouthWestIcon from '@mui/icons-material/SouthWest'; + +interface State extends SnackbarOrigin { + open: boolean; +} + +export default function PositionedSnackbar() { + const [state, setState] = React.useState({ + open: false, + vertical: 'top', + horizontal: 'center', + }); + const { vertical, horizontal, open } = state; + + const handleClick = (newState: SnackbarOrigin) => () => { + setState({ ...newState, open: true }); + }; + + const handleClose = () => { + setState({ ...state, open: false }); + }; + + const buttons = ( + + + + + + + + + + + + + + + + + + + + + + + ); + + return ( + + {buttons} + + I love snacks + + + ); +} diff --git a/docs/data/joy/components/snackbar/PositionedSnackbar.tsx.preview b/docs/data/joy/components/snackbar/PositionedSnackbar.tsx.preview new file mode 100644 index 00000000000000..55489f2580350a --- /dev/null +++ b/docs/data/joy/components/snackbar/PositionedSnackbar.tsx.preview @@ -0,0 +1,9 @@ +{buttons} + + I love snacks + \ No newline at end of file diff --git a/docs/data/joy/components/snackbar/SnackbarCloseReason.js b/docs/data/joy/components/snackbar/SnackbarCloseReason.js new file mode 100644 index 00000000000000..946bfa482c30d2 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarCloseReason.js @@ -0,0 +1,79 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import List from '@mui/joy/List'; +import ListItem from '@mui/joy/ListItem'; +import Typography from '@mui/joy/Typography'; +import Stack from '@mui/joy/Stack'; +import Snackbar from '@mui/joy/Snackbar'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; + +export default function SnackbarCloseReason() { + const [open, setOpen] = React.useState(false); + const [reasons, setReasons] = React.useState([]); + React.useEffect(() => { + if ( + ['timeout', 'clickaway', 'escapeKeyDown'].every((item) => + reasons.includes(item), + ) + ) { + setOpen(false); + } + }, [reasons]); + return ( +
+ + { + setReasons((prev) => [...new Set([...prev, reason])]); + }} + onUnmount={() => { + setReasons([]); + }} + sx={{ minWidth: 360 }} + > + + + To close this snackbar, you have to: + + + + {reasons.includes('timeout') ? ( + + ) : ( + + )}{' '} + Wait for 3 seconds. + + + {reasons.includes('clickaway') ? ( + + ) : ( + + )}{' '} + Click outside of the snackbar. + + + {reasons.includes('escapeKeyDown') ? ( + + ) : ( + + )}{' '} + Press ESC key. + + + + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarCloseReason.tsx b/docs/data/joy/components/snackbar/SnackbarCloseReason.tsx new file mode 100644 index 00000000000000..91709fbe4fde16 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarCloseReason.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import List from '@mui/joy/List'; +import ListItem from '@mui/joy/ListItem'; +import Typography from '@mui/joy/Typography'; +import Stack from '@mui/joy/Stack'; +import Snackbar, { + SnackbarCloseReason as SnackbarCloseReasonType, +} from '@mui/joy/Snackbar'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; + +export default function SnackbarCloseReason() { + const [open, setOpen] = React.useState(false); + const [reasons, setReasons] = React.useState([]); + React.useEffect(() => { + if ( + (['timeout', 'clickaway', 'escapeKeyDown'] as const).every((item) => + reasons.includes(item), + ) + ) { + setOpen(false); + } + }, [reasons]); + return ( +
+ + { + // @ts-ignore + setReasons((prev) => [...new Set([...prev, reason])]); + }} + onUnmount={() => { + setReasons([]); + }} + sx={{ minWidth: 360 }} + > + + + To close this snackbar, you have to: + + + + {reasons.includes('timeout') ? ( + + ) : ( + + )}{' '} + Wait for 3 seconds. + + + {reasons.includes('clickaway') ? ( + + ) : ( + + )}{' '} + Click outside of the snackbar. + + + {reasons.includes('escapeKeyDown') ? ( + + ) : ( + + )}{' '} + Press ESC key. + + + + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarColors.js b/docs/data/joy/components/snackbar/SnackbarColors.js new file mode 100644 index 00000000000000..e2b67594236ef2 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarColors.js @@ -0,0 +1,58 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; +import Snackbar from '@mui/joy/Snackbar'; + +export default function SnackbarColors() { + const [open, setOpen] = React.useState(false); + const [variant, setVariant] = React.useState('outlined'); + const [color, setColor] = React.useState('neutral'); + return ( + + + + {['primary', 'neutral', 'danger', 'success', 'warning'].map( + (currentColor) => ( + + ), + )} + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + {variant} snackbar with {color} color. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarColors.tsx b/docs/data/joy/components/snackbar/SnackbarColors.tsx new file mode 100644 index 00000000000000..d060f961f25c33 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarColors.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; +import Snackbar, { SnackbarProps } from '@mui/joy/Snackbar'; + +export default function SnackbarColors() { + const [open, setOpen] = React.useState(false); + const [variant, setVariant] = React.useState('outlined'); + const [color, setColor] = React.useState('neutral'); + return ( + + + + {(['primary', 'neutral', 'danger', 'success', 'warning'] as const).map( + (currentColor) => ( + + ), + )} + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + {variant} snackbar with {color} color. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarHideDuration.js b/docs/data/joy/components/snackbar/SnackbarHideDuration.js new file mode 100644 index 00000000000000..8c3968bd9bd7e3 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarHideDuration.js @@ -0,0 +1,83 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import Input from '@mui/joy/Input'; +import Stack from '@mui/joy/Stack'; +import Snackbar from '@mui/joy/Snackbar'; + +export default function SnackbarHideDuration() { + const [open, setOpen] = React.useState(false); + const [duration, setDuration] = React.useState(); + const [left, setLeft] = React.useState(); + const timer = React.useRef(); + const countdown = () => { + timer.current = window.setInterval(() => { + setLeft((prev) => (prev === undefined ? prev : Math.max(0, prev - 100))); + }, 100); + }; + React.useEffect(() => { + if (open && duration !== undefined && duration > 0) { + setLeft(duration); + countdown(); + } else { + window.clearInterval(timer.current); + } + }, [open, duration]); + const handlePause = () => { + window.clearInterval(timer.current); + }; + const handleResume = () => { + countdown(); + }; + return ( +
+ + + + Auto Hide Duration (ms) + + { + setDuration(event.target.valueAsNumber || undefined); + }} + /> + + + + setLeft(undefined)} + open={open} + onClose={() => { + setOpen(false); + }} + > + This snackbar will{' '} + {left !== undefined + ? `disappear in ${left}ms` + : `not disappear until you click away`} + . + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarHideDuration.tsx b/docs/data/joy/components/snackbar/SnackbarHideDuration.tsx new file mode 100644 index 00000000000000..c98174c2cf33c5 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarHideDuration.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import Input from '@mui/joy/Input'; +import Stack from '@mui/joy/Stack'; +import Snackbar from '@mui/joy/Snackbar'; + +export default function SnackbarHideDuration() { + const [open, setOpen] = React.useState(false); + const [duration, setDuration] = React.useState(); + const [left, setLeft] = React.useState(); + const timer = React.useRef(); + const countdown = () => { + timer.current = window.setInterval(() => { + setLeft((prev) => (prev === undefined ? prev : Math.max(0, prev - 100))); + }, 100); + }; + React.useEffect(() => { + if (open && duration !== undefined && duration > 0) { + setLeft(duration); + countdown(); + } else { + window.clearInterval(timer.current); + } + }, [open, duration]); + const handlePause = () => { + window.clearInterval(timer.current); + }; + const handleResume = () => { + countdown(); + }; + return ( +
+ + + + Auto Hide Duration (ms) + + { + setDuration(event.target.valueAsNumber || undefined); + }} + /> + + + + setLeft(undefined)} + open={open} + onClose={() => { + setOpen(false); + }} + > + This snackbar will{' '} + {left !== undefined + ? `disappear in ${left}ms` + : `not disappear until you click away`} + . + +
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarInvertedColors.js b/docs/data/joy/components/snackbar/SnackbarInvertedColors.js new file mode 100644 index 00000000000000..7981fdf74f3b13 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarInvertedColors.js @@ -0,0 +1,50 @@ +import * as React from 'react'; +import Snackbar from '@mui/joy/Snackbar'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Typography from '@mui/joy/Typography'; + +export default function SnackbarInvertedColors() { + const [open, setOpen] = React.useState(false); + + return ( + + + setOpen(false)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + sx={(theme) => ({ + background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`, + maxWidth: 360, + })} + > +
+ Hey, Wait!! + + Are you sure, you want to leave this page without confirming your order? + + + + + +
+
+
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarInvertedColors.tsx b/docs/data/joy/components/snackbar/SnackbarInvertedColors.tsx new file mode 100644 index 00000000000000..7981fdf74f3b13 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarInvertedColors.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import Snackbar from '@mui/joy/Snackbar'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Typography from '@mui/joy/Typography'; + +export default function SnackbarInvertedColors() { + const [open, setOpen] = React.useState(false); + + return ( + + + setOpen(false)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + sx={(theme) => ({ + background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`, + maxWidth: 360, + })} + > +
+ Hey, Wait!! + + Are you sure, you want to leave this page without confirming your order? + + + + + +
+
+
+ ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarSizes.js b/docs/data/joy/components/snackbar/SnackbarSizes.js new file mode 100644 index 00000000000000..3eb71979e16564 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarSizes.js @@ -0,0 +1,59 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Snackbar from '@mui/joy/Snackbar'; + +export default function SnackbarSizes() { + const [open, setOpen] = React.useState(false); + const [size, setSize] = React.useState('md'); + return ( + + + + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + A snackbar with {size} size. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarSizes.tsx b/docs/data/joy/components/snackbar/SnackbarSizes.tsx new file mode 100644 index 00000000000000..0c211bd82dcf3e --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarSizes.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Snackbar, { SnackbarProps } from '@mui/joy/Snackbar'; + +export default function SnackbarSizes() { + const [open, setOpen] = React.useState(false); + const [size, setSize] = React.useState('md'); + return ( + + + + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + A snackbar with {size} size. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarUsage.js b/docs/data/joy/components/snackbar/SnackbarUsage.js new file mode 100644 index 00000000000000..8e202f3be6304e --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarUsage.js @@ -0,0 +1,78 @@ +import * as React from 'react'; +import Snackbar from '@mui/joy/Snackbar'; +import Button from '@mui/joy/Button'; +import IconButton from '@mui/joy/IconButton'; +import Typography from '@mui/joy/Typography'; +import Close from '@mui/icons-material/Close'; +import JoyUsageDemo from 'docs/src/modules/components/JoyUsageDemo'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; + +export default function SnackbarUsage() { + const [open, setOpen] = React.useState(false); + + return ( + ( + + + { + if (reason === 'clickaway') { + return; + } + + setOpen(false); + }} + startDecorator={} + endDecorator={ + setOpen(false)} + sx={{ color: 'inherit', '--Icon-color': 'inherit' }} + > + + + } + {...props} + > +
+ + Notification alert + + + 102 unread messages since last month. + +
+
+
+ )} + /> + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarVariants.js b/docs/data/joy/components/snackbar/SnackbarVariants.js new file mode 100644 index 00000000000000..de7d0346ceda84 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarVariants.js @@ -0,0 +1,66 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Snackbar from '@mui/joy/Snackbar'; + +export default function SnackbarVariants() { + const [open, setOpen] = React.useState(false); + const [variant, setVariant] = React.useState('outlined'); + return ( + + + + + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + A snackbar with {variant} variant. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarVariants.tsx b/docs/data/joy/components/snackbar/SnackbarVariants.tsx new file mode 100644 index 00000000000000..b1163c36ae7d0f --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarVariants.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Snackbar, { SnackbarProps } from '@mui/joy/Snackbar'; + +export default function SnackbarVariants() { + const [open, setOpen] = React.useState(false); + const [variant, setVariant] = React.useState('outlined'); + return ( + + + + + + { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }} + > + A snackbar with {variant} variant. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarWithDecorators.js b/docs/data/joy/components/snackbar/SnackbarWithDecorators.js new file mode 100644 index 00000000000000..99d1da756c28f0 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarWithDecorators.js @@ -0,0 +1,36 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Snackbar from '@mui/joy/Snackbar'; +import PlaylistAddCheckCircleRoundedIcon from '@mui/icons-material/PlaylistAddCheckCircleRounded'; + +export default function SnackbarWithDecorators() { + const [open, setOpen] = React.useState(false); + + return ( + + + setOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + startDecorator={} + endDecorator={ + + } + > + Your message was sent successfully. + + + ); +} diff --git a/docs/data/joy/components/snackbar/SnackbarWithDecorators.tsx b/docs/data/joy/components/snackbar/SnackbarWithDecorators.tsx new file mode 100644 index 00000000000000..99d1da756c28f0 --- /dev/null +++ b/docs/data/joy/components/snackbar/SnackbarWithDecorators.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Snackbar from '@mui/joy/Snackbar'; +import PlaylistAddCheckCircleRoundedIcon from '@mui/icons-material/PlaylistAddCheckCircleRounded'; + +export default function SnackbarWithDecorators() { + const [open, setOpen] = React.useState(false); + + return ( + + + setOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + startDecorator={} + endDecorator={ + + } + > + Your message was sent successfully. + + + ); +} diff --git a/docs/data/joy/components/snackbar/snackbar.md b/docs/data/joy/components/snackbar/snackbar.md index abe0396b0854b9..b7e4ca61740a7f 100644 --- a/docs/data/joy/components/snackbar/snackbar.md +++ b/docs/data/joy/components/snackbar/snackbar.md @@ -1,76 +1,125 @@ --- productId: joy-ui title: React Snackbar component +components: Snackbar githubLabel: 'component: snackbar' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/alert/ --- # Snackbar

The Snackbar, also commonly referred to as Toast, component informs users that an action has been or will be performed by the app.

+{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +## Introduction + +Snackbars are designed to provide brief, non-intrusive notifications to users, informing them about processes an app has performed or will perform. + +By default, the snackbar is displayed in the lower-right corner of the screen. They are designed not to disrupt the user's workflow and can be dismissed automatically without the need of any user interaction. + +Snackbars contain a single line of text directly related to the operation performed. They can contain text actions, but no icons. + +{{"demo": "SnackbarUsage.js", "hideToolbar": true, "bg": "gradient"}} + +## Basics + +```jsx +import Snackbar from '@mui/joy/Snackbar'; +``` + +### Position + +The position of the snackbar can be controlled by specifying the `anchorOrigin` prop. + +In wider layouts, snackbars can be aligned to the left or centered, especially if they are consistently positioned in a specific location at the bottom of the screen. However, in some cases, you may need more flexible positioning. + +{{"demo": "PositionedSnackbar.js"}} + +## Customization + +### Variants + +The Snackbar component supports Joy UI's four [global variants](/joy-ui/main-features/global-variants/): `plain`, `outlined` (default), `soft`, and `solid`. + +{{"demo": "SnackbarVariants.js"}} + :::info -The Joy UI Snackbar component is still in development. -If you're in need of it, please upvote **[this GitHub issue](https://github.com/mui/material-ui/issues/36603)** to help us prioritize the next batch of new components. +To learn how to add your own variants, check out [Themed components—Extend variants](/joy-ui/customization/themed-components/#extend-variants). +Note that you lose the global variants when you add custom variants. ::: -## Integration with headless UI libraries +### Sizes + +The Snackbar component comes in three sizes: `sm`, `md` (default), and `lg`. + +{{"demo": "SnackbarSizes.js"}} + +:::info +To learn how to add custom sizes to the component, check out [Themed components—Extend sizes](/joy-ui/customization/themed-components/#extend-sizes). +::: + +### Colors + +Every palette included in the theme is available via the `color` prop. +Play around combining different colors with different variants. + +{{"demo": "SnackbarColors.js"}} + +### Hide duration + +Use `autoHideDuration` prop to control how long the Snackbar is displayed. If it is not provided, the Snackbar will be displayed until the user dismisses it. -In the meantime, you can still adopt Joy UI today for building a snackbar! +{{"demo": "SnackbarHideDuration.js"}} -This document shows how to construct it with existing Joy UI components combined with popular headless UI libraries. +### Close reason -### Parting from the Alert component +There are three reasons for the Snackbar to close: -Joy UI's [`Alert`](/joy-ui/react-alert/) component is perfect for building a snackbar (or toast) because of the default role—`alert` and support for decorators. +- `timeout`: The Snackbar is closed after the `autoHideDuration` prop timer expires. +- `clickaway`: The Snackbar is closed when the user interacts outside of the Snackbar. +- `escapeKeyDown`: The Snackbar is closed when the user presses the escape key. -### With Radix UI +You can access the value from the second argument of the `onClose` callback. -Using Joy UI's Alert component as a starting point, pass Radix UI's Toast to component prop. -Radix will enhance the functionalities by preserving the styles of Joy UI components. +```js + { + // reason will be one of: timeout, clickaway, escapeKeyDown +}}> +``` -Animation is created by targeting `data-*` attributes injected by Radix UI's `Toast.Root` component. -In this demo, it uses `@mui/system` keyframes API, same as emotion's keyframes, to build the animation stylesheet. +{{"demo": "SnackbarCloseReason.js"}} -- [Install Radix UI's Toast](https://www.radix-ui.com/primitives/docs/components/toast#installation) -- [Toast component documentation](https://www.radix-ui.com/primitives/docs/components/toast) +#### Ignore clickaway - +This pattern is useful when you don't want the Snackbar to close when the user clicks outside of it. -### With React Aria +```js + { + if (reason === 'clickaway') { + return; + } + }} +> +``` -React Aria provides the `useToast` hook that can be used with Joy UI's `Alert` component. +### Decorators -- [Install React Aria's Toast](https://react-spectrum.adobe.com/react-aria/useToast.html) -- [Toast component documentation](https://react-spectrum.adobe.com/react-aria/useToast.html#features) +Use the `startDecorator` and `endDecorator` props to append icons and/or actions to either side of the Snackbar. - +{{"demo": "SnackbarWithDecorators.js"}} -### With Sonner +### Inverted colors -[Sonner](https://sonner.emilkowal.ski/), an opinionated toast component for React, comes with features like stackable toasts and swipe-to-dismiss animation. +When the Snackbar's variant is `soft` or `solid`, you can set `invertedColors` prop to `true` to invert the colors of the children for increasing the contrast. -To use Sonner with Joy UI, override Sonner's CSS variables with Joy UI color tokens by targeting `[data-sonner-toaster][data-theme]` selector. +To learn more about this, check out [Color Inversion](/joy-ui/main-features/color-inversion/) feature. -You can also pass Joy UI's Alert component to [`toast.custom()`](https://github.com/emilkowalski/sonner#headless) to fully control the structure of the notification. +{{"demo": "SnackbarInvertedColors.js"}} -Lastly, it is also possible to enhance Sonner's `toast` function with a new method -that closely resemble Joy UI's semantics (i.e.: adding a `warning` toast). +### Animation -- [Sonner documentation](https://github.com/emilkowalski/sonner#introduction) +To apply a custom animation, provide the `animationDuration` prop, which we'll use to match the component's unmount animation accurately. - +{{"demo": "CustomAnimatedSnackbar.js"}} diff --git a/docs/data/joy/pages.ts b/docs/data/joy/pages.ts index 4e5940a7a5d264..f09ab1756f8ea7 100644 --- a/docs/data/joy/pages.ts +++ b/docs/data/joy/pages.ts @@ -74,7 +74,7 @@ const pages: readonly MuiPage[] = [ { pathname: '/joy-ui/react-linear-progress', title: 'Linear Progress' }, { pathname: '/joy-ui/react-modal' }, { pathname: '/joy-ui/react-skeleton', newFeature: true }, - { pathname: '/joy-ui/react-snackbar', planned: true }, + { pathname: '/joy-ui/react-snackbar', newFeature: true }, ], }, { diff --git a/docs/data/joy/pagesApi.js b/docs/data/joy/pagesApi.js index 792274682953a3..ae524c077f845c 100644 --- a/docs/data/joy/pagesApi.js +++ b/docs/data/joy/pagesApi.js @@ -59,6 +59,7 @@ module.exports = [ { pathname: '/joy-ui/api/sheet' }, { pathname: '/joy-ui/api/skeleton' }, { pathname: '/joy-ui/api/slider' }, + { pathname: '/joy-ui/api/snackbar' }, { pathname: '/joy-ui/api/stack' }, { pathname: '/joy-ui/api/svg-icon' }, { pathname: '/joy-ui/api/switch' }, diff --git a/docs/pages/base-ui/api/snackbar.json b/docs/pages/base-ui/api/snackbar.json index 7fbded8b892229..709e8a25b5dde2 100644 --- a/docs/pages/base-ui/api/snackbar.json +++ b/docs/pages/base-ui/api/snackbar.json @@ -44,6 +44,6 @@ "forwardsRefTo": "HTMLDivElement", "filename": "/packages/mui-base/src/Snackbar/Snackbar.tsx", "inheritance": null, - "demos": "", + "demos": "", "cssComponent": false } diff --git a/docs/pages/joy-ui/api/snackbar.js b/docs/pages/joy-ui/api/snackbar.js new file mode 100644 index 00000000000000..025d0c4d798970 --- /dev/null +++ b/docs/pages/joy-ui/api/snackbar.js @@ -0,0 +1,19 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './snackbar.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context('docs/translations/api-docs-joy/snackbar', false, /snackbar.*.json$/); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/joy-ui/api/snackbar.json b/docs/pages/joy-ui/api/snackbar.json new file mode 100644 index 00000000000000..30f37cd03097cf --- /dev/null +++ b/docs/pages/joy-ui/api/snackbar.json @@ -0,0 +1,132 @@ +{ + "props": { + "open": { "type": { "name": "bool" }, "required": true }, + "anchorOrigin": { + "type": { + "name": "shape", + "description": "{ horizontal: 'center'
| 'left'
| 'right', vertical: 'bottom'
| 'top' }" + }, + "default": "{ vertical: 'bottom', horizontal: 'right' }" + }, + "animationDuration": { "type": { "name": "number" }, "default": "300" }, + "autoHideDuration": { "type": { "name": "number" }, "default": "null" }, + "color": { + "type": { + "name": "enum", + "description": "'danger'
| 'neutral'
| 'primary'
| 'success'
| 'warning'" + }, + "default": "'neutral'", + "additionalInfo": { "joy-color": true } + }, + "component": { "type": { "name": "elementType" } }, + "disableWindowBlurListener": { "type": { "name": "bool" }, "default": "false" }, + "endDecorator": { "type": { "name": "node" } }, + "invertedColors": { "type": { "name": "bool" }, "default": "false" }, + "key": { "type": { "name": "custom", "description": "any" } }, + "onClose": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.SyntheticEvent | Event, reason: string) => void", + "describedArgs": ["event", "reason"] + } + }, + "onUnmount": { "type": { "name": "func" } }, + "resumeHideDuration": { "type": { "name": "number" } }, + "size": { + "type": { "name": "enum", "description": "'sm'
| 'md'
| 'lg'" }, + "default": "'md'", + "additionalInfo": { "joy-size": true } + }, + "slotProps": { + "type": { + "name": "shape", + "description": "{ clickAway?: func
| { children: element, disableReactTree?: bool, mouseEvent?: 'onClick'
| 'onMouseDown'
| 'onMouseUp'
| 'onPointerDown'
| 'onPointerUp'
| false, onClickAway: func, touchEvent?: 'onTouchEnd'
| 'onTouchStart'
| false }, endDecorator?: func
| object, root?: func
| object, startDecorator?: func
| object }" + }, + "default": "{}" + }, + "slots": { + "type": { + "name": "shape", + "description": "{ clickAway?: elementType, endDecorator?: elementType, root?: elementType, startDecorator?: elementType }" + }, + "default": "{}", + "additionalInfo": { "slotsApi": true } + }, + "startDecorator": { "type": { "name": "node" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + }, + "variant": { + "type": { + "name": "enum", + "description": "'outlined'
| 'plain'
| 'soft'
| 'solid'" + }, + "default": "'outlined'", + "additionalInfo": { "joy-variant": true } + } + }, + "name": "Snackbar", + "imports": ["import Snackbar from '@mui/joy/Snackbar';", "import { Snackbar } from '@mui/joy';"], + "styles": { "classes": [], "globalClasses": {}, "name": "MuiSnackbar" }, + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": ".MuiSnackbar-root" + }, + { + "name": "startDecorator", + "description": "The component that renders the start decorator.", + "default": "'span'", + "class": ".MuiSnackbar-startDecorator" + }, + { + "name": "endDecorator", + "description": "The component that renders the end decorator.", + "default": "'span'", + "class": ".MuiSnackbar-endDecorator" + }, + { + "name": "clickAway", + "description": "The component that renders the click away.", + "default": "ClickAwayListener", + "class": null + } + ], + "classes": { + "classes": [ + "anchorOriginBottomCenter", + "anchorOriginBottomLeft", + "anchorOriginBottomRight", + "anchorOriginTopCenter", + "anchorOriginTopLeft", + "anchorOriginTopRight", + "colorDanger", + "colorNeutral", + "colorPrimary", + "colorSuccess", + "colorWarning", + "sizeLg", + "sizeMd", + "sizeSm", + "variantOutlined", + "variantPlain", + "variantSoft", + "variantSolid" + ], + "globalClasses": {} + }, + "spread": false, + "themeDefaultProps": true, + "muiName": "JoySnackbar", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-joy/src/Snackbar/Snackbar.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/src/modules/components/JoyUsageDemo.tsx b/docs/src/modules/components/JoyUsageDemo.tsx index dcc1eadb069406..00bde782ba070a 100644 --- a/docs/src/modules/components/JoyUsageDemo.tsx +++ b/docs/src/modules/components/JoyUsageDemo.tsx @@ -7,6 +7,7 @@ import Divider from '@mui/joy/Divider'; import Chip from '@mui/joy/Chip'; import FormControl from '@mui/joy/FormControl'; import FormLabel, { formLabelClasses } from '@mui/joy/FormLabel'; +import FormHelperText from '@mui/joy/FormHelperText'; import IconButton from '@mui/joy/IconButton'; import Input, { inputClasses } from '@mui/joy/Input'; import ListItemDecorator, { listItemDecoratorClasses } from '@mui/joy/ListItemDecorator'; @@ -155,6 +156,10 @@ interface JoyUsageDemoProps { * If not provided, the `propName` is displayed as Pascal case. */ formLabel?: string; + /** + * The helper text to be displayed for the knob. + */ + helperText?: string; }>; /** * A function to override the code block result. @@ -296,7 +301,15 @@ export default function JoyUsageDemo({ }} > {data.map( - ({ propName, formLabel = propName, knob, options = [], defaultValue, labels }) => { + ({ + propName, + formLabel = propName, + knob, + options = [], + defaultValue, + labels, + helperText, + }) => { const resolvedValue = props[propName] ?? defaultValue; if (!knob) { return null; @@ -306,10 +319,9 @@ export default function JoyUsageDemo({ - {formLabel} + {formLabel} @@ -328,6 +340,11 @@ export default function JoyUsageDemo({ }, }} /> + {helperText && ( + + {helperText} + + )} ); } @@ -379,6 +396,7 @@ export default function JoyUsageDemo({ ); })} + {helperText && {helperText}} ); } @@ -432,6 +450,7 @@ export default function JoyUsageDemo({ ); })} + {helperText && {helperText}} ); } @@ -513,6 +532,7 @@ export default function JoyUsageDemo({ }, )} + {helperText && {helperText}} ); } @@ -557,6 +577,7 @@ export default function JoyUsageDemo({ ))} + {helperText && {helperText}} ); } @@ -580,6 +601,7 @@ export default function JoyUsageDemo({ }, }} /> + {helperText && {helperText}} ); } @@ -610,6 +632,7 @@ export default function JoyUsageDemo({ }, }} /> + {helperText && {helperText}} ); } @@ -710,6 +733,7 @@ export default function JoyUsageDemo({ ))} + {helperText && {helperText}} ); } diff --git a/docs/translations/api-docs-joy/snackbar/snackbar.json b/docs/translations/api-docs-joy/snackbar/snackbar.json new file mode 100644 index 00000000000000..8fd52978670428 --- /dev/null +++ b/docs/translations/api-docs-joy/snackbar/snackbar.json @@ -0,0 +1,161 @@ +{ + "componentDescription": "", + "propDescriptions": { + "anchorOrigin": { + "description": "The anchor of the Snackbar. On smaller screens, the component grows to occupy all the available width, the horizontal alignment is ignored." + }, + "animationDuration": { + "description": "The duration of the animation in milliseconds. This value is used to control the length of time it takes for an animation to complete one cycle. It is also utilized for delaying the unmount of the component. Provide this value if you have your own animation so that we can precisely time the component's unmount to match your custom animation." + }, + "autoHideDuration": { + "description": "The number of milliseconds to wait before automatically calling the onClose function. onClose should then set the state of the open prop to hide the Snackbar. This behavior is disabled by default with the null value." + }, + "color": { + "description": "The color of the component. It supports those theme colors that make sense for this component." + }, + "component": { + "description": "The component used for the root node. Either a string to use a HTML element or a component." + }, + "disableWindowBlurListener": { + "description": "If true, the autoHideDuration timer will expire even if the window is not focused." + }, + "endDecorator": { "description": "Element placed after the children." }, + "invertedColors": { + "description": "If true, the children with an implicit color prop invert their colors to match the component's variant and color." + }, + "key": { + "description": "When displaying multiple consecutive snackbars using a single parent-rendered <Snackbar/>, add the key prop to ensure independent treatment of each message. For instance, use <Snackbar key={message} />. Otherwise, messages might update in place, and features like autoHideDuration could be affected." + }, + "onClose": { + "description": "Callback fired when the component requests to be closed. Typically onClose is used to set state in the parent component, which is used to control the Snackbar open prop. The reason parameter can optionally be used to control the response to onClose, for example ignoring clickaway.", + "typeDescriptions": { + "event": "The event source of the callback.", + "reason": "Can be: "timeout" (autoHideDuration expired), "clickaway", or "escapeKeyDown"." + } + }, + "onUnmount": { "description": "A callback fired when the component is about to be unmounted." }, + "open": { "description": "If true, the component is shown." }, + "resumeHideDuration": { + "description": "The number of milliseconds to wait before dismissing after user interaction. If autoHideDuration prop isn't specified, it does nothing. If autoHideDuration prop is specified but resumeHideDuration isn't, we default to autoHideDuration / 2 ms." + }, + "size": { "description": "The size of the component." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "startDecorator": { "description": "Element placed before the children." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + }, + "variant": { + "description": "The global variant to use." + } + }, + "classDescriptions": { + "root": { "description": "Class name applied to the root element." }, + "anchorOriginTopCenter": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'top', 'center' }}" + }, + "anchorOriginBottomCenter": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'bottom', 'center' }}" + }, + "anchorOriginTopRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'top', 'right' }}" + }, + "anchorOriginBottomRight": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'bottom', 'right' }}" + }, + "anchorOriginTopLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'top', 'left' }}" + }, + "anchorOriginBottomLeft": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "anchorOrigin={{ 'bottom', 'left' }}" + }, + "colorPrimary": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "color=\"primary\"" + }, + "colorDanger": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "color=\"danger\"" + }, + "colorNeutral": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "color=\"neutral\"" + }, + "colorSuccess": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "color=\"success\"" + }, + "colorWarning": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "color=\"warning\"" + }, + "endDecorator": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the endDecorator element", + "conditions": "supplied" + }, + "sizeSm": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "size=\"sm\"" + }, + "sizeMd": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "size=\"md\"" + }, + "sizeLg": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "size=\"lg\"" + }, + "startDecorator": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the startDecorator element", + "conditions": "supplied" + }, + "variantPlain": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "variant=\"plain\"" + }, + "variantOutlined": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "variant=\"outlined\"" + }, + "variantSoft": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "variant=\"soft\"" + }, + "variantSolid": { + "description": "Class name applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "variant=\"solid\"" + } + }, + "slotDescriptions": { + "root": "The component that renders the root.", + "startDecorator": "The component that renders the start decorator.", + "endDecorator": "The component that renders the end decorator.", + "clickAway": "The component that renders the click away." + } +} diff --git a/packages/mui-base/src/Snackbar/Snackbar.tsx b/packages/mui-base/src/Snackbar/Snackbar.tsx index 8593516d21a247..1ba54db99b2045 100644 --- a/packages/mui-base/src/Snackbar/Snackbar.tsx +++ b/packages/mui-base/src/Snackbar/Snackbar.tsx @@ -27,6 +27,7 @@ const useUtilityClasses = () => { * Demos: * * - [Snackbar](https://mui.com/base-ui/react-snackbar/) + * - [Snackbar](https://mui.com/joy-ui/react-snackbar/) * - [Snackbar](https://mui.com/material-ui/react-snackbar/) * * API: diff --git a/packages/mui-joy/src/Snackbar/Snackbar.test.tsx b/packages/mui-joy/src/Snackbar/Snackbar.test.tsx new file mode 100644 index 00000000000000..b8c12d8535e237 --- /dev/null +++ b/packages/mui-joy/src/Snackbar/Snackbar.test.tsx @@ -0,0 +1,452 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { describeConformance, createRenderer, fireEvent, act } from '@mui-internal/test-utils'; +import Snackbar, { snackbarClasses as classes } from '@mui/joy/Snackbar'; +import { ThemeProvider } from '@mui/joy/styles'; + +describe('Joy ', () => { + const { render: clientRender, clock } = createRenderer({ clock: 'fake' }); + + /** + * @type {typeof plainRender extends (...args: infer T) => any ? T : never} args + * + * @remarks + * This is for all intents and purposes the same as our client render method. + * `plainRender` is already wrapped in act(). + * However, React has a bug that flushes effects in a portal synchronously. + * We have to defer the effect manually like `useEffect` would so we have to flush the effect manually instead of relying on `act()`. + * React bug: https://github.com/facebook/react/issues/20074 + */ + function render(...args: [React.ReactElement]) { + const result = clientRender(...args); + clock.tick(0); + return result; + } + + describeConformance( + + Hello World! + , + () => ({ + render, + classes, + ThemeProvider, + muiName: 'JoySnackbar', + refInstanceof: window.HTMLDivElement, + testVariantProps: { variant: 'solid' }, + slots: { + root: { expectedClassName: classes.root }, + startDecorator: { expectedClassName: classes.startDecorator }, + endDecorator: { expectedClassName: classes.endDecorator }, + }, + skip: ['propsSpread', 'componentsProp', 'classesRoot'], + }), + ); + + describe('prop: onClose', () => { + it('should be called when clicking away', () => { + const handleClose = spy(); + render( + + Message + , + ); + + const event = new window.Event('click', { bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([event, 'clickaway']); + }); + + it('should be called when pressing Escape', () => { + const handleClose = spy(); + render( + + Message + , + ); + + expect(fireEvent.keyDown(document.body, { key: 'Escape' })).to.equal(true); + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0][1]).to.equal('escapeKeyDown'); + }); + + it('can limit which Snackbars are closed when pressing Escape', () => { + const handleCloseA = spy((event) => event.preventDefault()); + const handleCloseB = spy(); + render( + + + Message A + + + Message B + + , + ); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + + expect(handleCloseA.callCount).to.equal(1); + expect(handleCloseB.callCount).to.equal(0); + }); + }); + + describe('prop: autoHideDuration', () => { + it('should call onClose when the timer is done', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + const { setProps } = render( + + Message + , + ); + + setProps({ open: true }); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + + it('calls onClose at timeout even if the prop changes', () => { + const handleClose1 = spy(); + const handleClose2 = spy(); + const autoHideDuration = 2e3; + const { setProps } = render( + + Message + , + ); + + setProps({ open: true }); + clock.tick(autoHideDuration / 2); + setProps({ open: true, onClose: handleClose2 }); + clock.tick(autoHideDuration / 2); + + expect(handleClose1.callCount).to.equal(0); + expect(handleClose2.callCount).to.equal(1); + }); + + it('should not call onClose when the autoHideDuration is reset', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + const { setProps } = render( + + Message + , + ); + + setProps({ open: true }); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration / 2); + setProps({ autoHideDuration: undefined }); + clock.tick(autoHideDuration / 2); + + expect(handleClose.callCount).to.equal(0); + }); + + it('should not call onClose if autoHideDuration is undefined', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + render( + + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(0); + }); + + it('should not call onClose if autoHideDuration is null', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + + render( + + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(0); + }); + + it('should not call onClose when closed', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + + const { setProps } = render( + + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration / 2); + setProps({ open: false }); + clock.tick(autoHideDuration / 2); + + expect(handleClose.callCount).to.equal(0); + }); + }); + + [ + { + type: 'mouse', + enter: (container: HTMLElement) => fireEvent.mouseEnter(container.querySelector('button')!), + leave: (container: HTMLElement) => fireEvent.mouseLeave(container.querySelector('button')!), + }, + { + type: 'keyboard', + enter: (container: HTMLElement) => act(() => container.querySelector('button')!.focus()), + leave: (container: HTMLElement) => act(() => container.querySelector('button')!.blur()), + }, + ].forEach((userInteraction) => { + describe(`interacting with ${userInteraction.type}`, () => { + it('should be able to interrupt the timer', () => { + const handleMouseEnter = spy(); + const handleMouseLeave = spy(); + const handleBlur = spy(); + const handleFocus = spy(); + const handleClose = spy(); + const autoHideDuration = 2e3; + + const { container } = render( + undo} + open + onBlur={handleBlur} + onFocus={handleFocus} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onClose={handleClose} + autoHideDuration={autoHideDuration} + > + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration / 2); + userInteraction.enter(container.querySelector('div')!); + + if (userInteraction.type === 'keyboard') { + expect(handleFocus.callCount).to.equal(1); + } else { + expect(handleMouseEnter.callCount).to.equal(1); + } + + clock.tick(autoHideDuration / 2); + userInteraction.leave(container.querySelector('div')!); + + if (userInteraction.type === 'keyboard') { + expect(handleBlur.callCount).to.equal(1); + } else { + expect(handleMouseLeave.callCount).to.equal(1); + } + expect(handleClose.callCount).to.equal(0); + + clock.tick(2e3); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + + it('should not call onClose with not timeout after user interaction', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + const resumeHideDuration = 3e3; + + const { container } = render( + undo} + open + onClose={handleClose} + autoHideDuration={autoHideDuration} + resumeHideDuration={resumeHideDuration} + > + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration / 2); + userInteraction.enter(container.querySelector('div')!); + clock.tick(autoHideDuration / 2); + userInteraction.leave(container.querySelector('div')!); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(2e3); + + expect(handleClose.callCount).to.equal(0); + }); + + it('should call onClose when timer done after user interaction', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + const resumeHideDuration = 3e3; + + const { container } = render( + undo} + open + onClose={handleClose} + autoHideDuration={autoHideDuration} + resumeHideDuration={resumeHideDuration} + > + Message + , + ); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration / 2); + userInteraction.enter(container.querySelector('div')!); + clock.tick(autoHideDuration / 2); + userInteraction.leave(container.querySelector('div')!); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(resumeHideDuration); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + + it('should call onClose immediately after user interaction when 0', () => { + const handleClose = spy(); + const autoHideDuration = 6e3; + const resumeHideDuration = 0; + const { setProps, container } = render( + undo} + open + onClose={handleClose} + autoHideDuration={autoHideDuration} + resumeHideDuration={resumeHideDuration} + > + Message + , + ); + + setProps({ open: true }); + + expect(handleClose.callCount).to.equal(0); + + userInteraction.enter(container.querySelector('div')!); + clock.tick(100); + userInteraction.leave(container.querySelector('div')!); + clock.tick(resumeHideDuration); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + }); + }); + + describe('prop: disableWindowBlurListener', () => { + it('should pause auto hide when not disabled and window lost focus', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + render( + + Message + , + ); + + act(() => { + const bEvent = new window.Event('blur', { + bubbles: false, + cancelable: false, + }); + window.dispatchEvent(bEvent); + }); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(0); + + act(() => { + const fEvent = new window.Event('focus', { + bubbles: false, + cancelable: false, + }); + window.dispatchEvent(fEvent); + }); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + + it('should not pause auto hide when disabled and window lost focus', () => { + const handleClose = spy(); + const autoHideDuration = 2e3; + render( + + Message + , + ); + + act(() => { + const event = new window.Event('blur', { bubbles: false, cancelable: false }); + window.dispatchEvent(event); + }); + + expect(handleClose.callCount).to.equal(0); + + clock.tick(autoHideDuration); + + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0]).to.deep.equal([null, 'timeout']); + }); + }); + + describe('prop: open', () => { + it('should not render anything when closed', () => { + const { container } = render(Hello World!); + expect(container).to.have.text(''); + }); + + it('should be able show it after mounted', () => { + const { container, setProps } = render(Hello World!); + expect(container).to.have.text(''); + setProps({ open: true }); + expect(container).to.have.text('Hello World!'); + }); + }); +}); diff --git a/packages/mui-joy/src/Snackbar/Snackbar.tsx b/packages/mui-joy/src/Snackbar/Snackbar.tsx new file mode 100644 index 00000000000000..998af3fa07a161 --- /dev/null +++ b/packages/mui-joy/src/Snackbar/Snackbar.tsx @@ -0,0 +1,487 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { unstable_composeClasses as composeClasses } from '@mui/base'; +import { ClickAwayListener } from '@mui/base/ClickAwayListener'; +import { useSnackbar } from '@mui/base/useSnackbar'; +import { unstable_capitalize as capitalize } from '@mui/utils'; +import { OverridableComponent } from '@mui/types'; +import { keyframes } from '@mui/system'; +import useSlot from '../utils/useSlot'; +import styled from '../styles/styled'; +import { useThemeProps } from '../styles'; +import { resolveSxValue } from '../styles/styleUtils'; +import { applySolidInversion, applySoftInversion } from '../colorInversion'; +import { SnackbarProps, SnackbarOwnerState, SnackbarTypeMap } from './SnackbarProps'; +import { getSnackbarUtilityClass } from './snackbarClasses'; + +const useUtilityClasses = (ownerState: SnackbarOwnerState) => { + const { variant, color, size, anchorOrigin } = ownerState; + + const slots = { + root: [ + 'root', + size && `size${capitalize(size)}`, + color && `color${capitalize(color)}`, + variant && `variant${capitalize(variant)}`, + `anchorOrigin${capitalize(anchorOrigin!.vertical)}${capitalize(anchorOrigin!.horizontal)}`, + ], + startDecorator: ['startDecorator'], + endDecorator: ['endDecorator'], + }; + + return composeClasses(slots, getSnackbarUtilityClass, {}); +}; + +const enterAnimation = keyframes` + 0% { + transform: translateX(var(--Snackbar-translateX, 0px)) translateY(calc(var(--_Snackbar-anchorBottom, 1) * 100%)); + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + transform: translateX(var(--Snackbar-translateX, 0px)) translateY(0); + } +`; + +const exitAnimation = keyframes` + 0% { + transform: translateX(var(--Snackbar-translateX, 0px)) translateY(0); + opacity: 1; + } + 100% { + transform: translateX(var(--Snackbar-translateX, 0px)) translateY(calc(var(--_Snackbar-anchorBottom, 1) * 100%)); + opacity: 0; + } +`; + +const SnackbarRoot = styled('div', { + name: 'JoySnackbar', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})<{ ownerState: SnackbarOwnerState }>(({ theme, ownerState }) => { + const { p, padding, borderRadius } = resolveSxValue({ theme, ownerState }, [ + 'p', + 'padding', + 'borderRadius', + ]); + + return [ + { + '--Snackbar-radius': theme.vars.radius.sm, + '--Snackbar-decoratorChildRadius': + 'max((var(--Snackbar-radius) - var(--variant-borderWidth, 0px)) - var(--Snackbar-padding), min(var(--Snackbar-padding) + var(--variant-borderWidth, 0px), var(--Snackbar-radius) / 2))', + '--Button-minHeight': 'var(--Snackbar-decoratorChildHeight)', + '--IconButton-size': 'var(--Snackbar-decoratorChildHeight)', + '--Button-radius': 'var(--Snackbar-decoratorChildRadius)', + '--IconButton-radius': 'var(--Snackbar-decoratorChildRadius)', + '--Icon-color': 'currentColor', + ...(ownerState.size === 'sm' && { + '--Snackbar-padding': '0.75rem', + '--Snackbar-inset': '0.5rem', + '--Snackbar-decoratorChildHeight': '1.5rem', + '--Icon-fontSize': theme.vars.fontSize.xl, + gap: '0.5rem', + }), + ...(ownerState.size === 'md' && { + '--Snackbar-padding': '1rem', + '--Snackbar-inset': '0.75rem', // the spacing between Snackbar and the viewport + '--Snackbar-decoratorChildHeight': '2rem', + '--Icon-fontSize': theme.vars.fontSize.xl, + gap: '0.625rem', + }), + ...(ownerState.size === 'lg' && { + '--Snackbar-padding': '1.25rem', + '--Snackbar-inset': '1rem', + '--Snackbar-decoratorChildHeight': '2.375rem', + '--Icon-fontSize': theme.vars.fontSize.xl2, + gap: '0.875rem', + }), + zIndex: theme.vars.zIndex.snackbar, + position: 'fixed', + display: 'flex', + alignItems: 'center', + minWidth: 300, + top: ownerState.anchorOrigin?.vertical === 'top' ? 'var(--Snackbar-inset)' : undefined, + left: ownerState.anchorOrigin?.horizontal === 'left' ? 'var(--Snackbar-inset)' : undefined, + bottom: ownerState.anchorOrigin?.vertical === 'bottom' ? 'var(--Snackbar-inset)' : undefined, + right: ownerState.anchorOrigin?.horizontal === 'right' ? 'var(--Snackbar-inset)' : undefined, + ...(ownerState.anchorOrigin?.horizontal === 'center' && { + '--Snackbar-translateX': '-50%', + left: '50%', + transform: 'translateX(var(--Snackbar-translateX))', + }), + ...(ownerState.anchorOrigin?.vertical === 'top' && { + '--_Snackbar-anchorBottom': '-1', + }), + animation: `${enterAnimation} ${ownerState.animationDuration}ms forwards`, + ...(!ownerState.open && { + animationName: exitAnimation, + }), + boxShadow: theme.vars.shadow.lg, + backgroundColor: theme.vars.palette.background.surface, + padding: `var(--Snackbar-padding)`, + borderRadius: 'var(--Snackbar-radius)', + ...theme.typography[`body-${({ sm: 'xs', md: 'sm', lg: 'md' } as const)[ownerState.size!]}`], + ...(ownerState.variant === 'solid' && + ownerState.color && + ownerState.invertedColors && + applySolidInversion(ownerState.color)(theme)), + ...(ownerState.variant === 'soft' && + ownerState.color && + ownerState.invertedColors && + applySoftInversion(ownerState.color)(theme)), + ...theme.variants[ownerState.variant!]?.[ownerState.color!], + } as const, + p !== undefined && { '--Snackbar-padding': p }, + padding !== undefined && { '--Snackbar-padding': padding }, + borderRadius !== undefined && { '--Snackbar-radius': borderRadius }, + ]; +}); + +const SnackbarStartDecorator = styled('span', { + name: 'JoySnackbar', + slot: 'StartDecorator', + overridesResolver: (props, styles) => styles.startDecorator, +})({ + display: 'inherit', + flex: 'none', +}); + +const SnackbarEndDecorator = styled('span', { + name: 'JoySnackbar', + slot: 'EndDecorator', + overridesResolver: (props, styles) => styles.endDecorator, +})({ + display: 'inherit', + flex: 'none', + marginLeft: 'auto', +}); + +const defaultAnchorOrigin = { vertical: 'bottom', horizontal: 'right' } as const; + +/** + * + * Demos: + * + * - [Snackbar](https://mui.com/joy-ui/react-snackbar/) + * + * API: + * + * - [Snackbar API](https://mui.com/joy-ui/api/snackbar/) + */ +const Snackbar = React.forwardRef(function Snackbar(inProps, ref) { + const props = useThemeProps({ + props: inProps, + name: 'JoySnackbar', + }); + + const { + anchorOrigin = defaultAnchorOrigin, + animationDuration = 300, + autoHideDuration = null, + color = 'neutral', + children, + className, + component, + disableWindowBlurListener = false, + endDecorator, + invertedColors = false, + onBlur, + onClose, + onFocus, + onMouseEnter, + onMouseLeave, + onUnmount, + open, + resumeHideDuration, + size = 'md', + slots = {}, + slotProps, + startDecorator, + variant = 'outlined', + ...other + } = props; + + // For animation + const [exited, setExited] = React.useState(true); + + // `exiting` is a state for preventing click away event during exiting + // because there is a case where the Snackbar is exiting and the user open a Snackbar again. + // Without this state, the snack will open and close immediately since click away is called immediately after the click event. + const [exiting, setExiting] = React.useState(false); + + // To call a function when the component is about to be unmounted. + // Useful for preserving content in the Snackbar when undergoing exit animation. + const unmountRef = React.useRef(onUnmount); + unmountRef.current = onUnmount; + + React.useEffect(() => { + if (open) { + setExiting(false); + setExited(false); + } else { + setExiting(true); + const timer = setTimeout(() => { + setExited(true); + setExiting(false); + unmountRef.current?.(); + }, animationDuration); + return () => { + clearTimeout(timer); + }; + } + return undefined; + }, [open, animationDuration]); + + const ownerState = { + ...props, + anchorOrigin, + autoHideDuration, + color, + animationDuration, + disableWindowBlurListener, + invertedColors, + size, + variant, + }; + delete ownerState.onUnmount; // `on*` are considered as event handler which does not work with ClickAwayListener + + const classes = useUtilityClasses(ownerState); + + const { getRootProps, onClickAway } = useSnackbar(ownerState); + + const handleClickAway = (event: React.SyntheticEvent | Event) => { + if (!exiting) { + onClickAway(event); + } + }; + + const externalForwardedProps = { ...other, component, slots, slotProps }; + + const [SlotRoot, rootProps] = useSlot('root', { + ref, + className: clsx(classes.root, className), + elementType: SnackbarRoot, + externalForwardedProps, + getSlotProps: getRootProps, + ownerState, + }); + + const [SlotStartDecorator, startDecoratorProps] = useSlot('startDecorator', { + className: classes.startDecorator, + elementType: SnackbarStartDecorator, + externalForwardedProps, + ownerState, + }); + + const [SlotEndDecorator, endDecoratorProps] = useSlot('endDecorator', { + className: classes.endDecorator, + elementType: SnackbarEndDecorator, + externalForwardedProps, + ownerState, + }); + + const SlotClickAway = slots.clickAway || ClickAwayListener; + + // So we only render active snackbars. + if (!open && exited) { + return null; + } + + return ( + + + {startDecorator && ( + {startDecorator} + )} + + {children} + {endDecorator && {endDecorator}} + + + ); +}) as OverridableComponent; + +Snackbar.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * The anchor of the `Snackbar`. + * On smaller screens, the component grows to occupy all the available width, + * the horizontal alignment is ignored. + * @default { vertical: 'bottom', horizontal: 'right' } + */ + anchorOrigin: PropTypes.shape({ + horizontal: PropTypes.oneOf(['center', 'left', 'right']).isRequired, + vertical: PropTypes.oneOf(['bottom', 'top']).isRequired, + }), + /** + * The duration of the animation in milliseconds. This value is used to control + * the length of time it takes for an animation to complete one cycle. It is also + * utilized for delaying the unmount of the component. + * Provide this value if you have your own animation so that we can precisely + * time the component's unmount to match your custom animation. + * @default 300 + */ + animationDuration: PropTypes.number, + /** + * The number of milliseconds to wait before automatically calling the + * `onClose` function. `onClose` should then set the state of the `open` + * prop to hide the Snackbar. This behavior is disabled by default with + * the `null` value. + * @default null + */ + autoHideDuration: PropTypes.number, + /** + * @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 'neutral' + */ + color: PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']), + /** + * The component used for the root node. + * Either a string to use a HTML element or a component. + */ + component: PropTypes.elementType, + /** + * If `true`, the `autoHideDuration` timer will expire even if the window is not focused. + * @default false + */ + disableWindowBlurListener: PropTypes.bool, + /** + * Element placed after the children. + */ + endDecorator: PropTypes.node, + /** + * If `true`, the children with an implicit color prop invert their colors to match the component's variant and color. + * @default false + */ + invertedColors: PropTypes.bool, + /** + * When displaying multiple consecutive snackbars using a single parent-rendered + * ``, add the `key` prop to ensure independent treatment of each message. + * For instance, use ``. Otherwise, messages might update + * in place, and features like `autoHideDuration` could be affected. + */ + key: () => null, + /** + * @ignore + */ + onBlur: PropTypes.func, + /** + * Callback fired when the component requests to be closed. + * Typically `onClose` is used to set state in the parent component, + * which is used to control the `Snackbar` `open` prop. + * The `reason` parameter can optionally be used to control the response to `onClose`, + * for example ignoring `clickaway`. + * + * @param {React.SyntheticEvent | Event} event The event source of the callback. + * @param {string} reason Can be: `"timeout"` (`autoHideDuration` expired), `"clickaway"`, or `"escapeKeyDown"`. + */ + onClose: PropTypes.func, + /** + * @ignore + */ + onFocus: PropTypes.func, + /** + * @ignore + */ + onMouseEnter: PropTypes.func, + /** + * @ignore + */ + onMouseLeave: PropTypes.func, + /** + * A callback fired when the component is about to be unmounted. + */ + onUnmount: PropTypes.func, + /** + * If `true`, the component is shown. + */ + open: PropTypes.bool.isRequired, + /** + * The number of milliseconds to wait before dismissing after user interaction. + * If `autoHideDuration` prop isn't specified, it does nothing. + * If `autoHideDuration` prop is specified but `resumeHideDuration` isn't, + * we default to `autoHideDuration / 2` ms. + */ + resumeHideDuration: PropTypes.number, + /** + * The size of the component. + * @default 'md' + */ + size: PropTypes.oneOf(['sm', 'md', 'lg']), + /** + * The props used for each slot inside. + * @default {} + */ + slotProps: PropTypes.shape({ + clickAway: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + children: PropTypes.element.isRequired, + disableReactTree: PropTypes.bool, + mouseEvent: PropTypes.oneOf([ + 'onClick', + 'onMouseDown', + 'onMouseUp', + 'onPointerDown', + 'onPointerUp', + false, + ]), + onClickAway: PropTypes.func.isRequired, + touchEvent: PropTypes.oneOf(['onTouchEnd', 'onTouchStart', false]), + }), + ]), + endDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + startDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + * @default {} + */ + slots: PropTypes.shape({ + clickAway: PropTypes.elementType, + endDecorator: PropTypes.elementType, + root: PropTypes.elementType, + startDecorator: PropTypes.elementType, + }), + /** + * Element placed before the children. + */ + startDecorator: PropTypes.node, + /** + * 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 [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use. + * @default 'outlined' + */ + variant: PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']), +} as any; + +export default Snackbar; diff --git a/packages/mui-joy/src/Snackbar/SnackbarProps.ts b/packages/mui-joy/src/Snackbar/SnackbarProps.ts new file mode 100644 index 00000000000000..67ca08cb77691a --- /dev/null +++ b/packages/mui-joy/src/Snackbar/SnackbarProps.ts @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { OverrideProps, OverridableStringUnion } from '@mui/types'; +import { ClickAwayListenerProps } from '@mui/base/ClickAwayListener'; +import { UseSnackbarParameters } from '@mui/base/useSnackbar'; +import { ColorPaletteProp, VariantProp, ApplyColorInversion, SxProps } from '../styles/types'; +import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types'; + +export type SnackbarSlot = 'root' | 'startDecorator' | 'endDecorator' | 'clickAway'; + +export interface SnackbarSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType; + /** + * The component that renders the start decorator. + * @default 'span' + */ + startDecorator?: React.ElementType; + /** + * The component that renders the end decorator. + * @default 'span' + */ + endDecorator?: React.ElementType; + /** + * The component that renders the click away. + * @default ClickAwayListener + */ + clickAway?: React.ElementType; +} + +export type SnackbarSlotsAndSlotProps = CreateSlotsAndSlotProps< + SnackbarSlots, + { + root: SlotProps<'div', {}, SnackbarOwnerState>; + startDecorator: SlotProps<'span', {}, SnackbarOwnerState>; + endDecorator: SlotProps<'span', {}, SnackbarOwnerState>; + clickAway: + | ClickAwayListenerProps + | ((ownerState: SnackbarOwnerState) => ClickAwayListenerProps); + } +>; + +export interface SnackbarPropsColorOverrides {} +export interface SnackbarPropsSizeOverrides {} +export interface SnackbarPropsVariantOverrides {} + +export interface SnackbarOrigin { + vertical: 'top' | 'bottom'; + horizontal: 'left' | 'center' | 'right'; +} + +export type { SnackbarCloseReason } from '@mui/base/useSnackbar'; + +export interface SnackbarTypeMap

{ + props: P & + Omit & { + /** + * The anchor of the `Snackbar`. + * On smaller screens, the component grows to occupy all the available width, + * the horizontal alignment is ignored. + * @default { vertical: 'bottom', horizontal: 'right' } + */ + anchorOrigin?: SnackbarOrigin; + /** + * The duration of the animation in milliseconds. This value is used to control + * the length of time it takes for an animation to complete one cycle. It is also + * utilized for delaying the unmount of the component. + * Provide this value if you have your own animation so that we can precisely + * time the component's unmount to match your custom animation. + * @default 300 + */ + animationDuration?: number; + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'neutral' + */ + color?: OverridableStringUnion; + /** + * Element placed after the children. + */ + endDecorator?: React.ReactNode; + /** + * If `true`, the children with an implicit color prop invert their colors to match the component's variant and color. + * @default false + */ + invertedColors?: boolean; + /** + * When displaying multiple consecutive snackbars using a single parent-rendered + * ``, add the `key` prop to ensure independent treatment of each message. + * For instance, use ``. Otherwise, messages might update + * in place, and features like `autoHideDuration` could be affected. + */ + key?: any; + /** + * A callback fired when the component is about to be unmounted. + */ + onUnmount?: () => void; + /** + * If `true`, the component is shown. + */ + open: boolean; + /** + * The size of the component. + * @default 'md' + */ + size?: OverridableStringUnion<'sm' | 'md' | 'lg', SnackbarPropsSizeOverrides>; + /** + * Element placed before the children. + */ + startDecorator?: React.ReactNode; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use. + * @default 'outlined' + */ + variant?: OverridableStringUnion; + } & SnackbarSlotsAndSlotProps; + defaultComponent: D; +} + +export type SnackbarProps< + D extends React.ElementType = SnackbarTypeMap['defaultComponent'], + P = { component?: React.ElementType }, +> = OverrideProps, D>; + +export interface SnackbarOwnerState extends ApplyColorInversion {} diff --git a/packages/mui-joy/src/Snackbar/index.ts b/packages/mui-joy/src/Snackbar/index.ts new file mode 100644 index 00000000000000..9a1de25f1c8376 --- /dev/null +++ b/packages/mui-joy/src/Snackbar/index.ts @@ -0,0 +1,5 @@ +'use client'; +export { default } from './Snackbar'; +export * from './snackbarClasses'; +export { default as snackbarClasses } from './snackbarClasses'; +export * from './SnackbarProps'; diff --git a/packages/mui-joy/src/Snackbar/snackbarClasses.ts b/packages/mui-joy/src/Snackbar/snackbarClasses.ts new file mode 100644 index 00000000000000..9a294d04a6b06b --- /dev/null +++ b/packages/mui-joy/src/Snackbar/snackbarClasses.ts @@ -0,0 +1,78 @@ +import { generateUtilityClass, generateUtilityClasses } from '../className'; + +export interface SnackbarClasses { + /** Class name applied to the root element. */ + root: string; + /** Styles applied to the root element if `anchorOrigin={{ 'top', 'center' }}`. */ + anchorOriginTopCenter: string; + /** Styles applied to the root element if `anchorOrigin={{ 'bottom', 'center' }}`. */ + anchorOriginBottomCenter: string; + /** Styles applied to the root element if `anchorOrigin={{ 'top', 'right' }}`. */ + anchorOriginTopRight: string; + /** Styles applied to the root element if `anchorOrigin={{ 'bottom', 'right' }}`. */ + anchorOriginBottomRight: string; + /** Styles applied to the root element if `anchorOrigin={{ 'top', 'left' }}`. */ + anchorOriginTopLeft: string; + /** Styles applied to the root element if `anchorOrigin={{ 'bottom', 'left' }}`. */ + anchorOriginBottomLeft: string; + /** Class name applied to the root element if `color="primary"`. */ + colorPrimary: string; + /** Class name applied to the root element if `color="danger"`. */ + colorDanger: string; + /** Class name applied to the root element if `color="neutral"`. */ + colorNeutral: string; + /** Class name applied to the root element if `color="success"`. */ + colorSuccess: string; + /** Class name applied to the root element if `color="warning"`. */ + colorWarning: string; + /** Class name applied to the endDecorator element if supplied. */ + endDecorator: string; + /** Class name applied to the root element if `size="sm"`. */ + sizeSm: string; + /** Class name applied to the root element if `size="md"`. */ + sizeMd: string; + /** Class name applied to the root element if `size="lg"`. */ + sizeLg: string; + /** Class name applied to the startDecorator element if supplied. */ + startDecorator: string; + /** Class name applied to the root element if `variant="plain"`. */ + variantPlain: string; + /** Class name applied to the root element if `variant="outlined"`. */ + variantOutlined: string; + /** Class name applied to the root element if `variant="soft"`. */ + variantSoft: string; + /** Class name applied to the root element if `variant="solid"`. */ + variantSolid: string; +} + +export type SnackbarClassKey = keyof SnackbarClasses; + +export function getSnackbarUtilityClass(slot: string): string { + return generateUtilityClass('MuiSnackbar', slot); +} + +const snackbarClasses: SnackbarClasses = generateUtilityClasses('MuiSnackbar', [ + 'root', + 'anchorOriginTopCenter', + 'anchorOriginBottomCenter', + 'anchorOriginTopRight', + 'anchorOriginBottomRight', + 'anchorOriginTopLeft', + 'anchorOriginBottomLeft', + 'colorPrimary', + 'colorDanger', + 'colorNeutral', + 'colorSuccess', + 'colorWarning', + 'endDecorator', + 'sizeSm', + 'sizeMd', + 'sizeLg', + 'startDecorator', + 'variantPlain', + 'variantOutlined', + 'variantSoft', + 'variantSolid', +]); + +export default snackbarClasses; diff --git a/packages/mui-joy/src/index.ts b/packages/mui-joy/src/index.ts index 388ba8cd91d83b..e4119c9d1fee81 100644 --- a/packages/mui-joy/src/index.ts +++ b/packages/mui-joy/src/index.ts @@ -196,6 +196,9 @@ export * from './Skeleton'; export { default as Slider } from './Slider'; export * from './Slider'; +export { default as Snackbar } from './Snackbar'; +export * from './Snackbar'; + export { default as Stack } from './Stack'; export * from './Stack'; diff --git a/packages/mui-joy/src/styles/components.d.ts b/packages/mui-joy/src/styles/components.d.ts index f12a710f7ee8c6..03072c89286c4b 100644 --- a/packages/mui-joy/src/styles/components.d.ts +++ b/packages/mui-joy/src/styles/components.d.ts @@ -194,6 +194,7 @@ import { SkeletonProps, SkeletonOwnerState, SkeletonSlot } from '../Skeleton/Ske import { SelectProps, SelectOwnerState, SelectSlot } from '../Select/SelectProps'; import { OptionProps, OptionOwnerState, OptionSlot } from '../Option/OptionProps'; import { SliderProps, SliderOwnerState, SliderSlot } from '../Slider/SliderProps'; +import { SnackbarProps, SnackbarOwnerState, SnackbarSlot } from '../Snackbar/SnackbarProps'; import { StackProps, StackSlot } from '../Stack/StackProps'; import { SvgIconProps, SvgIconOwnerState, SvgIconSlot } from '../SvgIcon/SvgIconProps'; import { SwitchProps, SwitchOwnerState, SwitchSlot } from '../Switch/SwitchProps'; @@ -467,6 +468,10 @@ export interface Components { defaultProps?: Partial; styleOverrides?: StyleOverrides; }; + JoySnackbar?: { + defaultProps?: Partial; + styleOverrides?: StyleOverrides; + }; JoyTabs?: { defaultProps?: Partial; styleOverrides?: StyleOverrides; diff --git a/packages/mui-joy/src/styles/extendTheme.spec.ts b/packages/mui-joy/src/styles/extendTheme.spec.ts index 38c31364a17de1..6508292a435d6d 100644 --- a/packages/mui-joy/src/styles/extendTheme.spec.ts +++ b/packages/mui-joy/src/styles/extendTheme.spec.ts @@ -57,6 +57,7 @@ import { RadioGroupOwnerState } from '@mui/joy/RadioGroup'; import { SelectOwnerState } from '@mui/joy/Select'; import { SheetOwnerState } from '@mui/joy/Sheet'; import { SliderOwnerState } from '@mui/joy/Slider'; +import { SnackbarOwnerState } from '@mui/joy/Snackbar'; import { StackProps } from '@mui/joy/Stack'; import { extendTheme } from '@mui/joy/styles'; import { SvgIconOwnerState } from '@mui/joy/SvgIcon'; @@ -1101,6 +1102,30 @@ extendTheme({ }, }, }, + JoySnackbar: { + defaultProps: { + variant: 'plain', + color: 'neutral', + }, + styleOverrides: { + root: ({ ownerState }) => { + expectType, typeof ownerState>(ownerState); + return {}; + }, + startDecorator: ({ ownerState }) => { + expectType, typeof ownerState>(ownerState); + return {}; + }, + endDecorator: ({ ownerState }) => { + expectType, typeof ownerState>(ownerState); + return {}; + }, + clickAway: ({ ownerState }) => { + expectType, typeof ownerState>(ownerState); + return {}; + }, + }, + }, JoyStack: { defaultProps: { spacing: 1, diff --git a/packages/mui-joy/src/styles/extendTheme.ts b/packages/mui-joy/src/styles/extendTheme.ts index 818cd124fcbdb0..546d3347bc4620 100644 --- a/packages/mui-joy/src/styles/extendTheme.ts +++ b/packages/mui-joy/src/styles/extendTheme.ts @@ -419,6 +419,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { table: 10, popup: 1000, modal: 1300, + snackbar: 1400, tooltip: 1500, }, diff --git a/packages/mui-joy/src/styles/types/zIndex.ts b/packages/mui-joy/src/styles/types/zIndex.ts index f91fb675cb09b9..ab91eab0913581 100644 --- a/packages/mui-joy/src/styles/types/zIndex.ts +++ b/packages/mui-joy/src/styles/types/zIndex.ts @@ -15,6 +15,7 @@ export interface DefaultZIndex { popup: number; modal: number; tooltip: number; + snackbar: number; } export interface ZIndexOverrides {} export interface ZIndex extends OverridableRecord {}