Skip to content

Commit 2d6ae74

Browse files
jrgarciadevwzc520pyfmwingkwongryo-manba
authored
Feat/add draggable modal (#3983)
* 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 <[email protected]> * 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 <[email protected]> Co-authored-by: աɨռɢӄաօռɢ <[email protected]> Co-authored-by: Ryo Matsukawa <[email protected]>
1 parent d90ad05 commit 2d6ae74

File tree

18 files changed

+532
-21
lines changed

18 files changed

+532
-21
lines changed

.changeset/soft-apricots-sleep.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@nextui-org/modal": patch
3+
"@nextui-org/use-draggable": patch
4+
---
5+
6+
Add draggable modal (#2647)

apps/docs/config/routes.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@
287287
"key": "modal",
288288
"title": "Modal",
289289
"keywords": "modal, dialog box, popup, overlay, content focus",
290-
"path": "/docs/components/modal.mdx"
290+
"path": "/docs/components/modal.mdx",
291+
"updated": true
291292
},
292293
{
293294
"key": "navbar",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const App = `import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, useDisclosure, useDraggable} from "@nextui-org/react";
2+
3+
export default function App() {
4+
const {isOpen, onOpen, onOpenChange} = useDisclosure();
5+
const targetRef = React.useRef(null);
6+
const {moveProps} = useDraggable({targetRef, canOverflow: true});
7+
8+
return (
9+
<>
10+
<Button onPress={onOpen}>Open Modal</Button>
11+
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
12+
<ModalContent>
13+
{(onClose) => (
14+
<>
15+
<ModalHeader {...moveProps} className="flex flex-col gap-1">Modal Title</ModalHeader>
16+
<ModalBody>
17+
<p>
18+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
19+
Nullam pulvinar risus non risus hendrerit venenatis.
20+
Pellentesque sit amet hendrerit risus, sed porttitor quam.
21+
</p>
22+
</ModalBody>
23+
<ModalFooter>
24+
<Button color="danger" variant="light" onPress={onClose}>
25+
Close
26+
</Button>
27+
<Button color="primary" onPress={onClose}>
28+
Action
29+
</Button>
30+
</ModalFooter>
31+
</>
32+
)}
33+
</ModalContent>
34+
</Modal>
35+
</>
36+
);
37+
}`;
38+
39+
const react = {
40+
"/App.jsx": App,
41+
};
42+
43+
export default {
44+
...react,
45+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const App = `import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, useDisclosure, useDraggable} from "@nextui-org/react";
2+
3+
export default function App() {
4+
const {isOpen, onOpen, onOpenChange} = useDisclosure();
5+
const targetRef = React.useRef(null);
6+
const {moveProps} = useDraggable({ targetRef });
7+
8+
return (
9+
<>
10+
<Button onPress={onOpen}>Open Modal</Button>
11+
<Modal ref={targetRef} isOpen={isOpen} onOpenChange={onOpenChange}>
12+
<ModalContent>
13+
{(onClose) => (
14+
<>
15+
<ModalHeader {...moveProps} className="flex flex-col gap-1">Modal Title</ModalHeader>
16+
<ModalBody>
17+
<p>
18+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
19+
Nullam pulvinar risus non risus hendrerit venenatis.
20+
Pellentesque sit amet hendrerit risus, sed porttitor quam.
21+
</p>
22+
</ModalBody>
23+
<ModalFooter>
24+
<Button color="danger" variant="light" onPress={onClose}>
25+
Close
26+
</Button>
27+
<Button color="primary" onPress={onClose}>
28+
Action
29+
</Button>
30+
</ModalFooter>
31+
</>
32+
)}
33+
</ModalContent>
34+
</Modal>
35+
</>
36+
);
37+
}`;
38+
39+
const react = {
40+
"/App.jsx": App,
41+
};
42+
43+
export default {
44+
...react,
45+
};

apps/docs/content/components/modal/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import backdrop from "./backdrop";
88
import customBackdrop from "./custom-backdrop";
99
import customMotion from "./custom-motion";
1010
import customStyles from "./custom-styles";
11+
import draggable from "./draggable";
12+
import draggableOverflow from "./draggable-overflow";
1113

1214
export const modalContent = {
1315
usage,
@@ -20,4 +22,6 @@ export const modalContent = {
2022
customBackdrop,
2123
customMotion,
2224
customStyles,
25+
draggable,
26+
draggableOverflow,
2327
};

apps/docs/content/docs/components/modal.mdx

+22-10
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,18 @@ NextUI exports 5 modal-related components:
4242
<ImportTabs
4343
commands={{
4444
main: `import {
45-
Modal,
46-
ModalContent,
47-
ModalHeader,
48-
ModalBody,
45+
Modal,
46+
ModalContent,
47+
ModalHeader,
48+
ModalBody,
4949
ModalFooter
5050
} from "@nextui-org/react";`,
5151
individual:
5252
`import {
53-
Modal,
54-
ModalContent,
55-
ModalHeader,
56-
ModalBody,
53+
Modal,
54+
ModalContent,
55+
ModalHeader,
56+
ModalBody,
5757
ModalFooter
5858
} from "@nextui-org/modal";`,
5959
}}
@@ -72,9 +72,9 @@ When the modal opens:
7272

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

75-
### Non-dissmissable
75+
### Non-dismissible
7676

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

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

139139
> Learn more about Framer motion variants [here](https://www.framer.com/motion/animation/#variants).
140140
141+
### Draggable
142+
143+
Try to drag the header part.
144+
145+
<CodeDemo title="Draggable" files={modalContent.draggable} />
146+
147+
### Draggable Overflow
148+
149+
Set overflow to true can drag overflow the viewport.
150+
151+
<CodeDemo title="Draggable Overflow" files={modalContent.draggableOverflow} />
152+
141153
## Slots
142154

143155
- **wrapper**: The wrapper slot of the modal. It wraps the `base` and the `backdrop` slots.

packages/components/modal/__tests__/modal.test.tsx

+106-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,28 @@ import * as React from "react";
22
import {render, fireEvent} from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44

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

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

11+
const ModalDraggable = ({canOverflow = false, isDisabled = false}) => {
12+
const targetRef = React.useRef(null);
13+
14+
const {moveProps} = useDraggable({targetRef, canOverflow, isDisabled});
15+
16+
return (
17+
<Modal ref={targetRef} isOpen>
18+
<ModalContent>
19+
<ModalHeader {...moveProps}>Modal header</ModalHeader>
20+
<ModalBody>Modal body</ModalBody>
21+
<ModalFooter>Modal footer</ModalFooter>
22+
</ModalContent>
23+
</Modal>
24+
);
25+
};
26+
1127
describe("Modal", () => {
1228
afterEach(() => {
1329
jest.clearAllMocks();
@@ -109,4 +125,93 @@ describe("Modal", () => {
109125
fireEvent.keyDown(modal, {key: "Escape"});
110126
expect(onClose).toHaveBeenCalledTimes(1);
111127
});
128+
129+
it("should be rendered a draggable modal", () => {
130+
// mock viewport size to 1920x1080
131+
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
132+
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
133+
134+
const wrapper = render(<ModalDraggable />);
135+
136+
const modal = wrapper.getByRole("dialog");
137+
const modalHeader = wrapper.getByText("Modal header");
138+
139+
fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
140+
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
141+
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
142+
143+
expect(() => wrapper.unmount()).not.toThrow();
144+
expect(document.documentElement.clientWidth).toBe(1920);
145+
expect(document.documentElement.clientHeight).toBe(1080);
146+
expect(modalHeader.style.cursor).toBe("move");
147+
expect(modal.style.transform).toBe("translate(100px, 50px)");
148+
});
149+
150+
it("should be rendered a draggable modal on mobile", () => {
151+
// mock viewport size to 375x667
152+
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 375);
153+
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 667);
154+
155+
const wrapper = render(<ModalDraggable />);
156+
157+
const modal = wrapper.getByRole("dialog");
158+
const modalHeader = wrapper.getByText("Modal header");
159+
160+
fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
161+
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 0, pageY: 50}]});
162+
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 0, pageY: 50}]});
163+
164+
expect(document.documentElement.clientWidth).toBe(375);
165+
expect(document.documentElement.clientHeight).toBe(667);
166+
expect(modal.style.transform).toBe("translate(0px, 50px)");
167+
});
168+
169+
it("should not drag overflow viewport", () => {
170+
// mock viewport size to 1920x1080
171+
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
172+
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
173+
const wrapper = render(<ModalDraggable />);
174+
const modal = wrapper.getByRole("dialog");
175+
const modalHeader = wrapper.getByText("Modal header");
176+
177+
fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
178+
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 10000, pageY: 5000}]});
179+
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 10000, pageY: 5000}]});
180+
181+
expect(modal.style.transform).toBe("translate(1920px, 1080px)");
182+
});
183+
184+
it("should not drag when disabled", () => {
185+
// mock viewport size to 1920x1080
186+
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
187+
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
188+
const wrapper = render(<ModalDraggable isDisabled />);
189+
const modal = wrapper.getByRole("dialog");
190+
const modalHeader = wrapper.getByText("Modal header");
191+
192+
fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 100, pageY: 50}]});
193+
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 200, pageY: 100}]});
194+
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 200, pageY: 100}]});
195+
196+
expect(modal.style.transform).toBe("");
197+
});
198+
199+
test("should be rendered a draggable modal with overflow", () => {
200+
// mock viewport size to 1920x1080
201+
jest.spyOn(document.documentElement, "clientWidth", "get").mockImplementation(() => 1920);
202+
jest.spyOn(document.documentElement, "clientHeight", "get").mockImplementation(() => 1080);
203+
204+
const wrapper = render(<ModalDraggable canOverflow />);
205+
206+
const modal = wrapper.getByRole("dialog");
207+
const modalHeader = wrapper.getByText("Modal header");
208+
209+
fireEvent.touchStart(modalHeader, {changedTouches: [{pageX: 0, pageY: 0}]});
210+
fireEvent.touchMove(modalHeader, {changedTouches: [{pageX: 2000, pageY: 1500}]});
211+
fireEvent.touchEnd(modalHeader, {changedTouches: [{pageX: 2000, pageY: 1500}]});
212+
213+
expect(document.documentElement.clientWidth).toBe(1920);
214+
expect(document.documentElement.clientHeight).toBe(1080);
215+
expect(modal.style.transform).toBe("translate(2000px, 1500px)");
216+
});
112217
});

