[WEB-4980] dev: propel modal portal component (#7851)
This commit is contained in:
parent
586a7a48ba
commit
7f28cbebcf
8 changed files with 474 additions and 0 deletions
|
|
@ -35,6 +35,7 @@
|
||||||
"./menu": "./dist/menu/index.js",
|
"./menu": "./dist/menu/index.js",
|
||||||
"./pill": "./dist/pill/index.js",
|
"./pill": "./dist/pill/index.js",
|
||||||
"./popover": "./dist/popover/index.js",
|
"./popover": "./dist/popover/index.js",
|
||||||
|
"./portal": "./dist/portal/index.js",
|
||||||
"./scrollarea": "./dist/scrollarea/index.js",
|
"./scrollarea": "./dist/scrollarea/index.js",
|
||||||
"./skeleton": "./dist/skeleton/index.js",
|
"./skeleton": "./dist/skeleton/index.js",
|
||||||
"./styles/fonts": "./dist/styles/fonts/index.css",
|
"./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/menu/index.ts",
|
||||||
"src/pill/index.ts",
|
"src/pill/index.ts",
|
||||||
"src/popover/index.ts",
|
"src/popover/index.ts",
|
||||||
|
"src/portal/index.ts",
|
||||||
"src/scrollarea/index.ts",
|
"src/scrollarea/index.ts",
|
||||||
"src/skeleton/index.ts",
|
"src/skeleton/index.ts",
|
||||||
"src/switch/index.ts",
|
"src/switch/index.ts",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue