Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): [modal] add draggable modal #2818

Open
wants to merge 30 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
03b0fc5
feat(hooks): add use-draggable hook
wzc520pyfm Apr 20, 2024
a19945f
feat(components): [modal] export use-draggable
wzc520pyfm Apr 20, 2024
24aa1ce
docs(components): [modal] add draggable modal
wzc520pyfm Apr 20, 2024
34e2f53
feat(components): [modal] add ref prop for modal-header
wzc520pyfm Apr 20, 2024
56d6cf3
chore(components): [modal] add draggable modal for storybook
wzc520pyfm Apr 20, 2024
bf9ca51
chore: add changeset for draggable modal
wzc520pyfm Apr 20, 2024
9bb1a89
docs(hooks): [use-draggable] fix typo
wzc520pyfm Apr 20, 2024
2ba3742
chore: upper changeset
wzc520pyfm Apr 20, 2024
0e9caa6
chore(components): [modal] add overflow draggable modal to sb
wzc520pyfm Apr 21, 2024
a64ec22
test(components): [modal] add draggable modal tests
wzc520pyfm Apr 21, 2024
f9463c5
build: update pnpm-lock
wzc520pyfm Apr 21, 2024
ec5789d
Merge branch 'main' into pr/2818
wingkwong Apr 22, 2024
b6198b6
chore(changeset): include issue number
wingkwong Apr 22, 2024
c9b6ac6
Merge branch 'nextui-org:main' into feat/add-draggable-modal
wzc520pyfm Apr 22, 2024
e0a8dea
feat(hooks): [use-draggable] set user-select to none when during the …
wzc520pyfm Apr 22, 2024
7c27351
docs(components): [modal] update code demo title
wzc520pyfm Apr 22, 2024
90699f2
docs(components): [modal] condense description for draggable overflow
wzc520pyfm Apr 22, 2024
6680886
feat(hooks): [use-draggable] change version to 0.1.0
wzc520pyfm Apr 22, 2024
a9cb28b
refactor(hooks): [use-draggable] use use-move implement use-draggable
wzc520pyfm Apr 24, 2024
a9e9cac
feat(hooks): [use-draggable] remove repeated user-select
wzc520pyfm Apr 25, 2024
a0e2b98
test(components): [modal] update test case to use-draggable base use-…
wzc520pyfm Apr 25, 2024
f0bddfa
docs(components): [modal] update draggable examples
wzc520pyfm Apr 25, 2024
663aec4
fix(hooks): [use-draggable] fix mobile device touchmove event conflict
wzc520pyfm Apr 29, 2024
b46fd73
refactor(hooks): [use-draggable] remove drag ref prop
wzc520pyfm May 4, 2024
142b3ff
refactor(hooks): [use-draggable] draggable2is-disabled overflow2can-o…
wzc520pyfm May 4, 2024
28b114b
test(components): [modal] add draggble disable test
wzc520pyfm May 4, 2024
b85e591
chore(hooks): [use-draggable] add commant for body touchmove
wzc520pyfm May 4, 2024
cd96af7
Update packages/hooks/use-draggable/src/index.ts
wzc520pyfm May 8, 2024
2aff343
fix(hooks): [use-draggable] import use-callback
wzc520pyfm May 8, 2024
678d3e7
test(components): [modal] add mobile-sized test for draggable
wzc520pyfm May 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soft-apricots-sleep.md
@@ -0,0 +1,6 @@
---
"@nextui-org/modal": minor
"@nextui-org/use-draggable": minor
---

Add draggable modal
46 changes: 46 additions & 0 deletions apps/docs/content/components/modal/draggable-overflow.ts
@@ -0,0 +1,46 @@
const App = `import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, useDisclosure, useDraggable} from "@nextui-org/react";

export default function App() {
const {isOpen, onOpen, onOpenChange} = useDisclosure();
const targetRef = React.useRef(null);
const dragRef = React.useRef(null);
useDraggable({targetRef, dragRef, draggable: true, overflow: true});

return (
<>
<Button onPress={onOpen}>Open Modal</Button>
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader ref={dragRef} className="flex flex-col gap-1">Modal Title</ModalHeader>
<ModalBody>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nullam pulvinar risus non risus hendrerit venenatis.
Pellentesque sit amet hendrerit risus, sed porttitor quam.
</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={onClose}>
Action
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}`;

