From 23f87368ee3e0119c037ad796f580dbff9e0617b Mon Sep 17 00:00:00 2001 From: shawn wee Date: Tue, 29 Jul 2025 18:54:26 +0800 Subject: [PATCH 01/21] [ModalV2][SW] add new modal v2 --- src/index.ts | 1 + src/modal-v2/index.ts | 8 ++ src/modal-v2/modal-box-v2.styles.tsx | 56 ++++++++++ src/modal-v2/modal-box-v2.tsx | 42 ++++++++ src/modal-v2/modal-v2.styles.tsx | 75 ++++++++++++++ src/modal-v2/modal-v2.tsx | 146 +++++++++++++++++++++++++++ src/modal-v2/types.ts | 25 +++++ src/overlay/overlay.styles.tsx | 8 +- src/overlay/overlay.tsx | 4 +- src/overlay/types.ts | 1 + 10 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/modal-v2/index.ts create mode 100644 src/modal-v2/modal-box-v2.styles.tsx create mode 100644 src/modal-v2/modal-box-v2.tsx create mode 100644 src/modal-v2/modal-v2.styles.tsx create mode 100644 src/modal-v2/modal-v2.tsx create mode 100644 src/modal-v2/types.ts diff --git a/src/index.ts b/src/index.ts index 21a2eb8af..29c955f10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ export * from "./masonry"; export * from "./masthead"; export * from "./menu"; export * from "./modal"; +export * from "./modal-v2"; export * from "./navbar"; export * from "./notification-banner"; export * from "./otp-input"; diff --git a/src/modal-v2/index.ts b/src/modal-v2/index.ts new file mode 100644 index 000000000..a54d03ff8 --- /dev/null +++ b/src/modal-v2/index.ts @@ -0,0 +1,8 @@ +import { ModalV2 as Base } from "./modal-v2"; +import { ModalBoxV2 as Box } from "./modal-box-v2"; + +export const ModalV2 = Object.assign(Base, { + Box, +}); + +export * from "./types"; diff --git a/src/modal-v2/modal-box-v2.styles.tsx b/src/modal-v2/modal-box-v2.styles.tsx new file mode 100644 index 000000000..9d9ec6968 --- /dev/null +++ b/src/modal-v2/modal-box-v2.styles.tsx @@ -0,0 +1,56 @@ +import styled from "styled-components"; +import { ClickableIcon } from "../shared/clickable-icon"; +import { Colour, MediaQuery, Radius, Spacing } from "../theme"; + +// ============================================================================= +// STYLE INTERFACES +// ============================================================================= +interface CloseButtonProps { + $insetTop?: string | undefined; + $insetRight?: string | undefined; +} + +// ============================================================================= +// STYLING +// ============================================================================= +export const Box = styled.div` + position: relative; + width: 40rem; + background: ${Colour.bg}; + box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); + border-radius: ${Radius["lg"]}; + overflow: hidden; + + display: flex; + flex-direction: column-reverse; + + > div { + /* minus the space taken up by the block CloseButton */ + margin-top: calc( + 0px - 32px - var(--close-button-top-inset, ${Spacing["spacing-16"]}) + ); + } + + ${MediaQuery.MaxWidth.md} { + width: 100%; + } +`; + +export const CloseButton = styled(ClickableIcon)` + margin-top: var(--close-button-top-inset, ${Spacing["spacing-16"]}); + margin-right: var(--close-button-right-inset, ${Spacing["spacing-16"]}); + margin-left: auto; + padding: 0; + color: ${Colour.icon}; + + z-index: 2; // since it's column-reverse display, this would naturally get covered by ModalBox contents + + svg { + height: 2rem; + width: 2rem; + } + + ${MediaQuery.MaxWidth.sm} { + right: var(--close-button-right-inset, ${Spacing["spacing-20"]}); + } +`; diff --git a/src/modal-v2/modal-box-v2.tsx b/src/modal-v2/modal-box-v2.tsx new file mode 100644 index 000000000..916b042b2 --- /dev/null +++ b/src/modal-v2/modal-box-v2.tsx @@ -0,0 +1,42 @@ +import { CrossIcon } from "@lifesg/react-icons"; +import { Box, CloseButton } from "./modal-box-v2.styles"; +import { ModalBoxV2Props } from "./types"; + +export const ModalBoxV2 = ({ + id = "modal-box", + children, + onClose, + showCloseButton = true, + ...otherProps +}: ModalBoxV2Props) => { + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + const handleOnClick = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + const renderCloseButton = () => { + return ( + + + + ); + }; + + return ( + + {children} + {showCloseButton && renderCloseButton()} + + ); +}; diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx new file mode 100644 index 000000000..bc204a36e --- /dev/null +++ b/src/modal-v2/modal-v2.styles.tsx @@ -0,0 +1,75 @@ +import styled, { css } from "styled-components"; +import { MediaQuery } from "../theme"; +import { ModalAnimationDirection } from "./types"; + +interface Props { + $show: boolean; + $animationFrom?: ModalAnimationDirection; + $verticalHeight?: number; + $offsetTop?: number; + $fixedHeight?: boolean; +} + +const visibilityStyle = ( + show: boolean, + animationFrom: ModalAnimationDirection +) => { + if (show) { + return ` + ${animationFrom}: 0; + opacity: 1; + transition: all 300ms cubic-bezier(0.21, 0.79, 0.53, 1); + transition-delay: 200ms; + `; + } + + return ` + ${animationFrom}: -3%; + opacity: 0; + transition: all 300ms cubic-bezier(0.4, 0.34, 0.38, 1); + `; +}; + +export const Container = styled.div` + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + + ${(props) => + props.$fixedHeight + ? css` + min-height: 100%; + padding-top: 4rem; + padding-bottom: 4rem; + ` + : css` + height: 100%; + `} + + overflow: hidden; + ${(props) => visibilityStyle(props.$show, props.$animationFrom || "bottom")} + + ${MediaQuery.MaxWidth.sm} { + ${(props) => { + if (!props.$fixedHeight) { + return css` + height: calc( + ${props.$verticalHeight + ? `${props.$verticalHeight}px` + : "1vh"} * 100 + ); + `; + } + }} + + top: ${(props) => props.$offsetTop || 0}px; + } +`; + +export const InnerContainer = styled.div` + ${MediaQuery.MaxWidth.md} { + width: 90%; + } +`; diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx new file mode 100644 index 000000000..494575daf --- /dev/null +++ b/src/modal-v2/modal-v2.tsx @@ -0,0 +1,146 @@ +import { + FloatingFocusManager, + FloatingPortal, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +import { useEffect, useState } from "react"; +import { Overlay } from "../overlay/overlay"; +import { Container, InnerContainer } from "./modal-v2.styles"; +import { ModalV2Props } from "./types"; + +export const ModalV2 = ({ + id = "modal", + show, + onClose, + animationFrom = "bottom", + children, + enableOverlayClick = true, + rootComponentId, + zIndex, + onOverlayClick, + dismissKeyboardOnShow = true, + enableScroll, + ...otherProps +}: ModalV2Props): JSX.Element => { + // ============================================================================= + // CONST, STATE, REF + // ============================================================================= + const [verticalHeight, setVerticalHeight] = useState(); + const [offsetTop, setOffsetTop] = useState(); + + const { refs, context } = useFloating({ + open: show, + onOpenChange: (isOpen) => { + if (!isOpen) { + onClose(); + } + }, + }); + const role = useRole(context); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([ + role, + dismiss, + ]); + + // ============================================================================= + // EFFECTS + // ============================================================================= + useEffect(() => { + // set initial vh + + // use VisualViewport API if available, it gives more accurate dimensions when iOS software keyboard is active + if (window.visualViewport) { + handleViewportResize(); + window.visualViewport.addEventListener( + "resize", + handleViewportResize + ); + return () => { + window.visualViewport?.removeEventListener( + "resize", + handleViewportResize + ); + }; + } else { + // fallback to Window API + handleWindowResize(); + window.addEventListener("resize", handleWindowResize); + return () => { + window.removeEventListener("resize", handleWindowResize); + }; + } + }, []); + + useEffect(() => { + if (show && dismissKeyboardOnShow) { + // dismiss software keyboard to put modal in fullscreen + (document.activeElement as HTMLElement)?.blur?.(); + } + }, [dismissKeyboardOnShow, show]); + + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + const handleWindowResize = () => { + const newVerticalHeight = window.innerHeight * 0.01; + setVerticalHeight(newVerticalHeight); + }; + + const handleViewportResize = () => { + if (window.visualViewport) { + const newVerticalHeight = window.visualViewport.height * 0.01; + setVerticalHeight(newVerticalHeight); + setOffsetTop(window.visualViewport.offsetTop); + } + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + return ( + <> +
+ {show && ( + + + + + + {children} + + + + + + )} + + ); +}; diff --git a/src/modal-v2/types.ts b/src/modal-v2/types.ts new file mode 100644 index 000000000..f97b351d5 --- /dev/null +++ b/src/modal-v2/types.ts @@ -0,0 +1,25 @@ +import React from "react"; + +export type ModalAnimationDirection = "top" | "bottom" | "left" | "right"; + +export interface ModalBoxV2Props extends React.HTMLAttributes { + children: React.ReactNode; + showCloseButton?: boolean | undefined; + onClose?: (() => void) | undefined; +} + +export interface ModalV2Props extends React.HTMLAttributes { + show: boolean; + children: React.ReactNode; + onClose: () => void; + /** Animation direction of appearance and dismissal. Values: "top" | "bottom" | "left" | "right" */ + animationFrom?: ModalAnimationDirection | undefined; + enableOverlayClick?: boolean | undefined; + /** The identifier of the element to inject the Modal into */ + rootComponentId?: string | undefined; + zIndex?: number | undefined; + onOverlayClick?: (() => void) | undefined; + /** Dismiss keyboard to keep modal in fullscreen */ + dismissKeyboardOnShow?: boolean | undefined; + enableScroll?: boolean | undefined; +} diff --git a/src/overlay/overlay.styles.tsx b/src/overlay/overlay.styles.tsx index a2aca254a..332b41f93 100644 --- a/src/overlay/overlay.styles.tsx +++ b/src/overlay/overlay.styles.tsx @@ -11,6 +11,7 @@ interface StyleProps { $disableTransition?: boolean | undefined; $zIndex?: number | undefined; $stacked?: boolean | undefined; + $enableScroll?: boolean | undefined; } // ============================================================================= @@ -80,7 +81,12 @@ export const Wrapper = styled.div` transition: none; `; } + if (props.$enableScroll) { + customStyles += css` + overflow: auto; + `; + } return customStyles; - }} + }}; `; diff --git a/src/overlay/overlay.tsx b/src/overlay/overlay.tsx index a1f955325..c572ad830 100644 --- a/src/overlay/overlay.tsx +++ b/src/overlay/overlay.tsx @@ -21,6 +21,7 @@ const OverlayComponent = ({ enableOverlayClick = false, zIndex: customZIndex, id, + enableScroll, }: OverlayProps): JSX.Element | null => { // ============================================================================= // CONST, STATE, REF @@ -250,12 +251,13 @@ const OverlayComponent = ({ const renderWrapper = () => ( {childWithRef} diff --git a/src/overlay/types.ts b/src/overlay/types.ts index de6642691..28b9434dc 100644 --- a/src/overlay/types.ts +++ b/src/overlay/types.ts @@ -12,6 +12,7 @@ export interface OverlayProps { zIndex?: number | undefined; onOverlayClick?: (() => void) | undefined; id?: string | undefined; + enableScroll?: boolean | undefined; } /** From 3ad7b8d2c708d19ef4da52eb062dc3b5fcb84532 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Tue, 29 Jul 2025 21:13:54 +0800 Subject: [PATCH 02/21] [ModalV2][SW] add modal v2 stories --- stories/modal-v2/modal-v2.mdx | 72 ++++++++ stories/modal-v2/modal-v2.stories.tsx | 234 ++++++++++++++++++++++++ stories/modal-v2/props-table.tsx | 135 ++++++++++++++ stories/modal-v2/style-tokens-table.tsx | 36 ++++ 4 files changed, 477 insertions(+) create mode 100644 stories/modal-v2/modal-v2.mdx create mode 100644 stories/modal-v2/modal-v2.stories.tsx create mode 100644 stories/modal-v2/props-table.tsx create mode 100644 stories/modal-v2/style-tokens-table.tsx diff --git a/stories/modal-v2/modal-v2.mdx b/stories/modal-v2/modal-v2.mdx new file mode 100644 index 000000000..a73dcc826 --- /dev/null +++ b/stories/modal-v2/modal-v2.mdx @@ -0,0 +1,72 @@ +import { Canvas, Meta } from "@storybook/blocks"; +import * as ModalV2Stories from "./modal-v2.stories"; +import { PropsTable } from "./props-table"; +import { StyleTokensTable } from "./style-tokens-table"; + + + +# Modal + +## Overview + +A window or pop up that displays over other page contents and provides information or actions +that a user can perform. + +```tsx +import { ModalV2 } from "@lifesg/react-design-system/modal-v2"; +``` + +There are 2 components that can be used: + +- `Modal` represents the base modal overlay. This is a **mandatory component** to be used. +- `Modal.Box` represents the dialog box that comes with a rounded border and a close button. + + + +## Custom content + + + +## Stacked modals + +In some cases, you would require a stacked modal layout. Here is how you can construct it. + + + +## Scrollable modals + +If the content of your modal is longer than the screen size, use scrollable modals. + + + +## Modal.Box + +This component holds the dialog box. + +You're recommended to apply this spacing around your content to avoid overlap with the close button: + +| Viewport | Vertical | Horizontal | +| -------------- | -------- | ---------- | +| Desktop/tablet | 4rem | 4rem | +| Mobile | 4rem | 1.25rem | + +> **Note**: This component does not come with a scrollable container and it is the +> onus on the child element to possess the scroll behaviour + +```tsx + + + I am a Modal + + +``` + +## Component API + +Both components also inherit the [HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement). + + + +## Component style tokens + + diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx new file mode 100644 index 000000000..586e8cf26 --- /dev/null +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -0,0 +1,234 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Button } from "src/button"; +import { ModalV2 } from "src/modal-v2"; +import { Typography } from "src/typography"; + +type Component = typeof ModalV2; + +const meta: Meta = { + title: "Overlays/ModalV2", + component: ModalV2, +}; + +export default meta; + +export const Default: StoryObj = { + render: () => { + const [show, setShow] = useState(false); + const openModal = () => setShow(true); + const closeModal = () => setShow(false); + return ( +
+ + Click to open + + + +
+ I am a Modal +
+
+
+
+ ); + }, +}; + +export const CustomContent: StoryObj = { + render: () => { + const [show, setShow] = useState(false); + const openModal = () => setShow(true); + const closeModal = () => setShow(false); + return ( + <> + + Open custom modal + + +
+ This is a custom component +
+
+ + ); + }, +}; + +export const StackedModals: StoryObj = { + render: () => { + const [showFirst, setShowFirst] = useState(false); + const [showStacked, setShowStacked] = useState(false); + const handleFirst = (show: boolean) => () => { + setShowFirst(show); + }; + const handleStacked = (show: boolean) => () => { + setShowStacked(show); + }; + return ( +
+ + Click to open + + + +
+ + I am the first Modal + +
+
+ + Click to open the stacked modal + +
+
+
+ + +
+ + I am the stacked Modal + +
+
+
+
+ ); + }, +}; + +export const ScrollableModals: StoryObj = { + render: () => { + const [show, setShow] = useState(false); + const openModal = () => setShow(true); + const closeModal = () => setShow(false); + return ( +
+ + Click to open scrollable modal + + + +
+ I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal + I am a Modal +
+
+
+
+ ); + }, +}; diff --git a/stories/modal-v2/props-table.tsx b/stories/modal-v2/props-table.tsx new file mode 100644 index 000000000..6035d52c0 --- /dev/null +++ b/stories/modal-v2/props-table.tsx @@ -0,0 +1,135 @@ +import { + DefaultCol, + DescriptionCol, + NameCol, + Table, +} from "../storybook-common/api-table"; +import { TabAttribute, Tabs } from "../storybook-common/tabs"; + +export const ModalTable = () => ( + + + show + + <> + Toggles the visibility of the Modal + + + + + + onClose + void"]}> + <> + Callback to close the modal. Called when using the `esc` key + while the modal is open. + + + + + + rootComponentId + + <> + The identifier of the element to inject the{" "} + Modal into. Not specifying the root element + will make {``} the root element. + + + + + + animationFrom + + <> + The animation direction of which the Modal will + appear + + + {[`"bottom"`]} + + + enableOverlayClick + + <> + Toggles whether Modal can be dismissed by + clicking on the overlay + + + + + + zIndex + + <> + Allows a custom z-index to be specified (useful + for modal stacking) + + + + + + onOverlayClick + void"]}> + <> + The callback when the overlay is being clicked on. Will be + triggered if enableOverlayClick + is specified to true + + + + + + dismissKeyboardOnShow + + <>Dismisses keyboard when modal is shown + + {["true"]} + + + enableScroll + + <> + Allows scrolling on the modal. Enable this if the modal + content is longer than the screen size. + + + {["true"]} + +
+); + +export const ModalBoxTable = () => ( + + + showCloseButton + + This toggles the visibility of the close button + + {["true"]} + + + onClose + void"]}> + Callback when the close button is clicked. Will be triggered if + the close button is visible + + + +
+); + +const PROPS_TABLE_DATA: TabAttribute[] = [ + { + title: "Modal", + component: , + }, + { + title: "Modal.Box", + component: , + }, +]; + +export const PropsTable = () => ; diff --git a/stories/modal-v2/style-tokens-table.tsx b/stories/modal-v2/style-tokens-table.tsx new file mode 100644 index 000000000..eb948a749 --- /dev/null +++ b/stories/modal-v2/style-tokens-table.tsx @@ -0,0 +1,36 @@ +import { ApiTable, ApiTableSectionProps } from "stories/storybook-common"; + +const DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "close-button-top-inset", + description: ( + <> + Distance of close button from top of{" "} + Modal.Box + + ), + defaultValue: 'Spacing["spacing-16"]', + }, + { + name: "close-button-right-inset", + description: ( + <> + Distance of close button from right of{" "} + Modal.Box + + ), + defaultValue: ( + <> + {'Spacing["spacing-16"]'} +
+ {'Spacing["spacing-20"]'} (≤ sm breakpoint) + + ), + }, + ], + }, +]; + +export const StyleTokensTable = () => ; From faf7e9e75f31f737886c9b69d86b93c80cb2b965 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Tue, 29 Jul 2025 21:15:05 +0800 Subject: [PATCH 03/21] [ModalV2][SW] clean up export of ModalAnimationDirection from ModalV1 --- src/modal/modal.styles.tsx | 2 +- src/modal/types.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/modal/modal.styles.tsx b/src/modal/modal.styles.tsx index daa3a979d..2ab09dfbd 100644 --- a/src/modal/modal.styles.tsx +++ b/src/modal/modal.styles.tsx @@ -1,5 +1,5 @@ import styled from "styled-components"; -import { ModalAnimationDirection } from "./types"; +import { ModalAnimationDirection } from "../modal-v2/types"; import { MediaQuery } from "../theme"; interface Props { diff --git a/src/modal/types.ts b/src/modal/types.ts index 03813e020..1f7b57c06 100644 --- a/src/modal/types.ts +++ b/src/modal/types.ts @@ -1,6 +1,4 @@ -import React from "react"; - -export type ModalAnimationDirection = "top" | "bottom" | "left" | "right"; +import { ModalAnimationDirection } from "../modal-v2/types"; export interface ModalProps extends React.HTMLAttributes { show: boolean; From 975ac5ff3b4b2d6b7637bdc377acbb0a0ae258c9 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Wed, 30 Jul 2025 12:46:12 +0800 Subject: [PATCH 04/21] [ModalV2][SW] fix scrollable modal story --- stories/modal-v2/modal-v2.stories.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index 586e8cf26..a44a0da13 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -156,8 +156,6 @@ export const ScrollableModals: StoryObj = {
I am a Modal From 297bb7e4a4faf43e0b2bc90283e169ebf90356cf Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 31 Jul 2025 11:52:17 +0800 Subject: [PATCH 05/21] [ModalV2][SW] move scrolling from Overlay to Container --- src/modal-v2/modal-box-v2.styles.tsx | 1 - src/modal-v2/modal-v2.styles.tsx | 42 +++++++------ src/modal-v2/modal-v2.tsx | 91 +++++++++++++--------------- src/overlay/overlay.styles.tsx | 6 -- src/overlay/overlay.tsx | 2 - src/overlay/types.ts | 1 - 6 files changed, 64 insertions(+), 79 deletions(-) diff --git a/src/modal-v2/modal-box-v2.styles.tsx b/src/modal-v2/modal-box-v2.styles.tsx index 9d9ec6968..1c53cebcb 100644 --- a/src/modal-v2/modal-box-v2.styles.tsx +++ b/src/modal-v2/modal-box-v2.styles.tsx @@ -19,7 +19,6 @@ export const Box = styled.div` background: ${Colour.bg}; box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); border-radius: ${Radius["lg"]}; - overflow: hidden; display: flex; flex-direction: column-reverse; diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index bc204a36e..b45af8b9b 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -7,7 +7,7 @@ interface Props { $animationFrom?: ModalAnimationDirection; $verticalHeight?: number; $offsetTop?: number; - $fixedHeight?: boolean; + $enableScroll?: boolean; } const visibilityStyle = ( @@ -32,43 +32,45 @@ const visibilityStyle = ( export const Container = styled.div` position: relative; - display: flex; - justify-content: center; - align-items: center; width: 100%; + height: 100%; ${(props) => - props.$fixedHeight + props.$enableScroll ? css` - min-height: 100%; - padding-top: 4rem; - padding-bottom: 4rem; + overflow: auto; ` : css` - height: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; `} - overflow: hidden; ${(props) => visibilityStyle(props.$show, props.$animationFrom || "bottom")} ${MediaQuery.MaxWidth.sm} { ${(props) => { - if (!props.$fixedHeight) { - return css` - height: calc( - ${props.$verticalHeight - ? `${props.$verticalHeight}px` - : "1vh"} * 100 - ); - `; - } + return css` + height: calc( + ${props.$verticalHeight + ? `${props.$verticalHeight}px` + : "1vh"} * 100 + ); + `; }} top: ${(props) => props.$offsetTop || 0}px; } `; -export const InnerContainer = styled.div` +export const ScrollContainer = styled.div` + margin: 4rem 0; + display: flex; + justify-content: center; +`; + +export const ModalContainer = styled.div` ${MediaQuery.MaxWidth.md} { width: 90%; } diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 494575daf..b1e4dd7df 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -1,14 +1,12 @@ import { FloatingFocusManager, - FloatingPortal, useDismiss, useFloating, useInteractions, - useRole, } from "@floating-ui/react"; import { useEffect, useState } from "react"; import { Overlay } from "../overlay/overlay"; -import { Container, InnerContainer } from "./modal-v2.styles"; +import { Container, ModalContainer, ScrollContainer } from "./modal-v2.styles"; import { ModalV2Props } from "./types"; export const ModalV2 = ({ @@ -39,12 +37,8 @@ export const ModalV2 = ({ } }, }); - const role = useRole(context); const dismiss = useDismiss(context); - const { getReferenceProps, getFloatingProps } = useInteractions([ - role, - dismiss, - ]); + const { getFloatingProps } = useInteractions([dismiss]); // ============================================================================= // EFFECTS @@ -101,46 +95,45 @@ export const ModalV2 = ({ // ============================================================================= // RENDER FUNCTIONS // ============================================================================= - return ( - <> -
- {show && ( - - - - - - {children} - - - - - - )} - + const renderInnerModal = () => ( + + + {children} + + + ); + + return show ? ( + + + {enableScroll ? ( + {renderInnerModal()} + ) : ( + renderInnerModal() + )} + + + ) : ( + <> ); }; diff --git a/src/overlay/overlay.styles.tsx b/src/overlay/overlay.styles.tsx index 332b41f93..c480f039f 100644 --- a/src/overlay/overlay.styles.tsx +++ b/src/overlay/overlay.styles.tsx @@ -11,7 +11,6 @@ interface StyleProps { $disableTransition?: boolean | undefined; $zIndex?: number | undefined; $stacked?: boolean | undefined; - $enableScroll?: boolean | undefined; } // ============================================================================= @@ -81,11 +80,6 @@ export const Wrapper = styled.div` transition: none; `; } - if (props.$enableScroll) { - customStyles += css` - overflow: auto; - `; - } return customStyles; }}; diff --git a/src/overlay/overlay.tsx b/src/overlay/overlay.tsx index c572ad830..239e27d61 100644 --- a/src/overlay/overlay.tsx +++ b/src/overlay/overlay.tsx @@ -21,7 +21,6 @@ const OverlayComponent = ({ enableOverlayClick = false, zIndex: customZIndex, id, - enableScroll, }: OverlayProps): JSX.Element | null => { // ============================================================================= // CONST, STATE, REF @@ -257,7 +256,6 @@ const OverlayComponent = ({ $backgroundBlur={backgroundBlur} $disableTransition={disableTransition} onClick={handleWrapperClick} - $enableScroll={enableScroll} > {childWithRef} diff --git a/src/overlay/types.ts b/src/overlay/types.ts index 28b9434dc..de6642691 100644 --- a/src/overlay/types.ts +++ b/src/overlay/types.ts @@ -12,7 +12,6 @@ export interface OverlayProps { zIndex?: number | undefined; onOverlayClick?: (() => void) | undefined; id?: string | undefined; - enableScroll?: boolean | undefined; } /** From c06a81e22c7d5a9dc34e19f7f05e13909f58f10c Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 31 Jul 2025 16:04:34 +0800 Subject: [PATCH 06/21] [ModalV2][SW] handle transition animations --- src/modal-v2/modal-v2.styles.tsx | 42 +++++++++++++++----------------- src/modal-v2/modal-v2.tsx | 25 +++++++++++-------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index b45af8b9b..96dc38b34 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -10,26 +10,6 @@ interface Props { $enableScroll?: boolean; } -const visibilityStyle = ( - show: boolean, - animationFrom: ModalAnimationDirection -) => { - if (show) { - return ` - ${animationFrom}: 0; - opacity: 1; - transition: all 300ms cubic-bezier(0.21, 0.79, 0.53, 1); - transition-delay: 200ms; - `; - } - - return ` - ${animationFrom}: -3%; - opacity: 0; - transition: all 300ms cubic-bezier(0.4, 0.34, 0.38, 1); - `; -}; - export const Container = styled.div` position: relative; width: 100%; @@ -47,8 +27,6 @@ export const Container = styled.div` overflow: hidden; `} - ${(props) => visibilityStyle(props.$show, props.$animationFrom || "bottom")} - ${MediaQuery.MaxWidth.sm} { ${(props) => { return css` @@ -62,6 +40,26 @@ export const Container = styled.div` top: ${(props) => props.$offsetTop || 0}px; } + + ${(props) => css` + &[data-status="initial"] { + opacity: 0; + ${props.$animationFrom}: -3%; + } + + &[data-status="open"] { + opacity: 1; + ${props.$animationFrom}: 0; + transition: all 300ms cubic-bezier(0.21, 0.79, 0.53, 1); + transition-delay: 200ms; + } + + &[data-status="close"] { + opacity: 0; + ${props.$animationFrom}: -3%; + transition: all 300ms cubic-bezier(0.4, 0.34, 0.38, 1); + } + `} `; export const ScrollContainer = styled.div` diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index b1e4dd7df..360b79be0 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -3,6 +3,7 @@ import { useDismiss, useFloating, useInteractions, + useTransitionStatus, } from "@floating-ui/react"; import { useEffect, useState } from "react"; import { Overlay } from "../overlay/overlay"; @@ -37,6 +38,10 @@ export const ModalV2 = ({ } }, }); + const { isMounted, status } = useTransitionStatus(context, { + duration: 300, + }); + const dismiss = useDismiss(context); const { getFloatingProps } = useInteractions([dismiss]); @@ -107,10 +112,10 @@ export const ModalV2 = ({ ); - return show ? ( + return ( - {enableScroll ? ( - {renderInnerModal()} - ) : ( - renderInnerModal() - )} + {isMounted && + (enableScroll ? ( + {renderInnerModal()} + ) : ( + renderInnerModal() + ))} - ) : ( - <> ); }; From ded663fe966b024a54bcc4a2eab75d4c72763939 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Fri, 1 Aug 2025 14:57:47 +0800 Subject: [PATCH 07/21] [ModalV2][SW] implement slots for modalv2 --- src/modal-v2/index.ts | 6 ++- src/modal-v2/modal-box-v2.styles.tsx | 55 ------------------------ src/modal-v2/modal-box-v2.tsx | 42 ------------------- src/modal-v2/modal-context.tsx | 11 +++++ src/modal-v2/modal-v2.tsx | 22 ++++++---- src/modal-v2/slots/card.tsx | 44 +++++++++++++++++++ src/modal-v2/slots/close-button.tsx | 23 ++++++++++ src/modal-v2/slots/content.tsx | 5 +++ src/modal-v2/slots/index.tsx | 3 ++ src/modal-v2/slots/slot-styles.tsx | 63 ++++++++++++++++++++++++++++ src/modal-v2/types.ts | 16 ++++--- 11 files changed, 179 insertions(+), 111 deletions(-) delete mode 100644 src/modal-v2/modal-box-v2.styles.tsx delete mode 100644 src/modal-v2/modal-box-v2.tsx create mode 100644 src/modal-v2/modal-context.tsx create mode 100644 src/modal-v2/slots/card.tsx create mode 100644 src/modal-v2/slots/close-button.tsx create mode 100644 src/modal-v2/slots/content.tsx create mode 100644 src/modal-v2/slots/index.tsx create mode 100644 src/modal-v2/slots/slot-styles.tsx diff --git a/src/modal-v2/index.ts b/src/modal-v2/index.ts index a54d03ff8..1a1c7ab15 100644 --- a/src/modal-v2/index.ts +++ b/src/modal-v2/index.ts @@ -1,8 +1,10 @@ import { ModalV2 as Base } from "./modal-v2"; -import { ModalBoxV2 as Box } from "./modal-box-v2"; +import { Card, CloseButton, Content } from "./slots"; export const ModalV2 = Object.assign(Base, { - Box, + Card, + CloseButton, + Content, }); export * from "./types"; diff --git a/src/modal-v2/modal-box-v2.styles.tsx b/src/modal-v2/modal-box-v2.styles.tsx deleted file mode 100644 index 1c53cebcb..000000000 --- a/src/modal-v2/modal-box-v2.styles.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import styled from "styled-components"; -import { ClickableIcon } from "../shared/clickable-icon"; -import { Colour, MediaQuery, Radius, Spacing } from "../theme"; - -// ============================================================================= -// STYLE INTERFACES -// ============================================================================= -interface CloseButtonProps { - $insetTop?: string | undefined; - $insetRight?: string | undefined; -} - -// ============================================================================= -// STYLING -// ============================================================================= -export const Box = styled.div` - position: relative; - width: 40rem; - background: ${Colour.bg}; - box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); - border-radius: ${Radius["lg"]}; - - display: flex; - flex-direction: column-reverse; - - > div { - /* minus the space taken up by the block CloseButton */ - margin-top: calc( - 0px - 32px - var(--close-button-top-inset, ${Spacing["spacing-16"]}) - ); - } - - ${MediaQuery.MaxWidth.md} { - width: 100%; - } -`; - -export const CloseButton = styled(ClickableIcon)` - margin-top: var(--close-button-top-inset, ${Spacing["spacing-16"]}); - margin-right: var(--close-button-right-inset, ${Spacing["spacing-16"]}); - margin-left: auto; - padding: 0; - color: ${Colour.icon}; - - z-index: 2; // since it's column-reverse display, this would naturally get covered by ModalBox contents - - svg { - height: 2rem; - width: 2rem; - } - - ${MediaQuery.MaxWidth.sm} { - right: var(--close-button-right-inset, ${Spacing["spacing-20"]}); - } -`; diff --git a/src/modal-v2/modal-box-v2.tsx b/src/modal-v2/modal-box-v2.tsx deleted file mode 100644 index 916b042b2..000000000 --- a/src/modal-v2/modal-box-v2.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { CrossIcon } from "@lifesg/react-icons"; -import { Box, CloseButton } from "./modal-box-v2.styles"; -import { ModalBoxV2Props } from "./types"; - -export const ModalBoxV2 = ({ - id = "modal-box", - children, - onClose, - showCloseButton = true, - ...otherProps -}: ModalBoxV2Props) => { - // ============================================================================= - // EVENT HANDLERS - // ============================================================================= - const handleOnClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - - // ============================================================================= - // RENDER FUNCTIONS - // ============================================================================= - const renderCloseButton = () => { - return ( - - - - ); - }; - - return ( - - {children} - {showCloseButton && renderCloseButton()} - - ); -}; diff --git a/src/modal-v2/modal-context.tsx b/src/modal-v2/modal-context.tsx new file mode 100644 index 000000000..e8e1491d0 --- /dev/null +++ b/src/modal-v2/modal-context.tsx @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +interface IModalContext { + onClose: () => void; +} + +export const ModalContext = createContext({ + onClose: () => { + // default does nothing + }, +}); diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 360b79be0..d512faffd 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -7,6 +7,7 @@ import { } from "@floating-ui/react"; import { useEffect, useState } from "react"; import { Overlay } from "../overlay/overlay"; +import { ModalContext } from "./modal-context"; import { Container, ModalContainer, ScrollContainer } from "./modal-v2.styles"; import { ModalV2Props } from "./types"; @@ -42,7 +43,10 @@ export const ModalV2 = ({ duration: 300, }); - const dismiss = useDismiss(context); + const dismiss = useDismiss(context, { + /* handled by overlayclick */ + outsidePress: false, + }); const { getFloatingProps } = useInteractions([dismiss]); // ============================================================================= @@ -132,12 +136,16 @@ export const ModalV2 = ({ {...otherProps} data-status={status} > - {isMounted && - (enableScroll ? ( - {renderInnerModal()} - ) : ( - renderInnerModal() - ))} + + {isMounted && + (enableScroll ? ( + + {renderInnerModal()} + + ) : ( + renderInnerModal() + ))} + ); diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx new file mode 100644 index 000000000..11bf3d6d6 --- /dev/null +++ b/src/modal-v2/slots/card.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { ModalCardProps } from "../types"; +import { CloseButton } from "./close-button"; +import { Content } from "./content"; +import { ModalCard } from "./slot-styles"; + +export const Card = ({ + id = "modal-card", + children, + customStyle, + ...otherProps +}: ModalCardProps) => { + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + const handleOnClick = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + const CloseButtonSlot = React.Children.toArray(children).find( + (child) => (child as React.ReactPortal).type === CloseButton + ); + const hasCloseButton = !!CloseButtonSlot; + + const ContentSlot = React.Children.toArray(children).find( + (child) => (child as React.ReactPortal).type === Content + ); + + return ( + + {ContentSlot} + {hasCloseButton && CloseButtonSlot} + + ); +}; diff --git a/src/modal-v2/slots/close-button.tsx b/src/modal-v2/slots/close-button.tsx new file mode 100644 index 000000000..5c4445898 --- /dev/null +++ b/src/modal-v2/slots/close-button.tsx @@ -0,0 +1,23 @@ +import { CrossIcon } from "@lifesg/react-icons/cross"; +import { useContext } from "react"; +import { ModalContext } from "../modal-context"; +import { ModalCloseButtonProps } from "../types"; +import { ClickableContainer, StyledClickableIcon } from "./slot-styles"; + +export const CloseButton = ({ customStyle }: ModalCloseButtonProps) => { + const { onClose } = useContext(ModalContext); + + return ( + + + + + + ); +}; diff --git a/src/modal-v2/slots/content.tsx b/src/modal-v2/slots/content.tsx new file mode 100644 index 000000000..44d9ecaaf --- /dev/null +++ b/src/modal-v2/slots/content.tsx @@ -0,0 +1,5 @@ +import { ModalContentProps } from "../types"; + +export const Content = ({ customStyle, children }: ModalContentProps) => { + return
{children}
; +}; diff --git a/src/modal-v2/slots/index.tsx b/src/modal-v2/slots/index.tsx new file mode 100644 index 000000000..dac525ed2 --- /dev/null +++ b/src/modal-v2/slots/index.tsx @@ -0,0 +1,3 @@ +export { Card } from "./card"; +export { CloseButton } from "./close-button"; +export { Content } from "./content"; diff --git a/src/modal-v2/slots/slot-styles.tsx b/src/modal-v2/slots/slot-styles.tsx new file mode 100644 index 000000000..7b0502d25 --- /dev/null +++ b/src/modal-v2/slots/slot-styles.tsx @@ -0,0 +1,63 @@ +import styled, { css } from "styled-components"; +import { ClickableIcon as _ClickableIcon } from "../../shared/clickable-icon"; +import { Colour, MediaQuery, Radius, Spacing } from "../../theme"; + +// ============================================================================= +// Card +// ============================================================================= +interface ModalCardProps { + $hasCloseButton?: boolean; +} +export const ModalCard = styled.div` + width: 40rem; + background: ${Colour.bg}; + box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); + border-radius: ${Radius["lg"]}; + + display: flex; + flex-direction: column-reverse; + + ${MediaQuery.MaxWidth.md} { + width: 100%; + } + + padding: ${(props) => + props.$hasCloseButton + ? css` + ${Spacing["spacing-32"]} ${Spacing["spacing-32"]} ${Spacing[ + "spacing-48" + ]}; + ` + : css` + ${Spacing["spacing-32"]}; + `}; +`; + +// ============================================================================= +// Close Button +// ============================================================================= +export const ClickableContainer = styled.div` + margin-top: calc( + var(--close-button-top-inset, ${Spacing["spacing-16"]}) * -1 + ); + margin-right: calc( + var(--close-button-top-inset, ${Spacing["spacing-16"]}) * -1 + ); + margin-left: auto; + + ${MediaQuery.MaxWidth.sm} { + right: var(--close-button-right-inset, ${Spacing["spacing-20"]}); + } +`; + +export const StyledClickableIcon = styled(_ClickableIcon)` + padding: 0; + color: ${Colour.icon}; + + z-index: 2; // since it's column-reverse display, this would naturally get covered by ModalBox contents + + svg { + height: 2rem; + width: 2rem; + } +`; diff --git a/src/modal-v2/types.ts b/src/modal-v2/types.ts index f97b351d5..978672880 100644 --- a/src/modal-v2/types.ts +++ b/src/modal-v2/types.ts @@ -1,11 +1,17 @@ -import React from "react"; - export type ModalAnimationDirection = "top" | "bottom" | "left" | "right"; -export interface ModalBoxV2Props extends React.HTMLAttributes { +export interface ModalCardProps extends React.HTMLAttributes { + children: React.ReactNode; + customStyle?: React.CSSProperties | undefined; +} + +export interface ModalCloseButtonProps { + customStyle?: React.CSSProperties | undefined; +} + +export interface ModalContentProps { children: React.ReactNode; - showCloseButton?: boolean | undefined; - onClose?: (() => void) | undefined; + customStyle?: React.CSSProperties | undefined; } export interface ModalV2Props extends React.HTMLAttributes { From 2b9d772199224b0235ec60d9504f709ac31b5310 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Fri, 1 Aug 2025 14:58:29 +0800 Subject: [PATCH 08/21] [ModalV2][SW] update stories for modalv2 --- stories/modal-v2/modal-v2.mdx | 46 ++++---- stories/modal-v2/modal-v2.stories.tsx | 150 +++++++++++++++++--------- stories/modal-v2/props-table.tsx | 48 ++++++--- 3 files changed, 155 insertions(+), 89 deletions(-) diff --git a/stories/modal-v2/modal-v2.mdx b/stories/modal-v2/modal-v2.mdx index a73dcc826..657e796b8 100644 --- a/stories/modal-v2/modal-v2.mdx +++ b/stories/modal-v2/modal-v2.mdx @@ -5,7 +5,7 @@ import { StyleTokensTable } from "./style-tokens-table"; -# Modal +# ModalV2 ## Overview @@ -16,13 +16,29 @@ that a user can perform. import { ModalV2 } from "@lifesg/react-design-system/modal-v2"; ``` -There are 2 components that can be used: +There are 4 components that can be used. -- `Modal` represents the base modal overlay. This is a **mandatory component** to be used. -- `Modal.Box` represents the dialog box that comes with a rounded border and a close button. +Correct usage of these components will ensure the modal will have +the correct padding and accessibility behaviours. + +- `ModalV2` represents the base modal overlay. This is a **mandatory component** to be used. +- `ModalV2.Card` represents the dialog box that comes with a rounded border. + - It must be rendered as a child of `ModalV2` + - Will only render children that are `ModalV2.Content` or `ModalV2.CloseButton` + - Stylable with `customStyle` prop +- `ModalV2.Content` represents the content to be displayed within ModalV2.Card. + - It must be rendered as a child of ModalV2.Card + - Stylable with `customStyle` prop +- `ModalV2.CloseButton` represents the close button to close the modal. + - Optional, but if used, it must be rendered as a child of ModalV2.Card + - Stylable with `customStyle` prop +## Without close button + + + ## Custom content @@ -39,28 +55,6 @@ If the content of your modal is longer than the screen size, use scrollable moda -## Modal.Box - -This component holds the dialog box. - -You're recommended to apply this spacing around your content to avoid overlap with the close button: - -| Viewport | Vertical | Horizontal | -| -------------- | -------- | ---------- | -| Desktop/tablet | 4rem | 4rem | -| Mobile | 4rem | 1.25rem | - -> **Note**: This component does not come with a scrollable container and it is the -> onus on the child element to possess the scroll behaviour - -```tsx - - - I am a Modal - - -``` - ## Component API Both components also inherit the [HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement). diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index a44a0da13..c36c8cbee 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -28,17 +28,44 @@ export const Default: StoryObj = { onOverlayClick={closeModal} onClose={closeModal} > - -
- I am a Modal -
-
+ + + + + I am a Modal with very long text in a para graph + to fill up the width + + + + +
+ ); + }, +}; + +export const WithoutCloseButton: StoryObj = { + render: () => { + const [show, setShow] = useState(false); + const openModal = () => setShow(true); + const closeModal = () => setShow(false); + return ( +
+ + Click to open + + + + + + I am a Modal with very long text in a para graph + to fill up the width + + +
); @@ -50,6 +77,7 @@ export const CustomContent: StoryObj = { const [show, setShow] = useState(false); const openModal = () => setShow(true); const closeModal = () => setShow(false); + return ( <> @@ -61,15 +89,23 @@ export const CustomContent: StoryObj = { enableOverlayClick={true} onClose={closeModal} > -
- This is a custom component -
+ + + This is a custom component + + ); @@ -80,56 +116,66 @@ export const StackedModals: StoryObj = { render: () => { const [showFirst, setShowFirst] = useState(false); const [showStacked, setShowStacked] = useState(false); - const handleFirst = (show: boolean) => () => { + const handleFirst = (show: boolean) => { setShowFirst(show); }; - const handleStacked = (show: boolean) => () => { + const handleStacked = (show: boolean) => { setShowStacked(show); }; return (
- + { + handleFirst(true); + }} + > Click to open { + handleFirst(false); + }} + onClose={() => { + handleFirst(false); + }} > - -
+ + + I am the first Modal

- + { + handleStacked(true); + }} + > Click to open the stacked modal -
-
+ +
{ + handleStacked(false); + }} + onClose={() => { + handleStacked(false); + }} > - -
+ + + I am the stacked Modal -
-
+ +
); @@ -152,13 +198,12 @@ export const ScrollableModals: StoryObj = { onClose={closeModal} enableScroll > - -
- I am a Modal + + + + + this is the topic + I am a Modal I am a Modal I am a Modal @@ -193,6 +238,9 @@ export const ScrollableModals: StoryObj = { I am a Modal I am a Modal I am a Modal + + Some button + I am a Modal I am a Modal I am a Modal @@ -223,8 +271,8 @@ export const ScrollableModals: StoryObj = { I am a Modal I am a Modal I am a Modal -
-
+ +
); diff --git a/stories/modal-v2/props-table.tsx b/stories/modal-v2/props-table.tsx index 6035d52c0..b2dc20781 100644 --- a/stories/modal-v2/props-table.tsx +++ b/stories/modal-v2/props-table.tsx @@ -101,20 +101,36 @@ export const ModalTable = () => ( ); -export const ModalBoxTable = () => ( +export const ModalCardTable = () => ( - showCloseButton - - This toggles the visibility of the close button + customStyle + + Custom styles for the modal dialog box - {["true"]} + +
+); + +export const ModalContentTable = () => ( + - onClose - void"]}> - Callback when the close button is clicked. Will be triggered if - the close button is visible + customStyle + + Custom styles for the modal content container + + + +
+); + +export const ModalCloseButtonTable = () => ( + + + customStyle + + Custom styles for the modal close Button @@ -123,12 +139,20 @@ export const ModalBoxTable = () => ( const PROPS_TABLE_DATA: TabAttribute[] = [ { - title: "Modal", + title: "ModalV2", component: , }, { - title: "Modal.Box", - component: , + title: "ModalV2.Card", + component: , + }, + { + title: "ModalV2.Content", + component: , + }, + { + title: "ModalV2.CloseButton", + component: , }, ]; From 3d7d45ec21c05687d66f32cb78c4e8bdd9237b48 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 14 Aug 2025 16:54:46 +0800 Subject: [PATCH 09/21] [ModalV2][SW] remove enableScroll prop and default to always enabled --- src/modal-v2/modal-v2.tsx | 39 +++++++++++++-------------- src/modal-v2/types.ts | 1 - stories/modal-v2/modal-v2.stories.tsx | 1 - stories/modal-v2/props-table.tsx | 10 ------- 4 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index d512faffd..4a0a8b086 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -22,7 +22,6 @@ export const ModalV2 = ({ zIndex, onOverlayClick, dismissKeyboardOnShow = true, - enableScroll, ...otherProps }: ModalV2Props): JSX.Element => { // ============================================================================= @@ -104,17 +103,6 @@ export const ModalV2 = ({ // ============================================================================= // RENDER FUNCTIONS // ============================================================================= - const renderInnerModal = () => ( - - - {children} - - - ); return ( - {isMounted && - (enableScroll ? ( - - {renderInnerModal()} - - ) : ( - renderInnerModal() - ))} + {isMounted && ( + + + + {children} + + + + )} diff --git a/src/modal-v2/types.ts b/src/modal-v2/types.ts index 978672880..1f0bc2a1d 100644 --- a/src/modal-v2/types.ts +++ b/src/modal-v2/types.ts @@ -27,5 +27,4 @@ export interface ModalV2Props extends React.HTMLAttributes { onOverlayClick?: (() => void) | undefined; /** Dismiss keyboard to keep modal in fullscreen */ dismissKeyboardOnShow?: boolean | undefined; - enableScroll?: boolean | undefined; } diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index c36c8cbee..beb755652 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -196,7 +196,6 @@ export const ScrollableModals: StoryObj = { show={show} onOverlayClick={closeModal} onClose={closeModal} - enableScroll > diff --git a/stories/modal-v2/props-table.tsx b/stories/modal-v2/props-table.tsx index b2dc20781..e1c33070d 100644 --- a/stories/modal-v2/props-table.tsx +++ b/stories/modal-v2/props-table.tsx @@ -88,16 +88,6 @@ export const ModalTable = () => ( {["true"]} - - enableScroll - - <> - Allows scrolling on the modal. Enable this if the modal - content is longer than the screen size. - - - {["true"]} -
); From b15bcc91bc36ee6dd7566cb7ff12f4a0de356810 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 14 Aug 2025 16:58:17 +0800 Subject: [PATCH 10/21] [ModalV2][SW] remove custom style prop from slots and extend html attributes --- src/modal-v2/slots/card.tsx | 2 -- src/modal-v2/slots/close-button.tsx | 4 ++-- src/modal-v2/slots/content.tsx | 4 ++-- src/modal-v2/types.ts | 10 ++++------ stories/modal-v2/modal-v2.stories.tsx | 4 ++-- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx index 11bf3d6d6..1169a9108 100644 --- a/src/modal-v2/slots/card.tsx +++ b/src/modal-v2/slots/card.tsx @@ -7,7 +7,6 @@ import { ModalCard } from "./slot-styles"; export const Card = ({ id = "modal-card", children, - customStyle, ...otherProps }: ModalCardProps) => { // ============================================================================= @@ -31,7 +30,6 @@ export const Card = ({ return ( { +export const CloseButton = ({ ...otherProps }: ModalCloseButtonProps) => { const { onClose } = useContext(ModalContext); return ( - + { - return
{children}
; +export const Content = ({ children, ...otherProps }: ModalContentProps) => { + return
{children}
; }; diff --git a/src/modal-v2/types.ts b/src/modal-v2/types.ts index 1f0bc2a1d..03d64ddf1 100644 --- a/src/modal-v2/types.ts +++ b/src/modal-v2/types.ts @@ -2,16 +2,14 @@ export type ModalAnimationDirection = "top" | "bottom" | "left" | "right"; export interface ModalCardProps extends React.HTMLAttributes { children: React.ReactNode; - customStyle?: React.CSSProperties | undefined; } -export interface ModalCloseButtonProps { - customStyle?: React.CSSProperties | undefined; -} +export interface ModalCloseButtonProps + extends React.HTMLAttributes {} -export interface ModalContentProps { +export interface ModalContentProps + extends React.HTMLAttributes { children: React.ReactNode; - customStyle?: React.CSSProperties | undefined; } export interface ModalV2Props extends React.HTMLAttributes { diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index beb755652..c4527bb64 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -90,13 +90,13 @@ export const CustomContent: StoryObj = { onClose={closeModal} > Date: Mon, 18 Aug 2025 15:53:41 +0800 Subject: [PATCH 11/21] [ModalV2][SW] update transitions to use Motion tokens --- src/modal-v2/modal-v2.styles.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index 96dc38b34..19ba3d4c7 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -1,5 +1,5 @@ import styled, { css } from "styled-components"; -import { MediaQuery } from "../theme"; +import { MediaQuery, Motion } from "../theme"; import { ModalAnimationDirection } from "./types"; interface Props { @@ -50,14 +50,16 @@ export const Container = styled.div` &[data-status="open"] { opacity: 1; ${props.$animationFrom}: 0; - transition: all 300ms cubic-bezier(0.21, 0.79, 0.53, 1); - transition-delay: 200ms; + transition: all ${Motion["duration-250"]} + ${Motion["ease-entrance"]} + transition-delay: ${Motion["duration-150"]}; } &[data-status="close"] { opacity: 0; ${props.$animationFrom}: -3%; - transition: all 300ms cubic-bezier(0.4, 0.34, 0.38, 1); + transition: all ${Motion["duration-250"]} + ${Motion["ease-exit"]}; } `} `; From 5557d07d6c2b44e5d29e41c57dc0fa165cff8a7f Mon Sep 17 00:00:00 2001 From: shawn wee Date: Mon, 18 Aug 2025 16:02:03 +0800 Subject: [PATCH 12/21] [ModalV2][SW] update css --- src/modal-v2/modal-v2.styles.tsx | 18 +++++------------- src/modal-v2/modal-v2.tsx | 5 ++--- stories/modal-v2/modal-v2.stories.tsx | 2 +- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index 19ba3d4c7..d31cb67e4 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -7,7 +7,6 @@ interface Props { $animationFrom?: ModalAnimationDirection; $verticalHeight?: number; $offsetTop?: number; - $enableScroll?: boolean; } export const Container = styled.div` @@ -15,17 +14,7 @@ export const Container = styled.div` width: 100%; height: 100%; - ${(props) => - props.$enableScroll - ? css` - overflow: auto; - ` - : css` - display: flex; - justify-content: center; - align-items: center; - overflow: hidden; - `} + overflow: auto; ${MediaQuery.MaxWidth.sm} { ${(props) => { @@ -65,9 +54,12 @@ export const Container = styled.div` `; export const ScrollContainer = styled.div` - margin: 4rem 0; + padding: 4rem 0; display: flex; justify-content: center; + align-items: center; + min-height: 100%; + pointer-events: none; `; export const ModalContainer = styled.div` diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 4a0a8b086..23ea0e0d0 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -107,7 +107,7 @@ export const ModalV2 = ({ return ( diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index c4527bb64..b7136a89b 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -86,7 +86,7 @@ export const CustomContent: StoryObj = { Date: Thu, 28 Aug 2025 11:35:51 +0800 Subject: [PATCH 13/21] [ModalV2][SW] fix click event propagation --- src/modal-v2/modal-v2.styles.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index d31cb67e4..2e3f7cf8a 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -63,6 +63,7 @@ export const ScrollContainer = styled.div` `; export const ModalContainer = styled.div` + pointer-events: auto; ${MediaQuery.MaxWidth.md} { width: 90%; } From 9788515aab44636f44e73ccc3357cfefb75a29d6 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 28 Aug 2025 11:38:50 +0800 Subject: [PATCH 14/21] [ModalV2][SW] update testids --- src/modal-v2/modal-v2.tsx | 23 ++++++++++++----------- src/modal-v2/slots/card.tsx | 6 ++++-- src/modal-v2/slots/close-button.tsx | 7 +++++-- src/modal-v2/slots/content.tsx | 12 ++++++++++-- src/modal-v2/types.ts | 7 ++++++- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 23ea0e0d0..ce54e5e8f 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -12,7 +12,7 @@ import { Container, ModalContainer, ScrollContainer } from "./modal-v2.styles"; import { ModalV2Props } from "./types"; export const ModalV2 = ({ - id = "modal", + id, show, onClose, animationFrom = "bottom", @@ -22,6 +22,7 @@ export const ModalV2 = ({ zIndex, onOverlayClick, dismissKeyboardOnShow = true, + "data-testid": testId = "modal", ...otherProps }: ModalV2Props): JSX.Element => { // ============================================================================= @@ -106,7 +107,7 @@ export const ModalV2 = ({ return ( - + {isMounted && ( - - + + {children} - - + + )} diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx index 1169a9108..c969aaa72 100644 --- a/src/modal-v2/slots/card.tsx +++ b/src/modal-v2/slots/card.tsx @@ -5,7 +5,8 @@ import { Content } from "./content"; import { ModalCard } from "./slot-styles"; export const Card = ({ - id = "modal-card", + id, + "data-testid": testId = "modal-card", children, ...otherProps }: ModalCardProps) => { @@ -30,7 +31,8 @@ export const Card = ({ return ( { +export const CloseButton = ({ + "data-testid": testId = "close-button", + ...otherProps +}: ModalCloseButtonProps) => { const { onClose } = useContext(ModalContext); return ( - + { - return
{children}
; +export const Content = ({ + "data-testid": testId = "modal-content", + children, + ...otherProps +}: ModalContentProps) => { + return ( +
+ {children} +
+ ); }; diff --git a/src/modal-v2/types.ts b/src/modal-v2/types.ts index 03d64ddf1..c02cd3de3 100644 --- a/src/modal-v2/types.ts +++ b/src/modal-v2/types.ts @@ -1,14 +1,18 @@ export type ModalAnimationDirection = "top" | "bottom" | "left" | "right"; export interface ModalCardProps extends React.HTMLAttributes { + "data-testid"?: string | undefined; children: React.ReactNode; } export interface ModalCloseButtonProps - extends React.HTMLAttributes {} + extends React.HTMLAttributes { + "data-testid"?: string | undefined; +} export interface ModalContentProps extends React.HTMLAttributes { + "data-testid"?: string | undefined; children: React.ReactNode; } @@ -25,4 +29,5 @@ export interface ModalV2Props extends React.HTMLAttributes { onOverlayClick?: (() => void) | undefined; /** Dismiss keyboard to keep modal in fullscreen */ dismissKeyboardOnShow?: boolean | undefined; + "data-testid"?: string | undefined; } From 6ac01c0e28eb5c88e7ef575415d33dcc686020de Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 28 Aug 2025 11:51:36 +0800 Subject: [PATCH 15/21] [ModalV2][SW] move window resize observer into common hook --- src/modal-v2/modal-v2.tsx | 49 ++---------------- src/modal/modal.tsx | 48 ++---------------- src/shared/hooks/index.tsx | 1 + src/shared/hooks/useWindowResizeObserver.tsx | 53 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 91 deletions(-) create mode 100644 src/shared/hooks/index.tsx create mode 100644 src/shared/hooks/useWindowResizeObserver.tsx diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index ce54e5e8f..0508c5d08 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -5,8 +5,9 @@ import { useInteractions, useTransitionStatus, } from "@floating-ui/react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Overlay } from "../overlay/overlay"; +import { useWindowResizeObserver } from "../shared/hooks"; import { ModalContext } from "./modal-context"; import { Container, ModalContainer, ScrollContainer } from "./modal-v2.styles"; import { ModalV2Props } from "./types"; @@ -28,8 +29,7 @@ export const ModalV2 = ({ // ============================================================================= // CONST, STATE, REF // ============================================================================= - const [verticalHeight, setVerticalHeight] = useState(); - const [offsetTop, setOffsetTop] = useState(); + const { verticalHeight, offsetTop } = useWindowResizeObserver(); const { refs, context } = useFloating({ open: show, @@ -52,32 +52,6 @@ export const ModalV2 = ({ // ============================================================================= // EFFECTS // ============================================================================= - useEffect(() => { - // set initial vh - - // use VisualViewport API if available, it gives more accurate dimensions when iOS software keyboard is active - if (window.visualViewport) { - handleViewportResize(); - window.visualViewport.addEventListener( - "resize", - handleViewportResize - ); - return () => { - window.visualViewport?.removeEventListener( - "resize", - handleViewportResize - ); - }; - } else { - // fallback to Window API - handleWindowResize(); - window.addEventListener("resize", handleWindowResize); - return () => { - window.removeEventListener("resize", handleWindowResize); - }; - } - }, []); - useEffect(() => { if (show && dismissKeyboardOnShow) { // dismiss software keyboard to put modal in fullscreen @@ -85,26 +59,9 @@ export const ModalV2 = ({ } }, [dismissKeyboardOnShow, show]); - // ============================================================================= - // EVENT HANDLERS - // ============================================================================= - const handleWindowResize = () => { - const newVerticalHeight = window.innerHeight * 0.01; - setVerticalHeight(newVerticalHeight); - }; - - const handleViewportResize = () => { - if (window.visualViewport) { - const newVerticalHeight = window.visualViewport.height * 0.01; - setVerticalHeight(newVerticalHeight); - setOffsetTop(window.visualViewport.offsetTop); - } - }; - // ============================================================================= // RENDER FUNCTIONS // ============================================================================= - return ( (); - const [offsetTop, setOffsetTop] = useState(); + const { verticalHeight, offsetTop } = useWindowResizeObserver(); // ============================================================================= // EFFECTS // ============================================================================= - useEffect(() => { - //set initial vh - - // use VisualViewport API if available, it gives more accurate dimensions when iOS software keyboard is active - if (window.visualViewport) { - handleViewportResize(); - window.visualViewport.addEventListener( - "resize", - handleViewportResize - ); - return () => { - window.visualViewport?.removeEventListener( - "resize", - handleViewportResize - ); - }; - } else { - // fallback to Window API - handleWindowResize(); - window.addEventListener("resize", handleWindowResize); - return () => { - window.removeEventListener("resize", handleWindowResize); - }; - } - }, []); - useEffect(() => { if (show && dismissKeyboardOnShow) { // dismiss software keyboard to put modal in fullscreen @@ -57,22 +31,6 @@ export const Modal = ({ } }, [show]); - // ============================================================================= - // EVENT HANDLERS - // ============================================================================= - const handleWindowResize = () => { - const newVerticalHeight = window.innerHeight * 0.01; - setVerticalHeight(newVerticalHeight); - }; - - const handleViewportResize = () => { - if (window.visualViewport) { - const newVerticalHeight = window.visualViewport.height * 0.01; - setVerticalHeight(newVerticalHeight); - setOffsetTop(window.visualViewport.offsetTop); - } - }; - // ============================================================================= // RENDER FUNCTIONS // ============================================================================= diff --git a/src/shared/hooks/index.tsx b/src/shared/hooks/index.tsx new file mode 100644 index 000000000..f6c161c95 --- /dev/null +++ b/src/shared/hooks/index.tsx @@ -0,0 +1 @@ +export * from "./useWindowResizeObserver"; diff --git a/src/shared/hooks/useWindowResizeObserver.tsx b/src/shared/hooks/useWindowResizeObserver.tsx new file mode 100644 index 000000000..d6997975a --- /dev/null +++ b/src/shared/hooks/useWindowResizeObserver.tsx @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from "react"; + +export const useWindowResizeObserver = () => { + const [verticalHeight, setVerticalHeight] = useState(); + const [offsetTop, setOffsetTop] = useState(); + + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + const handleWindowResize = useCallback(() => { + const newVerticalHeight = window.innerHeight * 0.01; + setVerticalHeight(newVerticalHeight); + }, []); + + const handleViewportResize = useCallback(() => { + if (window.visualViewport) { + const newVerticalHeight = window.visualViewport.height * 0.01; + setVerticalHeight(newVerticalHeight); + setOffsetTop(window.visualViewport.offsetTop); + } + }, []); + + useEffect(() => { + // set initial vh + + // use VisualViewport API if available, it gives more accurate dimensions when iOS software keyboard is active + if (window.visualViewport) { + handleViewportResize(); + window.visualViewport.addEventListener( + "resize", + handleViewportResize + ); + return () => { + window.visualViewport?.removeEventListener( + "resize", + handleViewportResize + ); + }; + } else { + // fallback to Window API + handleWindowResize(); + window.addEventListener("resize", handleWindowResize); + return () => { + window.removeEventListener("resize", handleWindowResize); + }; + } + }, []); + + return { + verticalHeight, + offsetTop, + }; +}; From 2ea4e56b4cee0cdf0ad17d894f2681c81071001d Mon Sep 17 00:00:00 2001 From: shawn wee Date: Thu, 28 Aug 2025 13:27:07 +0800 Subject: [PATCH 16/21] [ModalV2][SW] move width css to card --- src/modal-v2/modal-v2.styles.tsx | 4 +--- src/modal-v2/slots/slot-styles.tsx | 5 +++-- stories/modal-v2/modal-v2.stories.tsx | 5 +++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index 2e3f7cf8a..a5004c9a3 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -64,7 +64,5 @@ export const ScrollContainer = styled.div` export const ModalContainer = styled.div` pointer-events: auto; - ${MediaQuery.MaxWidth.md} { - width: 90%; - } + width: 100%; `; diff --git a/src/modal-v2/slots/slot-styles.tsx b/src/modal-v2/slots/slot-styles.tsx index 7b0502d25..98f418991 100644 --- a/src/modal-v2/slots/slot-styles.tsx +++ b/src/modal-v2/slots/slot-styles.tsx @@ -1,6 +1,6 @@ import styled, { css } from "styled-components"; import { ClickableIcon as _ClickableIcon } from "../../shared/clickable-icon"; -import { Colour, MediaQuery, Radius, Spacing } from "../../theme"; +import { Breakpoint, Colour, MediaQuery, Radius, Spacing } from "../../theme"; // ============================================================================= // Card @@ -10,6 +10,7 @@ interface ModalCardProps { } export const ModalCard = styled.div` width: 40rem; + margin: 0 auto; background: ${Colour.bg}; box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); border-radius: ${Radius["lg"]}; @@ -18,7 +19,7 @@ export const ModalCard = styled.div` flex-direction: column-reverse; ${MediaQuery.MaxWidth.md} { - width: 100%; + width: calc(100% - ${Breakpoint["md-margin"]}px); } padding: ${(props) => diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index b7136a89b..fb23732fd 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -89,10 +89,11 @@ export const CustomContent: StoryObj = { enableOverlayClick onClose={closeModal} > - = { This is a custom component - +
); From 58f5df88afc07e41c7e95b04b542a1cd7d2eed03 Mon Sep 17 00:00:00 2001 From: Quek Ruo Ling Date: Mon, 1 Sep 2025 09:00:00 +0800 Subject: [PATCH 17/21] [MISC][RL] Make onClose optional --- src/modal-v2/modal-context.tsx | 2 +- src/modal-v2/modal-v2.tsx | 19 +++++++++++-------- src/modal-v2/types.ts | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/modal-v2/modal-context.tsx b/src/modal-v2/modal-context.tsx index e8e1491d0..6cadc6dd7 100644 --- a/src/modal-v2/modal-context.tsx +++ b/src/modal-v2/modal-context.tsx @@ -1,7 +1,7 @@ import { createContext } from "react"; interface IModalContext { - onClose: () => void; + onClose?: (() => void) | undefined; } export const ModalContext = createContext({ diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 0508c5d08..075ba0f85 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -26,32 +26,35 @@ export const ModalV2 = ({ "data-testid": testId = "modal", ...otherProps }: ModalV2Props): JSX.Element => { - // ============================================================================= + // ========================================================================= // CONST, STATE, REF - // ============================================================================= + // ========================================================================= const { verticalHeight, offsetTop } = useWindowResizeObserver(); + // ========================================================================= + // FLOATING UI CONFIG + // ========================================================================= const { refs, context } = useFloating({ open: show, onOpenChange: (isOpen) => { if (!isOpen) { - onClose(); + onClose?.(); } }, }); const { isMounted, status } = useTransitionStatus(context, { duration: 300, }); - const dismiss = useDismiss(context, { /* handled by overlayclick */ outsidePress: false, + enabled: !!onClose, }); const { getFloatingProps } = useInteractions([dismiss]); - // ============================================================================= + // ========================================================================= // EFFECTS - // ============================================================================= + // ========================================================================= useEffect(() => { if (show && dismissKeyboardOnShow) { // dismiss software keyboard to put modal in fullscreen @@ -59,9 +62,9 @@ export const ModalV2 = ({ } }, [dismissKeyboardOnShow, show]); - // ============================================================================= + // ========================================================================= // RENDER FUNCTIONS - // ============================================================================= + // ========================================================================= return ( { + "data-testid"?: string | undefined; show: boolean; children: React.ReactNode; - onClose: () => void; /** Animation direction of appearance and dismissal. Values: "top" | "bottom" | "left" | "right" */ animationFrom?: ModalAnimationDirection | undefined; enableOverlayClick?: boolean | undefined; /** The identifier of the element to inject the Modal into */ rootComponentId?: string | undefined; zIndex?: number | undefined; + onClose?: (() => void) | undefined; onOverlayClick?: (() => void) | undefined; /** Dismiss keyboard to keep modal in fullscreen */ dismissKeyboardOnShow?: boolean | undefined; - "data-testid"?: string | undefined; } From 431abd44f879bb82e0cc4c76f7ea0c20623d94b9 Mon Sep 17 00:00:00 2001 From: Quek Ruo Ling Date: Mon, 1 Sep 2025 09:01:23 +0800 Subject: [PATCH 18/21] [MISC][RL] Update documentation --- src/modal-v2/slots/card.tsx | 2 + src/modal-v2/slots/close-button.tsx | 2 + src/modal-v2/slots/content.tsx | 2 + stories/modal-v2/modal-v2.mdx | 40 ++-- stories/modal-v2/modal-v2.stories.tsx | 145 +++++--------- stories/modal-v2/props-table.tsx | 261 +++++++++++++------------- 6 files changed, 203 insertions(+), 249 deletions(-) diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx index c969aaa72..c7bb155c2 100644 --- a/src/modal-v2/slots/card.tsx +++ b/src/modal-v2/slots/card.tsx @@ -42,3 +42,5 @@ export const Card = ({ ); }; + +Card.displayName = "ModalV2.Card"; diff --git a/src/modal-v2/slots/close-button.tsx b/src/modal-v2/slots/close-button.tsx index 9b4d2f8b3..45297dbd5 100644 --- a/src/modal-v2/slots/close-button.tsx +++ b/src/modal-v2/slots/close-button.tsx @@ -24,3 +24,5 @@ export const CloseButton = ({ ); }; + +CloseButton.displayName = "ModalV2.CloseButton"; diff --git a/src/modal-v2/slots/content.tsx b/src/modal-v2/slots/content.tsx index 95f61025b..5129b1634 100644 --- a/src/modal-v2/slots/content.tsx +++ b/src/modal-v2/slots/content.tsx @@ -11,3 +11,5 @@ export const Content = ({ ); }; + +Content.displayName = "ModalV2.Content"; diff --git a/stories/modal-v2/modal-v2.mdx b/stories/modal-v2/modal-v2.mdx index 657e796b8..9b75c63a9 100644 --- a/stories/modal-v2/modal-v2.mdx +++ b/stories/modal-v2/modal-v2.mdx @@ -9,29 +9,26 @@ import { StyleTokensTable } from "./style-tokens-table"; ## Overview -A window or pop up that displays over other page contents and provides information or actions -that a user can perform. +A window or pop up that displays over other page content and provides +information or actions that a user can perform. ```tsx import { ModalV2 } from "@lifesg/react-design-system/modal-v2"; ``` -There are 4 components that can be used. +This module contains several components, intended to be used together. -Correct usage of these components will ensure the modal will have -the correct padding and accessibility behaviours. +Correct usage of these components ensures the modal will have the correct +appearance and accessible behaviour. -- `ModalV2` represents the base modal overlay. This is a **mandatory component** to be used. -- `ModalV2.Card` represents the dialog box that comes with a rounded border. +- `ModalV2` represents the base modal overlay. This is **mandatory**. +- `ModalV2.Card` represents the default dialog box. - It must be rendered as a child of `ModalV2` - - Will only render children that are `ModalV2.Content` or `ModalV2.CloseButton` - - Stylable with `customStyle` prop -- `ModalV2.Content` represents the content to be displayed within ModalV2.Card. - - It must be rendered as a child of ModalV2.Card - - Stylable with `customStyle` prop -- `ModalV2.CloseButton` represents the close button to close the modal. - - Optional, but if used, it must be rendered as a child of ModalV2.Card - - Stylable with `customStyle` prop + - It will only render children that are `ModalV2.Content` or `ModalV2.CloseButton` +- `ModalV2.Content` represents the content to be displayed within `ModalV2.Card`. + - It must be rendered as a child of `ModalV2.Card` +- `ModalV2.CloseButton` represents the section containing the close button. + - Optional, but if used, it must be rendered as a child of `ModalV2.Card` @@ -45,19 +42,22 @@ the correct padding and accessibility behaviours. ## Stacked modals -In some cases, you would require a stacked modal layout. Here is how you can construct it. +In some cases, you would require a stacked modal layout. Here is how you can +construct it. -## Scrollable modals +## Scroll handling -If the content of your modal is longer than the screen size, use scrollable modals. +If the content of your modal is taller than the screen, the modal will become +scrollable. - + ## Component API -Both components also inherit the [HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement). +All components also inherit the +[HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement). diff --git a/stories/modal-v2/modal-v2.stories.tsx b/stories/modal-v2/modal-v2.stories.tsx index fb23732fd..8068f611a 100644 --- a/stories/modal-v2/modal-v2.stories.tsx +++ b/stories/modal-v2/modal-v2.stories.tsx @@ -18,8 +18,9 @@ export const Default: StoryObj = { const [show, setShow] = useState(false); const openModal = () => setShow(true); const closeModal = () => setShow(false); + return ( -
+ <> Click to open @@ -32,13 +33,19 @@ export const Default: StoryObj = { - I am a Modal with very long text in a para graph - to fill up the width + Lorem ipsum dolor sit amet consectetur + adipisicing elit. Totam debitis explicabo rerum + velit quod recusandae, cum odio inventore + repellendus non quas quis praesentium suscipit, + soluta incidunt officiis omnis, quae error! + + CTA + -
+ ); }, }; @@ -48,26 +55,32 @@ export const WithoutCloseButton: StoryObj = { const [show, setShow] = useState(false); const openModal = () => setShow(true); const closeModal = () => setShow(false); + return ( -
+ <> Click to open - + - I am a Modal with very long text in a para graph - to fill up the width + Lorem ipsum dolor sit amet consectetur + adipisicing elit. Totam debitis explicabo rerum + velit quod recusandae, cum odio inventore + repellendus non quas quis praesentium suscipit, + soluta incidunt officiis omnis, quae error! + + Dismiss + -
+ ); }, }; @@ -86,26 +99,17 @@ export const CustomContent: StoryObj = {
- - - This is a custom component - + This is a custom component
@@ -183,15 +187,15 @@ export const StackedModals: StoryObj = { }, }; -export const ScrollableModals: StoryObj = { - render: () => { +export const ScrollHandling: StoryObj = { + render: (_args) => { const [show, setShow] = useState(false); const openModal = () => setShow(true); const closeModal = () => setShow(false); return ( -
+ <> - Click to open scrollable modal + Click to open = { - - this is the topic - - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - - Some button - - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal - I am a Modal + + This is the start of content. + +
+ + This is the end of content. + -
+ ); }, }; diff --git a/stories/modal-v2/props-table.tsx b/stories/modal-v2/props-table.tsx index e1c33070d..83ee5ce58 100644 --- a/stories/modal-v2/props-table.tsx +++ b/stories/modal-v2/props-table.tsx @@ -1,148 +1,155 @@ import { - DefaultCol, - DescriptionCol, - NameCol, - Table, -} from "../storybook-common/api-table"; -import { TabAttribute, Tabs } from "../storybook-common/tabs"; + ApiTable, + ApiTableSectionProps, + TabAttribute, + Tabs, +} from "stories/storybook-common"; -export const ModalTable = () => ( - - - show - - <> - Toggles the visibility of the Modal - - - - - - onClose - void"]}> - <> - Callback to close the modal. Called when using the `esc` key - while the modal is open. - - - - - - rootComponentId - - <> - The identifier of the element to inject the{" "} - Modal into. Not specifying the root element - will make {``} the root element. - - - - - - animationFrom - - <> - The animation direction of which the Modal will - appear - - - {[`"bottom"`]} - - - enableOverlayClick - - <> - Toggles whether Modal can be dismissed by - clicking on the overlay - - - - - - zIndex - - <> - Allows a custom z-index to be specified (useful - for modal stacking) - - - - - - onOverlayClick - void"]}> - <> - The callback when the overlay is being clicked on. Will be - triggered if enableOverlayClick - is specified to true - - - - - - dismissKeyboardOnShow - - <>Dismisses keyboard when modal is shown - - {["true"]} - -
-); +const MODAL_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "data-testid", + description: "The test identifier for the component", + propTypes: ["string"], + }, + { + name: "show", + mandatory: true, + description: ( + <> + Toggles the visibility of the Modal + + ), + propTypes: ["boolean"], + }, + { + name: "rootComponentId", + description: ( + <> + The identifier of the element to inject the{" "} + Modal into. Not specifying the root element + will make {``} the root element. + + ), + propTypes: ["string"], + }, + { + name: "animationFrom", + description: ( + <> + The animation direction of which the Modal{" "} + will appear + + ), + propTypes: [`"top"`, `"bottom"`, `"left"`, `"right"`], + defaultValue: `"bottom"`, + }, + { + name: "enableOverlayClick", + description: ( + <> + Toggles whether Modal can be dismissed by + clicking on the overlay + + ), + propTypes: ["boolean"], + defaultValue: "true", + }, + { + name: "zIndex", + description: ( + <> + Allows a custom z-index to be specified + (useful for modal stacking) + + ), + propTypes: ["number"], + }, + { + name: "onClose", + description: ( + <> + Callback when the modal is closed. Can be triggered by + the close button or pressing the Escape key while the + modal is open. + + ), + propTypes: ["() => void"], + }, + { + name: "onOverlayClick", + description: ( + <> + Callback when the overlay outside of the modal is + clicked. Triggered if enableOverlayClick + is true + + ), + propTypes: ["() => void"], + }, + { + name: "dismissKeyboardOnShow", + description: <>Dismisses keyboard when modal is shown, + propTypes: ["boolean"], + defaultValue: "true", + }, + ], + }, +]; -export const ModalCardTable = () => ( - - - customStyle - - Custom styles for the modal dialog box - - - -
-); +const MODAL_CARD_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "data-testid", + description: "The test identifier for the component", + propTypes: ["string"], + }, + ], + }, +]; -export const ModalContentTable = () => ( - - - customStyle - - Custom styles for the modal content container - - - -
-); +const MODAL_CONTENT_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "data-testid", + description: "The test identifier for the component", + propTypes: ["string"], + }, + ], + }, +]; -export const ModalCloseButtonTable = () => ( - - - customStyle - - Custom styles for the modal close Button - - - -
-); +const MODAL_CLOSE_BUTTON_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "data-testid", + description: "The test identifier for the component", + propTypes: ["string"], + }, + ], + }, +]; const PROPS_TABLE_DATA: TabAttribute[] = [ { title: "ModalV2", - component: , + component: , }, { title: "ModalV2.Card", - component: , + component: , }, { title: "ModalV2.Content", - component: , + component: , }, { title: "ModalV2.CloseButton", - component: , + component: , }, ]; From 871d31026676da812c545380b4523cd9918ade62 Mon Sep 17 00:00:00 2001 From: shawn wee Date: Mon, 22 Sep 2025 14:59:21 +0800 Subject: [PATCH 19/21] [ModalV2][SW] find styled slots --- src/modal-v2/slots/card.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx index c7bb155c2..825bf14dd 100644 --- a/src/modal-v2/slots/card.tsx +++ b/src/modal-v2/slots/card.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { isStyledComponent } from "styled-components"; import { ModalCardProps } from "../types"; import { CloseButton } from "./close-button"; import { Content } from "./content"; @@ -17,16 +18,21 @@ export const Card = ({ event.stopPropagation(); }; + const findByType = (child: React.ReactPortal, type: any) => + isStyledComponent(child.type) + ? (child.type as unknown as { target: any }).target === type + : child.type === type; + // ============================================================================= // RENDER FUNCTIONS // ============================================================================= - const CloseButtonSlot = React.Children.toArray(children).find( - (child) => (child as React.ReactPortal).type === CloseButton + const CloseButtonSlot = React.Children.toArray(children).find((child) => + findByType(child as React.ReactPortal, CloseButton) ); const hasCloseButton = !!CloseButtonSlot; - const ContentSlot = React.Children.toArray(children).find( - (child) => (child as React.ReactPortal).type === Content + const ContentSlot = React.Children.toArray(children).find((child) => + findByType(child as React.ReactPortal, Content) ); return ( From d71498e74e2ddfa05bebb1043ed5b8d0fd8ebf5e Mon Sep 17 00:00:00 2001 From: shawn wee Date: Tue, 23 Sep 2025 09:37:34 +0800 Subject: [PATCH 20/21] [ModalV2][SW] rename useWindowResizeObserver -> useViewport --- src/modal-v2/modal-v2.tsx | 4 ++-- src/modal/modal.tsx | 4 ++-- src/shared/hooks/index.tsx | 2 +- .../hooks/{useWindowResizeObserver.tsx => useViewport.tsx} | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/shared/hooks/{useWindowResizeObserver.tsx => useViewport.tsx} (97%) diff --git a/src/modal-v2/modal-v2.tsx b/src/modal-v2/modal-v2.tsx index 075ba0f85..8ae01a60a 100644 --- a/src/modal-v2/modal-v2.tsx +++ b/src/modal-v2/modal-v2.tsx @@ -7,7 +7,7 @@ import { } from "@floating-ui/react"; import { useEffect } from "react"; import { Overlay } from "../overlay/overlay"; -import { useWindowResizeObserver } from "../shared/hooks"; +import { useViewport } from "../shared/hooks"; import { ModalContext } from "./modal-context"; import { Container, ModalContainer, ScrollContainer } from "./modal-v2.styles"; import { ModalV2Props } from "./types"; @@ -29,7 +29,7 @@ export const ModalV2 = ({ // ========================================================================= // CONST, STATE, REF // ========================================================================= - const { verticalHeight, offsetTop } = useWindowResizeObserver(); + const { verticalHeight, offsetTop } = useViewport(); // ========================================================================= // FLOATING UI CONFIG diff --git a/src/modal/modal.tsx b/src/modal/modal.tsx index 66cbf4af0..81577d2d6 100644 --- a/src/modal/modal.tsx +++ b/src/modal/modal.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { Overlay } from "../overlay/overlay"; -import { useWindowResizeObserver } from "../shared/hooks"; +import { useViewport } from "../shared/hooks"; import { Container } from "./modal.styles"; import { ModalProps } from "./types"; @@ -19,7 +19,7 @@ export const Modal = ({ // ============================================================================= // CONST, STATE, REF // ============================================================================= - const { verticalHeight, offsetTop } = useWindowResizeObserver(); + const { verticalHeight, offsetTop } = useViewport(); // ============================================================================= // EFFECTS diff --git a/src/shared/hooks/index.tsx b/src/shared/hooks/index.tsx index f6c161c95..5ebd38642 100644 --- a/src/shared/hooks/index.tsx +++ b/src/shared/hooks/index.tsx @@ -1 +1 @@ -export * from "./useWindowResizeObserver"; +export * from "./useViewport"; diff --git a/src/shared/hooks/useWindowResizeObserver.tsx b/src/shared/hooks/useViewport.tsx similarity index 97% rename from src/shared/hooks/useWindowResizeObserver.tsx rename to src/shared/hooks/useViewport.tsx index d6997975a..b3bca784f 100644 --- a/src/shared/hooks/useWindowResizeObserver.tsx +++ b/src/shared/hooks/useViewport.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -export const useWindowResizeObserver = () => { +export const useViewport = () => { const [verticalHeight, setVerticalHeight] = useState(); const [offsetTop, setOffsetTop] = useState(); From 7e7511e68d30687c11e60f18f676a2660644509d Mon Sep 17 00:00:00 2001 From: shawn wee Date: Tue, 23 Sep 2025 11:14:37 +0800 Subject: [PATCH 21/21] [ModalV2][SW] update styles to use tokens --- src/modal-v2/modal-v2.styles.tsx | 1 - src/modal-v2/slots/card.tsx | 9 +++-- src/modal-v2/slots/slot-styles.tsx | 53 ++++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/modal-v2/modal-v2.styles.tsx b/src/modal-v2/modal-v2.styles.tsx index a5004c9a3..31890dc56 100644 --- a/src/modal-v2/modal-v2.styles.tsx +++ b/src/modal-v2/modal-v2.styles.tsx @@ -54,7 +54,6 @@ export const Container = styled.div` `; export const ScrollContainer = styled.div` - padding: 4rem 0; display: flex; justify-content: center; align-items: center; diff --git a/src/modal-v2/slots/card.tsx b/src/modal-v2/slots/card.tsx index 825bf14dd..8e4edbad2 100644 --- a/src/modal-v2/slots/card.tsx +++ b/src/modal-v2/slots/card.tsx @@ -18,7 +18,10 @@ export const Card = ({ event.stopPropagation(); }; - const findByType = (child: React.ReactPortal, type: any) => + // ========================================================================= + // HELPERS + // ========================================================================= + const isComponentType = (child: React.ReactPortal, type: any) => isStyledComponent(child.type) ? (child.type as unknown as { target: any }).target === type : child.type === type; @@ -27,12 +30,12 @@ export const Card = ({ // RENDER FUNCTIONS // ============================================================================= const CloseButtonSlot = React.Children.toArray(children).find((child) => - findByType(child as React.ReactPortal, CloseButton) + isComponentType(child as React.ReactPortal, CloseButton) ); const hasCloseButton = !!CloseButtonSlot; const ContentSlot = React.Children.toArray(children).find((child) => - findByType(child as React.ReactPortal, Content) + isComponentType(child as React.ReactPortal, Content) ); return ( diff --git a/src/modal-v2/slots/slot-styles.tsx b/src/modal-v2/slots/slot-styles.tsx index 98f418991..bf6de24e6 100644 --- a/src/modal-v2/slots/slot-styles.tsx +++ b/src/modal-v2/slots/slot-styles.tsx @@ -1,6 +1,13 @@ import styled, { css } from "styled-components"; import { ClickableIcon as _ClickableIcon } from "../../shared/clickable-icon"; -import { Breakpoint, Colour, MediaQuery, Radius, Spacing } from "../../theme"; +import { + Breakpoint, + Colour, + MediaQuery, + Radius, + Shadow, + Spacing, +} from "../../theme"; // ============================================================================= // Card @@ -10,18 +17,14 @@ interface ModalCardProps { } export const ModalCard = styled.div` width: 40rem; - margin: 0 auto; + margin: ${Spacing["spacing-64"]} auto; background: ${Colour.bg}; - box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.45); + box-shadow: ${Shadow["xs-strong"]}; border-radius: ${Radius["lg"]}; display: flex; flex-direction: column-reverse; - ${MediaQuery.MaxWidth.md} { - width: calc(100% - ${Breakpoint["md-margin"]}px); - } - padding: ${(props) => props.$hasCloseButton ? css` @@ -32,22 +35,44 @@ export const ModalCard = styled.div` : css` ${Spacing["spacing-32"]}; `}; + + max-width: calc(100% - ${Breakpoint["xxl-margin"]}px * 2); + + ${MediaQuery.MaxWidth.xl} { + max-width: calc(100% - ${Breakpoint["xl-margin"]}px * 2); + } + + ${MediaQuery.MaxWidth.lg} { + max-width: calc(100% - ${Breakpoint["lg-margin"]}px * 2); + } + + ${MediaQuery.MaxWidth.md} { + max-width: calc(100% - ${Breakpoint["md-margin"]}px * 2); + } + + ${MediaQuery.MaxWidth.sm} { + max-width: calc(100% - ${Breakpoint["sm-margin"]}px * 2); + } + + ${MediaQuery.MaxWidth.xs} { + max-width: calc(100% - ${Breakpoint["xs-margin"]}px * 2); + } + + ${MediaQuery.MaxWidth.xxs} { + max-width: calc(100% - ${Breakpoint["xxs-margin"]}px * 2); + } `; // ============================================================================= // Close Button // ============================================================================= export const ClickableContainer = styled.div` - margin-top: calc( - var(--close-button-top-inset, ${Spacing["spacing-16"]}) * -1 - ); - margin-right: calc( - var(--close-button-top-inset, ${Spacing["spacing-16"]}) * -1 - ); + margin-top: calc(${Spacing["spacing-16"]} * -1); + margin-right: calc(${Spacing["spacing-16"]} * -1); margin-left: auto; ${MediaQuery.MaxWidth.sm} { - right: var(--close-button-right-inset, ${Spacing["spacing-20"]}); + right: ${Spacing["spacing-20"]}; } `;