feat(modal): initial structure created

This commit is contained in:
Junior Garcia 2023-04-13 23:27:30 -03:00
parent d3bb78d9c1
commit 6bf21ad1ac
20 changed files with 782 additions and 9 deletions

View File

@ -51,7 +51,7 @@ export interface Props<T extends object = {}>
*/
startContent?: ReactNode;
/**
* The properties passed to the underlying `Collapse` component.
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: CollapseTransitionProps;
/**

View File

@ -0,0 +1,24 @@
# @nextui-org/modal
A Quick description of the component
> This is an internal utility, not intended for public usage.
## Installation
```sh
yarn add @nextui-org/modal
# or
npm i @nextui-org/modal
```
## Contribution
Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.
## Licence
This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).

View File

@ -0,0 +1,19 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {Modal} from "../src";
describe("Modal", () => {
it("should render correctly", () => {
const wrapper = render(<Modal />);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
render(<Modal ref={ref} />);
expect(ref.current).not.toBeNull();
});
});

View File

@ -0,0 +1,70 @@
{
"name": "@nextui-org/modal",
"version": "2.0.0-beta.1",
"description": "Displays a dialog with a custom content that requires attention or provides additional information.",
"keywords": [
"modal"
],
"author": "Junior Garcia <jrgarciadev@gmail.com>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/components/modal"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "yarn build:fast -- --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18"
},
"dependencies": {
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/framer-transitions": "workspace:*",
"@nextui-org/use-aria-button": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@react-aria/dialog": "^3.5.1",
"@react-aria/interactions": "^3.15.0",
"@react-aria/overlays": "^3.14.0",
"@react-aria/utils": "^3.16.0",
"@react-stately/overlays": "^3.5.1",
"@react-aria/focus": "^3.12.0"
},
"devDependencies": {
"@nextui-org/input": "workspace:*",
"framer-motion": "^10.11.2",
"@react-types/overlays": "^3.7.1",
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}

View File

@ -0,0 +1,17 @@
import Modal from "./modal";
import ModalTrigger from "./modal-trigger";
import ModalContent from "./modal-content";
// export types
export type {ModalProps} from "./modal";
export type {ModalTriggerProps} from "./modal-trigger";
export type {ModalContentProps} from "./modal-content";
// export hooks
export {useModal} from "./use-modal";
// export context
export * from "./modal-context";
// export components
export {Modal, ModalTrigger, ModalContent};

View File

@ -0,0 +1,100 @@
import type {AriaDialogProps} from "@react-aria/dialog";
import type {HTMLMotionProps} from "framer-motion";
import {DOMAttributes, ReactNode, useMemo} from "react";
import {forwardRef} from "@nextui-org/system";
import {DismissButton} from "@react-aria/overlays";
import {FocusScope} from "@react-aria/focus";
import {TRANSITION_VARIANTS} from "@nextui-org/framer-transitions";
import {motion} from "framer-motion";
import {useDialog} from "@react-aria/dialog";
import {mergeProps} from "@react-aria/utils";
import {FocusableElement} from "@react-types/shared";
import {useModalContext} from "./modal-context";
export interface ModalContentProps extends AriaDialogProps {
children: ReactNode | ((titleProps: DOMAttributes<FocusableElement>) => ReactNode);
}
const ModalContent = forwardRef<ModalContentProps, "section">((props, _) => {
const {as, children, ...otherProps} = props;
const {
Component: DialogComponent,
dialogRef,
slots,
classNames,
motionProps,
backdropVariant,
disableAnimation,
getDialogProps,
getBackdropProps,
onClose,
} = useModalContext();
const Component = as || DialogComponent || "section";
const {dialogProps, titleProps} = useDialog(
{
role: "dialog",
},
dialogRef,
);
const content = (
<>
<DismissButton onDismiss={onClose} />
<Component {...getDialogProps(mergeProps(dialogProps, otherProps))}>
<FocusScope contain restoreFocus>
{typeof children === "function" ? children(titleProps) : children}
</FocusScope>
</Component>
<DismissButton onDismiss={onClose} />
</>
);
const backdrop = useMemo(() => {
if (backdropVariant === "transparent") {
return null;
}
if (disableAnimation) {
return <div {...getBackdropProps()} />;
}
return (
<motion.div
animate="enter"
exit="exit"
initial="exit"
variants={TRANSITION_VARIANTS.fade}
{...(getBackdropProps() as HTMLMotionProps<"div">)}
/>
);
}, [backdropVariant, disableAnimation, getBackdropProps]);
return (
<div tabIndex={-1}>
{backdrop}
{disableAnimation ? (
<div className={slots.wrapper({class: classNames?.wrapper})}>content</div>
) : (
<motion.div
animate="enter"
className={slots.wrapper({class: classNames?.wrapper})}
exit="exit"
initial="exit"
variants={TRANSITION_VARIANTS.scaleSpring}
{...motionProps}
>
{content}
</motion.div>
)}
</div>
);
});
ModalContent.displayName = "NextUI.ModalContent";
export default ModalContent;

View File

@ -0,0 +1,9 @@
import {createContext} from "@nextui-org/shared-utils";
import {UseModalReturn} from "./use-modal";
export const [ModalProvider, useModalContext] = createContext<UseModalReturn>({
name: "ModalContext",
errorMessage:
"useModalContext: `context` is undefined. Seems you forgot to wrap all popover components within `<Modal />`",
});

View File

@ -0,0 +1,50 @@
import {forwardRef} from "@nextui-org/system";
import React, {Children, cloneElement, useMemo} from "react";
import {pickChildren} from "@nextui-org/shared-utils";
import {useAriaButton} from "@nextui-org/use-aria-button";
import {Button} from "@nextui-org/button";
import {mergeProps} from "@react-aria/utils";
import {useModalContext} from "./modal-context";
export interface ModalTriggerProps {
children?: React.ReactNode;
}
/**
* ModalTrigger opens the popover's content. It must be an interactive element
* such as `button` or `a`.
*/
const ModalTrigger = forwardRef<ModalTriggerProps, "button">((props, _) => {
const {triggerRef, getTriggerProps} = useModalContext();
const {children, ...otherProps} = props;
// force a single child
const child = useMemo<any>(() => {
if (typeof children === "string") return <p>{children}</p>;
return Children.only(children) as React.ReactElement & {
ref?: React.Ref<any>;
};
}, [children]);
const {onPress, ...rest} = useMemo(() => {
return getTriggerProps(mergeProps(child.props, otherProps), child.ref);
}, [getTriggerProps, child.props, otherProps, child.ref]);
// validates if contains a NextUI Button as a child
const [, triggerChildren] = pickChildren(children, Button);
const {buttonProps} = useAriaButton({onPress}, triggerRef);
const hasNextUIButton = useMemo<boolean>(() => {
return triggerChildren?.[0] !== undefined;
}, [triggerChildren]);
return cloneElement(child, mergeProps(rest, hasNextUIButton ? {onPress} : buttonProps));
});
ModalTrigger.displayName = "NextUI.ModalTrigger";
export default ModalTrigger;

