Skip to content

Commit

Permalink
Feat/add draggable modal (#3983)
Browse files Browse the repository at this point in the history
* feat(hooks): add use-draggable hook

* feat(components): [modal] export use-draggable

* docs(components): [modal] add draggable modal

* feat(components): [modal] add ref prop for modal-header

* chore(components): [modal] add draggable modal for storybook

* chore: add changeset for draggable modal

* docs(hooks): [use-draggable] fix typo

* chore: upper changeset

* chore(components): [modal] add overflow draggable modal to sb

* test(components): [modal] add draggable modal tests

* build: update pnpm-lock

* chore(changeset): include issue number

* feat(hooks): [use-draggable] set user-select to none when during the dragging

* docs(components): [modal] update code demo title

* docs(components): [modal] condense description for draggable overflow

* feat(hooks): [use-draggable] change version to 0.1.0

* refactor(hooks): [use-draggable] use use-move implement use-draggable

* feat(hooks): [use-draggable] remove repeated user-select

* test(components): [modal] update test case to use-draggable base use-move

* docs(components): [modal] update draggable examples

* fix(hooks): [use-draggable] fix mobile device touchmove event conflict

* refactor(hooks): [use-draggable] remove drag ref prop

* refactor(hooks): [use-draggable] draggable2is-disabled overflow2can-overflow

* test(components): [modal] add draggble disable test

* chore(hooks): [use-draggable] add commant for body touchmove

* Update packages/hooks/use-draggable/src/index.ts

Co-authored-by: Ryo Matsukawa <76232929+ryo-manba@users.noreply.github.com>

* fix(hooks): [use-draggable] import use-callback

* test(components): [modal] add mobile-sized test for draggable

* chore(hooks): [use-draggable] add use-callback for func

* chore(hooks): [use-draggable] update version to 2.0.0

* chore: fix typo

* Update .changeset/soft-apricots-sleep.md

* fix: pnpm lock

* fix: build

* chore: add updated moadl

---------

Co-authored-by: wzc520pyfm <1528857653@qq.com>
Co-authored-by: աɨռɢӄաօռɢ <wingkwong.code@gmail.com>
Co-authored-by: Ryo Matsukawa <76232929+ryo-manba@users.noreply.github.com>
  • Loading branch information
4 people authored Nov 4, 2024
1 parent d90ad05 commit 2d6ae74
Show file tree
Hide file tree
Showing 18 changed files with 532 additions and 21 deletions.
6 changes: 6 additions & 0 deletions .changeset/soft-apricots-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@nextui-org/modal": patch
"@nextui-org/use-draggable": patch
---

Add draggable modal (#2647)
3 changes: 2 additions & 1 deletion apps/docs/config/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@
"key": "modal",
"title": "Modal",
"keywords": "modal, dialog box, popup, overlay, content focus",
"path": "/docs/components/modal.mdx"
"path": "/docs/components/modal.mdx",
"updated": true
},
{
"key": "navbar",
Expand Down
45 changes: 45 additions & 0 deletions apps/docs/content/components/modal/draggable-overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 {moveProps} = useDraggable({targetRef, canOverflow: true});
return (
<>
<Button onPress={onOpen}>Open Modal</Button>
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader {...moveProps} 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,
};
45 changes: 45 additions & 0 deletions apps/docs/content/components/modal/draggable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 {moveProps} = useDraggable({ targetRef });
return (
<>
<Button onPress={onOpen}>Open Modal</Button>
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader {...moveProps} 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
Original file line number Diff line number Diff line change
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,
};
32 changes: 22 additions & 10 deletions apps/docs/content/docs/components/modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,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 @@ -72,9 +72,9 @@ When the modal opens:

<CodeDemo title="Sizes" files={modalContent.sizes} />

### Non-dissmissable
### Non-dismissible

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 @@ -138,6 +138,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 overflow to true can drag overflow the viewport.

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

## Slots

- **wrapper**: The wrapper slot of the modal. It wraps the `base` and the `backdrop` slots.
Expand Down
107 changes: 106 additions & 1 deletion packages/components/modal/__tests__/modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@ import * as React from "react";
import {render, fireEvent} from "@testing-library/react";
import userEvent from "@testing-library/user-event";

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 = ({canOverflow = false, isDisabled = false}) => {
const targetRef = React.useRef(null);

const {moveProps} = useDraggable({targetRef, canOverflow, isDisabled});

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

describe("Modal", () => {
afterEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -109,4 +125,93 @@ 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.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 100, pageY: 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 be rendered a draggable modal on mobile", () => {
// mock viewport size to 375x667
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 375);
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 667);

const wrapper = render(<ModalDraggable />);

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

fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 0, pageY: 50}]});
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 0, pageY: 50}]});

expect(document.documentElement.clientWidth).toBe(375);
expect(document.documentElement.clientHeight).toBe(667);
expect(modal.style.transform).toBe("translate(0px, 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.touchStart(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 10000, pageY: 5000}]});
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 10000, pageY: 5000}]});

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

it("should not drag when disabled", () => {
// 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 isDisabled />);
const modal = wrapper.getByRole("dialog");
const modalHeader = wrapper.getByText("Modal header");

fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 200, pageY: 100}]});
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 200, pageY: 100}]});

expect(modal.style.transform).toBe("");
});

test("should be rendered a draggable modal with overflow", () => {
// 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 canOverflow />);

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

fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 2000, pageY: 1500}]});
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 2000, pageY: 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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 2d6ae74

Please sign in to comment.