@fabio.caffarello/react-design-system 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +4 -4
- package/dist/index.js +696 -246
- package/dist/ui/atoms/ErrorMessage/ErrorMessage.d.ts +18 -0
- package/dist/ui/atoms/ErrorMessage/ErrorMessage.stories.d.ts +7 -0
- package/dist/ui/atoms/ErrorMessage/ErrorMessage.test.d.ts +1 -0
- package/dist/ui/atoms/Label/Label.d.ts +20 -0
- package/dist/ui/atoms/Label/Label.stories.d.ts +8 -0
- package/dist/ui/atoms/Label/Label.test.d.ts +1 -0
- package/dist/ui/atoms/NavLink/NavLink.d.ts +20 -0
- package/dist/ui/atoms/NavLink/NavLink.stories.d.ts +8 -0
- package/dist/ui/atoms/NavLink/NavLink.test.d.ts +1 -0
- package/dist/ui/atoms/index.d.ts +3 -0
- package/dist/ui/molecules/Breadcrumb/Breadcrumb.d.ts +28 -0
- package/dist/ui/molecules/Breadcrumb/Breadcrumb.stories.d.ts +9 -0
- package/dist/ui/molecules/Breadcrumb/Breadcrumb.test.d.ts +1 -0
- package/dist/ui/molecules/Form/Form.d.ts +24 -0
- package/dist/ui/molecules/Form/Form.stories.d.ts +9 -0
- package/dist/ui/molecules/Form/Form.test.d.ts +1 -0
- package/dist/ui/molecules/Pagination/Pagination.d.ts +28 -0
- package/dist/ui/molecules/Pagination/Pagination.stories.d.ts +10 -0
- package/dist/ui/molecules/Pagination/Pagination.test.d.ts +1 -0
- package/dist/ui/molecules/index.d.ts +4 -0
- package/dist/ui/organisms/Modal/Modal.d.ts +25 -0
- package/dist/ui/organisms/Modal/Modal.stories.d.ts +9 -0
- package/dist/ui/organisms/Modal/Modal.test.d.ts +1 -0
- package/dist/ui/organisms/Table/Table.d.ts +35 -0
- package/dist/ui/organisms/Table/Table.stories.d.ts +9 -0
- package/dist/ui/organisms/Table/Table.test.d.ts +1 -0
- package/dist/ui/organisms/index.d.ts +3 -0
- package/package.json +9 -1
- package/src/ui/atoms/ErrorMessage/ErrorMessage.stories.tsx +81 -0
- package/src/ui/atoms/ErrorMessage/ErrorMessage.test.tsx +40 -0
- package/src/ui/atoms/ErrorMessage/ErrorMessage.tsx +62 -0
- package/src/ui/atoms/Label/Label.stories.tsx +94 -0
- package/src/ui/atoms/Label/Label.test.tsx +47 -0
- package/src/ui/atoms/Label/Label.tsx +51 -0
- package/src/ui/atoms/NavLink/NavLink.stories.tsx +71 -0
- package/src/ui/atoms/NavLink/NavLink.test.tsx +44 -0
- package/src/ui/atoms/NavLink/NavLink.tsx +63 -0
- package/src/ui/atoms/index.ts +6 -0
- package/src/ui/molecules/Breadcrumb/Breadcrumb.stories.tsx +75 -0
- package/src/ui/molecules/Breadcrumb/Breadcrumb.test.tsx +89 -0
- package/src/ui/molecules/Breadcrumb/Breadcrumb.tsx +79 -0
- package/src/ui/molecules/Form/Form.stories.tsx +195 -0
- package/src/ui/molecules/Form/Form.test.tsx +87 -0
- package/src/ui/molecules/Form/Form.tsx +76 -0
- package/src/ui/molecules/Pagination/Pagination.stories.tsx +116 -0
- package/src/ui/molecules/Pagination/Pagination.test.tsx +112 -0
- package/src/ui/molecules/Pagination/Pagination.tsx +168 -0
- package/src/ui/molecules/index.ts +7 -0
- package/src/ui/organisms/Modal/Modal.stories.tsx +102 -0
- package/src/ui/organisms/Modal/Modal.test.tsx +111 -0
- package/src/ui/organisms/Modal/Modal.tsx +203 -0
- package/src/ui/organisms/Table/Table.stories.tsx +137 -0
- package/src/ui/organisms/Table/Table.test.tsx +109 -0
- package/src/ui/organisms/Table/Table.tsx +128 -0
- package/src/ui/organisms/index.ts +5 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { Button } from "../../atoms";
|
|
3
|
+
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
currentPage: number;
|
|
6
|
+
totalPages: number;
|
|
7
|
+
onPageChange: (page: number) => void;
|
|
8
|
+
totalItems?: number;
|
|
9
|
+
itemsPerPage?: number;
|
|
10
|
+
showPageInfo?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pagination Component
|
|
15
|
+
*
|
|
16
|
+
* A pagination component for navigating through pages of data.
|
|
17
|
+
* Follows Atomic Design principles as a Molecule component.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <Pagination
|
|
22
|
+
* currentPage={1}
|
|
23
|
+
* totalPages={10}
|
|
24
|
+
* onPageChange={(page) => setPage(page)}
|
|
25
|
+
* totalItems={100}
|
|
26
|
+
* itemsPerPage={10}
|
|
27
|
+
* />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export default function Pagination({
|
|
31
|
+
currentPage,
|
|
32
|
+
totalPages,
|
|
33
|
+
onPageChange,
|
|
34
|
+
totalItems,
|
|
35
|
+
itemsPerPage,
|
|
36
|
+
showPageInfo = true,
|
|
37
|
+
className = "",
|
|
38
|
+
...props
|
|
39
|
+
}: Props) {
|
|
40
|
+
const handlePrevious = () => {
|
|
41
|
+
if (currentPage > 1) {
|
|
42
|
+
onPageChange(currentPage - 1);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleNext = () => {
|
|
47
|
+
if (currentPage < totalPages) {
|
|
48
|
+
onPageChange(currentPage + 1);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handlePageClick = (page: number) => {
|
|
53
|
+
if (page >= 1 && page <= totalPages && page !== currentPage) {
|
|
54
|
+
onPageChange(page);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const getPageNumbers = () => {
|
|
59
|
+
const pages: (number | string)[] = [];
|
|
60
|
+
const maxVisible = 5;
|
|
61
|
+
|
|
62
|
+
if (totalPages <= maxVisible) {
|
|
63
|
+
for (let i = 1; i <= totalPages; i++) {
|
|
64
|
+
pages.push(i);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
if (currentPage <= 3) {
|
|
68
|
+
for (let i = 1; i <= 4; i++) {
|
|
69
|
+
pages.push(i);
|
|
70
|
+
}
|
|
71
|
+
pages.push('ellipsis');
|
|
72
|
+
pages.push(totalPages);
|
|
73
|
+
} else if (currentPage >= totalPages - 2) {
|
|
74
|
+
pages.push(1);
|
|
75
|
+
pages.push('ellipsis');
|
|
76
|
+
for (let i = totalPages - 3; i <= totalPages; i++) {
|
|
77
|
+
pages.push(i);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
pages.push(1);
|
|
81
|
+
pages.push('ellipsis');
|
|
82
|
+
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
|
83
|
+
pages.push(i);
|
|
84
|
+
}
|
|
85
|
+
pages.push('ellipsis');
|
|
86
|
+
pages.push(totalPages);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return pages;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const startItem = totalItems && itemsPerPage ? (currentPage - 1) * itemsPerPage + 1 : undefined;
|
|
94
|
+
const endItem = totalItems && itemsPerPage
|
|
95
|
+
? Math.min(currentPage * itemsPerPage, totalItems)
|
|
96
|
+
: undefined;
|
|
97
|
+
|
|
98
|
+
const baseClasses = [
|
|
99
|
+
"flex",
|
|
100
|
+
"items-center",
|
|
101
|
+
"justify-between",
|
|
102
|
+
"px-4",
|
|
103
|
+
"py-3",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const classes = [
|
|
107
|
+
...baseClasses,
|
|
108
|
+
className,
|
|
109
|
+
].filter(Boolean).join(" ");
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<nav className={classes} aria-label="Pagination" {...props}>
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<Button
|
|
115
|
+
variant="secondary"
|
|
116
|
+
onClick={handlePrevious}
|
|
117
|
+
disabled={currentPage === 1}
|
|
118
|
+
className="px-3 py-1 text-sm"
|
|
119
|
+
>
|
|
120
|
+
Previous
|
|
121
|
+
</Button>
|
|
122
|
+
<div className="flex items-center gap-1">
|
|
123
|
+
{getPageNumbers().map((page, index) => {
|
|
124
|
+
if (page === 'ellipsis') {
|
|
125
|
+
return (
|
|
126
|
+
<span key={`ellipsis-${index}`} className="px-2 text-gray-500">
|
|
127
|
+
...
|
|
128
|
+
</span>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pageNum = page as number;
|
|
133
|
+
const isActive = pageNum === currentPage;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<button
|
|
137
|
+
key={pageNum}
|
|
138
|
+
onClick={() => handlePageClick(pageNum)}
|
|
139
|
+
className={`px-3 py-1 text-sm rounded ${
|
|
140
|
+
isActive
|
|
141
|
+
? 'bg-indigo-600 text-white'
|
|
142
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
143
|
+
}`}
|
|
144
|
+
aria-current={isActive ? 'page' : undefined}
|
|
145
|
+
aria-label={`Go to page ${pageNum}`}
|
|
146
|
+
>
|
|
147
|
+
{pageNum}
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
<Button
|
|
153
|
+
variant="secondary"
|
|
154
|
+
onClick={handleNext}
|
|
155
|
+
disabled={currentPage === totalPages}
|
|
156
|
+
className="px-3 py-1 text-sm"
|
|
157
|
+
>
|
|
158
|
+
Next
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
{showPageInfo && totalItems && itemsPerPage && (
|
|
162
|
+
<div className="text-sm text-gray-700">
|
|
163
|
+
Showing {startItem} to {endItem} of {totalItems} results
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</nav>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
export { default as InputWithLabel } from "./InputWithLabel/InputWithLabel";
|
|
2
2
|
|
|
3
3
|
export { default as Card } from "./Card/Card";
|
|
4
|
+
|
|
5
|
+
export { default as Form } from "./Form/Form";
|
|
6
|
+
|
|
7
|
+
export { default as Breadcrumb } from "./Breadcrumb/Breadcrumb";
|
|
8
|
+
export type { BreadcrumbItem } from "./Breadcrumb/Breadcrumb";
|
|
9
|
+
|
|
10
|
+
export { default as Pagination } from "./Pagination/Pagination";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import Modal from "./Modal";
|
|
4
|
+
import { Button, Text } from "../../atoms";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Modal> = {
|
|
7
|
+
title: "UI/Organisms/Modal",
|
|
8
|
+
component: Modal,
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component: "A modal/dialog component with overlay, portal rendering, focus trap, and accessibility. Supports multiple sizes and custom footers.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
argTypes: {
|
|
17
|
+
isOpen: {
|
|
18
|
+
control: "boolean",
|
|
19
|
+
description: "Whether the modal is open",
|
|
20
|
+
},
|
|
21
|
+
variant: {
|
|
22
|
+
control: "select",
|
|
23
|
+
options: ["default", "large", "fullscreen"],
|
|
24
|
+
description: "Size variant of the modal",
|
|
25
|
+
},
|
|
26
|
+
showCloseButton: {
|
|
27
|
+
control: "boolean",
|
|
28
|
+
description: "Whether to show the close button",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ModalWrapper = ({ children, ...args }: any) => {
|
|
34
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<Button onClick={() => setIsOpen(true)}>Open Modal</Button>
|
|
38
|
+
<Modal {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
|
39
|
+
{children}
|
|
40
|
+
</Modal>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Default: StoryObj<typeof Modal> = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<ModalWrapper title="Confirm Action">
|
|
48
|
+
<Text as="p">Are you sure you want to proceed with this action?</Text>
|
|
49
|
+
</ModalWrapper>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithFooter: StoryObj<typeof Modal> = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<ModalWrapper
|
|
56
|
+
title="Delete Epic"
|
|
57
|
+
footer={
|
|
58
|
+
<>
|
|
59
|
+
<Button variant="secondary" onClick={() => {}}>
|
|
60
|
+
Cancel
|
|
61
|
+
</Button>
|
|
62
|
+
<Button variant="error" onClick={() => {}}>
|
|
63
|
+
Delete
|
|
64
|
+
</Button>
|
|
65
|
+
</>
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
<Text as="p">This action cannot be undone. Are you sure you want to delete this epic?</Text>
|
|
69
|
+
</ModalWrapper>
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const Large: StoryObj<typeof Modal> = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<ModalWrapper title="Large Modal" variant="large">
|
|
76
|
+
<Text as="p" className="mb-4">
|
|
77
|
+
This is a large modal that can accommodate more content.
|
|
78
|
+
</Text>
|
|
79
|
+
<div className="space-y-2">
|
|
80
|
+
<Text as="p">You can add forms, tables, or any other content here.</Text>
|
|
81
|
+
</div>
|
|
82
|
+
</ModalWrapper>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const WithoutTitle: StoryObj<typeof Modal> = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<ModalWrapper>
|
|
89
|
+
<Text as="p">This modal doesn't have a title, but still has a close button.</Text>
|
|
90
|
+
</ModalWrapper>
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const WithoutCloseButton: StoryObj<typeof Modal> = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<ModalWrapper title="No Close Button" showCloseButton={false}>
|
|
97
|
+
<Text as="p">This modal doesn't have a close button. Users must use the footer actions.</Text>
|
|
98
|
+
</ModalWrapper>
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default meta;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import Modal from "./Modal";
|
|
4
|
+
import { Button } from "../../atoms";
|
|
5
|
+
|
|
6
|
+
// Mock createPortal
|
|
7
|
+
vi.mock("react-dom", async () => {
|
|
8
|
+
const actual = await vi.importActual("react-dom");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
createPortal: (node: any) => node,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("Modal", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
document.body.style.overflow = "";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders when isOpen is true", () => {
|
|
25
|
+
render(
|
|
26
|
+
<Modal isOpen={true} onClose={() => {}} title="Test Modal">
|
|
27
|
+
<p>Modal content</p>
|
|
28
|
+
</Modal>
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByText("Modal content")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not render when isOpen is false", () => {
|
|
34
|
+
render(
|
|
35
|
+
<Modal isOpen={false} onClose={() => {}} title="Test Modal">
|
|
36
|
+
<p>Modal content</p>
|
|
37
|
+
</Modal>
|
|
38
|
+
);
|
|
39
|
+
expect(screen.queryByText("Modal content")).not.toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("calls onClose when close button is clicked", () => {
|
|
43
|
+
const handleClose = vi.fn();
|
|
44
|
+
render(
|
|
45
|
+
<Modal isOpen={true} onClose={handleClose} title="Test Modal">
|
|
46
|
+
<p>Content</p>
|
|
47
|
+
</Modal>
|
|
48
|
+
);
|
|
49
|
+
const closeButton = screen.getByLabelText("Close modal");
|
|
50
|
+
fireEvent.click(closeButton);
|
|
51
|
+
expect(handleClose).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("calls onClose when ESC key is pressed", () => {
|
|
55
|
+
const handleClose = vi.fn();
|
|
56
|
+
render(
|
|
57
|
+
<Modal isOpen={true} onClose={handleClose} title="Test Modal">
|
|
58
|
+
<p>Content</p>
|
|
59
|
+
</Modal>
|
|
60
|
+
);
|
|
61
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
62
|
+
expect(handleClose).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("calls onClose when overlay is clicked", () => {
|
|
66
|
+
const handleClose = vi.fn();
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<Modal isOpen={true} onClose={handleClose} title="Test Modal">
|
|
69
|
+
<p>Content</p>
|
|
70
|
+
</Modal>
|
|
71
|
+
);
|
|
72
|
+
const overlay = container.querySelector('[role="dialog"]');
|
|
73
|
+
if (overlay) {
|
|
74
|
+
fireEvent.click(overlay);
|
|
75
|
+
}
|
|
76
|
+
expect(handleClose).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("has dialog role and aria-modal", () => {
|
|
80
|
+
const { container } = render(
|
|
81
|
+
<Modal isOpen={true} onClose={() => {}} title="Test Modal">
|
|
82
|
+
<p>Content</p>
|
|
83
|
+
</Modal>
|
|
84
|
+
);
|
|
85
|
+
const dialog = container.querySelector('[role="dialog"]');
|
|
86
|
+
expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders title when provided", () => {
|
|
90
|
+
render(
|
|
91
|
+
<Modal isOpen={true} onClose={() => {}} title="Test Title">
|
|
92
|
+
<p>Content</p>
|
|
93
|
+
</Modal>
|
|
94
|
+
);
|
|
95
|
+
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders footer when provided", () => {
|
|
99
|
+
render(
|
|
100
|
+
<Modal
|
|
101
|
+
isOpen={true}
|
|
102
|
+
onClose={() => {}}
|
|
103
|
+
title="Test"
|
|
104
|
+
footer={<Button>Action</Button>}
|
|
105
|
+
>
|
|
106
|
+
<p>Content</p>
|
|
107
|
+
</Modal>
|
|
108
|
+
);
|
|
109
|
+
expect(screen.getByText("Action")).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
|
|
5
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
title?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
variant?: "default" | "large" | "fullscreen";
|
|
11
|
+
showCloseButton?: boolean;
|
|
12
|
+
footer?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Modal Component
|
|
17
|
+
*
|
|
18
|
+
* A modal/dialog component with overlay, portal rendering, and accessibility.
|
|
19
|
+
* Follows Atomic Design principles as an Organism component.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <Modal isOpen={isOpen} onClose={handleClose} title="Confirm Action">
|
|
24
|
+
* <p>Are you sure?</p>
|
|
25
|
+
* </Modal>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export default function Modal({
|
|
29
|
+
isOpen,
|
|
30
|
+
onClose,
|
|
31
|
+
title,
|
|
32
|
+
children,
|
|
33
|
+
variant = "default",
|
|
34
|
+
showCloseButton = true,
|
|
35
|
+
footer,
|
|
36
|
+
className = "",
|
|
37
|
+
...props
|
|
38
|
+
}: Props) {
|
|
39
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
41
|
+
|
|
42
|
+
// Focus trap and ESC key handling
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!isOpen) return;
|
|
45
|
+
|
|
46
|
+
// Store previous active element
|
|
47
|
+
previousActiveElement.current = document.activeElement as HTMLElement;
|
|
48
|
+
|
|
49
|
+
// Focus modal on open
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
modalRef.current?.focus();
|
|
52
|
+
}, 0);
|
|
53
|
+
|
|
54
|
+
// Handle ESC key
|
|
55
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
56
|
+
if (e.key === "Escape") {
|
|
57
|
+
onClose();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
document.addEventListener("keydown", handleEscape);
|
|
62
|
+
|
|
63
|
+
// Restore focus on close
|
|
64
|
+
return () => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
document.removeEventListener("keydown", handleEscape);
|
|
67
|
+
previousActiveElement.current?.focus();
|
|
68
|
+
};
|
|
69
|
+
}, [isOpen, onClose]);
|
|
70
|
+
|
|
71
|
+
// Prevent body scroll when modal is open
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (isOpen) {
|
|
74
|
+
document.body.style.overflow = "hidden";
|
|
75
|
+
} else {
|
|
76
|
+
document.body.style.overflow = "";
|
|
77
|
+
}
|
|
78
|
+
return () => {
|
|
79
|
+
document.body.style.overflow = "";
|
|
80
|
+
};
|
|
81
|
+
}, [isOpen]);
|
|
82
|
+
|
|
83
|
+
if (!isOpen) return null;
|
|
84
|
+
|
|
85
|
+
const baseClasses = [
|
|
86
|
+
"fixed",
|
|
87
|
+
"inset-0",
|
|
88
|
+
"z-50",
|
|
89
|
+
"overflow-y-auto",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const overlayClasses = [
|
|
93
|
+
"fixed",
|
|
94
|
+
"inset-0",
|
|
95
|
+
"bg-black",
|
|
96
|
+
"bg-opacity-50",
|
|
97
|
+
"transition-opacity",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const modalSizeClasses: Record<NonNullable<Props["variant"]>, string> = {
|
|
101
|
+
default: "max-w-md",
|
|
102
|
+
large: "max-w-2xl",
|
|
103
|
+
fullscreen: "max-w-full h-full",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const modalClasses = [
|
|
107
|
+
"relative",
|
|
108
|
+
"bg-white",
|
|
109
|
+
"rounded-lg",
|
|
110
|
+
"shadow-xl",
|
|
111
|
+
"my-8",
|
|
112
|
+
"mx-auto",
|
|
113
|
+
modalSizeClasses[variant],
|
|
114
|
+
"p-6",
|
|
115
|
+
className,
|
|
116
|
+
].filter(Boolean).join(" ");
|
|
117
|
+
|
|
118
|
+
const modalContent = (
|
|
119
|
+
<div
|
|
120
|
+
className={baseClasses.join(" ")}
|
|
121
|
+
role="dialog"
|
|
122
|
+
aria-modal="true"
|
|
123
|
+
aria-labelledby={title ? "modal-title" : undefined}
|
|
124
|
+
onClick={(e) => {
|
|
125
|
+
if (e.target === e.currentTarget) {
|
|
126
|
+
onClose();
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<div className={overlayClasses.join(" ")} aria-hidden="true" />
|
|
131
|
+
<div className="flex min-h-full items-center justify-center p-4">
|
|
132
|
+
<div
|
|
133
|
+
ref={modalRef}
|
|
134
|
+
tabIndex={-1}
|
|
135
|
+
className={modalClasses}
|
|
136
|
+
onClick={(e) => e.stopPropagation()}
|
|
137
|
+
{...props}
|
|
138
|
+
>
|
|
139
|
+
{title && (
|
|
140
|
+
<div className="flex justify-between items-center mb-4">
|
|
141
|
+
<h2 id="modal-title" className="text-xl font-semibold text-gray-900">
|
|
142
|
+
{title}
|
|
143
|
+
</h2>
|
|
144
|
+
{showCloseButton && (
|
|
145
|
+
<button
|
|
146
|
+
onClick={onClose}
|
|
147
|
+
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
|
148
|
+
aria-label="Close modal"
|
|
149
|
+
>
|
|
150
|
+
<svg
|
|
151
|
+
className="h-6 w-6"
|
|
152
|
+
fill="none"
|
|
153
|
+
viewBox="0 0 24 24"
|
|
154
|
+
stroke="currentColor"
|
|
155
|
+
>
|
|
156
|
+
<path
|
|
157
|
+
strokeLinecap="round"
|
|
158
|
+
strokeLinejoin="round"
|
|
159
|
+
strokeWidth={2}
|
|
160
|
+
d="M6 18L18 6M6 6l12 12"
|
|
161
|
+
/>
|
|
162
|
+
</svg>
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
{!title && showCloseButton && (
|
|
168
|
+
<div className="flex justify-end mb-4">
|
|
169
|
+
<button
|
|
170
|
+
onClick={onClose}
|
|
171
|
+
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
|
172
|
+
aria-label="Close modal"
|
|
173
|
+
>
|
|
174
|
+
<svg
|
|
175
|
+
className="h-6 w-6"
|
|
176
|
+
fill="none"
|
|
177
|
+
viewBox="0 0 24 24"
|
|
178
|
+
stroke="currentColor"
|
|
179
|
+
>
|
|
180
|
+
<path
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
strokeLinejoin="round"
|
|
183
|
+
strokeWidth={2}
|
|
184
|
+
d="M6 18L18 6M6 6l12 12"
|
|
185
|
+
/>
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
<div className="mb-4">{children}</div>
|
|
191
|
+
{footer && <div className="flex justify-end gap-2 mt-4">{footer}</div>}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Portal rendering to avoid z-index issues
|
|
198
|
+
if (typeof window !== "undefined") {
|
|
199
|
+
return createPortal(modalContent, document.body);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return modalContent;
|
|
203
|
+
}
|