base
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./modal-portal";
|
||||
export * from "./portal-wrapper";
|
||||
export * from "./constants";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 type { 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 function ModalPortal({
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
portalId = DEFAULT_PORTAL_ID,
|
||||
className,
|
||||
overlayClassName,
|
||||
contentClassName,
|
||||
width = EPortalWidth.HALF,
|
||||
position = EPortalPosition.RIGHT,
|
||||
fullScreen = false,
|
||||
showOverlay = true,
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
}: ModalPortalProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(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(
|
||||
"shadow-lg absolute top-0 h-full bg-white transition-transform duration-300 ease-out",
|
||||
widthClass,
|
||||
positionClass,
|
||||
contentClassName
|
||||
);
|
||||
}, [fullScreen, width, position, contentClassName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn("absolute inset-0 h-full w-full overflow-y-auto", className)}
|
||||
style={{ zIndex: MODAL_Z_INDEX }}
|
||||
role="dialog"
|
||||
>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-black/50 transition-colors duration-300", overlayClassName)}
|
||||
onClick={handleOverlayClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div ref={contentRef} className={cn(modalClasses)} style={{ zIndex: MODAL_Z_INDEX + 1 }} role="document">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <PortalWrapper portalId={portalId}>{content}</PortalWrapper>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useLayoutEffect, useState, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { DEFAULT_PORTAL_ID } from "./constants";
|
||||
import type { 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 function PortalWrapper({
|
||||
children,
|
||||
portalId = DEFAULT_PORTAL_ID,
|
||||
fallbackToDocument = true,
|
||||
className,
|
||||
onMount,
|
||||
onUnmount,
|
||||
}: PortalWrapperProps) {
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(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 ? <div className={className}>{children}</div> : 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;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "../button/button";
|
||||
import type { TButtonVariant } from "../button/helper";
|
||||
import { EPortalWidth, EPortalPosition } from "./constants";
|
||||
import { ModalPortal, PortalWrapper } from "./";
|
||||
|
||||
const 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"],
|
||||
args: {
|
||||
isOpen: false,
|
||||
children: null,
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<ModalDemo {...args} buttonText="Open Modal">
|
||||
<ModalContent
|
||||
title="Default Modal"
|
||||
description="A standard modal with all default settings. Demonstrates focus management, keyboard navigation, and accessibility features."
|
||||
/>
|
||||
</ModalDemo>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof ModalPortal>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Helper component for interactive stories
|
||||
function ModalDemo({
|
||||
children,
|
||||
buttonText = "Open Modal",
|
||||
buttonVariant = "primary",
|
||||
...modalProps
|
||||
}: Omit<Parameters<typeof ModalPortal>[0], "isOpen" | "onClose"> & {
|
||||
buttonText?: string;
|
||||
buttonVariant?: TButtonVariant;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={buttonVariant} onClick={() => setIsOpen(true)}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<ModalPortal {...modalProps} isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
||||
{children}
|
||||
</ModalPortal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function 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;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="border-gray-200 flex items-center justify-between border-b p-6">
|
||||
<div>
|
||||
<h2 className="text-gray-900 text-18 font-semibold">{title}</h2>
|
||||
<p className="text-gray-500 mt-1 text-13">Modal demonstration</p>
|
||||
</div>
|
||||
{showCloseButton && onClose && (
|
||||
<Button variant="ghost" onClick={onClose} aria-label="Close modal">
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<p className="text-gray-600 mb-6">{description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-gray-900 mb-2 font-medium">Feature Highlights</h3>
|
||||
<ul className="text-gray-600 space-y-1 text-13">
|
||||
<li>• ESC key closes the modal</li>
|
||||
<li>• Click outside overlay to close</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Positions: Story = {
|
||||
name: "Different Positions",
|
||||
render() {
|
||||
const [activeModal, setActiveModal] = useState<EPortalPosition | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{Object.values(EPortalPosition).map((position) => (
|
||||
<React.Fragment key={position}>
|
||||
<Button variant="secondary" onClick={() => setActiveModal(position)}>
|
||||
{position.charAt(0).toUpperCase() + position.slice(1)}
|
||||
</Button>
|
||||
<ModalPortal
|
||||
isOpen={activeModal === position}
|
||||
onClose={() => setActiveModal(null)}
|
||||
width={EPortalWidth.HALF}
|
||||
position={position}
|
||||
>
|
||||
<ModalContent
|
||||
title={`${position.charAt(0).toUpperCase() + position.slice(1)} Modal`}
|
||||
description={`This modal is positioned at ${position}. Try different positions to see how the modal appears in different areas of the screen.`}
|
||||
onClose={() => setActiveModal(null)}
|
||||
/>
|
||||
</ModalPortal>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Widths: Story = {
|
||||
name: "Different Widths",
|
||||
render() {
|
||||
const [activeModal, setActiveModal] = useState<EPortalWidth | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{Object.values(EPortalWidth).map((width) => (
|
||||
<React.Fragment key={width}>
|
||||
<Button variant="secondary" onClick={() => setActiveModal(width)}>
|
||||
{width.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Button>
|
||||
<ModalPortal
|
||||
isOpen={activeModal === width}
|
||||
onClose={() => setActiveModal(null)}
|
||||
width={width}
|
||||
position={EPortalPosition.RIGHT}
|
||||
>
|
||||
<ModalContent
|
||||
title={`${width.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} Width`}
|
||||
description={`This modal uses ${width} width. Compare different widths to find the perfect size for your content.`}
|
||||
onClose={() => setActiveModal(null)}
|
||||
/>
|
||||
</ModalPortal>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const BasicPortal: Story = {
|
||||
render() {
|
||||
return (
|
||||
<div className="relative">
|
||||
<p>This content renders in the normal document flow.</p>
|
||||
<PortalWrapper portalId="storybook-portal">
|
||||
<div className="bg-blue-500 shadow-lg fixed top-4 right-4 z-50 rounded-sm p-4 text-on-color">
|
||||
This content is rendered in a portal!
|
||||
</div>
|
||||
</PortalWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode, MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { 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;
|
||||
Reference in New Issue
Block a user