View File

@ -0,0 +1,39 @@
import {forwardRef} from "@nextui-org/system";
import {Children, ReactNode} from "react";
import {AnimatePresence} from "framer-motion";
import {Overlay} from "@react-aria/overlays";
import {UseModalProps, useModal} from "./use-modal";
import {ModalProvider} from "./modal-context";
export interface ModalProps extends Omit<UseModalProps, "ref"> {
/**
* The content of the popover. It is usually the `ModalTrigger`,
* and `ModalContent`
*/
children: ReactNode[];
}
const Modal = forwardRef<ModalProps, "div">((props, ref) => {
const {children, ...otherProps} = props;
const context = useModal({ref, ...otherProps});
const [trigger, content] = Children.toArray(children);
const overlay = <Overlay>{content}</Overlay>;
return (
<ModalProvider value={context}>
{trigger}
{context.disableAnimation && context.isOpen ? (
overlay
) : (
<AnimatePresence initial={false}>{context.isOpen ? overlay : null}</AnimatePresence>
)}
</ModalProvider>
);
});
Modal.displayName = "NextUI.Modal";
export default Modal;

View File

@ -0,0 +1,176 @@
import type {ModalVariantProps, SlotsToClasses, ModalSlots} from "@nextui-org/theme";
import type {HTMLMotionProps} from "framer-motion";
import {AriaModalOverlayProps, useModalOverlay} from "@react-aria/overlays";
import {
RefObject,
Ref,
useCallback,
useId,
useRef,
useState,
useMemo,
useImperativeHandle,
} from "react";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {modal} from "@nextui-org/theme";
import {clsx, ReactRef} from "@nextui-org/shared-utils";
import {useOverlayTrigger} from "@react-aria/overlays";
import {createDOMRef} from "@nextui-org/dom-utils";
import {useOverlayTriggerState} from "@react-stately/overlays";
import {OverlayTriggerProps} from "@react-stately/overlays";
import {mergeRefs, mergeProps} from "@react-aria/utils";
interface Props extends HTMLNextUIProps<"div"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
/**
* The ref for the element which the overlay positions itself with respect to.
*/
triggerRef?: RefObject<HTMLElement>;
/**
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: HTMLMotionProps<"div">;
/**
* Whether the animation should be disabled.
* @default false
*/
disableAnimation?: boolean;
/**
* Classname or List of classes to change the classNames of the element.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <Modal classNames={{
* base:"base-classes",
* header: "header-classes",
* body: "body-classes",
* footer: "footer-classes",
* }} />
* ```
*/
classNames?: SlotsToClasses<ModalSlots>;
}
export type UseModalProps = Props & OverlayTriggerProps & AriaModalOverlayProps & ModalVariantProps;
export function useModal(originalProps: UseModalProps) {
const [props, variantProps] = mapPropsVariants(originalProps, modal.variantKeys);
const {
ref,
as,
className,
classNames,
triggerRef: triggerRefProp,
disableAnimation = false,
isOpen,
defaultOpen,
onOpenChange,
motionProps,
...otherProps
} = props;
const Component = as || "section";
const dialogRef = useRef<HTMLElement>(null);
const domTriggerRef = useRef<HTMLElement>(null);
const [headerMounted, setHeaderMounted] = useState(false);
const [bodyMounted, setBodyMounted] = useState(false);
const triggerRef = triggerRefProp || domTriggerRef;
const dialogId = useId();
const headerId = useId();
const bodyId = useId();
// Sync ref with popoverRef from passed ref.
useImperativeHandle(ref, () =>
// @ts-ignore
createDOMRef(dialogRef),
);
const state = useOverlayTriggerState({
isOpen,
defaultOpen,
onOpenChange,
});
const {triggerProps} = useOverlayTrigger({type: "dialog"}, state, triggerRef);
const {modalProps, underlayProps} = useModalOverlay(originalProps, state, dialogRef);
const baseStyles = clsx(classNames?.base, className);
const slots = useMemo(
() =>
modal({
...variantProps,
}),
[...Object.values(variantProps)],
);
const getDialogProps: PropGetter = useCallback(
(props = {}, ref = null) => ({
ref: mergeRefs(ref, dialogRef),
...mergeProps(modalProps, otherProps, props),
className: slots.base({class: clsx(baseStyles, props.className)}),
id: dialogId,
"aria-modal": true,
"aria-labelledby": headerMounted ? headerId : undefined,
"aria-describedby": bodyMounted ? bodyId : undefined,
}),
[],
);
const getTriggerProps = useCallback<PropGetter>(
(props = {}, _ref: Ref<any> | null | undefined = null) => {
return {
...mergeProps(triggerProps, props),
className: slots.trigger({class: clsx(classNames?.trigger, props.className)}),
ref: mergeRefs(_ref, triggerRef),
"aria-controls": dialogId,
"aria-haspopup": "dialog",
};
},
[isOpen, dialogId, state, triggerProps, triggerRef],
);
const getBackdropProps = useCallback<PropGetter>(
(props = {}) => ({
className: slots.backdrop({class: classNames?.backdrop}),
onClick: () => state.close(),
...underlayProps,
...props,
}),
[slots, classNames, underlayProps],
);
return {
Component,
slots,
dialogRef,
headerId,
bodyId,
triggerRef,
motionProps,
classNames,
backdropVariant: originalProps.backdropVariant ?? "opaque",
isOpen: state.isOpen,
onClose: state.close,
disableAnimation,
setBodyMounted,
setHeaderMounted,
getDialogProps,
getTriggerProps,
getBackdropProps,
};
}
export type UseModalReturn = ReturnType<typeof useModal>;

