diff --git a/package-lock.json b/package-lock.json index 78dd7786..4274770d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@mapbox/mr-ui", - "version": "2.0.0-beta.19", + "version": "2.0.0", "license": "BSD-2-Clause", "dependencies": { "@mapbox/mbx-assembly": "^1.3.0", @@ -20,6 +20,7 @@ "@radix-ui/react-slider": "^1.0.0", "@radix-ui/react-switch": "^1.0.0", "@radix-ui/react-tabs": "^1.0.0", + "@radix-ui/react-toast": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0", "@radix-ui/react-visually-hidden": "^1.0.0", "classnames": "^2.2.6", @@ -60,7 +61,6 @@ "react-app-rewired": "^2.2.1", "react-docgen": "^5.4.3", "react-dom": "^16.14.0", - "react-helmet": "^5.2.0", "react-scripts": "5.0.1", "typescript": "^4.7.4", "web-vitals": "^2.1.4" @@ -4324,6 +4324,30 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.0.0.tgz", + "integrity": "sha512-mdoF6rahgushdev0OX+9a7JKoH0xZAZBo2Ktf/s779S7EnkZeL3/MFiRIV5LpRP5CtASmfdSD3FLnEvG1RHRtQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.0", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-dismissable-layer": "1.0.0", + "@radix-ui/react-portal": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.0", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + "@radix-ui/react-visually-hidden": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.0.tgz", @@ -20224,25 +20248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "dev": true, - "license": "MIT" - }, - "node_modules/react-helmet": { - "version": "5.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1", - "prop-types": "^15.5.4", - "react-fast-compare": "^2.0.2", - "react-side-effect": "^1.1.0" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, "node_modules/react-is": { "version": "17.0.2", "dev": true, @@ -21303,17 +21308,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/react-side-effect": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shallowequal": "^1.0.1" - }, - "peerDependencies": { - "react": "^0.13.0 || ^0.14.0 || ^15.0.0 || ^16.0.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -22303,11 +22297,6 @@ "version": "1.2.1", "license": "MIT" }, - "node_modules/shallowequal": { - "version": "1.1.0", - "dev": true, - "license": "MIT" - }, "node_modules/shebang-command": { "version": "1.2.0", "dev": true, @@ -28194,6 +28183,26 @@ "@radix-ui/react-use-controllable-state": "1.0.0" } }, + "@radix-ui/react-toast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.0.0.tgz", + "integrity": "sha512-mdoF6rahgushdev0OX+9a7JKoH0xZAZBo2Ktf/s779S7EnkZeL3/MFiRIV5LpRP5CtASmfdSD3FLnEvG1RHRtQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.0", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-dismissable-layer": "1.0.0", + "@radix-ui/react-portal": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.0", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + "@radix-ui/react-visually-hidden": "1.0.0" + } + }, "@radix-ui/react-tooltip": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.0.tgz", @@ -38421,20 +38430,6 @@ "version": "6.0.11", "dev": true }, - "react-fast-compare": { - "version": "2.0.4", - "dev": true - }, - "react-helmet": { - "version": "5.2.1", - "dev": true, - "requires": { - "object-assign": "^4.1.1", - "prop-types": "^15.5.4", - "react-fast-compare": "^2.0.2", - "react-side-effect": "^1.1.0" - } - }, "react-is": { "version": "17.0.2", "dev": true @@ -38955,13 +38950,6 @@ } } }, - "react-side-effect": { - "version": "1.2.0", - "dev": true, - "requires": { - "shallowequal": "^1.0.1" - } - }, "react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -39610,10 +39598,6 @@ "shallow-equal": { "version": "1.2.1" }, - "shallowequal": { - "version": "1.1.0", - "dev": true - }, "shebang-command": { "version": "1.2.0", "dev": true, diff --git a/package.json b/package.json index 7576c8b7..5e982232 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-tabs": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0", "@radix-ui/react-visually-hidden": "^1.0.0", + "@radix-ui/react-toast": "^1.0.0", "classnames": "^2.2.6", "clipboard": "^2.0.0", "debounce": "^1.1.0", diff --git a/src/components/toast/__snapshots__/toast.test.tsx.snap b/src/components/toast/__snapshots__/toast.test.tsx.snap new file mode 100644 index 00000000..ba1c63e7 --- /dev/null +++ b/src/components/toast/__snapshots__/toast.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toast render with options renders basic 1`] = ` + +
+ +
+
+
+ + +`; diff --git a/src/components/toast/examples/toast-example-basic.tsx b/src/components/toast/examples/toast-example-basic.tsx new file mode 100644 index 00000000..e2ee3e78 --- /dev/null +++ b/src/components/toast/examples/toast-example-basic.tsx @@ -0,0 +1,27 @@ +/* +A toast message with an action. +*/ +import React, { ReactElement, useState } from 'react'; +import Toast from '../toast'; + +export default function Example() { + const [open, setOpen] = useState(false); + const renderToast = (): ReactElement => { + return ( + {} }} + onExit={() => setOpen(false)} + > + + + ); + }; + + return
{renderToast()}
; +} diff --git a/src/components/toast/examples/toast-example-overflow.tsx b/src/components/toast/examples/toast-example-overflow.tsx new file mode 100644 index 00000000..d8cf9b49 --- /dev/null +++ b/src/components/toast/examples/toast-example-overflow.tsx @@ -0,0 +1,26 @@ +/* +A simple toast message without an action button, with a close button, and with a long message that truncates. +*/ +import React, { ReactElement, useState } from 'react'; +import Toast from '../toast'; + +export default function Example() { + const [open, setOpen] = useState(false); + const renderToast = (): ReactElement => { + return ( + setOpen(false)} + > + + + ); + }; + + return
{renderToast()}
; +} diff --git a/src/components/toast/index.js b/src/components/toast/index.js new file mode 100644 index 00000000..2277d21e --- /dev/null +++ b/src/components/toast/index.js @@ -0,0 +1,3 @@ +import main from './toast'; + +export default main; diff --git a/src/components/toast/toast.test.tsx b/src/components/toast/toast.test.tsx new file mode 100644 index 00000000..96c46e4c --- /dev/null +++ b/src/components/toast/toast.test.tsx @@ -0,0 +1,81 @@ +import Toast from './toast'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('Toast', () => { + describe('render with options', () => { + let mockedOnExit = jest.fn(); + let props = { + content: 'Toast message', + active: true, + action: { + text: 'Open folder', + callback: jest.fn() + }, + children: , + onExit: mockedOnExit + }; + + test('renders basic', () => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); + }); + + test('renders without close', () => { + render(); + expect(screen.queryByTestId('toast-close')).toBeNull(); + }); + + test('renders without action button', () => { + render( + trigger} + /> + ); + expect(screen.queryByTestId('toast-action')).toBeNull(); + }); + }); + + describe('toast dismissed', () => { + let mockedOnExit = jest.fn(); + const props = { + content: 'Toast message', + active: true, + action: { + text: 'Open folder', + callback: jest.fn() + }, + children: , + onExit: mockedOnExit, + duration: 1000 + } as const; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('fires onExit after duration', () => { + render(); + expect(mockedOnExit).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(1001); + expect(mockedOnExit).toHaveBeenCalledTimes(1); + }); + test('fires onExit when closing with close button', () => { + render(); + fireEvent.click(screen.getByTestId('toast-close')); + expect(mockedOnExit).toHaveBeenCalledTimes(1); + }); + + test('fires onExit when pressing action button', () => { + render(); + fireEvent.click(screen.getByTestId('toast-action')); + expect(mockedOnExit).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 00000000..d6f1694e --- /dev/null +++ b/src/components/toast/toast.tsx @@ -0,0 +1,111 @@ +import React, { ReactElement, ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import * as ToastPrimitive from '@radix-ui/react-toast'; +import Icon from '../icon'; + +interface Props { + content: string; + children: ReactNode; + active: boolean; + onExit: () => void; + action?: { + text: string; + callback: () => void; + }; + duration?: number; + closeButton?: boolean; +} + +export default function Toast({ + content, + children, + active, + onExit, + duration = 5000, + action, + closeButton = true +}: Props): ReactElement { + let actionBtnClass = closeButton ? '' : 'pr12'; + return ( + + {children} + + + {content} + + + {action && ( + + {action.text} + + )} + {closeButton && ( + + + + )} + + + + + ); +} + +Toast.propTypes = { + /** + * The trigger element. + */ + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + /** + * The toast content. This can either be a string, valid JSX, or a function + * returning either. Content text will truncate. + */ + content: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + /** + * A function called when popover is dismissed. You need to use this callback + * to remove the Toast from the rendered page. + */ + onExit: PropTypes.func.isRequired, + /** + * Triggers the active state of the toast. When true, the toast appears. + */ + active: PropTypes.bool.isRequired, + /** + * The primary action for the toast. + * + * The value is an object with the following properties: + * - `text`: **(required)** The text of the button. + * - `callback`: **(required)** Invoked when the button is clicked. + */ + action: PropTypes.shape({ + text: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired + }), + /** + * The duration the toast should appear for. Recommended toast duration is 5 seconds with 1 extra second for every additional 300 characters in the toast body. + */ + duration: PropTypes.number, + /** + * When `true` the toast will have a separate close button (in addition to the call-to-action button). + * When `false` toast will only have action button. + */ + closeButton: PropTypes.bool +};