This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -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;