View File

@ -0,0 +1,73 @@
import React from "react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {modal} from "@nextui-org/theme";
import {Button} from "@nextui-org/button";
import {Modal, ModalContent, ModalTrigger, ModalProps} from "../src";
export default {
title: "Components/Modal",
component: Modal,
argTypes: {
size: {
control: {
type: "select",
options: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "full", "prose"],
},
},
radius: {
control: {
type: "select",
options: ["none", "base", "sm", "md", "lg", "xl"],
},
},
backdropVariant: {
control: {
type: "select",
options: ["transparent", "blur", "opaque"],
},
},
disableAnimation: {
control: {
type: "boolean",
},
},
children: {
control: {
disable: true,
},
},
},
decorators: [
(Story) => (
<div className="flex items-center justify-center w-screen h-screen">
<Story />
</div>
),
],
} as ComponentMeta<typeof Modal>;
const defaultProps = {
...modal.defaultVariants,
};
const Template: ComponentStory<typeof Modal> = (args: ModalProps) => {
return (
<Modal {...args}>
<ModalTrigger>
<Button disableAnimation={!!args.disableAnimation}>Open popover</Button>
</ModalTrigger>
<ModalContent>
<div className="px-1 py-2">
<div className="text-sm font-bold">Modal Content</div>
<div className="text-xs">This is a content of the modal</div>
</div>
</ModalContent>
</Modal>
);
};
export const Default = Template.bind({});
Default.args = {
...defaultProps,
};