const react = {
"/App.jsx": App,
};

export default {
...react,
};
46 changes: 46 additions & 0 deletions apps/docs/content/components/modal/draggable.ts
@@ -0,0 +1,46 @@
const App = `import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, useDisclosure, useDraggable} from "@nextui-org/react";

export default function App() {
const {isOpen, onOpen, onOpenChange} = useDisclosure();
const targetRef = React.useRef(null);
const dragRef = React.useRef(null);
useDraggable({ targetRef, dragRef });

return (
<>
<Button onPress={onOpen}>Open Modal</Button>
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader ref={dragRef} className="flex flex-col gap-1">Modal Title</ModalHeader>
<ModalBody>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nullam pulvinar risus non risus hendrerit venenatis.
Pellentesque sit amet hendrerit risus, sed porttitor quam.
</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={onClose}>
Action
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}`;

const react = {
"/App.jsx": App,
};

export default {
...react,
};
4 changes: 4 additions & 0 deletions apps/docs/content/components/modal/index.ts
Expand Up @@ -8,6 +8,8 @@ import backdrop from "./backdrop";
import customBackdrop from "./custom-backdrop";
import customMotion from "./custom-motion";
import customStyles from "./custom-styles";
import draggable from "./draggable";
import draggableOverflow from "./draggable-overflow";

