From 7674b2848b7e7bba0685f7de0c3dc1025a84f007 Mon Sep 17 00:00:00 2001 From: Dominika Hustinova Date: Tue, 20 Aug 2024 21:29:49 +0200 Subject: [PATCH] feat(Modal): reimplement Modal --- .../src/NewModal/Modal.stories.tsx | 390 ++++++++++++++++++ .../src/NewModal/ModalContext.tsx | 21 + .../src/NewModal/ModalFooter/index.tsx | 29 ++ .../src/NewModal/ModalHeader/index.tsx | 102 +++++ .../src/NewModal/ModalSection/index.tsx | 93 +++++ .../orbit-components/src/NewModal/consts.ts | 35 ++ .../orbit-components/src/NewModal/index.tsx | 205 +++++++++ 7 files changed, 875 insertions(+) create mode 100644 packages/orbit-components/src/NewModal/Modal.stories.tsx create mode 100644 packages/orbit-components/src/NewModal/ModalContext.tsx create mode 100644 packages/orbit-components/src/NewModal/ModalFooter/index.tsx create mode 100644 packages/orbit-components/src/NewModal/ModalHeader/index.tsx create mode 100644 packages/orbit-components/src/NewModal/ModalSection/index.tsx create mode 100644 packages/orbit-components/src/NewModal/consts.ts create mode 100644 packages/orbit-components/src/NewModal/index.tsx diff --git a/packages/orbit-components/src/NewModal/Modal.stories.tsx b/packages/orbit-components/src/NewModal/Modal.stories.tsx new file mode 100644 index 0000000000..d9a88b4717 --- /dev/null +++ b/packages/orbit-components/src/NewModal/Modal.stories.tsx @@ -0,0 +1,390 @@ +import * as React from "react"; +// import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; + +import Button from "../Button"; +import Text from "../Text"; +import Tile from "../Tile"; +import Stack from "../Stack"; +import CarrierLogo from "../CarrierLogo"; +import FlightDirect from "../icons/FlightDirect"; +import Illustration from "../Illustration"; +import ChevronBackward from "../icons/ChevronBackward"; +import Tooltip from "../Tooltip"; +import Card from "../Card"; +import CardSection from "../Card/CardSection"; +import ModalHeader from "./ModalHeader"; +import ModalSection from "./ModalSection"; +import ModalFooter from "./ModalFooter"; + +import Modal from "."; + +type ModalArgsAndCustomProps = React.ComponentProps & { + suppressed?: boolean; +}; + +const meta: Meta = { + title: "NewModal", + component: Modal, + + args: { + suppressed: false, + }, +}; + +type Story = StoryObj; +export default meta; + +export const HeaderSectionFooter: Story = { + render: () => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + <> + + {isOpen && ( + + } + description="Select a flight below to see the menu (where available)" + /> + + + Lorem ipsum dolor sit amet}> + + OUTBOUND + + + + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + /> + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + /> + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + /> + + + + + + + INBOUND + + + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + expandable + /> + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + /> + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + actions={ + + } + /> + + + + + + + + + )} + + ); + }, +}; + +export const HeaderSection = { + args: { + title: "Orbit design system", + description: "I'm lovely description", + content: + "You can try all possible configurations of this component. However, check Orbit.Kiwi for more detailed design guidelines.", + }, + + render: ({ title, description, content, suppressed }) => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + <> + + {isOpen && ( + + {description} + + {content} + + + {content} + + + {content} + + + {content} + + + )} + + ); + }, +}; + +export const HeaderFooter = () => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + <> + + {isOpen && ( + + } + description="Select a flight below to see the menu (where available)" + > + {/* Hello */} + + {/* {showMore && ( */} + {/* */} + {/* Lorem ipsum dolor sit amet */} + {/* */} + {/* )} */} + + + + + + )} + + ); +}; + +export const Section = () => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + <> + + {isOpen && ( + + + + + OUTBOUND + + + + + + Sat, Mar 31 Trip length: 1h55m + + + London LHR + + Prague PRG + + + + } + > + Hidden Content + + + + + )} + + ); +}; + +export const HeaderFooterOneButton = () => { + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + <> + + {isOpen && ( + + } + description="Select a flight below to see the menu (where available)" + > + {/* Hello */} + + {/* {showMore && ( */} + {/* */} + {/* Lorem ipsum dolor sit amet */} + {/* */} + {/* )} */} + + + + + )} + + ); +}; diff --git a/packages/orbit-components/src/NewModal/ModalContext.tsx b/packages/orbit-components/src/NewModal/ModalContext.tsx new file mode 100644 index 0000000000..3ed8af3ba0 --- /dev/null +++ b/packages/orbit-components/src/NewModal/ModalContext.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +export interface Props { + readonly hasModalHeader?: boolean; + readonly setHasModalHeader?: React.Dispatch>; + readonly isModalHeaderSuppressed?: boolean; + readonly setIsModalHeaderSuppressed?: React.Dispatch>; + readonly hasModalSection?: boolean; + readonly setHasModalSection?: () => void; + readonly removeHasModalSection?: () => void; +} + +export const ModalContext = React.createContext({ + setHasModalHeader: () => {}, + setIsModalHeaderSuppressed: () => {}, + setHasModalSection: () => {}, + removeHasModalSection: () => {}, + hasModalHeader: false, + hasModalSection: false, + isModalHeaderSuppressed: false, +}); diff --git a/packages/orbit-components/src/NewModal/ModalFooter/index.tsx b/packages/orbit-components/src/NewModal/ModalFooter/index.tsx new file mode 100644 index 0000000000..5f8e75f3c1 --- /dev/null +++ b/packages/orbit-components/src/NewModal/ModalFooter/index.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import cx from "clsx"; + +interface Props { + readonly children: React.ReactNode; +} + +const ModalFooter = ({ children }: Props) => { + const childrenLength = React.Children.toArray(children).length; + + return ( +
1 ? "lm:justify-between" : "lm:justify-end", + "[&_.orbit-modal-footer-child:last-of-type]:p-0", + )} + > + {children} +
+ ); +}; + +export default ModalFooter; diff --git a/packages/orbit-components/src/NewModal/ModalHeader/index.tsx b/packages/orbit-components/src/NewModal/ModalHeader/index.tsx new file mode 100644 index 0000000000..0fb853e1d4 --- /dev/null +++ b/packages/orbit-components/src/NewModal/ModalHeader/index.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import cx from "clsx"; + +import type * as Common from "../../common/types"; +import Text from "../../Text"; +import { ModalContext } from "../ModalContext"; + +export interface Props extends Common.Globals { + readonly children?: React.ReactNode; + readonly illustration?: React.ReactNode; + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly suppressed?: boolean; +} + +export const ModalHeaderWrapper = ({ + className, + suppressed, + dataTest, + children, +}: { + className?: string; + suppressed?: boolean; + dataTest?: string; + children?: React.ReactNode; +}) => { + return ( +
+ {children} +
+ ); +}; + +const ModalHeader = ({ + description, + title, + illustration, + suppressed, + dataTest, + children, +}: Props) => { + const { setHasModalHeader, setIsModalHeaderSuppressed } = React.useContext(ModalContext); + + React.useEffect(() => { + if (setHasModalHeader) setHasModalHeader(true); + }); + + React.useEffect(() => { + if (suppressed) setIsModalHeaderSuppressed?.(true); + return () => { + setIsModalHeaderSuppressed?.(true); + }; + }, [suppressed, setIsModalHeaderSuppressed]); + + const hasHeader = Boolean(title || description); + + return ( + + {illustration} + {title && ( +

+ {title} +

+ )} + {description && ( +
+ + {description} + +
+ )} + {children && ( +
+ {children} +
+ )} + {/* TODO: mobileHeader */} +
+ ); +}; + +export default ModalHeader; diff --git a/packages/orbit-components/src/NewModal/ModalSection/index.tsx b/packages/orbit-components/src/NewModal/ModalSection/index.tsx new file mode 100644 index 0000000000..9420e85c57 --- /dev/null +++ b/packages/orbit-components/src/NewModal/ModalSection/index.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import cx from "clsx"; + +import type * as Common from "../../common/types"; +import type { Props as ModalContextProps } from "../ModalContext"; +import { ModalContext } from "../ModalContext"; + +export interface Props extends Common.Globals, ModalContextProps { + readonly children: React.ReactNode; + readonly suppressed?: boolean; +} + +export const ModalSectionWrapper = ({ + className, + children, + suppressed, + // closable, + isMobileFullPage, + dataTest, + hasModalHeader, + isModalHeaderSuppressed, +}: { + className?: string; + children: React.ReactNode; + suppressed?: boolean; + // closable?: boolean; + isMobileFullPage?: boolean; + dataTest?: string; + hasModalHeader?: boolean; + isModalHeaderSuppressed?: boolean; +}) => { + return ( +
+ {children} +
+ ); +}; + +const ModalSection = ({ suppressed, children, dataTest }: Props) => { + const { hasModalHeader, isModalHeaderSuppressed, removeHasModalSection, setHasModalSection } = + React.useContext(ModalContext); + + /* + Run on every re-render to prevent setting hasModalSection to false when there's more sections + */ + React.useEffect(() => { + if (setHasModalSection) setHasModalSection(); + }); + + React.useEffect(() => { + return () => { + if (removeHasModalSection) removeHasModalSection(); + }; + }, [removeHasModalSection]); + + return ( + + {children} + + ); +}; + +export default ModalSection; diff --git a/packages/orbit-components/src/NewModal/consts.ts b/packages/orbit-components/src/NewModal/consts.ts new file mode 100644 index 0000000000..c338f8f977 --- /dev/null +++ b/packages/orbit-components/src/NewModal/consts.ts @@ -0,0 +1,35 @@ +export enum SIZES { + EXTRASMALL = "extraSmall", + SMALL = "small", + NORMAL = "normal", + LARGE = "large", + EXTRALARGE = "extraLarge", +} + +export const CLOSE_BUTTON_DATA_TEST = "ModalCloseButton"; + +export const maxWidthClasses: { + [K in SIZES | "largeMobile" | "footer"]: K extends SIZES ? string : Record; +} = { + [SIZES.EXTRASMALL]: "max-w-modal-extra-small", + [SIZES.SMALL]: "max-w-modal-small", + [SIZES.NORMAL]: "max-w-modal-normal", + [SIZES.LARGE]: "max-w-modal-large", + [SIZES.EXTRALARGE]: "max-w-modal-extra-large", + largeMobile: { + [SIZES.EXTRASMALL]: "lm:max-w-modal-extra-small", + [SIZES.SMALL]: "lm:max-w-modal-small", + [SIZES.NORMAL]: "lm:max-w-modal-normal", + [SIZES.LARGE]: "lm:max-w-modal-large", + [SIZES.EXTRALARGE]: "lm:max-w-modal-extra-large", + }, + footer: { + [SIZES.EXTRASMALL]: "lm:[&_.orbit-modal-footer]:max-w-modal-extra-small", + [SIZES.SMALL]: "lm:[&_.orbit-modal-footer]:max-w-modal-small", + [SIZES.NORMAL]: "lm:[&_.orbit-modal-footer]:max-w-modal-normal", + [SIZES.LARGE]: "lm:[&_.orbit-modal-footer]:max-w-modal-large", + [SIZES.EXTRALARGE]: "lm:[&_.orbit-modal-footer]:max-w-modal-extra-large", + }, +}; + +export const OFFSET = 40; diff --git a/packages/orbit-components/src/NewModal/index.tsx b/packages/orbit-components/src/NewModal/index.tsx new file mode 100644 index 0000000000..ce2457486f --- /dev/null +++ b/packages/orbit-components/src/NewModal/index.tsx @@ -0,0 +1,205 @@ +import * as React from "react"; +import cx from "clsx"; + +import { ModalContext } from "./ModalContext"; +import Close from "../icons/Close"; +import ButtonLink from "../ButtonLink"; +import { maxWidthClasses, OFFSET, SIZES } from "./consts"; + +type Size = "extraSmall" | "small" | "normal" | "large" | "extraLarge"; + +interface Props { + size?: Size; + onClose?: () => void; + hasCloseButton?: boolean; + closeOnOverlayClick?: boolean; + children: React.ReactNode; + fixedFooter?: boolean; +} + +// interface ScrollPosition { +// scrollTop: number | null; +// scrollHeight: number | null; +// clientHeight: number | null; +// } + +const NewModal = ({ + size = SIZES.NORMAL, + onClose, + hasCloseButton = true, + closeOnOverlayClick = true, + children, + fixedFooter = false, +}: Props) => { + const [hasModalHeader, setHasModalHeader] = React.useState(false); + const [isModalHeaderSuppressed, setIsModalHeaderSuppressed] = React.useState(false); + const [hasModalSection, setHasModalSection] = React.useState(false); + // const [scrollPosition, setScrollPosition] = React.useState({ + // scrollTop: null, + // scrollHeight: null, + // clientHeight: null, + // }); + const [isFullyScrolled, setIsFullyScrolled] = React.useState(false); + const [modalWidth, setModalWidth] = React.useState(0); + const [footerHeight, setFooterHeight] = React.useState(0); + + const modalBody = React.useRef(null); + const modalContent = React.useRef(null); + + const setDimensions = () => { + const content = modalContent.current; + + if (!content) return; + + const contentDimensions = content.getBoundingClientRect(); + setModalWidth(contentDimensions.width); + + const footerEl = content.querySelector(".orbit-modal-footer"); + if (footerEl?.clientHeight) { + setFooterHeight(footerEl.clientHeight); + } + }; + + const modalBodyRef = React.useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + modalBody.current = node; + + const { scrollTop, scrollHeight, clientHeight } = node; + // setScrollPosition({ scrollTop, scrollHeight, clientHeight }); + setIsFullyScrolled(scrollHeight - scrollTop <= clientHeight + OFFSET); + } + }, []); + + const modalContentRef = React.useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + modalContent.current = node; + + setDimensions(); + } + }, []); + + const handleOverlayClick = () => { + if (onClose && closeOnOverlayClick) onClose(); + }; + + const handleScroll = () => { + if (modalBody.current) { + const { scrollTop, scrollHeight, clientHeight } = modalBody.current; + // setScrollPosition({ scrollTop, scrollHeight, clientHeight }); + + setIsFullyScrolled(scrollHeight - scrollTop <= clientHeight + OFFSET); + } + }; + + React.useEffect(() => { + const handleResize = () => { + setDimensions(); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + const value = React.useMemo( + () => ({ + hasModalHeader, + isModalHeaderSuppressed, + hasModalSection, + setHasModalSection: () => setHasModalSection(true), + removeHasModalSection: () => setHasModalSection(false), + setHasModalHeader, + setIsModalHeaderSuppressed, + }), + [hasModalHeader, isModalHeaderSuppressed, hasModalSection], + ); + + // console.log({ footerHeight, isFullyScrolled }); + + // TODO: before footerHeight is set, shadow is not visible + const footerClasses = cx( + fixedFooter && [ + "[&_.orbit-modal-footer]:p-400 [&_.orbit-modal-footer]:fixed [&_.orbit-modal-footer]:bottom-0", + "[&_.orbit-modal-footer]:duration-fast [&_.orbit-modal-footer]:transition-shadow [&_.orbit-modal-footer]:ease-in-out", + // isFullyScrolled + // ? "[&_.orbit-modal-footer]:shadow-modal-scrolled" + // : "[&_.orbit-modal-footer]:shadow-modal lm:[&_.orbit-modal-footer]:rounded-b-none", + "[&_.orbit-modal-section:last-of-type]:pb-600 [&_.orbit-modal-section:last-of-type]:mb-0", + ], + fixedFooter + ? [ + "lm:[&_.orbit-modal-footer]:!p-800", + isFullyScrolled + ? [ + "lm:[&_.orbit-modal-footer]:absolute", + "[&_.orbit-modal-footer]:shadow-modal-scrolled", + ] + : ["[&_.orbit-modal-footer]:shadow-modal lm:[&_.orbit-modal-footer]:rounded-b-none"], + ] + : "lm:[&_.orbit-modal-footer]:p-800", + isFullyScrolled && "lm:[&_.orbit-modal-footer]:shadow-none", + // isFullyScrolled + // ? "[&_.orbit-modal-footer]:shadow-modal-scrolled" + // : "[&_.orbit-modal-footer]:shadow-modal lm:[&_.orbit-modal-footer]:rounded-b-none", + modalWidth + ? "lm:[&_.orbit-modal-footer]:max-w-[var(--orbit-modal-width)]" + : maxWidthClasses.footer[size], + ); + + // const footerClasses = cx( + // fixedFooter && isFullyScrolled ? ["lm:[&_.orbit-modal-footer]:absolute"] : [], + // modalWidth + // ? "lm:[&_.orbit-modal-footer]:max-w-[var(--orbit-modal-width)]" + // : maxWidthClasses.footer[size], + // ); + + const cssVars = { + "--orbit-modal-width": `${modalWidth}px`, + "--orbit-modal-footer-height": fixedFooter ? `${footerHeight}px` : "0", + }; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
+ {/*
*/} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} + className={cx( + "orbit-modal-wrapper-content relative", + "bg-white-normal rounded-modal mx-auto overflow-y-auto", + "lm:[&_.orbit-modal-section:last-of-type]:pb-1000 lm:[&_.orbit-modal-section:last-of-type:after]:content-none lm:[&_.orbit-modal-section:last-of-type]:mb-[var(--orbit-modal-footer-height,0px)]", + !hasModalSection && + "[&_.orbit-modal-header-container]:mb-800 lm:[&_.orbit-modal-header-container]:mb-[var(--orbit-modal-footer-height,0px)]", + maxWidthClasses.largeMobile[size], + footerClasses, + )} + style={cssVars as React.CSSProperties} + ref={modalContentRef} + // onScroll={handleScroll} + > + {/* Close button */} + {onClose && hasCloseButton && ( +
+ } type="secondary" /> +
+ )} + {children} +
+ {/*
*/} +
+ ); +}; + +export default NewModal;