diff --git a/src/Toast/README.md b/src/Toast/README.md index 9b36e17520..cbf867b713 100644 --- a/src/Toast/README.md +++ b/src/Toast/README.md @@ -5,7 +5,7 @@ components: - Toast categories: - Overlays -status: 'New' +status: 'Stable' designStatus: 'Done' devStatus: 'Done' notes: '' @@ -39,7 +39,7 @@ notes: '' Example of a basic Toast. - + ); } @@ -64,7 +64,7 @@ notes: '' Success! Example of a Toast with a button. - + ); } @@ -89,7 +89,7 @@ notes: '' Success! Example of a Toast with a link. - + ); } diff --git a/src/Toast/Toast.test.jsx b/src/Toast/Toast.test.tsx similarity index 76% rename from src/Toast/Toast.test.jsx rename to src/Toast/Toast.test.tsx index 9e591054fc..dabf881f84 100644 --- a/src/Toast/Toast.test.jsx +++ b/src/Toast/Toast.test.tsx @@ -1,12 +1,10 @@ -import React from 'react'; import { render, screen } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; import userEvent from '@testing-library/user-event'; import Toast from '.'; -/* eslint-disable-next-line react/prop-types */ -function ToastWrapper({ children, ...props }) { +function ToastWrapper({ children, ...props }: React.ComponentProps) { return ( @@ -17,7 +15,7 @@ function ToastWrapper({ children, ...props }) { } describe('', () => { - const onCloseHandler = () => {}; + const onCloseHandler = jest.fn(); const props = { onClose: onCloseHandler, show: true, @@ -44,7 +42,7 @@ describe('', () => { {...props} action={{ label: 'Optional action', - onClick: () => {}, + onClick: jest.fn(), }} > Success message. @@ -55,19 +53,19 @@ describe('', () => { }); it('autohide is set to false on onMouseOver and true on onMouseLeave', async () => { render( - + Success message. , ); - const toast = screen.getByTestId('toast'); + const toast = screen.getByRole('alert'); await userEvent.hover(toast); setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(true); + expect(screen.getByText('Success message.')).toBeTruthy(); expect(toast).toHaveLength(1); }, 6000); await userEvent.unhover(toast); setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(false); + expect(screen.getByText('Success message.')).toBeTruthy(); expect(toast).toHaveLength(1); }, 6000); }); @@ -77,15 +75,15 @@ describe('', () => { Success message. , ); - const toast = screen.getByTestId('toast'); + const toast = screen.getByRole('alert'); toast.focus(); setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(true); + expect(screen.getByText('Success message.')).toBeTruthy(); expect(toast).toHaveLength(1); }, 6000); await userEvent.tab(); setTimeout(() => { - expect(screen.getByText('Success message.')).toEqual(false); + expect(screen.getByText('Success message.')).toBeTruthy(); expect(toast).toHaveLength(1); }, 6000); }); diff --git a/src/Toast/ToastContainer.jsx b/src/Toast/ToastContainer.jsx deleted file mode 100644 index 05049ae0f4..0000000000 --- a/src/Toast/ToastContainer.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; - -class ToastContainer extends React.Component { - constructor(props) { - super(props); - this.toastRootName = 'toast-root'; - if (typeof document === 'undefined') { - this.rootElement = null; - } else if (document.getElementById(this.toastRootName)) { - this.rootElement = document.getElementById(this.toastRootName); - } else { - const rootElement = document.createElement('div'); - rootElement.setAttribute('id', this.toastRootName); - rootElement.setAttribute('class', 'toast-container'); - rootElement.setAttribute('role', 'alert'); - rootElement.setAttribute('aria-live', 'polite'); - rootElement.setAttribute('aria-atomic', 'true'); - this.rootElement = document.body.appendChild(rootElement); - } - } - - render() { - if (this.rootElement) { - return ReactDOM.createPortal( - this.props.children, - this.rootElement, - ); - } - return null; - } -} - -ToastContainer.propTypes = { - /** Specifies contents of the component. */ - children: PropTypes.node.isRequired, -}; - -export default ToastContainer; diff --git a/src/Toast/ToastContainer.scss b/src/Toast/ToastContainer.scss index 423b419044..e7d4f2b4fd 100644 --- a/src/Toast/ToastContainer.scss +++ b/src/Toast/ToastContainer.scss @@ -1,3 +1,4 @@ +@use "sass:map"; @import "variables"; .toast-container { @@ -11,7 +12,7 @@ left: 0; } - @media only screen and (width <= 768px) { + @media (max-width: map.get($grid-breakpoints, "md")) { bottom: $toast-container-gutter-sm; right: $toast-container-gutter-sm; left: $toast-container-gutter-sm; diff --git a/src/Toast/ToastContainer.tsx b/src/Toast/ToastContainer.tsx new file mode 100644 index 0000000000..522c3696fd --- /dev/null +++ b/src/Toast/ToastContainer.tsx @@ -0,0 +1,32 @@ +import { ReactNode, useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; + +interface ToastContainerProps { + children: ReactNode; +} + +const TOAST_ROOT_ID = 'toast-root'; + +function ToastContainer({ children }: ToastContainerProps) { + const [rootElement, setRootElement] = useState(null); + + useEffect(() => { + if (typeof document !== 'undefined') { + let existingElement = document.getElementById(TOAST_ROOT_ID); + + if (!existingElement) { + existingElement = document.createElement('div'); + existingElement.id = TOAST_ROOT_ID; + existingElement.className = 'toast-container'; + existingElement.setAttribute('aria-live', 'polite'); + existingElement.setAttribute('aria-atomic', 'true'); + document.body.appendChild(existingElement); + } + setRootElement(existingElement); + } + }, []); + + return rootElement ? ReactDOM.createPortal(children, rootElement) : null; +} + +export default ToastContainer; diff --git a/src/Toast/index.scss b/src/Toast/index.scss index ffce11dbf1..f178287c23 100644 --- a/src/Toast/index.scss +++ b/src/Toast/index.scss @@ -1,3 +1,4 @@ +@use "sass:map"; @import "variables"; @import "~bootstrap/scss/toasts"; @@ -5,7 +6,7 @@ background-color: $toast-background-color; box-shadow: $toast-box-shadow; margin: 0; - padding: 1rem; + padding: $spacer; position: relative; border-radius: $toast-border-radius; z-index: 2; @@ -38,15 +39,15 @@ } & + .btn { - margin-top: 1rem; + margin-top: $spacer; } } - @media only screen and (width <= 768px) { + @media (max-width: map.get($grid-breakpoints, "md")) { max-width: 100%; } - @media only screen and (width >= 768px) { + @media (min-width: map.get($grid-breakpoints, "md")) { min-width: $toast-max-width; max-width: $toast-max-width; } diff --git a/src/Toast/index.jsx b/src/Toast/index.tsx similarity index 88% rename from src/Toast/index.jsx rename to src/Toast/index.tsx index 11461666c2..1ac20e6942 100644 --- a/src/Toast/index.jsx +++ b/src/Toast/index.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; - import BaseToast from 'react-bootstrap/Toast'; import { useIntl } from 'react-intl'; @@ -14,16 +13,40 @@ import IconButton from '../IconButton'; export const TOAST_CLOSE_LABEL_TEXT = 'Close'; export const TOAST_DELAY = 5000; +interface ToastAction { + label: string; + href?: string; + onClick?: () => void; +} + +interface ToastProps { + children: string; + onClose: () => void; + show: boolean; + action?: ToastAction; + closeLabel?: string; + delay?: number; + className?: string; +} + function Toast({ - action, children, className, closeLabel, onClose, show, ...rest -}) { + action, + children, + className, + closeLabel, + onClose, + show, + ...rest +}: ToastProps) { const intl = useIntl(); const [autoHide, setAutoHide] = useState(true); + const intlCloseLabel = closeLabel || intl.formatMessage({ id: 'pgn.Toast.closeLabel', defaultMessage: 'Close', description: 'Close label for Toast component', }); + return ( -
+

{children}