diff --git a/lib/components/Snackbar/SnackbarWrapper.tsx b/lib/components/Snackbar/SnackbarWrapper.tsx new file mode 100644 index 00000000..b9364a42 --- /dev/null +++ b/lib/components/Snackbar/SnackbarWrapper.tsx @@ -0,0 +1,42 @@ +import { StandalonePlaceholderRegularIcon } from "@deriv/quill-icons/Standalone"; +import { Snackbar } from "."; +import React, { useState } from "react"; + +export const SnackbarWrapper = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleActionClick = () => { + handleClose(); + }; + + const handleOpen = () => { + setIsOpen(true); + }; + + const handleClose = () => { + setTimeout(() => { + setIsOpen(false); + }, 1000); + }; + + return ( +
+ + + } + message="Enter message here" + actionText="Action" + onActionClick={handleActionClick} + isOpen={isOpen} + onClose={handleClose} + /> +
+ ); +}; diff --git a/lib/components/Snackbar/__tests__/__snapshots__/snackbar.test.tsx.snap b/lib/components/Snackbar/__tests__/__snapshots__/snackbar.test.tsx.snap new file mode 100644 index 00000000..f9bcb1b1 --- /dev/null +++ b/lib/components/Snackbar/__tests__/__snapshots__/snackbar.test.tsx.snap @@ -0,0 +1,365 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Snackbar calls onActionClick when action button is clicked 1`] = ` +
+
+
+

+ test message +

+
+ + +
+
+`; + +exports[`Snackbar calls onClose after a certain duration when Snackbar is open 1`] = ` +
+
+
+

+ test message +

+
+ +
+
+`; + +exports[`Snackbar calls onClose when close button is clicked 1`] = ` +
+
+
+

+ test message +

+
+ +
+
+`; + +exports[`Snackbar renders correctly with custom icon 1`] = ` +
+
+
+ Custom Icon +
+
+

+ test message +

+
+ +
+
+`; + +exports[`Snackbar renders correctly without action button 1`] = ` +
+
+
+

+ test message +

+
+ +
+
+`; + +exports[`Snackbar renders correctly without close button 1`] = ` +
+
+
+

+ test message +

+
+
+
+`; + +exports[`Snackbar renders with default props 1`] = ` +
+
+
+

+ test message +

+
+ +
+
+`; diff --git a/lib/components/Snackbar/__tests__/snackbar.test.tsx b/lib/components/Snackbar/__tests__/snackbar.test.tsx new file mode 100644 index 00000000..1cd906c8 --- /dev/null +++ b/lib/components/Snackbar/__tests__/snackbar.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { Snackbar } from ".."; + +describe("Snackbar", () => { + const testMessage = "test message" + const defaultProps = { + message: testMessage, + isOpen: true, + onClose: jest.fn(), + }; + + const renderComponent = (props = {}) => + render(); + + it("renders with default props", () => { + const { container } = renderComponent(); + expect(screen.getByText(testMessage)).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + it("calls onActionClick when action button is clicked", () => { + const onActionClickMock = jest.fn(); + const { container } = renderComponent({ + actionText: "Action", + onActionClick: onActionClickMock, + }); + fireEvent.click(screen.getByText("Action")); + expect(onActionClickMock).toHaveBeenCalled(); + expect(container).toMatchSnapshot(); + }); + it("calls onClose when close button is clicked", () => { + const { container } = renderComponent(); + fireEvent.click(screen.getByTestId('close-button')); + expect(defaultProps.onClose).toHaveBeenCalled(); + expect(container).toMatchSnapshot(); + }); + it("calls onClose after a certain duration when Snackbar is open", () => { + jest.useFakeTimers(); + const { container } = renderComponent(); + jest.advanceTimersByTime(3000); + expect(defaultProps.onClose).toHaveBeenCalled(); + jest.useRealTimers(); + expect(container).toMatchSnapshot(); + }); + it("renders correctly with custom icon", () => { + const { container } = renderComponent({ + icon: Custom Icon, + }); + expect(screen.getByAltText("Custom Icon")).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + it("renders correctly without action button", () => { + const { container } = renderComponent(); + expect(screen.queryByText("Action")).not.toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + it("renders correctly without close button", () => { + const { container } = renderComponent({ hasCloseButton: false }); + expect(screen.queryByText("x")).not.toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/lib/components/Snackbar/index.tsx b/lib/components/Snackbar/index.tsx new file mode 100644 index 00000000..e625daed --- /dev/null +++ b/lib/components/Snackbar/index.tsx @@ -0,0 +1,86 @@ +import React, { ReactNode, useEffect, useState, HTMLAttributes } from "react"; +import { Text } from "../Typography"; +import "./snackbar.scss"; +import clsx from "clsx"; +import { Button } from "../Button"; +import { LabelPairedXmarkSmBoldIcon } from "@deriv/quill-icons"; + +interface SnackbarProps extends HTMLAttributes { + icon?: ReactNode; + message: string; + actionText?: string; + hasCloseButton?: boolean; + onClose: () => void; + onActionClick?: () => void; + isOpen: boolean; +} + +export const Snackbar = ({ + icon: Icon, + message, + actionText, + onActionClick, + hasCloseButton = true, + isOpen = false, + onClose, + ...rest +}: SnackbarProps) => { + const animationSpeedObj = Object.freeze({ + fast: 'fast', + slow: 'slow' + }); + + const [animationSpeed, setAnimationSpeed] = useState(animationSpeedObj.slow); + useEffect(() => { + if (isOpen) { + setAnimationSpeed(animationSpeedObj.slow); + const timer = setTimeout(() => { + onClose?.(); + }, 3000); + + return () => { + clearTimeout(timer); + }; + } + }, [isOpen]); + + const handleClose = () => { + onClose?.(); + setAnimationSpeed(animationSpeedObj.fast); + }; + + const handleActionClick = () => { + onActionClick?.(); + setAnimationSpeed(animationSpeedObj.fast); + }; + return ( + <> + {isOpen && ( +
+ {Icon && ( +
{Icon}
+ )} +
+ + {message} + +
+ {actionText && ( +
+ )} + + ); +}; diff --git a/lib/components/Snackbar/snackbar.scss b/lib/components/Snackbar/snackbar.scss new file mode 100644 index 00000000..2551a785 --- /dev/null +++ b/lib/components/Snackbar/snackbar.scss @@ -0,0 +1,102 @@ +@import "@quill/spacing.scss"; +@import "@quill/border.scss"; +@import "@quill/breakpoints.scss"; +@import "@quill/motion.scss"; +@import "@quill/elevation.scss"; +@import "@quill/color.opacity.scss"; + +.snackbar { + display: flex; + width: calc(100vw - var(--core-spacing-1600)); + max-width: 560px; + min-height: var(--core-size-2400); + padding: var(--semantic-spacing-general-sm) + var(--semantic-spacing-general-md) var(--semantic-spacing-general-sm); + align-items: center; + gap: var(--semantic-spacing-gap-md); + border-radius: var(--semantic-borderRadius-md); + border: var(--borderWidth-xs) solid + var(--core-color-opacity-white-100); + background: var(--core-color-solid-slate-1400); + box-shadow: var(--core-elevation-shadow-330); + position: absolute; + z-index: 1; + bottom: -100%; + transition: bottom var(--motion-duration-snappy) + var(--motion-easing-inandout); + + &.slow-animation { + animation: + slide-up var(--motion-duration-snappy) var(--motion-easing-inandout) + forwards, + slide-down var(--motion-duration-snappy) var(--core-motion-duration-1300) + var(--motion-easing-inandout) forwards; + } + &.fast-animation { + animation: + slide-up var(--motion-duration-snappy) var(--motion-easing-inandout) + forwards, + slide-down var(--motion-duration-snappy) var(--core-motion-duration-800) + var(--motion-easing-inandout) forwards; + } + + @include breakpoint("sm") { + display: inline-flex; + width: 560px; + } + + &__icon--container { + display: flex; + width: var(--core-spacing-1200); + height: var(--core-spacing-1200); + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + } + &__message { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + flex: 1 0 0; + + text-overflow: ellipsis; + overflow: hidden; + word-wrap: break-word; /* Text gets broken up into another line for long words */ + color: var(--core-color-solid-slate-50); + + @include breakpoint("sm") { + -webkit-line-clamp: 1; + } + + &--container { + display: flex; + padding-right: var(--semantic-spacing-general-sm); + align-items: flex-start; + flex: 1 0 0; + min-width: var(--semantic-spacing-general-none); /* Text broken up into another line for long words */ + } + } + + & .button-label svg { + display: flex; + } +} + +@keyframes slide-up { + 0% { + bottom: -100%; + } + 100% { + bottom: var(--core-size-1600); + } +} + +@keyframes slide-down { + 0% { + bottom: var(--core-size-1600); + } + 100% { + bottom: -100%; + } +} diff --git a/lib/components/Snackbar/snackbar.stories.tsx b/lib/components/Snackbar/snackbar.stories.tsx new file mode 100644 index 00000000..ec95789e --- /dev/null +++ b/lib/components/Snackbar/snackbar.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StandalonePlaceholderRegularIcon } from "@deriv/quill-icons/Standalone"; +import { fn } from "@storybook/test"; +import { Snackbar } from "."; + +const meta = { + title: "Components/Snackbar/Snackbar", + component: Snackbar, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ["autodocs"], + args: { + icon: , + message: "Message goes here", + actionText: "Action", + isOpen: false, + onActionClick: fn(), + onClose: fn(), + hasCloseButton: true, + }, + argTypes: { + icon: { + description: + "Optional. There will be no icon if icon is not provided.", + table: { type: { summary: "Reactnode | undefined" } }, + }, + message: { + control: "text", + description: "Required. Enter any message.", + }, + actionText: { + control: "text", + description: + "Optional. Action button will not be included if actionText is null or an empty string.", + table: { type: { summary: "string | undefined" } }, + }, + onActionClick: { + description: + "Optional. Must be included if we have actionText to trigger action and close snackbar.", + }, + isOpen: { + description: + "Required. Controls opening and closing of snackbar. Should be set to false by default.", + control: "boolean", + }, + onClose: { + description: + "Required. Needed to close snackbar after a certain time.", + }, + hasCloseButton: { + control: "boolean", + description: "Optional. Set to true by default.", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const defaultSnackbar: Story = { + parameters: { + docs: { + story: { + height: '100px', + } + } + } +}; + +export const SnackbarWithMessageOnly: Story = { + args: { + isOpen: true, + icon: "", + hasCloseButton: false, + actionText: "", + }, +}; + +export const SnackbarWithIcon: Story = { + args: { + isOpen: true, + icon: , + hasCloseButton: false, + actionText: "", + }, +}; + +export const SnackbarWithAction: Story = { + args: { + isOpen: true, + icon: "", + hasCloseButton: false, + actionText: "Action", + onActionClick: fn(), + }, +}; + +export const SnackbarWithCloseButton: Story = { + args: { + isOpen: true, + icon: "", + actionText: "", + onClose: fn(), + }, +}; + +export const SnackbarWithTwoLinesMessage: Story = { + args: { + isOpen: true, + icon: "", + message: + "This is an extremely long text that goes on another line. Lorem ipsum lorem lorem. Lorem ipsum lorem lorem. Lorem ipsum lorem lorem.", + hasCloseButton: false, + actionText: "", + }, +}; + +export const SnackbarWithTwoLinesMessageWithCloseButton: Story = { + args: { + isOpen: true, + icon: "", + message: + "This is an extremely long text that goes on another line. Lorem ipsum lorem lorem.", + onClose: fn(), + actionText: "", + }, +}; + +export const SnackbarWithTwoLinesMessageWithActionButton: Story = { + args: { + isOpen: true, + icon: "", + message: + "This is an extremely long text that goes on another line. Lorem ipsum lorem lorem.", + actionText: "Action", + hasCloseButton: false, + onActionClick: fn(), + }, +}; diff --git a/lib/styles/quill/breakpoints.scss b/lib/styles/quill/breakpoints.scss index 0e411960..871c9fb9 100644 --- a/lib/styles/quill/breakpoints.scss +++ b/lib/styles/quill/breakpoints.scss @@ -19,7 +19,8 @@ @media (min-width: 1440px) { @content; } - } @else { + } + @else { @warn "Unknown breakpoint: #{$breakpoint}."; } }