diff --git a/packages/core/package.json b/packages/core/package.json index 7f928ddbc3..5cb591c66b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,9 +91,11 @@ "monday-ui-style": "0.21.0", "prop-types": "^15.8.1", "react-dates": "21.8.0", + "react-focus-lock": "^2.13.2", "react-inlinesvg": "^4.1.3", "react-is": "^16.9.0", "react-popper": "^2.3.0", + "react-remove-scroll": "^2.6.0", "react-select": "npm:react-select-module@^3.2.5", "react-transition-group": "^4.4.5", "react-virtualized-auto-sizer": "^1.0.7", diff --git a/packages/core/src/components/ModalNew/Modal/Modal.module.scss b/packages/core/src/components/ModalNew/Modal/Modal.module.scss new file mode 100644 index 0000000000..6fd902b3c0 --- /dev/null +++ b/packages/core/src/components/ModalNew/Modal/Modal.module.scss @@ -0,0 +1,77 @@ +.overlay { + position: fixed; + inset: 0; + background-color: rgba(41, 47, 76, 0.7); +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + + display: flex; + flex-direction: column; + width: var(--modal-width, 50%); + max-height: var(--modal-max-height, 80%); + background-color: var(--primary-background-color); + overflow: hidden; + border-radius: var(--border-radius-big); + box-shadow: var(--box-shadow-large); + + &.sizeSmall { + --modal-max-height: 50%; + --modal-width: 45%; + } + + &.sizeMedium { + --modal-max-height: 80%; + --modal-width: 50%; + } + + &.sizeLarge { + --modal-max-height: 80%; + --modal-width: 70%; + } + + @media (min-width: 1280px) { + &.sizeSmall { + --modal-width: 40%; + } + + &.sizeMedium { + --modal-width: 44%; + } + + &.sizeLarge { + --modal-width: 66%; + } + } + + @media (min-width: 1440px) { + &.sizeSmall { + --modal-width: 35%; + } + + &.sizeMedium { + --modal-width: 38%; + } + + &.sizeLarge { + --modal-width: 60%; + } + } + + @media (min-width: 1720px) { + &.sizeSmall { + --modal-width: 34%; + } + + &.sizeMedium { + --modal-width: 36%; + } + + &.sizeLarge { + --modal-width: 58%; + } + } +} diff --git a/packages/core/src/components/ModalNew/Modal/Modal.tsx b/packages/core/src/components/ModalNew/Modal/Modal.tsx new file mode 100644 index 0000000000..e0dd6b9d32 --- /dev/null +++ b/packages/core/src/components/ModalNew/Modal/Modal.tsx @@ -0,0 +1,129 @@ +import React, { forwardRef, useCallback, useMemo, useState } from "react"; +import cx from "classnames"; +import { RemoveScroll } from "react-remove-scroll"; +import FocusLock from "react-focus-lock"; +import { motion, AnimatePresence } from "framer-motion"; +import { getTestId } from "../../../tests/test-ids-utils"; +import { ComponentDefaultTestId } from "../../../tests/constants"; +import styles from "./Modal.module.scss"; +import { ModalProps } from "./Modal.types"; +import ModalTopActions from "../ModalTopActions/ModalTopActions"; +import { getStyle } from "../../../helpers/typesciptCssModulesHelper"; +import { camelCase } from "lodash-es"; +import { ModalProvider } from "../context/ModalContext"; +import { ModalContextProps } from "../context/ModalContext.types"; +import useKeyEvent from "../../../hooks/useKeyEvent"; +import { keyCodes } from "../../../constants"; +import { + modalAnimationAnchorPopVariants, + modalAnimationCenterPopVariants, + modalAnimationOverlayVariants +} from "../utils/animationVariants"; + +const Modal = forwardRef( + ( + { + id, + show, + size = "medium", + renderHeaderAction, + closeButtonTheme, + closeButtonAriaLabel, + onClose = () => {}, + anchorElementRef, + children, + className, + "data-testid": dataTestId + }: ModalProps, + ref: React.ForwardedRef + ) => { + const [titleId, setTitleId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const setTitleIdCallback = useCallback((id: string) => setTitleId(id), []); + const setDescriptionIdCallback = useCallback((id: string) => setDescriptionId(id), []); + + const contextValue = useMemo( + () => ({ + modalId: id, + setTitleId: setTitleIdCallback, + setDescriptionId: setDescriptionIdCallback + }), + [id, setTitleIdCallback, setDescriptionIdCallback] + ); + + const onBackdropClick = useCallback>( + e => { + if (!show) return; + onClose(e); + }, + [onClose, show] + ); + + const onEscClick = useCallback>( + e => { + if (!show) return; + onClose(e); + }, + [onClose, show] + ); + + useKeyEvent({ + callback: onEscClick, + capture: true, + keys: [keyCodes.ESCAPE] + }); + + const modalAnimationVariants = anchorElementRef?.current + ? modalAnimationAnchorPopVariants + : modalAnimationCenterPopVariants; + + return ( + + {show && ( + + + + + + + {children} + + + + + )} + + ); + } +); + +export default Modal; diff --git a/packages/core/src/components/ModalNew/Modal/Modal.types.tsx b/packages/core/src/components/ModalNew/Modal/Modal.types.tsx new file mode 100644 index 0000000000..8869035f16 --- /dev/null +++ b/packages/core/src/components/ModalNew/Modal/Modal.types.tsx @@ -0,0 +1,21 @@ +import { VibeComponentProps } from "../../../types"; +import React from "react"; +import { ModalTopActionsProps } from "../ModalTopActions/ModalTopActions.types"; + +export type ModalSize = "small" | "medium" | "large"; + +export type ModalCloseEvent = + | React.MouseEvent + | React.KeyboardEvent; + +export interface ModalProps extends VibeComponentProps { + id: string; + show: boolean; + size?: ModalSize; + closeButtonTheme?: ModalTopActionsProps["color"]; + closeButtonAriaLabel?: ModalTopActionsProps["closeButtonAriaLabel"]; + onClose?: (event: ModalCloseEvent) => void; + renderHeaderAction?: ModalTopActionsProps["renderAction"]; + anchorElementRef?: React.RefObject; + children: React.ReactNode; +} diff --git a/packages/core/src/components/ModalNew/Modal/__tests__/Modal.test.tsx b/packages/core/src/components/ModalNew/Modal/__tests__/Modal.test.tsx new file mode 100644 index 0000000000..101ee35c60 --- /dev/null +++ b/packages/core/src/components/ModalNew/Modal/__tests__/Modal.test.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Modal from "../Modal"; +import ModalContent from "../../ModalContent/ModalContent"; + +jest.mock("framer-motion", () => { + const actual = jest.requireActual("framer-motion"); + return { + ...actual, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children} + }; +}); + +describe("Modal", () => { + const id = "modal-id"; + const closeButtonAriaLabel = "Close modal"; + const childrenContent = ( +
+ + My content +
+ ); + it("renders the modal with the correct role", () => { + const { getByTestId } = render( + + {childrenContent} + + ); + + expect(getByTestId("modal")).toHaveAttribute("role", "dialog"); + }); + + it("renders the modal with the correct aria-modal", () => { + const { getByTestId } = render( + + {childrenContent} + + ); + + expect(getByTestId("modal")).toHaveAttribute("aria-modal", "true"); + }); + + it("does not render when 'show' is false", () => { + const { queryByRole } = render( + + {childrenContent} + + ); + + expect(queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders the children content correctly", () => { + const { getByText } = render( + + {childrenContent} + + ); + + expect(getByText("My content")).toBeInTheDocument(); + }); + + it("applies default size as 'medium' when not supplied with a size", () => { + const { getByRole } = render( + + {childrenContent} + + ); + + expect(getByRole("dialog")).toHaveClass("sizeMedium"); + }); + + it("applies the correct given 'large' size", () => { + const { getByRole } = render( + + {childrenContent} + + ); + + expect(getByRole("dialog")).toHaveClass("sizeLarge"); + }); + + it("calls onClose when the close button is clicked with mouse", () => { + const mockOnClose = jest.fn(); + const { getByLabelText } = render( + + {childrenContent} + + ); + + fireEvent.click(getByLabelText(closeButtonAriaLabel)); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("calls onClose when the close button is clicked with keyboard", () => { + const mockOnClose = jest.fn(); + const { getByLabelText } = render( + + {childrenContent} + + ); + + fireEvent.focus(getByLabelText(closeButtonAriaLabel)); + userEvent.type(getByLabelText(closeButtonAriaLabel), "{space}"); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("calls onClose when the backdrop is clicked", () => { + const mockOnClose = jest.fn(); + const { getByTestId } = render( + + {childrenContent} + + ); + + fireEvent.click(getByTestId("modal-overlay_" + id)); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("calls onClose when the Escape key is pressed while focused on dialog", () => { + const mockOnClose = jest.fn(); + const { getByRole } = render( + + {childrenContent} + + ); + + fireEvent.keyDown(getByRole("dialog"), { key: "Escape" }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("calls onClose when the Escape key is pressed without focus", () => { + const mockOnClose = jest.fn(); + render( + + {childrenContent} + + ); + + userEvent.keyboard("{Escape}"); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("traps focus inside the modal when opened and move it to first non top-actions element", () => { + const { getByText, getByLabelText } = render( + <> + + + + + + ); + expect(getByText("Test button content")).toHaveFocus(); + userEvent.tab(); + expect(getByLabelText(closeButtonAriaLabel)).toHaveFocus(); + userEvent.tab(); + expect(getByText("Test button content")).toHaveFocus(); + }); + + it("releases focus lock inside the modal when closed", () => { + const { rerender, getByText } = render( + <> + + + + + + + ); + expect(getByText("Test button content")).toHaveFocus(); + + rerender( + <> + + + + + + + ); + + userEvent.tab(); + expect(getByText("Focusable outside 1")).toHaveFocus(); + userEvent.tab(); + expect(getByText("Focusable outside 2")).toHaveFocus(); + }); + + it("traps and moves focus between modal elements", () => { + const { getByLabelText, getByText } = render( + + + + + ); + expect(getByText("Focusable 1")).toHaveFocus(); + + userEvent.tab(); + expect(getByText("Focusable 2")).toHaveFocus(); + + userEvent.tab(); + const closeButton = getByLabelText(closeButtonAriaLabel); + expect(closeButton).toHaveFocus(); + }); + + it("traps and moves focus to focusable element inside ModalContent and cycle through full focus flow", () => { + const { getByLabelText, getByText } = render( + + + + + + + + ); + expect(getByText("Focusable inside ModalContent")).toHaveFocus(); + + userEvent.tab(); + expect(getByText("Focusable 2")).toHaveFocus(); + + userEvent.tab(); + expect(getByLabelText(closeButtonAriaLabel)).toHaveFocus(); + + userEvent.tab(); + expect(getByText("Focusable 1")).toHaveFocus(); + + userEvent.tab(); + expect(getByText("Focusable inside ModalContent")).toHaveFocus(); + }); + + it.todo("renders the correct aria-labelledby"); + + it.todo("renders the correct aria-describedby"); +}); diff --git a/packages/core/src/components/ModalNew/ModalContent/ModalContent.module.scss b/packages/core/src/components/ModalNew/ModalContent/ModalContent.module.scss new file mode 100644 index 0000000000..f463fb3e05 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalContent/ModalContent.module.scss @@ -0,0 +1,7 @@ +@import "~monday-ui-style/dist/mixins"; + +.content { + padding-block-end: var(--spacing-xl); + overflow: auto; + @include scroller; +} diff --git a/packages/core/src/components/ModalNew/ModalContent/ModalContent.tsx b/packages/core/src/components/ModalNew/ModalContent/ModalContent.tsx new file mode 100644 index 0000000000..4eaf49299d --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalContent/ModalContent.tsx @@ -0,0 +1,27 @@ +import React, { forwardRef } from "react"; +import cx from "classnames"; +import { getTestId } from "../../../tests/test-ids-utils"; +import { ComponentDefaultTestId } from "../../../tests/constants"; +import styles from "./ModalContent.module.scss"; +import { ModalContentProps } from "./ModalContent.types"; + +const ModalContent = forwardRef( + ( + { children, className, id, "data-testid": dataTestId }: ModalContentProps, + ref: React.ForwardedRef + ) => { + return ( +
+ {children} +
+ ); + } +); + +export default ModalContent; diff --git a/packages/core/src/components/ModalNew/ModalContent/ModalContent.types.ts b/packages/core/src/components/ModalNew/ModalContent/ModalContent.types.ts new file mode 100644 index 0000000000..d50def5bd6 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalContent/ModalContent.types.ts @@ -0,0 +1,6 @@ +import React from "react"; +import { VibeComponentProps } from "../../../types"; + +export interface ModalContentProps extends VibeComponentProps { + children?: React.ReactNode; +} diff --git a/packages/core/src/components/ModalNew/ModalContent/__tests__/ModalContent.test.tsx b/packages/core/src/components/ModalNew/ModalContent/__tests__/ModalContent.test.tsx new file mode 100644 index 0000000000..e2a5e534b3 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalContent/__tests__/ModalContent.test.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import ModalContent from "../ModalContent"; + +describe("ModalContent", () => { + const childrenContent = My content; + + it("renders the children correctly", () => { + const { getByText } = render({childrenContent}); + expect(getByText("My content")).toBeInTheDocument(); + }); + + it("renders when no children are provided", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.module.scss b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.module.scss new file mode 100644 index 0000000000..ee95079884 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.module.scss @@ -0,0 +1,7 @@ +.footer { + position: relative; + width: 100%; + padding: 20px var(--spacing-large); + flex-shrink: 0; + background-color: var(--primary-background-color); +} diff --git a/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.tsx b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.tsx new file mode 100644 index 0000000000..71a541f4e6 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from "react"; +import styles from "./ModalFooterBase.module.scss"; +import Button from "../../../Button/Button"; +import Flex from "../../../Flex/Flex"; +import { ModalFooterBaseProps } from "./ModalFooterBase.types"; +import cx from "classnames"; + +const ModalFooterBase = forwardRef( + ( + { primaryButton, secondaryButton, renderAction, id, className, "data-testid": dataTestId }: ModalFooterBaseProps, + ref: React.ForwardedRef + ) => { + return ( + + + {secondaryButton && ( + + )} + {renderAction} + + ); + } +); + +export default ModalFooterBase; diff --git a/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.types.ts b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.types.ts new file mode 100644 index 0000000000..9627288a69 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/ModalFooterBase.types.ts @@ -0,0 +1,13 @@ +import { ButtonProps } from "../../../Button"; +import React from "react"; +import { VibeComponentProps } from "../../../../types"; + +export interface ModalFooterActionProps extends Pick { + text: string; +} + +export interface ModalFooterBaseProps extends VibeComponentProps { + primaryButton: ModalFooterActionProps; + secondaryButton?: ModalFooterActionProps; + renderAction?: React.ReactNode; +} diff --git a/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/__tests__/ModalFooterBase.test.tsx b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/__tests__/ModalFooterBase.test.tsx new file mode 100644 index 0000000000..9f8e24f809 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalFooter/ModalFooterBase/__tests__/ModalFooterBase.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import ModalFooterBase from "../ModalFooterBase"; + +describe("ModalFooterBase", () => { + const primaryButton = { + text: "Save", + onClick: jest.fn() + }; + + const secondaryButton = { + text: "Cancel", + onClick: jest.fn() + }; + + it("renders the primary button with the correct text", () => { + const { getByText } = render(); + + const primaryButtonElement = getByText("Save"); + expect(primaryButtonElement).toBeInTheDocument(); + }); + + it("does not render more than one child when only supplying primary button", () => { + const { container } = render(); + expect(container.firstChild.childNodes).toHaveLength(1); + }); + + it("renders the secondary button with the correct text", () => { + const { getByText } = render(); + + const secondaryButtonElement = getByText("Cancel"); + expect(secondaryButtonElement).toBeInTheDocument(); + }); + + it("calls the primary button's onClick when clicked", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Save")); + expect(primaryButton.onClick).toHaveBeenCalled(); + }); + + it("calls the secondary button's onClick when clicked", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Cancel")); + expect(secondaryButton.onClick).toHaveBeenCalled(); + }); + + it("renders the custom action via renderAction", () => { + const customAction =
Custom Action
; + const { getByTestId } = render(); + + const customActionElement = getByTestId("custom-action"); + expect(customActionElement).toBeInTheDocument(); + expect(customActionElement).toHaveTextContent("Custom Action"); + }); +}); diff --git a/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.module.scss b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.module.scss new file mode 100644 index 0000000000..2da14a609b --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.module.scss @@ -0,0 +1,14 @@ +.header { + width: 100%; + align-self: flex-start; + + .title { + width: 100%; + } + + .descriptionIcon { + flex-shrink: 0; + margin-inline-end: var(--spacing-xs); + color: var(--icon-color); + } +} diff --git a/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.tsx b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.tsx new file mode 100644 index 0000000000..22dc94a6f1 --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.tsx @@ -0,0 +1,70 @@ +import React, { forwardRef, useEffect } from "react"; +import cx from "classnames"; +import { getTestId } from "../../../tests/test-ids-utils"; +import { ComponentDefaultTestId } from "../../../tests/constants"; +import styles from "./ModalHeader.module.scss"; +import { ModalHeaderProps } from "./ModalHeader.types"; +import Flex from "../../Flex/Flex"; +import Heading from "../../Heading/Heading"; +import Text from "../../Text/Text"; +import Icon from "../../Icon/Icon"; +import { useModal } from "../context/ModalContext"; + +const ModalHeader = forwardRef( + ( + { title, description, descriptionIcon, className, id, "data-testid": dataTestId }: ModalHeaderProps, + ref: React.ForwardedRef + ) => { + const { modalId, setDescriptionId, setTitleId } = useModal(); + const titleId = id ? `${modalId}_${id}_label` : `${modalId}_label`; + const descriptionId = id ? `${modalId}_${id}_desc` : `${modalId}_desc`; + + useEffect(() => { + if (!modalId) return; + setTitleId(titleId); + if (!description) return; + setDescriptionId(descriptionId); + }, [modalId, setTitleId, titleId, description, setDescriptionId, descriptionId]); + + return ( + + + {title} + + {description && ( + + {descriptionIcon && ( + + )} + {typeof description === "string" ? ( + + {description} + + ) : ( + description + )} + + )} + + ); + } +); + +export default ModalHeader; diff --git a/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.types.ts b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.types.ts new file mode 100644 index 0000000000..8e5f9a750f --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalHeader/ModalHeader.types.ts @@ -0,0 +1,19 @@ +import React from "react"; +import { SubIcon, VibeComponentProps } from "../../../types"; + +interface WithoutDescription { + description?: never; + descriptionIcon?: never; +} + +interface WithDescription { + description: string | React.ReactNode; + descriptionIcon?: + | SubIcon + | { + name: SubIcon; + className?: string; + }; +} + +export type ModalHeaderProps = { title: string } & (WithoutDescription | WithDescription) & VibeComponentProps; diff --git a/packages/core/src/components/ModalNew/ModalHeader/__tests__/ModalHeader.test.tsx b/packages/core/src/components/ModalNew/ModalHeader/__tests__/ModalHeader.test.tsx new file mode 100644 index 0000000000..0b36be0c8d --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalHeader/__tests__/ModalHeader.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import ModalHeader from "../ModalHeader"; +import { Text as TextIcon } from "@vibe/icons"; +import { useModal } from "../../context/ModalContext"; + +jest.mock("../../context/ModalContext", () => ({ + useModal: jest.fn() +})); +const useModalMocked = jest.mocked(useModal); + +describe("ModalHeader", () => { + const title = "Test Modal Header"; + const simpleDescription = "This is a description"; + const descriptionIcon = TextIcon; + + const useModalMockedReturnedValue = { + modalId: "modal-id", + setTitleId: jest.fn(), + setDescriptionId: jest.fn() + }; + + beforeEach(() => { + useModalMocked.mockReturnValue(useModalMockedReturnedValue); + }); + + it("renders the title correctly", () => { + const { getByText } = render(); + + expect(getByText(title)).toBeInTheDocument(); + }); + + it("renders the description correctly", () => { + const { getByText } = render(); + + expect(getByText(simpleDescription)).toBeInTheDocument(); + }); + + it("renders the description icon when provided", () => { + const { getByText, getByTestId } = render( + + ); + + expect(getByText(simpleDescription)).toBeInTheDocument(); + expect(getByTestId("icon")).toBeInTheDocument(); + }); + + it("renders custom description node", () => { + const customDescription = Custom description content; + + const { getByTestId } = render(); + + expect(getByTestId("custom-description")).toBeInTheDocument(); + }); + + it("does not render description when not provided", () => { + const { queryByText } = render(); + + expect(queryByText(simpleDescription)).not.toBeInTheDocument(); + }); + + it("renders with description icon when descriptionIcon is an object", () => { + const descriptionIconObject = { + name: TextIcon, + className: "with-custom-icon-class" + }; + + const { getByTestId } = render( + + ); + + const icon = getByTestId("icon"); + expect(icon).toHaveClass(descriptionIconObject.className); + }); + + it("sets the titleId and descriptionId in the context when rendered", () => { + const { getByText } = render(); + + expect(useModalMockedReturnedValue.setTitleId).toHaveBeenCalledWith("modal-id_label"); + expect(useModalMockedReturnedValue.setDescriptionId).toHaveBeenCalledWith("modal-id_desc"); + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(simpleDescription)).toBeInTheDocument(); + }); + + it("does not set descriptionId if no description is provided", () => { + render(); + + expect(useModalMockedReturnedValue.setTitleId).toHaveBeenCalledWith("modal-id_label"); + expect(useModalMockedReturnedValue.setDescriptionId).not.toHaveBeenCalled(); + }); + + it("renders the title with the correct id", () => { + const { getByText } = render(); + + const titleElement = getByText(title); + expect(titleElement).toHaveAttribute("id", "modal-id_label"); + }); + + it("renders the description container with the correct id when provided", () => { + const { getByText } = render(); + + const descriptionElement = getByText(simpleDescription); + expect(descriptionElement.parentElement).toHaveAttribute("id", "modal-id_desc"); + }); + + it("calls setTitleId and setDescriptionId with a custom id if provided", () => { + const customId = "custom-header-id"; + render(); + + expect(useModalMockedReturnedValue.setTitleId).toHaveBeenCalledWith("modal-id_custom-header-id_label"); + expect(useModalMockedReturnedValue.setDescriptionId).toHaveBeenCalledWith("modal-id_custom-header-id_desc"); + }); +}); diff --git a/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.module.scss b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.module.scss new file mode 100644 index 0000000000..999270f55b --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.module.scss @@ -0,0 +1,7 @@ +.actions { + display: flex; + align-items: center; + position: absolute; + right: var(--spacing-large); + top: var(--spacing-large); +} diff --git a/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.tsx b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.tsx new file mode 100644 index 0000000000..5a06ca4e3e --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import styles from "./ModalTopActions.module.scss"; +import { ModalTopActionsButtonColor, ModalTopActionsColor, ModalTopActionsProps } from "./ModalTopActions.types"; +import IconButton from "../../IconButton/IconButton"; +import { CloseMedium } from "@vibe/icons"; +import { ButtonColor } from "../../Button/ButtonConstants"; + +const colorToButtonColor: Record = { + dark: ButtonColor.ON_INVERTED_BACKGROUND, + light: ButtonColor.ON_PRIMARY_COLOR +}; + +const ModalTopActions = ({ renderAction, color, closeButtonAriaLabel, onClose }: ModalTopActionsProps) => { + const buttonColor = colorToButtonColor[color] || ButtonColor.PRIMARY; + + return ( +
+ {typeof renderAction === "function" ? renderAction(buttonColor) : renderAction} + +
+ ); +}; + +export default ModalTopActions; diff --git a/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.types.ts b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.types.ts new file mode 100644 index 0000000000..9ee221d54f --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalTopActions/ModalTopActions.types.ts @@ -0,0 +1,23 @@ +import React from "react"; +import MenuButton from "../../MenuButton/MenuButton"; +import IconButton from "../../IconButton/IconButton"; +import { ButtonColor } from "../../Button/ButtonConstants"; + +export type ModalTopActionsColor = "light" | "dark"; +export type ModalTopActionsButtonColor = + | ButtonColor.PRIMARY + | ButtonColor.ON_PRIMARY_COLOR + | ButtonColor.ON_INVERTED_BACKGROUND; + +export interface ModalTopActionsProps { + /** + * action can be passed either as a function or direct + * it allows passing back to consumer the color he chose, so he won't have to define it twice + */ + renderAction?: + | React.ReactElement + | ((color?: ModalTopActionsButtonColor) => React.ReactElement); + color?: ModalTopActionsColor; + closeButtonAriaLabel?: string; + onClose?: (event: React.MouseEvent) => void; +} diff --git a/packages/core/src/components/ModalNew/ModalTopActions/__tests__/ModalTopActions.test.tsx b/packages/core/src/components/ModalNew/ModalTopActions/__tests__/ModalTopActions.test.tsx new file mode 100644 index 0000000000..8901e0e9de --- /dev/null +++ b/packages/core/src/components/ModalNew/ModalTopActions/__tests__/ModalTopActions.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, fireEvent, within } from "@testing-library/react"; +import ModalTopActions from "../ModalTopActions"; +import IconButton from "../../../IconButton/IconButton"; +import { Feedback as FeedbackIcon } from "@vibe/icons"; +import { ButtonColor } from "../../../Button/ButtonConstants"; +import { camelCase } from "lodash-es"; + +describe("ModalTopActions", () => { + const closeButtonAriaLabel = "Close modal"; + + it("renders the close button with the correct aria-label", () => { + const { getByLabelText } = render(); + + expect(getByLabelText(closeButtonAriaLabel)).toBeInTheDocument(); + }); + + it("calls onClose when the close button is clicked", () => { + const mockOnClose = jest.fn(); + + const { getByLabelText } = render( + + ); + + fireEvent.click(getByLabelText(closeButtonAriaLabel)); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("does not fail when onClose is not provided", () => { + const { getByLabelText } = render(); + fireEvent.click(getByLabelText(closeButtonAriaLabel)); + expect(() => getByLabelText(closeButtonAriaLabel)).not.toThrow(); + }); + + it("renders the action button using the renderAction prop as a function", () => { + const renderAction = jest.fn(color => ); + const { getByTestId } = render(); + + expect(within(getByTestId("extra-action")).getByTestId("icon")).toBeInTheDocument(); + }); + + it("calls renderAction with correct color argument", () => { + const renderAction = jest.fn(color => ); + render(); + + expect(renderAction).toHaveBeenCalledWith(ButtonColor.ON_INVERTED_BACKGROUND); + }); + + it("renders the action button using the renderAction prop directly", () => { + const renderAction = ( + + ); + const { getByTestId } = render(); + + expect(within(getByTestId("extra-action")).getByTestId("icon")).toBeInTheDocument(); + }); + + it("applies the correct color when 'dark' is passed", () => { + const { getByLabelText } = render(); + expect(getByLabelText(closeButtonAriaLabel)).toHaveClass(camelCase("color-" + ButtonColor.ON_INVERTED_BACKGROUND)); + }); + + it("applies the correct color when 'light' is passed", () => { + const { getByLabelText } = render(); + expect(getByLabelText(closeButtonAriaLabel)).toHaveClass(camelCase("color-" + ButtonColor.ON_PRIMARY_COLOR)); + }); + + it("applies the default color when no color is passed", () => { + const { getByLabelText } = render(); + expect(getByLabelText(closeButtonAriaLabel)).toHaveClass(camelCase("color-" + ButtonColor.PRIMARY)); + }); +}); diff --git a/packages/core/src/components/ModalNew/context/ModalContext.tsx b/packages/core/src/components/ModalNew/context/ModalContext.tsx new file mode 100644 index 0000000000..b7a03d3f53 --- /dev/null +++ b/packages/core/src/components/ModalNew/context/ModalContext.tsx @@ -0,0 +1,16 @@ +import React, { createContext, useContext } from "react"; +import { ModalContextProps, ModalProviderProps } from "./ModalContext.types"; + +const ModalContext = createContext(undefined); + +export const ModalProvider = ({ value, children }: ModalProviderProps) => { + return {children}; +}; + +export const useModal = (): ModalContextProps => { + const context = useContext(ModalContext); + if (!context) { + throw new Error("useModal must be used within a ModalProvider"); + } + return context; +}; diff --git a/packages/core/src/components/ModalNew/context/ModalContext.types.ts b/packages/core/src/components/ModalNew/context/ModalContext.types.ts new file mode 100644 index 0000000000..0459db6cef --- /dev/null +++ b/packages/core/src/components/ModalNew/context/ModalContext.types.ts @@ -0,0 +1,14 @@ +import React from "react"; + +export type ModalProviderValue = { + modalId: string; + setTitleId: (id: string) => void; + setDescriptionId: (id: string) => void; +}; + +export type ModalContextProps = ModalProviderValue; + +export interface ModalProviderProps { + value: ModalProviderValue; + children: React.ReactNode; +} diff --git a/packages/core/src/components/ModalNew/utils/animationVariants.ts b/packages/core/src/components/ModalNew/utils/animationVariants.ts new file mode 100644 index 0000000000..3aea7d75c2 --- /dev/null +++ b/packages/core/src/components/ModalNew/utils/animationVariants.ts @@ -0,0 +1,83 @@ +import { Variants } from "framer-motion"; +import { RefObject } from "react"; + +export const modalAnimationOverlayVariants: Variants = { + enter: { + opacity: 1, + transition: { + duration: 0.2, + ease: [0.0, 0.0, 0.4, 1.0] + } + }, + exit: { + opacity: 0, + transition: { + duration: 0.15, + ease: [0.0, 0.0, 0.4, 1.0] + } + } +}; + +export const modalAnimationCenterPopVariants: Variants = { + enter: { + opacity: [0, 1, 1], + scale: [0.65, 1, 1], + x: "-50%", + y: "-50%", + transition: { + duration: 0.15, + ease: [0.0, 0.0, 0.4, 1.0], + times: [0, 0.5, 1] + } + }, + exit: { + opacity: [1, 1, 0], + scale: [1, 1, 0.65], + x: "-50%", + y: "-50%", + transition: { + duration: 0.1, + ease: [0.0, 0.0, 0.4, 1.0], + times: [0, 0.5, 1] + } + } +}; + +export const modalAnimationAnchorPopVariants: Variants = { + enter: { + opacity: [0, 1, 1], + scale: [0.65, 0.65, 1], + top: "50%", + left: "50%", + x: "-50%", + y: "-50%", + transition: { + delay: 0.05, + duration: 0.2, + ease: [0.0, 0.0, 0.4, 1.0], + times: [0, 0.2, 1] + } + }, + exit: (anchorElementRef: RefObject) => { + const anchorRect = anchorElementRef.current.getBoundingClientRect(); + const anchorCenterX = anchorRect.left + anchorRect.width / 2; + const anchorCenterY = anchorRect.top + anchorRect.height / 2; + + const x = `calc(-50% + ${anchorCenterX}px)`; + const y = `calc(-50% + ${anchorCenterY}px)`; + + return { + opacity: [1, 1, 0], + scale: [1, 1, 0.75], + top: 0, + left: 0, + x, + y, + transition: { + duration: 0.15, + ease: [0.6, 0.0, 1.0, 1.0], + times: [0, 0.33, 1] + } + }; + } +}; diff --git a/packages/core/src/tests/constants.ts b/packages/core/src/tests/constants.ts index 812079571c..2d8c4d89bf 100644 --- a/packages/core/src/tests/constants.ts +++ b/packages/core/src/tests/constants.ts @@ -104,6 +104,10 @@ export enum ComponentDefaultTestId { MODAL_CONTENT = "modal-content", MODAL_HEADER = "modal-header", MODAL_FOOTER_BUTTONS = "modal-footer-buttons", + MODAL_NEXT = "modal", + MODAL_NEXT_OVERLAY = "modal-overlay", + MODAL_NEXT_HEADER = "modal-header", + MODAL_NEXT_CONTENT = "modal-content", FORMATTED_NUMBER = "formatted-number", HIDDEN_TEXT = "hidden-text", DIALOG_CONTENT_CONTAINER = "dialog-content-container", diff --git a/yarn.lock b/yarn.lock index 7bf5590118..8574f458b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.12.13": + version "7.26.0" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.7.2": version "7.25.0" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb" @@ -10748,6 +10755,13 @@ flow-parser@0.*: resolved "https://registry.npmjs.org/flow-parser/-/flow-parser-0.231.0.tgz#13daa172b3c06ffacbb31025592dc0db41fe28f3" integrity sha512-WVzuqwq7ZnvBceCG0DGeTQebZE+iIU0mlk5PmJgYj9DDrt+0isGC2m1ezW9vxL4V+HERJJo9ExppOnwKH2op6Q== +focus-lock@^1.3.5: + version "1.3.5" + resolved "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.5.tgz#aa644576e5ec47d227b57eb14e1efb2abf33914c" + integrity sha512-QFaHbhv9WPUeLYBDe/PAuLKJ4Dd9OPvKs9xZBr3yLXnUrDNaVXKu2baDBXe3naPY30hgHYSsf2JW4jzas2mDEQ== + dependencies: + tslib "^2.0.3" + focusable-selectors@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/focusable-selectors/-/focusable-selectors-0.4.0.tgz#c93092bfe65c7cf7ef52aed82fb6f8df31072459" @@ -17515,6 +17529,13 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +react-clientside-effect@^1.2.6: + version "1.2.6" + resolved "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a" + integrity sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg== + dependencies: + "@babel/runtime" "^7.12.13" + react-colorful@^5.1.2: version "5.6.1" resolved "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" @@ -17608,6 +17629,18 @@ react-fast-compare@^3.0.1: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== +react-focus-lock@^2.13.2: + version "2.13.2" + resolved "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.2.tgz#e1addac2f8b9550bc0581f3c416755ba0f81f5ef" + integrity sha512-T/7bsofxYqnod2xadvuwjGKHOoL5GH7/EIPI5UyEvaU/c2CcphvGI371opFtuY/SYdbMsNiuF4HsHQ50nA/TKQ== + dependencies: + "@babel/runtime" "^7.0.0" + focus-lock "^1.3.5" + prop-types "^15.6.2" + react-clientside-effect "^1.2.6" + use-callback-ref "^1.3.2" + use-sidecar "^1.1.2" + react-from-dom@^0.7.2: version "0.7.3" resolved "https://registry.npmjs.org/react-from-dom/-/react-from-dom-0.7.3.tgz#60e75fde2369ceb0a8f87d88f9cfbeb67b730e43" @@ -17701,7 +17734,7 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-remove-scroll-bar@^2.3.3: +react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.6: version "2.3.6" resolved "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== @@ -17720,6 +17753,17 @@ react-remove-scroll@2.5.5: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-remove-scroll@^2.6.0: + version "2.6.0" + resolved "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz#fb03a0845d7768a4f1519a99fdb84983b793dc07" + integrity sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ== + dependencies: + react-remove-scroll-bar "^2.3.6" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + react-resizable@^3.0.4: version "3.0.5" resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz#362721f2efbd094976f1780ae13f1ad7739786c1" @@ -19339,7 +19383,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -19434,7 +19487,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -19448,6 +19501,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -20733,7 +20793,7 @@ url@^0.11.0: punycode "^1.4.1" qs "^6.11.2" -use-callback-ref@^1.3.0: +use-callback-ref@^1.3.0, use-callback-ref@^1.3.2: version "1.3.2" resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" integrity sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA== @@ -21270,7 +21330,7 @@ workerpool@6.2.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21288,6 +21348,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"