@fabio.caffarello/react-design-system 1.3.2 → 1.4.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 +535 -352
- package/dist/ui/atoms/Skeleton/Skeleton.d.ts +20 -0
- package/dist/ui/atoms/Skeleton/Skeleton.stories.d.ts +24 -0
- package/dist/ui/atoms/Skeleton/Skeleton.test.d.ts +1 -0
- package/dist/ui/atoms/Tooltip/Tooltip.d.ts +21 -0
- package/dist/ui/atoms/Tooltip/Tooltip.stories.d.ts +23 -0
- package/dist/ui/atoms/Tooltip/Tooltip.test.d.ts +1 -0
- package/dist/ui/atoms/index.d.ts +4 -0
- package/dist/ui/molecules/Dropdown/Dropdown.d.ts +31 -0
- package/dist/ui/molecules/Dropdown/Dropdown.stories.d.ts +25 -0
- package/dist/ui/molecules/Dropdown/Dropdown.test.d.ts +1 -0
- package/dist/ui/molecules/EmptyState/EmptyState.d.ts +26 -0
- package/dist/ui/molecules/EmptyState/EmptyState.stories.d.ts +21 -0
- package/dist/ui/molecules/EmptyState/EmptyState.test.d.ts +1 -0
- package/dist/ui/molecules/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/ui/atoms/Skeleton/Skeleton.stories.tsx +61 -0
- package/src/ui/atoms/Skeleton/Skeleton.test.tsx +40 -0
- package/src/ui/atoms/Skeleton/Skeleton.tsx +70 -0
- package/src/ui/atoms/Tooltip/Tooltip.stories.tsx +60 -0
- package/src/ui/atoms/Tooltip/Tooltip.test.tsx +53 -0
- package/src/ui/atoms/Tooltip/Tooltip.tsx +87 -0
- package/src/ui/atoms/index.ts +6 -0
- package/src/ui/molecules/Dropdown/Dropdown.stories.tsx +58 -0
- package/src/ui/molecules/Dropdown/Dropdown.test.tsx +73 -0
- package/src/ui/molecules/Dropdown/Dropdown.tsx +125 -0
- package/src/ui/molecules/EmptyState/EmptyState.stories.tsx +63 -0
- package/src/ui/molecules/EmptyState/EmptyState.test.tsx +55 -0
- package/src/ui/molecules/EmptyState/EmptyState.tsx +81 -0
- package/src/ui/molecules/index.ts +6 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import Dropdown from "./Dropdown";
|
|
4
|
+
import { Button } from "../../atoms";
|
|
5
|
+
|
|
6
|
+
describe("Dropdown", () => {
|
|
7
|
+
it("renders trigger", () => {
|
|
8
|
+
render(
|
|
9
|
+
<Dropdown
|
|
10
|
+
trigger={<Button>Actions</Button>}
|
|
11
|
+
items={[]}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(screen.getByText("Actions")).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("opens dropdown when trigger is clicked", () => {
|
|
19
|
+
render(
|
|
20
|
+
<Dropdown
|
|
21
|
+
trigger={<Button>Actions</Button>}
|
|
22
|
+
items={[
|
|
23
|
+
{ label: "Edit", onClick: vi.fn() },
|
|
24
|
+
]}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const trigger = screen.getByText("Actions");
|
|
29
|
+
fireEvent.click(trigger);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByText("Edit")).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("calls onClick when item is clicked", () => {
|
|
35
|
+
const handleClick = vi.fn();
|
|
36
|
+
render(
|
|
37
|
+
<Dropdown
|
|
38
|
+
trigger={<Button>Actions</Button>}
|
|
39
|
+
items={[
|
|
40
|
+
{ label: "Edit", onClick: handleClick },
|
|
41
|
+
]}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const trigger = screen.getByText("Actions");
|
|
46
|
+
fireEvent.click(trigger);
|
|
47
|
+
|
|
48
|
+
const item = screen.getByText("Edit");
|
|
49
|
+
fireEvent.click(item);
|
|
50
|
+
|
|
51
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("closes dropdown when clicking outside", () => {
|
|
55
|
+
render(
|
|
56
|
+
<Dropdown
|
|
57
|
+
trigger={<Button>Actions</Button>}
|
|
58
|
+
items={[
|
|
59
|
+
{ label: "Edit", onClick: vi.fn() },
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const trigger = screen.getByText("Actions");
|
|
65
|
+
fireEvent.click(trigger);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText("Edit")).toBeInTheDocument();
|
|
68
|
+
|
|
69
|
+
fireEvent.mouseDown(document.body);
|
|
70
|
+
|
|
71
|
+
expect(screen.queryByText("Edit")).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { useState, useRef, useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
export interface DropdownItem {
|
|
7
|
+
label: string;
|
|
8
|
+
onClick: () => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
variant?: "default" | "danger";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DropdownProps extends HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
trigger: ReactNode;
|
|
15
|
+
items: DropdownItem[];
|
|
16
|
+
align?: "left" | "right";
|
|
17
|
+
variant?: "default" | "minimal";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Dropdown Component
|
|
22
|
+
*
|
|
23
|
+
* A dropdown menu component for displaying actions and options.
|
|
24
|
+
* Follows Atomic Design principles as a Molecule component.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* <Dropdown
|
|
29
|
+
* trigger={<Button>Actions</Button>}
|
|
30
|
+
* items={[
|
|
31
|
+
* { label: "Edit", onClick: () => handleEdit() },
|
|
32
|
+
* { label: "Delete", onClick: () => handleDelete(), variant: "danger" },
|
|
33
|
+
* ]}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export default function Dropdown({
|
|
38
|
+
trigger,
|
|
39
|
+
items,
|
|
40
|
+
align = "right",
|
|
41
|
+
variant = "default",
|
|
42
|
+
className = "",
|
|
43
|
+
...props
|
|
44
|
+
}: DropdownProps) {
|
|
45
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
46
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
50
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
51
|
+
setIsOpen(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (isOpen) {
|
|
56
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
61
|
+
};
|
|
62
|
+
}, [isOpen]);
|
|
63
|
+
|
|
64
|
+
const handleItemClick = (item: DropdownItem) => {
|
|
65
|
+
if (!item.disabled) {
|
|
66
|
+
item.onClick();
|
|
67
|
+
setIsOpen(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const alignClasses = align === "right" ? "right-0" : "left-0";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={`relative inline-block ${className}`} ref={dropdownRef} {...props}>
|
|
75
|
+
<div onClick={() => setIsOpen(!isOpen)}>
|
|
76
|
+
{trigger}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{isOpen && (
|
|
80
|
+
<>
|
|
81
|
+
<div
|
|
82
|
+
className="fixed inset-0 z-10"
|
|
83
|
+
onClick={() => setIsOpen(false)}
|
|
84
|
+
/>
|
|
85
|
+
<div
|
|
86
|
+
className={`absolute z-20 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 ${alignClasses}`}
|
|
87
|
+
role="menu"
|
|
88
|
+
aria-orientation="vertical"
|
|
89
|
+
>
|
|
90
|
+
<div className="py-1" role="none">
|
|
91
|
+
{items.map((item, index) => {
|
|
92
|
+
const itemClasses = [
|
|
93
|
+
"block",
|
|
94
|
+
"px-4",
|
|
95
|
+
"py-2",
|
|
96
|
+
"text-sm",
|
|
97
|
+
"w-full",
|
|
98
|
+
"text-left",
|
|
99
|
+
item.disabled
|
|
100
|
+
? "text-gray-400 cursor-not-allowed"
|
|
101
|
+
: item.variant === "danger"
|
|
102
|
+
? "text-red-700 hover:bg-red-50"
|
|
103
|
+
: "text-gray-700 hover:bg-gray-100",
|
|
104
|
+
].filter(Boolean).join(" ");
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<button
|
|
108
|
+
key={index}
|
|
109
|
+
type="button"
|
|
110
|
+
className={itemClasses}
|
|
111
|
+
onClick={() => handleItemClick(item)}
|
|
112
|
+
disabled={item.disabled}
|
|
113
|
+
role="menuitem"
|
|
114
|
+
>
|
|
115
|
+
{item.label}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import EmptyState from "./EmptyState";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Molecules/EmptyState",
|
|
6
|
+
component: EmptyState,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: "centered",
|
|
9
|
+
},
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
argTypes: {
|
|
12
|
+
variant: {
|
|
13
|
+
control: "select",
|
|
14
|
+
options: ["default", "withAction", "withIllustration"],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
} satisfies Meta<typeof EmptyState>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
title: "No items found",
|
|
25
|
+
message: "There are no items to display at this time.",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const WithAction: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
title: "No epics yet",
|
|
32
|
+
message: "Get started by creating your first epic to organize your work.",
|
|
33
|
+
actionLabel: "Create Epic",
|
|
34
|
+
onAction: () => console.log("Action clicked"),
|
|
35
|
+
variant: "withAction",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const WithIllustration: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
title: "No stories found",
|
|
42
|
+
message: "This epic doesn't have any stories yet. Add a story to get started.",
|
|
43
|
+
actionLabel: "Add Story",
|
|
44
|
+
onAction: () => console.log("Action clicked"),
|
|
45
|
+
variant: "withIllustration",
|
|
46
|
+
illustration: (
|
|
47
|
+
<svg
|
|
48
|
+
className="mx-auto h-12 w-12 text-gray-400"
|
|
49
|
+
fill="none"
|
|
50
|
+
viewBox="0 0 24 24"
|
|
51
|
+
stroke="currentColor"
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
>
|
|
54
|
+
<path
|
|
55
|
+
strokeLinecap="round"
|
|
56
|
+
strokeLinejoin="round"
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
59
|
+
/>
|
|
60
|
+
</svg>
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import EmptyState from "./EmptyState";
|
|
4
|
+
|
|
5
|
+
describe("EmptyState", () => {
|
|
6
|
+
it("renders title and message", () => {
|
|
7
|
+
render(<EmptyState title="No items" message="No items found" />);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByText("No items")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("No items found")).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders action button when actionLabel and onAction are provided", () => {
|
|
14
|
+
const handleAction = vi.fn();
|
|
15
|
+
render(
|
|
16
|
+
<EmptyState
|
|
17
|
+
title="No items"
|
|
18
|
+
message="No items found"
|
|
19
|
+
actionLabel="Create Item"
|
|
20
|
+
onAction={handleAction}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const button = screen.getByText("Create Item");
|
|
25
|
+
expect(button).toBeInTheDocument();
|
|
26
|
+
|
|
27
|
+
button.click();
|
|
28
|
+
expect(handleAction).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders illustration when provided", () => {
|
|
32
|
+
const illustration = <div data-testid="illustration">Illustration</div>;
|
|
33
|
+
render(
|
|
34
|
+
<EmptyState
|
|
35
|
+
title="No items"
|
|
36
|
+
message="No items found"
|
|
37
|
+
illustration={illustration}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByTestId("illustration")).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("applies custom className", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<EmptyState
|
|
47
|
+
title="No items"
|
|
48
|
+
message="No items found"
|
|
49
|
+
className="custom-class"
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(container.firstChild).toHaveClass("custom-class");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { Button } from "../../atoms";
|
|
3
|
+
import { Text } from "../../atoms";
|
|
4
|
+
|
|
5
|
+
export interface EmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
title: string;
|
|
7
|
+
message: string;
|
|
8
|
+
actionLabel?: string;
|
|
9
|
+
onAction?: () => void;
|
|
10
|
+
illustration?: ReactNode;
|
|
11
|
+
variant?: "default" | "withAction" | "withIllustration";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* EmptyState Component
|
|
16
|
+
*
|
|
17
|
+
* A component for displaying empty states when there's no content to show.
|
|
18
|
+
* Follows Atomic Design principles as a Molecule component.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <EmptyState
|
|
23
|
+
* title="No epics yet"
|
|
24
|
+
* message="Get started by creating your first epic"
|
|
25
|
+
* actionLabel="Create Epic"
|
|
26
|
+
* onAction={() => router.push('/epics/new')}
|
|
27
|
+
* />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export default function EmptyState({
|
|
31
|
+
title,
|
|
32
|
+
message,
|
|
33
|
+
actionLabel,
|
|
34
|
+
onAction,
|
|
35
|
+
illustration,
|
|
36
|
+
variant = "default",
|
|
37
|
+
className = "",
|
|
38
|
+
...props
|
|
39
|
+
}: EmptyStateProps) {
|
|
40
|
+
const baseClasses = [
|
|
41
|
+
"flex",
|
|
42
|
+
"flex-col",
|
|
43
|
+
"items-center",
|
|
44
|
+
"justify-center",
|
|
45
|
+
"text-center",
|
|
46
|
+
"py-12",
|
|
47
|
+
"px-4",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const classes = [
|
|
51
|
+
...baseClasses,
|
|
52
|
+
className,
|
|
53
|
+
].filter(Boolean).join(" ");
|
|
54
|
+
|
|
55
|
+
const showAction = variant === "withAction" || (actionLabel && onAction);
|
|
56
|
+
const showIllustration = variant === "withIllustration" || illustration;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={classes} {...props}>
|
|
60
|
+
{showIllustration && illustration && (
|
|
61
|
+
<div className="mb-4">
|
|
62
|
+
{illustration}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<Text as="h3" className="text-lg font-semibold text-gray-900 mb-2">
|
|
67
|
+
{title}
|
|
68
|
+
</Text>
|
|
69
|
+
|
|
70
|
+
<Text as="p" className="text-sm text-gray-500 mb-6 max-w-sm">
|
|
71
|
+
{message}
|
|
72
|
+
</Text>
|
|
73
|
+
|
|
74
|
+
{showAction && actionLabel && onAction && (
|
|
75
|
+
<Button variant="regular" onClick={onAction}>
|
|
76
|
+
{actionLabel}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -8,3 +8,9 @@ export { default as Breadcrumb } from "./Breadcrumb/Breadcrumb";
|
|
|
8
8
|
export type { BreadcrumbItem } from "./Breadcrumb/Breadcrumb";
|
|
9
9
|
|
|
10
10
|
export { default as Pagination } from "./Pagination/Pagination";
|
|
11
|
+
|
|
12
|
+
export { default as EmptyState } from "./EmptyState/EmptyState";
|
|
13
|
+
export type { EmptyStateProps } from "./EmptyState/EmptyState";
|
|
14
|
+
|
|
15
|
+
export { default as Dropdown } from "./Dropdown/Dropdown";
|
|
16
|
+
export type { DropdownProps, DropdownItem } from "./Dropdown/Dropdown";
|