View File

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"tailwind-variants": ["../../../node_modules/tailwind-variants"]
}
},
"include": ["src", "index.ts"]
}

View File

@ -46,7 +46,6 @@
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@react-aria/button": "^3.7.1",
"@react-aria/dialog": "^3.5.1",
"@react-aria/interactions": "^3.15.0",
"@react-aria/overlays": "^3.14.0",

View File

@ -35,7 +35,7 @@ const PopoverContent = forwardRef<PopoverContentProps, "section">((props, _) =>
onClose,
} = usePopoverContext();
const Component = as || OverlayComponent || "div";
const Component = as || OverlayComponent || "section";
const dialogRef = useRef(null);
const {dialogProps, titleProps} = useDialog(

View File

@ -9,7 +9,7 @@ import {PopoverProvider} from "./popover-context";
export interface PopoverProps extends Omit<UsePopoverProps, "ref"> {
/**
* The content of the popover. It is usually the `PopoverTrigger`,
* and `Popover.Content`
* and `PopoverContent`
*/
children: ReactNode[];
}

View File

@ -52,7 +52,7 @@ export interface Props extends HTMLNextUIProps<"div"> {
*/
triggerType?: "dialog" | "menu" | "listbox" | "tree" | "grid";
/**
* The properties passed to the underlying `Collapse` component.
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: HTMLMotionProps<"div">;
/**

View File

@ -48,7 +48,7 @@ interface Props extends HTMLNextUIProps<"div"> {
*/
trigger?: "focus";
/**
* The properties passed to the underlying `Collapse` component.
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: HTMLMotionProps<"div">;
/**

View File

@ -28,3 +28,4 @@ export * from "./dropdown-item";
export * from "./dropdown-section";
export * from "./dropdown-menu";
export * from "./image";
export * from "./modal";

View File

@ -0,0 +1,131 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
// import {ringClasses} from "../utils";
/**
* Card **Tailwind Variants** component
*
* @example
* ```js
* const {base, trigger, backdrop, header, body, footer} = modal({...})
*
* <div>
* <button className={trigger()}>Open Modal</button>
* <div className={backdrop()}/>
* <div className={base()}>
* <div className={header()}>Header</div>
* <div className={body()}>Body</div>
* <div className={footer()}>Footer</div>
* </div>
* </div>
* ```
*/
const modal = tv({
slots: {
wrapper: [
"flex",
"w-screen",
"h-screen",
"fixed",
"inset-0",
"z-50",
"overflow-x-auto",
"overflow-y-hidden",
"justify-center",
"items-center",
"outline-none",
],
base: [
"relative",
"bg-white",
"z-50",
"w-full",
"shadow-lg",
"box-border",
"dark:bg-content1",
"border border-neutral-100",
],
trigger: [],
backdrop: ["hidden"],
header: [],
body: [],
footer: [],
},
variants: {
size: {
xs: {
base: "max-w-xs",
},
sm: {
base: "max-w-sm",
},
md: {
base: "max-w-md",
},
lg: {
base: "max-w-lg",
},
xl: {
base: "max-w-xl",
},
"2xl": {
base: "max-w-2xl",
},
"3xl": {
base: "max-w-3xl",
},
"4xl": {
base: "max-w-4xl",
},
"5xl": {
base: "max-w-5xl",
},
full: {
base: "max-w-full",
},
prose: {
base: "max-w-prose",
},
},
radius: {
none: {base: "rounded-none"},
base: {base: "rounded"},
sm: {base: "rounded-sm"},
md: {base: "rounded-md"},
lg: {base: "rounded-lg"},
xl: {base: "rounded-xl"},
"2xl": {base: "rounded-2xl"},
"3xl": {base: "rounded-3xl"},
},
backdropVariant: {
transparent: {},
opaque: {
backdrop: "bg-black/30 backdrop-opacity-50",
},
blur: {
backdrop: "backdrop-blur-sm backdrop-saturate-150 bg-black/20",
},
},
},
defaultVariants: {
size: "md",
radius: "lg",
backdropVariant: "opaque",
},
compoundVariants: [
// backdropVariant (opaque/blur)
{
backdropVariant: ["opaque", "blur"],
class: {
backdrop: "block w-full h-full fixed inset-0 z-0",
},
},
],
});
export type ModalVariantProps = VariantProps<typeof modal>;
export type ModalSlots = keyof ReturnType<typeof modal>;
export {modal};

61
pnpm-lock.yaml generated
View File

@ -1004,6 +1004,64 @@ importers:
specifier: ^18.2.0
version: 18.2.0
packages/components/modal:
dependencies:
'@nextui-org/button':
specifier: workspace:*
version: link:../button
'@nextui-org/dom-utils':
specifier: workspace:*
version: link:../../utilities/dom-utils
'@nextui-org/framer-transitions':
specifier: workspace:*
version: link:../../utilities/framer-transitions
'@nextui-org/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils
'@nextui-org/system':
specifier: workspace:*
version: link:../../core/system
'@nextui-org/theme':
specifier: workspace:*
version: link:../../core/theme
'@nextui-org/use-aria-button':
specifier: workspace:*
version: link:../../hooks/use-aria-button
'@react-aria/dialog':
specifier: ^3.5.1
version: 3.5.1(react-dom@18.2.0)(react@18.2.0)
'@react-aria/focus':
specifier: ^3.12.0
version: 3.12.0(react@18.2.0)
'@react-aria/interactions':
specifier: ^3.15.0
version: 3.15.0(react@18.2.0)
'@react-aria/overlays':
specifier: ^3.14.0
version: 3.14.0(react-dom@18.2.0)(react@18.2.0)
'@react-aria/utils':
specifier: ^3.16.0
version: 3.16.0(react@18.2.0)
'@react-stately/overlays':
specifier: ^3.5.1
version: 3.5.1(react@18.2.0)
devDependencies:
'@nextui-org/input':
specifier: workspace:*
version: link:../input
'@react-types/overlays':
specifier: ^3.7.1
version: 3.7.1(react@18.2.0)
clean-package:
specifier: 2.2.0
version: 2.2.0
framer-motion:
specifier: ^10.11.2
version: 10.11.2(react-dom@18.2.0)(react@18.2.0)
react:
specifier: ^18.2.0
version: 18.2.0
packages/components/pagination:
dependencies:
'@nextui-org/dom-utils':
@ -1067,9 +1125,6 @@ importers:
'@nextui-org/use-aria-button':
specifier: workspace:*
version: link:../../hooks/use-aria-button
'@react-aria/button':
specifier: ^3.7.1
version: 3.7.1(react@18.2.0)
'@react-aria/dialog':
specifier: ^3.5.1
version: 3.5.1(react-dom@18.2.0)(react@18.2.0)