diff --git a/.storybook/main.js b/.storybook/main.js index 24ecb83bad..62f27efebc 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -5,4 +5,16 @@ module.exports = { '@storybook/addon-storysource', '@storybook/addon-knobs', ], + webpackFinal: async (config, { configType }) => { + // Resolve error when webpack-ing storybook: + // Can't import the named export 'Children' from non EcmaScript module (only + // default export is available) + config.module.rules.push({ + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + }); + + return config; + }, }; diff --git a/package-lock.json b/package-lock.json index 71456c9f38..864dd0e531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.98.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@floating-ui/dom": "^0.1.10" + "@floating-ui/dom": "^0.1.10", + "framer-motion": "^4.1.17" }, "devDependencies": { "@babel/cli": "^7.17.10", @@ -2262,7 +2263,7 @@ "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "dev": true, + "devOptional": true, "dependencies": { "@emotion/memoize": "0.7.4" } @@ -2271,7 +2272,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "dev": true + "devOptional": true }, "node_modules/@emotion/react": { "version": "11.9.0", @@ -14056,6 +14057,33 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", + "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "popmotion": "9.3.6", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0", + "react-dom": ">=16.8 || ^17.0.0" + } + }, + "node_modules/framesync": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz", + "integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -14962,6 +14990,11 @@ "lower-case": "^1.1.1" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -20812,6 +20845,17 @@ "node": ">=10" } }, + "node_modules/popmotion": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz", + "integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -24324,6 +24368,15 @@ "inline-style-parser": "0.1.1" } }, + "node_modules/style-value-types": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz", + "integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "node_modules/styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -25194,8 +25247,7 @@ "node_modules/tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -29262,7 +29314,7 @@ "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "dev": true, + "devOptional": true, "requires": { "@emotion/memoize": "0.7.4" } @@ -29271,7 +29323,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "dev": true + "devOptional": true }, "@emotion/react": { "version": "11.9.0", @@ -38420,6 +38472,27 @@ "map-cache": "^0.2.2" } }, + "framer-motion": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", + "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "requires": { + "@emotion/is-prop-valid": "^0.8.2", + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "popmotion": "9.3.6", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, + "framesync": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz", + "integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==", + "requires": { + "tslib": "^2.1.0" + } + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -39132,6 +39205,11 @@ } } }, + "hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -43597,6 +43675,17 @@ "@babel/runtime": "^7.12.5" } }, + "popmotion": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz", + "integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==", + "requires": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -46419,6 +46508,15 @@ "inline-style-parser": "0.1.1" } }, + "style-value-types": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz", + "integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==", + "requires": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -47127,8 +47225,7 @@ "tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index e1c11e3ffc..abedf4478b 100644 --- a/package.json +++ b/package.json @@ -95,12 +95,12 @@ "vega-tooltip": "^0.27.0" }, "peerDependencies": { - "@js-temporal/polyfill": "^0.4.3", "@fortawesome/fontawesome-free": "^5.10.2", "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/react-fontawesome": "^0.1.14", + "@js-temporal/polyfill": "^0.4.3", "polished": "3.4.1", "pretty-bytes": "^5.6.0", "react": "^17.0.2", @@ -137,6 +137,7 @@ } }, "dependencies": { - "@floating-ui/dom": "^0.1.10" + "@floating-ui/dom": "^0.1.10", + "framer-motion": "^4.1.17" } } diff --git a/src/lib/components/progressbar/ProgressBar.component.tsx b/src/lib/components/progressbar/ProgressBar.component.tsx index 6da6294fcd..a3e5dee982 100644 --- a/src/lib/components/progressbar/ProgressBar.component.tsx +++ b/src/lib/components/progressbar/ProgressBar.component.tsx @@ -4,7 +4,7 @@ import * as defaultTheme from '../../style/theme'; import { Size } from '../constants'; export type ProgressBarProps = { percentage: number; - size?: Size; + size?: Size | 'custom'; color?: string; // The color of unfill bar backgroundColor?: string; @@ -15,12 +15,14 @@ export type ProgressBarProps = { buildinLabel?: string; // The animation to full the progress bar isAnimation?: boolean; + height?: React.CSSProperties['height']; }; const Container = styled.div``; const ProgressBarContainer = styled.div<{ backgroundColor: string; - size: keyof typeof defaultTheme.fontSize; + size: keyof typeof defaultTheme.fontSize | 'custom'; buildinLabel?: string; + height?: React.CSSProperties['height']; }>` display: flex; border-radius: 4px; @@ -52,6 +54,11 @@ const ProgressBarContainer = styled.div<{ height: 20px; `; + case 'custom': + return css` + height: ${props.height}; + font-size: ${props.height}; + `; default: return css` height: ${defaultTheme.fontSize.base}; @@ -148,6 +155,7 @@ function ProgressBar({ bottomRightLabel, buildinLabel, isAnimation = false, + height, ...rest }: ProgressBarProps) { return ( @@ -171,6 +179,7 @@ function ProgressBar({ size={size} buildinLabel={buildinLabel} backgroundColor={backgroundColor} + height={height} > void; + status?: ToastStatus; + position?: ToastPosition; + autoDismiss?: boolean; + duration?: number; + icon?: React.ReactNode; + action?: React.ReactNode; + width?: React.CSSProperties['width']; + exited?: boolean; + withProgressBar?: boolean; + style?: React.CSSProperties; +}; + +export const useGetBackgroundColor = (status: string) => { + const theme = useTheme(); + switch (status) { + case 'success': + return theme.statusHealthy; + case 'error': + return theme.statusCritical; + case 'warning': + return theme.statusWarning; + default: + return theme.infoPrimary; + } +}; + +const useGetRgbBackgroundColor = (status: string) => { + const theme = useTheme(); + switch (status) { + case 'success': + return 'rgba(10, 173, 166, 0.4)'; + case 'error': + return 'rgba(232, 72, 85, 0.4)'; + case 'warning': + return 'rgba(248, 243, 43, 0.4)'; + default: + return theme.infoSecondary; + } +}; + +const defaultIconName = (status: string) => { + switch (status) { + case 'success': + return 'Check-circle'; + case 'error': + return 'Times-circle'; + case 'warning': + return 'Exclamation-circle'; + default: + return 'Info-circle'; + } +}; + +const DefaultIcon = ({ status }: { status: string }) => { + const color = useGetBackgroundColor(status); + const iconName = defaultIconName(status); + + return ; +}; + +const DEFAULT_WIDTH = '25rem'; + +const IconContainer = styled.div<{ bgColor: string }>` + align-items: center; + align-self: stretch; + border-radius: 4px 0px 0px 4px; + display: flex; + gap: 16px; + justify-content: center; + position: relative; + width: 32px; + background-color: ${(props) => props.bgColor}; +`; + +const ContentContainer = styled.div` + align-items: center; + align-self: stretch; + display: flex; + flex: 1; + flex-grow: 1; + gap: 8px; + padding: 0px 16px; + position: relative; +`; + +function Toast({ + open, + message, + onClose, + position = 'top-right', + status = 'info', + autoDismiss = true, + duration = 5000, + icon = , + action, + width = DEFAULT_WIDTH, + exited = true, + withProgressBar = false, + style, +}: ToastProps) { + const ref = useRef(null); + const { params } = useToastParameters({ + open, + duration: autoDismiss ? duration : null, + onClose, + }); + + const positionStyle = positionOutput[position]; + + const bgColor = useGetBackgroundColor(status); + const rgbBgColor = useGetRgbBackgroundColor(status); + const theme = useTheme(); + + if (!open && exited) { + return null; + } + + return ( +
+ + {icon} + + + {message} +
+ {action} +
+
+ +
+ ); +} + +export { Toast }; diff --git a/src/lib/components/toast/ToastPositionHelpers.ts b/src/lib/components/toast/ToastPositionHelpers.ts new file mode 100644 index 0000000000..e9117f9cb7 --- /dev/null +++ b/src/lib/components/toast/ToastPositionHelpers.ts @@ -0,0 +1,36 @@ +export type ToastPosition = + | 'top-left' + | 'top-right' + | 'top-center' + | 'bottom-left' + | 'bottom-right' + | 'bottom-center'; + +export const positionOutput: Record = { + 'top-left': { + top: '1rem', + left: '1rem', + }, + 'top-right': { + top: '1rem', + right: '1rem', + }, + 'top-center': { + top: '1rem', + left: '50%', + transform: 'translateX(-50%)', + }, + 'bottom-left': { + bottom: '1rem', + left: '1rem', + }, + 'bottom-right': { + bottom: '1rem', + right: '1rem', + }, + 'bottom-center': { + bottom: '1rem', + left: '50%', + transform: 'translateX(-50%)', + }, +}; diff --git a/src/lib/components/toast/ToastProgressBar.tsx b/src/lib/components/toast/ToastProgressBar.tsx new file mode 100644 index 0000000000..95db71b90f --- /dev/null +++ b/src/lib/components/toast/ToastProgressBar.tsx @@ -0,0 +1,45 @@ +import { darken } from 'polished'; +import { useEffect, useState } from 'react'; +import { ProgressBar } from '../progressbar/ProgressBar.component'; + +export function ToastProgressBar({ + duration, + color, +}: { + duration: number | null; + color: string; +}) { + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (duration) { + const interval = setInterval(() => { + setProgress((prevProgress) => prevProgress + (100 / duration) * 1000); + }, 1000); + + return () => { + clearInterval(interval); + }; + } + }, [duration]); + + return ( +
+ +
+ ); +} diff --git a/src/lib/components/toast/Toasts.component.tsx b/src/lib/components/toast/Toasts.component.tsx new file mode 100644 index 0000000000..bbb43d4bbe --- /dev/null +++ b/src/lib/components/toast/Toasts.component.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { Toast, ToastProps } from './Toast.component'; + +export type ToastsProps = Omit; + +function Toasts({ + toasts, + open, + onToastClose, +}: { + toasts: ToastsProps[]; + open: boolean; + onToastClose?: () => void; +}) { + const [toastQueue, setToastQueue] = useState(toasts); + const [displayedToast, setDisplayedToast] = + useState(null); + + useEffect(() => { + if (toastQueue.length > 0 && !displayedToast) { + setDisplayedToast(toastQueue[0]); + } + }, [toastQueue, displayedToast]); + + useEffect(() => { + if (!toastQueue.length) { + setToastQueue(toasts); + onToastClose?.(); + } + }, [toastQueue, toasts, onToastClose]); + + const handleToastDismiss = () => { + setToastQueue((prevQueue) => prevQueue.slice(1)); + setDisplayedToast(null); + }; + + if (!displayedToast) { + return null; + } + + return ; +} + +export { Toasts }; diff --git a/src/lib/components/toast/useToastParameters.ts b/src/lib/components/toast/useToastParameters.ts new file mode 100644 index 0000000000..ef5fcd4139 --- /dev/null +++ b/src/lib/components/toast/useToastParameters.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from 'react'; + +interface ToastParameters { + duration?: number | null; + open?: boolean; + onClose?: () => void; +} + +export function useToastParameters(params: ToastParameters) { + const { duration = null, open, onClose } = params; + + const timerAutoHide = useRef>(); + + useEffect(() => { + if (!open) { + return undefined; + } + + function handleKeyDown(nativeEvent: KeyboardEvent) { + if (!nativeEvent.defaultPrevented) { + if (nativeEvent.key === 'Escape' || nativeEvent.key === 'Esc') { + onClose?.(); + } + } + } + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, onClose]); + + const setAutoHideTimer = useCallback( + (autoHideDurationParam: number | null) => { + if (!onClose || autoHideDurationParam == null) { + return; + } + + clearTimeout(timerAutoHide.current); + timerAutoHide.current = setTimeout(() => { + onClose?.(); + }, autoHideDurationParam); + }, + [onClose], + ); + + useEffect(() => { + if (open) { + setAutoHideTimer(duration); + } + + return () => { + clearTimeout(timerAutoHide.current); + }; + }, [open, duration, setAutoHideTimer]); + + return { params }; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index eb3119aae0..9a16b21e6c 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -69,3 +69,9 @@ export { Form, FormSection, FormGroup } from './components/form/Form.component'; export { FormattedDateTime } from './components/date/FormattedDateTime'; export { IconHelp } from './components/IconHelper'; export { Dropzone } from './components/dropzone/Dropzone'; +export { + Toast, + ToastProps, + ToastStatus, +} from './components/toast/Toast.component'; +export { Toasts, ToastsProps } from './components/toast/Toasts.component'; diff --git a/stories/toast.stories.tsx b/stories/toast.stories.tsx new file mode 100644 index 0000000000..ee49b7eeb8 --- /dev/null +++ b/stories/toast.stories.tsx @@ -0,0 +1,223 @@ +import React, { useMemo, useState } from 'react'; +import { + Toast, + ToastProps, + useGetBackgroundColor, +} from '../src/lib/components/toast/Toast.component'; +import { Toasts } from '../src/lib/components/toast/Toasts.component'; +import { Button } from '../src/lib/components/buttonv2/Buttonv2.component'; +import { Icon } from '../src/lib/components/icon/Icon.component'; +import { Link } from '../src/lib'; +import { ToastStatus } from '../src/lib/components/toast/Toast.component'; + +export default { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], + argTypes: { + open: { + control: { + disable: true, + }, + }, + message: { + control: 'text', + description: 'The message to display in the toast', + }, + status: { + control: 'radio', + options: ['success', 'error', 'warning', 'info'], + description: 'The status of the toast', + }, + position: { + control: 'select', + options: [ + 'top-center', + 'top-left', + 'top-right', + 'bottom-center', + 'bottom-left', + 'bottom-right', + ], + description: 'The position of the toast', + }, + autoDismiss: { + control: 'boolean', + description: 'Whether the toast should dismiss automatically', + }, + duration: { + control: 'number', + description: 'The duration of the toast', + }, + icon: { + control: { + disable: true, + description: 'The icon to display in the toast', + }, + }, + action: { + control: { + disable: true, + description: 'The action to display in the toast', + }, + }, + width: { + control: { + disable: true, + description: 'The width of the toast', + }, + }, + exited: { + control: { + disable: true, + }, + }, + withProgressBar: { + control: 'boolean', + description: 'Whether the toast should display a progress bar', + }, + progressColor: { + control: { + disable: true, + }, + }, + style: { + control: { + disable: true, + }, + }, + }, +}; + +const Template = (args: Omit) => { + const [open, setOpen] = useState(false); + const color = useGetBackgroundColor(args.status || 'info'); + const iconName = + args.status === 'error' + ? 'Times-circle' + : args.status === 'warning' + ? 'Exclamation-circle' + : args.status === 'success' + ? 'Check-circle' + : 'Info-circle'; + return ( + <> + {!open && ( +