export const modalContent = {
usage,
Expand All @@ -20,4 +22,6 @@ export const modalContent = {
customBackdrop,
customMotion,
customStyles,
draggable,
draggableOverflow,
};
30 changes: 21 additions & 9 deletions apps/docs/content/docs/components/modal.mdx
Expand Up @@ -41,18 +41,18 @@ NextUI exports 5 modal-related components:
<ImportTabs
commands={{
main: `import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter
} from "@nextui-org/react";`,
individual:
`import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter
} from "@nextui-org/modal";`,
}}
Expand All @@ -73,7 +73,7 @@ When the modal opens:

### Non-dissmissable

By default, the modal can be closed by clicking on the overlay or pressing the <Kbd>Esc</Kbd> key.
By default, the modal can be closed by clicking on the overlay or pressing the <Kbd>Esc</Kbd> key.
You can disable this behavior by setting the following properties:

- Set the `isDismissable` property to `false` to prevent the modal from closing when clicking on the overlay.
Expand Down Expand Up @@ -137,6 +137,18 @@ Modal offers a `motionProps` property to customize the `enter` / `exit` animatio

> Learn more about Framer motion variants [here](https://www.framer.com/motion/animation/#variants).

### Draggable

Try to drag the header part.

<CodeDemo title="Draggable" files={modalContent.draggable} />

### Draggable Overflow

Set draggable to true to drag, the default is true. Set overflow to true can drag overflow the viewport.
wzc520pyfm marked this conversation as resolved.
Show resolved Hide resolved

<CodeDemo title="Draggable" files={modalContent.draggableOverflow} />
wzc520pyfm marked this conversation as resolved.
Show resolved Hide resolved

## Slots

- **wrapper**: The wrapper slot of the modal. It wraps the `base` and the `backdrop` slots.
Expand Down
73 changes: 72 additions & 1 deletion packages/components/modal/__tests__/modal.test.tsx
@@ -1,12 +1,29 @@
import * as React from "react";
import {act, render, fireEvent} from "@testing-library/react";

import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../src";
import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter, useDraggable} from "../src";

// e.g. console.error Warning: Function components cannot be given refs.
// Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
const spy = jest.spyOn(console, "error").mockImplementation(() => {});

const ModalDraggable = ({overflow = false}) => {
const targetRef = React.useRef(null);
const dragRef = React.useRef(null);

useDraggable({targetRef, dragRef, overflow});

return (
<Modal ref={targetRef} isOpen>
<ModalContent>
<ModalHeader ref={dragRef}>Modal header</ModalHeader>
<ModalBody>Modal body</ModalBody>
<ModalFooter>Modal footer</ModalFooter>
</ModalContent>
</Modal>
);
};

describe("Modal", () => {
afterEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -108,4 +125,58 @@ describe("Modal", () => {
fireEvent.keyDown(modal, {key: "Escape"});
expect(onClose).toHaveBeenCalledTimes(1);
});

it("should be rendered a draggable modal", () => {
// mock viewport size to 1920x1080
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
const wrapper = render(<ModalDraggable />);

const modal = wrapper.getByRole("dialog");
const modalHeader = wrapper.getByText("Modal header");

fireEvent.mouseDown(modalHeader, {clientX: 0, clientY: 0});
fireEvent.mouseMove(modalHeader, {clientX: 100, clientY: 50});
fireEvent.mouseUp(modalHeader, {clientX: 100, clientY: 50});

expect(() => wrapper.unmount()).not.toThrow();
expect(document.documentElement.clientWidth).toBe(1920);
expect(document.documentElement.clientHeight).toBe(1080);
expect(modalHeader.style.cursor).toBe("move");
expect(modal.style.transform).toBe("translate(100px, 50px)");
});

it("should not drag overflow viewport", () => {
// mock viewport size to 1920x1080
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
const wrapper = render(<ModalDraggable />);
const modal = wrapper.getByRole("dialog");
const modalHeader = wrapper.getByText("Modal header");

fireEvent.mouseDown(modalHeader, {clientX: 100, clientY: 50});
fireEvent.mouseMove(modalHeader, {clientX: 10000, clientY: 5000});
fireEvent.mouseUp(modalHeader, {clientX: 10000, clientY: 5000});

expect(modal.style.transform).toBe("translate(1920px, 1080px)");
});

test("should be rendered a draggable modal with overflow", () => {
// mock viewport size to 1920x1080
wzc520pyfm marked this conversation as resolved.
Show resolved Hide resolved
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);

const wrapper = render(<ModalDraggable overflow />);

const modal = wrapper.getByRole("dialog");
const modalHeader = wrapper.getByText("Modal header");

fireEvent.mouseDown(modalHeader, {clientX: 0, clientY: 0});
fireEvent.mouseMove(modalHeader, {clientX: 2000, clientY: 1500});
fireEvent.mouseUp(modalHeader, {clientX: 2000, clientY: 1500});

expect(document.documentElement.clientWidth).toBe(1920);
expect(document.documentElement.clientHeight).toBe(1080);
expect(modal.style.transform).toBe("translate(2000px, 1500px)");
});
});
2 changes: 2 additions & 0 deletions packages/components/modal/package.json
Expand Up @@ -42,6 +42,7 @@
},
"dependencies": {
"@nextui-org/use-disclosure": "workspace:*",
"@nextui-org/use-draggable": "workspace:*",
"@nextui-org/use-aria-button": "workspace:*",
"@nextui-org/framer-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
Expand All @@ -63,6 +64,7 @@
"@nextui-org/checkbox": "workspace:*",
"@nextui-org/button": "workspace:*",
"@nextui-org/link": "workspace:*",
"@nextui-org/switch": "workspace:*",
"react-lorem-component": "0.13.0",
"framer-motion": "^11.0.22",
"clean-package": "2.2.0",
Expand Down
1 change: 1 addition & 0 deletions packages/components/modal/src/index.ts
Expand Up @@ -15,6 +15,7 @@ export type {UseDisclosureProps} from "@nextui-org/use-disclosure";
// export hooks
export {useModal} from "./use-modal";
export {useDisclosure} from "@nextui-org/use-disclosure";
export {useDraggable} from "@nextui-org/use-draggable";

// export context
export {ModalProvider, useModalContext} from "./modal-context";
Expand Down
9 changes: 7 additions & 2 deletions packages/components/modal/src/modal-header.tsx
@@ -1,11 +1,16 @@
import {useEffect} from "react";
import {forwardRef, HTMLNextUIProps} from "@nextui-org/system";
import {useDOMRef} from "@nextui-org/react-utils";
import {ReactRef, useDOMRef} from "@nextui-org/react-utils";
import {clsx} from "@nextui-org/shared-utils";

import {useModalContext} from "./modal-context";

export interface ModalHeaderProps extends HTMLNextUIProps<"header"> {}
export interface ModalHeaderProps extends HTMLNextUIProps<"header"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
}

const ModalHeader = forwardRef<"header", ModalHeaderProps>((props, ref) => {
const {as, children, className, ...otherProps} = props;
Expand Down