Sync: Community Changes #4295

This commit is contained in:
pushya22
2025-09-25 15:59:46 +05:30
committed by GitHub
8 changed files with 474 additions and 0 deletions

View File

@@ -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",

View 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;

View File

@@ -0,0 +1,4 @@
export * from "./modal-portal";
export * from "./portal-wrapper";
export * from "./constants";
export * from "./types";

View 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>;
};

View 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;
};

View 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,
},
};

View 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;

View File

@@ -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",