Skip to content

Commit c232e4d

Browse files
authored
Cleanup button props and consolidate IconButton into Button (#1502)
* Remove legacy props * Pass icon as part of children * Use onClick inside PanelHeaderActionButton * Remove unsued props for PanelHeaderActionButton * Use IconButton instead of PanelHeaderActionButton * Integrate IconButton functionality in Button * Fix some icon buttons * Use Button instead of IconButton * Update agents * Consolidate button styles * Rename variants * Rename base size to default * Use tooltip instead of title * Add JSDocs and tests * Use aria-label instead of span in children * Use asChild * Add NavButton * Simplify RouteButtons * Cleanup
1 parent 47d69a6 commit c232e4d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+582
-498
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Keep conversations concise. Sacrifice grammar for brevity.
66
- Use TypeScript for type safety
7+
- Do not change the VS Code setting `typescript.autoClosingTags`
78
- Prefer descriptive variable and function names over code comments
89
- Prefer simple to follow logic over clever concise code
910
- Prefer named function syntax over anonymous arrow functions for module-level
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { PropsWithChildren } from "react";
2+
3+
import { render, screen } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
6+
import { TooltipProvider } from "../Tooltip";
7+
import { Button, stopPropagation } from "./Button";
8+
9+
function Wrapper({ children }: PropsWithChildren) {
10+
return <TooltipProvider>{children}</TooltipProvider>;
11+
}
12+
13+
describe("Button", () => {
14+
test("should render children", () => {
15+
render(<Button>Click me</Button>);
16+
expect(screen.getByRole("button")).toHaveTextContent("Click me");
17+
});
18+
19+
test("should render tooltip when tooltip prop is provided", async () => {
20+
const user = userEvent.setup();
21+
render(
22+
<Wrapper>
23+
<Button tooltip="Helpful tip">Hover me</Button>
24+
</Wrapper>,
25+
);
26+
27+
const button = screen.getByRole("button");
28+
await user.hover(button);
29+
30+
expect(await screen.findByRole("tooltip")).toHaveTextContent("Helpful tip");
31+
});
32+
33+
test("should set aria-label when tooltip is provided", () => {
34+
render(
35+
<Wrapper>
36+
<Button tooltip="Accessible label">Click me</Button>
37+
</Wrapper>,
38+
);
39+
40+
expect(screen.getByRole("button")).toHaveAttribute(
41+
"aria-label",
42+
"Accessible label",
43+
);
44+
});
45+
46+
test("should not render tooltip when tooltip is not provided", () => {
47+
render(<Button>No tooltip</Button>);
48+
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
49+
});
50+
51+
test("should handle onClick events", async () => {
52+
const user = userEvent.setup();
53+
const handleClick = vi.fn();
54+
55+
render(<Button onClick={handleClick}>Click me</Button>);
56+
57+
await user.click(screen.getByRole("button"));
58+
expect(handleClick).toHaveBeenCalledTimes(1);
59+
});
60+
61+
test("should be disabled when disabled prop is true", () => {
62+
render(<Button disabled>Disabled</Button>);
63+
expect(screen.getByRole("button")).toBeDisabled();
64+
});
65+
});
66+
67+
describe("stopPropagation", () => {
68+
test("should stop event propagation and call action", async () => {
69+
const action = vi.fn();
70+
const event = {
71+
stopPropagation: vi.fn(),
72+
} as unknown as React.MouseEvent<HTMLButtonElement>;
73+
74+
await stopPropagation(action)(event);
75+
76+
expect(event.stopPropagation).toHaveBeenCalled();
77+
expect(action).toHaveBeenCalled();
78+
});
79+
80+
test("should handle async actions", async () => {
81+
const action = vi.fn().mockResolvedValue(undefined);
82+
const event = {
83+
stopPropagation: vi.fn(),
84+
} as unknown as React.MouseEvent<HTMLButtonElement>;
85+
86+
await stopPropagation(action)(event);
87+
88+
expect(event.stopPropagation).toHaveBeenCalled();
89+
expect(action).toHaveBeenCalled();
90+
});
91+
});

packages/graph-explorer/src/components/Button/Button.tsx

Lines changed: 51 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,96 @@
1-
import type { ReactNode } from "react";
2-
31
import { cva, type VariantProps } from "cva";
42
import { Slot } from "radix-ui";
53

64
import { cn } from "@/utils";
75

8-
export const buttonStyles = cva({
6+
import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip";
7+
8+
const buttonStyles = cva({
99
base: "inline-flex items-center justify-center gap-2 font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 disabled:saturate-0 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:saturate-0 [&_svg]:pointer-events-none [&_svg]:shrink-0",
1010
variants: {
1111
variant: {
12-
filled: "",
13-
default: "",
14-
text: "",
15-
outline: "",
16-
},
17-
color: {
18-
primary: "",
19-
danger: "",
20-
},
21-
size: {
22-
small: "h-8 rounded-md px-2 text-sm [&_svg]:size-4",
23-
base: "h-10 rounded-md px-4 text-base [&_svg]:size-5",
24-
large: "h-12 rounded-md px-5 text-lg [&_svg]:size-6",
25-
},
26-
},
27-
compoundVariants: [
28-
{
29-
variant: "filled",
30-
color: "primary",
31-
className:
12+
primary:
3213
"bg-brand hover:bg-brand-hover data-open:bg-brand-hover text-white",
33-
},
34-
{
35-
variant: "filled",
36-
color: "danger",
37-
className:
38-
"bg-danger hover:bg-danger-hover data-open:bg-danger-hover text-white",
39-
},
40-
{
41-
variant: "default",
42-
color: "primary",
43-
className:
14+
secondary:
4415
"text-text-primary bg-gray-100 hover:bg-gray-200 data-open:bg-gray-200",
45-
},
46-
{
47-
variant: "default",
48-
color: "danger",
49-
className:
50-
"bg-danger-subtle text-danger hover:bg-danger-subtle-hover data-open:bg-danger-subtle-hover",
51-
},
52-
{
53-
variant: "text",
54-
color: "primary",
55-
className:
16+
ghost:
5617
"text-primary-foreground hover:bg-primary-subtle data-open:bg-primary-subtle",
57-
},
58-
{
59-
variant: "text",
60-
color: "danger",
61-
className:
62-
"text-danger hover:bg-danger-subtle data-open:bg-danger-subtle",
63-
},
64-
{
65-
variant: "outline",
66-
color: "primary",
67-
className:
18+
outline:
6819
"text-text-primary border-input hover:bg-muted data-open:border-primary-main border bg-transparent shadow-xs",
20+
danger:
21+
"bg-danger-subtle text-danger hover:bg-danger-subtle-hover data-open:bg-danger-subtle-hover",
22+
"danger-ghost":
23+
"text-danger hover:bg-danger-subtle data-open:bg-danger-subtle",
6924
},
70-
{
71-
variant: "outline",
72-
color: "danger",
73-
className:
74-
"border-error-light text-danger hover:bg-danger-subtle data-open:border-error-main border shadow-xs",
25+
size: {
26+
small: "h-8 rounded-md px-2 text-sm [&_svg]:size-4",
27+
default: "h-10 rounded-md px-4 text-base [&_svg]:size-5",
28+
large: "h-12 rounded-md px-5 text-lg [&_svg]:size-6",
29+
30+
"icon-small": "h-8 min-w-8 rounded-md text-sm [&_svg]:size-4",
31+
icon: "h-10 min-w-10 rounded-md text-base [&_svg]:size-[1.325rem]",
32+
"icon-large": "h-12 min-w-12 rounded-md text-lg [&_svg]:size-6",
7533
},
76-
],
34+
},
7735
defaultVariants: {
78-
variant: "default",
79-
color: "primary",
80-
size: "base",
36+
variant: "secondary",
37+
size: "default",
8138
},
8239
});
8340

8441
export interface ButtonProps
8542
extends
86-
VariantProps<typeof buttonStyles>,
8743
VariantProps<typeof buttonStyles>,
8844
Omit<React.ComponentPropsWithRef<"button">, "color"> {
89-
icon?: ReactNode;
9045
asChild?: boolean;
91-
92-
// TODO: Remove these non-standard props and use the `disabled` and `onClick` props instead.
93-
// These are here to reduce the amount of changes to the rest of the codebase.
94-
isDisabled?: boolean;
95-
onPress?: () => void;
46+
/** When provided, displays a tooltip and adds screen reader text. */
47+
tooltip?: string;
9648
}
9749

50+
/**
51+
* A flexible button component supporting multiple variants and sizes.
52+
* When `tooltip` is provided, renders with a tooltip and screen reader text.
53+
*/
9854
function Button({
9955
className,
100-
icon,
101-
variant = "default",
56+
variant = "secondary",
10257
size,
103-
color,
58+
tooltip,
10459
children,
10560
asChild = false,
106-
isDisabled,
107-
onPress,
10861
...props
10962
}: ButtonProps) {
11063
const Component = asChild ? (Slot.Root as any) : "button";
111-
return (
64+
const content = (
11265
<Component
113-
className={cn(buttonStyles({ size, variant, color }), className)}
114-
disabled={isDisabled}
115-
onClick={onPress}
66+
data-slot="button"
67+
data-variant={variant}
68+
data-size={size}
69+
aria-label={tooltip}
70+
className={cn(buttonStyles({ size, variant }), className)}
11671
{...props}
11772
>
118-
{icon && icon}
11973
{children}
12074
</Component>
12175
);
76+
77+
if (tooltip) {
78+
return (
79+
<Tooltip>
80+
<TooltipTrigger asChild>{content}</TooltipTrigger>
81+
<TooltipContent>{tooltip}</TooltipContent>
82+
</Tooltip>
83+
);
84+
}
85+
86+
return content;
12287
}
12388
Button.displayName = "Button";
12489

125-
/** Wrap an action to stop button click propagation. */
90+
/**
91+
* Wraps an action to stop click event propagation before executing.
92+
* Useful for buttons inside clickable containers.
93+
*/
12694
export function stopPropagation(action: () => void | Promise<void>) {
12795
return async (event: React.MouseEvent<HTMLButtonElement>) => {
12896
event.stopPropagation();
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { MemoryRouter, useLocation } from "react-router";
4+
5+
import { NavButton } from "./NavButton";
6+
7+
function LocationDisplay() {
8+
const location = useLocation();
9+
return <div data-testid="location">{location.pathname}</div>;
10+
}
11+
12+
describe("NavButton", () => {
13+
test("should render children", () => {
14+
render(
15+
<MemoryRouter>
16+
<NavButton to="/test">Click me</NavButton>
17+
</MemoryRouter>,
18+
);
19+
expect(screen.getByRole("button")).toHaveTextContent("Click me");
20+
});
21+
22+
test("should navigate when clicked", async () => {
23+
const user = userEvent.setup();
24+
25+
render(
26+
<MemoryRouter initialEntries={["/"]}>
27+
<NavButton to="/destination">Navigate</NavButton>
28+
<LocationDisplay />
29+
</MemoryRouter>,
30+
);
31+
32+
expect(screen.getByTestId("location")).toHaveTextContent("/");
33+
34+
await user.click(screen.getByRole("button"));
35+
36+
expect(screen.getByTestId("location")).toHaveTextContent("/destination");
37+
});
38+
39+
test("should not navigate when disabled", async () => {
40+
const user = userEvent.setup();
41+
42+
render(
43+
<MemoryRouter initialEntries={["/"]}>
44+
<NavButton to="/destination" disabled>
45+
Navigate
46+
</NavButton>
47+
<LocationDisplay />
48+
</MemoryRouter>,
49+
);
50+
51+
expect(screen.getByTestId("location")).toHaveTextContent("/");
52+
53+
await user.click(screen.getByRole("button"));
54+
55+
expect(screen.getByTestId("location")).toHaveTextContent("/");
56+
});
57+
58+
test("should be disabled when disabled prop is true", () => {
59+
render(
60+
<MemoryRouter>
61+
<NavButton to="/test" disabled>
62+
Disabled
63+
</NavButton>
64+
</MemoryRouter>,
65+
);
66+
expect(screen.getByRole("button")).toBeDisabled();
67+
});
68+
69+
test("should pass variant to Button", () => {
70+
render(
71+
<MemoryRouter>
72+
<NavButton to="/test" variant="primary">
73+
Primary
74+
</NavButton>
75+
</MemoryRouter>,
76+
);
77+
expect(screen.getByRole("button")).toHaveAttribute(
78+
"data-variant",
79+
"primary",
80+
);
81+
});
82+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ComponentPropsWithRef } from "react";
2+
import type { NavigateOptions, To } from "react-router";
3+
4+
import { useNavigate } from "react-router";
5+
6+
import { Button } from "./Button";
7+
8+
export interface NavButtonProps extends Omit<
9+
ComponentPropsWithRef<typeof Button>,
10+
"onClick" | "asChild"
11+
> {
12+
/** The path to navigate to when clicked. */
13+
to: To;
14+
/** Options for the navigation. */
15+
navOptions?: NavigateOptions;
16+
}
17+
18+
/**
19+
* A button component that navigates to a route when clicked.
20+
* Unlike using Button with asChild and Link, this component prevents
21+
* navigation when disabled.
22+
*/
23+
function NavButton({ to, navOptions, ...props }: NavButtonProps) {
24+
const navigate = useNavigate();
25+
26+
function handleClick() {
27+
navigate(to, navOptions);
28+
}
29+
30+
return <Button {...props} onClick={handleClick} />;
31+
}
32+
33+
export { NavButton };

0 commit comments

Comments
 (0)