@fabio.caffarello/react-design-system 1.2.1 → 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.
Files changed (57) hide show
  1. package/dist/index.cjs +4 -4
  2. package/dist/index.js +696 -246
  3. package/dist/ui/atoms/ErrorMessage/ErrorMessage.d.ts +18 -0
  4. package/dist/ui/atoms/ErrorMessage/ErrorMessage.stories.d.ts +7 -0
  5. package/dist/ui/atoms/ErrorMessage/ErrorMessage.test.d.ts +1 -0
  6. package/dist/ui/atoms/Label/Label.d.ts +20 -0
  7. package/dist/ui/atoms/Label/Label.stories.d.ts +8 -0
  8. package/dist/ui/atoms/Label/Label.test.d.ts +1 -0
  9. package/dist/ui/atoms/NavLink/NavLink.d.ts +20 -0
  10. package/dist/ui/atoms/NavLink/NavLink.stories.d.ts +8 -0
  11. package/dist/ui/atoms/NavLink/NavLink.test.d.ts +1 -0
  12. package/dist/ui/atoms/index.d.ts +3 -0
  13. package/dist/ui/molecules/Breadcrumb/Breadcrumb.d.ts +28 -0
  14. package/dist/ui/molecules/Breadcrumb/Breadcrumb.stories.d.ts +9 -0
  15. package/dist/ui/molecules/Breadcrumb/Breadcrumb.test.d.ts +1 -0
  16. package/dist/ui/molecules/Form/Form.d.ts +24 -0
  17. package/dist/ui/molecules/Form/Form.stories.d.ts +9 -0
  18. package/dist/ui/molecules/Form/Form.test.d.ts +1 -0
  19. package/dist/ui/molecules/Pagination/Pagination.d.ts +28 -0
  20. package/dist/ui/molecules/Pagination/Pagination.stories.d.ts +10 -0
  21. package/dist/ui/molecules/Pagination/Pagination.test.d.ts +1 -0
  22. package/dist/ui/molecules/index.d.ts +4 -0
  23. package/dist/ui/organisms/Modal/Modal.d.ts +25 -0
  24. package/dist/ui/organisms/Modal/Modal.stories.d.ts +9 -0
  25. package/dist/ui/organisms/Modal/Modal.test.d.ts +1 -0
  26. package/dist/ui/organisms/Table/Table.d.ts +35 -0
  27. package/dist/ui/organisms/Table/Table.stories.d.ts +9 -0
  28. package/dist/ui/organisms/Table/Table.test.d.ts +1 -0
  29. package/dist/ui/organisms/index.d.ts +3 -0
  30. package/package.json +1 -1
  31. package/src/ui/atoms/ErrorMessage/ErrorMessage.stories.tsx +81 -0
  32. package/src/ui/atoms/ErrorMessage/ErrorMessage.test.tsx +40 -0
  33. package/src/ui/atoms/ErrorMessage/ErrorMessage.tsx +62 -0
  34. package/src/ui/atoms/Label/Label.stories.tsx +94 -0
  35. package/src/ui/atoms/Label/Label.test.tsx +47 -0
  36. package/src/ui/atoms/Label/Label.tsx +51 -0
  37. package/src/ui/atoms/NavLink/NavLink.stories.tsx +71 -0
  38. package/src/ui/atoms/NavLink/NavLink.test.tsx +44 -0
  39. package/src/ui/atoms/NavLink/NavLink.tsx +63 -0
  40. package/src/ui/atoms/index.ts +6 -0
  41. package/src/ui/molecules/Breadcrumb/Breadcrumb.stories.tsx +75 -0
  42. package/src/ui/molecules/Breadcrumb/Breadcrumb.test.tsx +89 -0
  43. package/src/ui/molecules/Breadcrumb/Breadcrumb.tsx +79 -0
  44. package/src/ui/molecules/Form/Form.stories.tsx +195 -0
  45. package/src/ui/molecules/Form/Form.test.tsx +87 -0
  46. package/src/ui/molecules/Form/Form.tsx +76 -0
  47. package/src/ui/molecules/Pagination/Pagination.stories.tsx +116 -0
  48. package/src/ui/molecules/Pagination/Pagination.test.tsx +112 -0
  49. package/src/ui/molecules/Pagination/Pagination.tsx +168 -0
  50. package/src/ui/molecules/index.ts +7 -0
  51. package/src/ui/organisms/Modal/Modal.stories.tsx +102 -0
  52. package/src/ui/organisms/Modal/Modal.test.tsx +111 -0
  53. package/src/ui/organisms/Modal/Modal.tsx +203 -0
  54. package/src/ui/organisms/Table/Table.stories.tsx +137 -0
  55. package/src/ui/organisms/Table/Table.test.tsx +109 -0
  56. package/src/ui/organisms/Table/Table.tsx +128 -0
  57. 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
+ }