From 7f28cbebcf00415bdfd15ecc4b7879e87a3e1c5a Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:56:33 +0530 Subject: [PATCH] [WEB-4980] dev: propel modal portal component (#7851) --- packages/propel/package.json | 1 + packages/propel/src/portal/constants.ts | 28 +++ packages/propel/src/portal/index.ts | 4 + packages/propel/src/portal/modal-portal.tsx | 110 +++++++++ packages/propel/src/portal/portal-wrapper.tsx | 76 ++++++ packages/propel/src/portal/portal.stories.tsx | 222 ++++++++++++++++++ packages/propel/src/portal/types.ts | 32 +++ packages/propel/tsdown.config.ts | 1 + 8 files changed, 474 insertions(+) create mode 100644 packages/propel/src/portal/constants.ts create mode 100644 packages/propel/src/portal/index.ts create mode 100644 packages/propel/src/portal/modal-portal.tsx create mode 100644 packages/propel/src/portal/portal-wrapper.tsx create mode 100644 packages/propel/src/portal/portal.stories.tsx create mode 100644 packages/propel/src/portal/types.ts diff --git a/packages/propel/package.json b/packages/propel/package.json index 6650905bb2..923ac78ab9 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -35,6 +35,7 @@ "./menu": "./dist/menu/index.js", "./pill": "./dist/pill/index.js", "./popover": "./dist/popover/index.js", + "./portal": "./dist/portal/index.js", "./scrollarea": "./dist/scrollarea/index.js", "./skeleton": "./dist/skeleton/index.js", "./styles/fonts": "./dist/styles/fonts/index.css", diff --git a/packages/propel/src/portal/constants.ts b/packages/propel/src/portal/constants.ts new file mode 100644 index 0000000000..d147ebe81f --- /dev/null +++ b/packages/propel/src/portal/constants.ts @@ -0,0 +1,28 @@ +export enum EPortalWidth { + QUARTER = "quarter", + HALF = "half", + THREE_QUARTER = "three-quarter", + FULL = "full", +} + +export enum EPortalPosition { + LEFT = "left", + RIGHT = "right", + CENTER = "center", +} + +export const PORTAL_WIDTH_CLASSES = { + [EPortalWidth.QUARTER]: "w-1/4 min-w-80 max-w-96", + [EPortalWidth.HALF]: "w-1/2 min-w-96 max-w-2xl", + [EPortalWidth.THREE_QUARTER]: "w-3/4 min-w-96 max-w-5xl", + [EPortalWidth.FULL]: "w-full", +} as const; + +export const PORTAL_POSITION_CLASSES = { + [EPortalPosition.LEFT]: "left-0", + [EPortalPosition.RIGHT]: "right-0", + [EPortalPosition.CENTER]: "left-1/2 -translate-x-1/2", +} as const; + +export const DEFAULT_PORTAL_ID = "full-screen-portal"; +export const MODAL_Z_INDEX = 25; diff --git a/packages/propel/src/portal/index.ts b/packages/propel/src/portal/index.ts new file mode 100644 index 0000000000..3594f57814 --- /dev/null +++ b/packages/propel/src/portal/index.ts @@ -0,0 +1,4 @@ +export * from "./modal-portal"; +export * from "./portal-wrapper"; +export * from "./constants"; +export * from "./types"; diff --git a/packages/propel/src/portal/modal-portal.tsx b/packages/propel/src/portal/modal-portal.tsx new file mode 100644 index 0000000000..bf98acf45b --- /dev/null +++ b/packages/propel/src/portal/modal-portal.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useMemo, useRef, useEffect } from "react"; +import { cn } from "../utils"; +import { + EPortalWidth, + EPortalPosition, + PORTAL_WIDTH_CLASSES, + PORTAL_POSITION_CLASSES, + DEFAULT_PORTAL_ID, + MODAL_Z_INDEX, +} from "./constants"; +import { PortalWrapper } from "./portal-wrapper"; +import { ModalPortalProps } from "./types"; + +/** + * @param children - The modal content to render + * @param isOpen - Whether the modal is open + * @param onClose - Function to call when modal should close + * @param portalId - The ID of the DOM element to render into + * @param className - Custom className for the modal container + * @param overlayClassName - Custom className for the overlay + * @param contentClassName - Custom className for the content area + * @param width - Predefined width options using EPortalWidth enum + * @param position - Position of the modal using EPortalPosition enum + * @param fullScreen - Whether to render in fullscreen mode + * @param showOverlay - Whether to show background overlay + * @param closeOnOverlayClick - Whether clicking overlay closes modal + * @param closeOnEscape - Whether pressing Escape closes modal + */ +export const ModalPortal: React.FC = ({ + children, + isOpen, + onClose, + portalId = DEFAULT_PORTAL_ID, + className, + overlayClassName, + contentClassName, + width = EPortalWidth.HALF, + position = EPortalPosition.RIGHT, + fullScreen = false, + showOverlay = true, + closeOnOverlayClick = true, + closeOnEscape = true, +}) => { + const contentRef = useRef(null); + + // Memoized overlay click handler + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (closeOnOverlayClick && onClose && e.target === e.currentTarget) { + onClose(); + } + }, + [closeOnOverlayClick, onClose] + ); + + // close on escape + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (closeOnEscape && onClose && e.key === "Escape") { + onClose(); + } + }, + [closeOnEscape, onClose] + ); + + // add event listener for escape + useEffect(() => { + if (!isOpen) return; + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, handleEscape]); + + // Memoized style classes + const modalClasses = useMemo(() => { + const widthClass = fullScreen ? "w-full h-full" : PORTAL_WIDTH_CLASSES[width]; + const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position]; + + return cn( + "top-0 h-full bg-white shadow-lg absolute transition-transform duration-300 ease-out", + widthClass, + positionClass, + contentClassName + ); + }, [fullScreen, width, position, contentClassName]); + + if (!isOpen) return null; + + const content = ( +
+ {showOverlay && ( + + ); + + return {content}; +}; diff --git a/packages/propel/src/portal/portal-wrapper.tsx b/packages/propel/src/portal/portal-wrapper.tsx new file mode 100644 index 0000000000..2f67540bd0 --- /dev/null +++ b/packages/propel/src/portal/portal-wrapper.tsx @@ -0,0 +1,76 @@ +import React, { useLayoutEffect, useState, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { DEFAULT_PORTAL_ID } from "./constants"; +import { PortalWrapperProps } from "./types"; + +/** + * PortalWrapper - A reusable portal component that renders children into a specific DOM element + * Optimized for SSR compatibility and performance + * + * @param children - The content to render inside the portal + * @param portalId - The ID of the DOM element to render into + * @param fallbackToDocument - Whether to render directly if portal container is not found + * @param className - Optional className to apply to the portal container div + * @param onMount - Callback fired when portal is mounted + * @param onUnmount - Callback fired when portal is unmounted + */ +export const PortalWrapper: React.FC = ({ + children, + portalId = DEFAULT_PORTAL_ID, + fallbackToDocument = true, + className, + onMount, + onUnmount, +}) => { + const [portalContainer, setPortalContainer] = useState(null); + const [isMounted, setIsMounted] = useState(false); + + useLayoutEffect(() => { + // Ensure we're in browser environment + if (typeof window === "undefined") return; + + let container = document.getElementById(portalId); + + // Create portal container if it doesn't exist + if (!container) { + container = document.createElement("div"); + container.id = portalId; + container.setAttribute("data-portal", "true"); + document.body.appendChild(container); + } + + setPortalContainer(container); + setIsMounted(true); + onMount?.(); + + return () => { + onUnmount?.(); + // Only remove if we created it and it's empty + if (container && container.children.length === 0 && container.hasAttribute("data-portal")) { + document.body.removeChild(container); + } + }; + }, [portalId, onMount, onUnmount]); + + const content = useMemo(() => { + if (!children) return null; + return className ?
{children}
: children; + }, [children, className]); + + // SSR: render nothing on server + if (!isMounted) { + return null; + } + + // If portal container exists, render into it + if (portalContainer) { + return createPortal(content, portalContainer); + } + + // Fallback behavior for client-side rendering + if (fallbackToDocument) { + return content ? (content as React.ReactElement) : null; + } + + return null; +}; diff --git a/packages/propel/src/portal/portal.stories.tsx b/packages/propel/src/portal/portal.stories.tsx new file mode 100644 index 0000000000..eeafd8dd93 --- /dev/null +++ b/packages/propel/src/portal/portal.stories.tsx @@ -0,0 +1,222 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button, EButtonVariant, EButtonSize } from "../button/button"; +import { EPortalWidth, EPortalPosition } from "./constants"; +import { ModalPortal, PortalWrapper } from "./"; + +const meta: Meta = { + title: "Components/Portal/ModalPortal", + component: ModalPortal, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +A high-performance, accessible modal portal component with comprehensive features: +Perfect for modals, drawers, overlays, and any UI that needs to appear above other content. + `, + }, + }, + }, + tags: ["autodocs"], + argTypes: { + width: { + control: "select", + options: Object.values(EPortalWidth), + description: "Modal width preset", + }, + position: { + control: "select", + options: Object.values(EPortalPosition), + description: "Modal position on screen", + }, + fullScreen: { + control: "boolean", + description: "Render modal in fullscreen mode", + }, + showOverlay: { + control: "boolean", + description: "Show/hide background overlay", + }, + closeOnOverlayClick: { + control: "boolean", + description: "Close modal when clicking overlay", + }, + closeOnEscape: { + control: "boolean", + description: "Close modal when pressing Escape", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Helper component for interactive stories +const ModalDemo = ({ + children, + buttonText = "Open Modal", + buttonVariant = EButtonVariant.PRIMARY, + ...modalProps +}: Omit[0], "isOpen" | "onClose"> & { + buttonText?: string; + buttonVariant?: Parameters[0]["variant"]; +}) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(false)}> + {children} + + + ); +}; + +const ModalContent = ({ + title = "Modal Title", + showCloseButton = true, + description = "This is a modal portal component with full accessibility support. Try pressing Tab to navigate through elements or Escape to close.", + onClose, +}: { + title?: string; + showCloseButton?: boolean; + description?: string; + onClose?: () => void; +}) => ( +
+
+
+

{title}

+

Modal demonstration

+
+ {showCloseButton && onClose && ( + + )} +
+
+

{description}

+ +
+
+

Feature Highlights

+
    +
  • • ESC key closes the modal
  • +
  • • Click outside overlay to close
  • +
+
+
+
+
+); + +export const Default: Story = { + render: () => ( + + + + ), +}; + +export const Positions: Story = { + name: "Different Positions", + render: () => { + const [activeModal, setActiveModal] = useState(null); + + return ( +
+ {Object.values(EPortalPosition).map((position) => ( + + + setActiveModal(null)} + width={EPortalWidth.HALF} + position={position} + > + setActiveModal(null)} + /> + + + ))} +
+ ); + }, +}; + +export const Widths: Story = { + name: "Different Widths", + render: () => { + const [activeModal, setActiveModal] = useState(null); + + return ( +
+ {Object.values(EPortalWidth).map((width) => ( + + + setActiveModal(null)} + width={width} + position={EPortalPosition.RIGHT} + > + l.toUpperCase())} Width`} + description={`This modal uses ${width} width. Compare different widths to find the perfect size for your content.`} + onClose={() => setActiveModal(null)} + /> + + + ))} +
+ ); + }, +}; + +// PortalWrapper Stories +const PortalWrapperMeta: Meta = { + title: "Components/Portal/PortalWrapper", + component: PortalWrapper, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +The PortalWrapper is a low-level component that handles rendering content into DOM portals. +It's used internally by ModalPortal but can also be used directly for custom portal needs.`, + }, + }, + }, + tags: ["autodocs"], +}; + +export const BasicPortal: StoryObj = { + render: () => ( +
+

This content renders in the normal document flow.

+ +
+ This content is rendered in a portal! +
+
+
+ ), + parameters: { + ...PortalWrapperMeta.parameters, + }, +}; diff --git a/packages/propel/src/portal/types.ts b/packages/propel/src/portal/types.ts new file mode 100644 index 0000000000..036c91911a --- /dev/null +++ b/packages/propel/src/portal/types.ts @@ -0,0 +1,32 @@ +import type { ReactNode, MouseEvent as ReactMouseEvent } from "react"; +import { EPortalWidth, EPortalPosition } from "./constants"; + +export interface BasePortalProps { + children: ReactNode; + className?: string; +} + +export interface PortalWrapperProps extends BasePortalProps { + portalId?: string; + fallbackToDocument?: boolean; + onMount?: () => void; + onUnmount?: () => void; +} + +export interface ModalPortalProps extends BasePortalProps { + isOpen: boolean; + onClose?: () => void; + portalId?: string; + overlayClassName?: string; + contentClassName?: string; + width?: EPortalWidth; + position?: EPortalPosition; + fullScreen?: boolean; + showOverlay?: boolean; + closeOnOverlayClick?: boolean; + closeOnEscape?: boolean; +} + +export type PortalEventHandler = () => void; +export type PortalKeyboardHandler = (event: KeyboardEvent) => void; +export type PortalMouseHandler = (event: ReactMouseEvent) => void; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index 20f242fb56..5ad6a8c112 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "src/menu/index.ts", "src/pill/index.ts", "src/popover/index.ts", + "src/portal/index.ts", "src/scrollarea/index.ts", "src/skeleton/index.ts", "src/switch/index.ts",