mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
Sync: Community Changes #4295
This commit is contained in:
@@ -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",
|
||||
|
||||
28
packages/propel/src/portal/constants.ts
Normal file
28
packages/propel/src/portal/constants.ts
Normal file
@@ -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;
|
||||
4
packages/propel/src/portal/index.ts
Normal file
4
packages/propel/src/portal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./modal-portal";
|
||||
export * from "./portal-wrapper";
|
||||
export * from "./constants";
|
||||
export * from "./types";
|
||||
110
packages/propel/src/portal/modal-portal.tsx
Normal file
110
packages/propel/src/portal/modal-portal.tsx
Normal file
@@ -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<ModalPortalProps> = ({
|
||||
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<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(
|
||||
"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 = (
|
||||
<div
|
||||
className={cn("fixed 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 bg-opacity-50 transition-opacity 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>;
|
||||
};
|
||||
76
packages/propel/src/portal/portal-wrapper.tsx
Normal file
76
packages/propel/src/portal/portal-wrapper.tsx
Normal file
@@ -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<PortalWrapperProps> = ({
|
||||
children,
|
||||
portalId = DEFAULT_PORTAL_ID,
|
||||
fallbackToDocument = true,
|
||||
className,
|
||||
onMount,
|
||||
onUnmount,
|
||||
}) => {
|
||||
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;
|
||||
};
|
||||
222
packages/propel/src/portal/portal.stories.tsx
Normal file
222
packages/propel/src/portal/portal.stories.tsx
Normal file
@@ -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<typeof ModalPortal> = {
|
||||
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<typeof ModalPortal>;
|
||||
|
||||
// Helper component for interactive stories
|
||||
const ModalDemo = ({
|
||||
children,
|
||||
buttonText = "Open Modal",
|
||||
buttonVariant = EButtonVariant.PRIMARY,
|
||||
...modalProps
|
||||
}: Omit<Parameters<typeof ModalPortal>[0], "isOpen" | "onClose"> & {
|
||||
buttonText?: string;
|
||||
buttonVariant?: Parameters<typeof Button>[0]["variant"];
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={buttonVariant} onClick={() => setIsOpen(true)}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<ModalPortal {...modalProps} isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
||||
{children}
|
||||
</ModalPortal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Modal demonstration</p>
|
||||
</div>
|
||||
{showCloseButton && onClose && (
|
||||
<Button variant={EButtonVariant.GHOST} size={EButtonSize.SM} onClick={onClose} aria-label="Close modal">
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 p-6 overflow-y-auto">
|
||||
<p className="text-gray-600 mb-6">{description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-2">Feature Highlights</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• ESC key closes the modal</li>
|
||||
<li>• Click outside overlay to close</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ModalDemo buttonText="Open Modal">
|
||||
<ModalContent
|
||||
title="Default Modal"
|
||||
description="A standard modal with all default settings. Demonstrates focus management, keyboard navigation, and accessibility features."
|
||||
/>
|
||||
</ModalDemo>
|
||||
),
|
||||
};
|
||||
|
||||
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={EButtonVariant.OUTLINE} 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={EButtonVariant.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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// PortalWrapper Stories
|
||||
const PortalWrapperMeta: Meta<typeof PortalWrapper> = {
|
||||
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<typeof PortalWrapper> = {
|
||||
render: () => (
|
||||
<div className="relative">
|
||||
<p>This content renders in the normal document flow.</p>
|
||||
<PortalWrapper portalId="storybook-portal">
|
||||
<div className="fixed top-4 right-4 p-4 bg-blue-500 text-white rounded shadow-lg z-50">
|
||||
This content is rendered in a portal!
|
||||
</div>
|
||||
</PortalWrapper>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
...PortalWrapperMeta.parameters,
|
||||
},
|
||||
};
|
||||
32
packages/propel/src/portal/types.ts
Normal file
32
packages/propel/src/portal/types.ts
Normal file
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user