packages/components/modal/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"dependencies": {
4444
"@nextui-org/use-disclosure": "workspace:*",
45+
"@nextui-org/use-draggable": "workspace:*",
4546
"@nextui-org/use-aria-button": "workspace:*",
4647
"@nextui-org/framer-utils": "workspace:*",
4748
"@nextui-org/shared-utils": "workspace:*",
@@ -63,6 +64,7 @@
6364
"@nextui-org/checkbox": "workspace:*",
6465
"@nextui-org/button": "workspace:*",
6566
"@nextui-org/link": "workspace:*",
67+
"@nextui-org/switch": "workspace:*",
6668
"react-lorem-component": "0.13.0",
6769
"framer-motion": "^11.0.22",
6870
"clean-package": "2.2.0",

packages/components/modal/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type {UseDisclosureProps} from "@nextui-org/use-disclosure";
1515
// export hooks
1616
export {useModal} from "./use-modal";
1717
export {useDisclosure} from "@nextui-org/use-disclosure";
18+
export {useDraggable} from "@nextui-org/use-draggable";
1819

1920
// export context
2021
export {ModalProvider, useModalContext} from "./modal-context";

packages/components/modal/src/modal-header.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import {useEffect} from "react";
22
import {forwardRef, HTMLNextUIProps} from "@nextui-org/system";
3-
import {useDOMRef} from "@nextui-org/react-utils";
3+
import {ReactRef, useDOMRef} from "@nextui-org/react-utils";
44
import {clsx} from "@nextui-org/shared-utils";
55

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

8-
export interface ModalHeaderProps extends HTMLNextUIProps<"header"> {}
8+
export interface ModalHeaderProps extends HTMLNextUIProps<"header"> {
9+
/**
10+
* Ref to the DOM node.
11+
*/
12+
ref?: ReactRef<HTMLElement | null>;
13+
}
914

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

0 commit comments

Comments
 (0)