Skip to content

Commit f347dcd

Browse files
authored
fix(button)!: default to type="button"
Default a native button to type="button" so it does not submit a host form. An omitted or undefined type stays "button". asChild omits type unless the caller passed one. Closes #142
1 parent c3fc71d commit f347dcd

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

‎src/ui/button.test.tsx‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, mock, test } from "bun:test";
2+
import {
3+
act,
4+
createElement,
5+
type ComponentProps,
6+
type ReactElement,
7+
} from "react";
8+
import { createRoot } from "react-dom/client";
9+
10+
import { Button } from "./button.js";
11+
12+
function mountInForm(child: ReactElement) {
13+
const onSubmit = mock((event: Event) => event.preventDefault());
14+
const container = document.createElement("div");
15+
document.body.appendChild(container);
16+
const root = createRoot(container);
17+
act(() => {
18+
root.render(createElement("form", { onSubmit }, child));
19+
});
20+
return {
21+
onSubmit,
22+
container,
23+
unmount: () => {
24+
act(() => root.unmount());
25+
container.remove();
26+
},
27+
};
28+
}
29+
30+
function requireButton(container: ParentNode): HTMLButtonElement {
31+
const button = container.querySelector("button");
32+
if (!(button instanceof HTMLButtonElement)) {
33+
throw new Error("expected a button");
34+
}
35+
return button;
36+
}
37+
38+
function requireAnchor(container: ParentNode): HTMLAnchorElement {
39+
const link = container.querySelector("a");
40+
if (!(link instanceof HTMLAnchorElement)) {
41+
throw new Error("expected an anchor");
42+
}
43+
return link;
44+
}
45+
46+
describe("Button", () => {
47+
test("a bare Button inside a form does not submit it", () => {
48+
const { onSubmit, container, unmount } = mountInForm(
49+
createElement(Button, null, "Retry"),
50+
);
51+
const button = requireButton(container);
52+
53+
expect(button.getAttribute("type")).toBe("button");
54+
act(() => {
55+
button.click();
56+
});
57+
expect(onSubmit).not.toHaveBeenCalled();
58+
unmount();
59+
});
60+
61+
test("a forwarded type={undefined} keeps the button default", () => {
62+
const { onSubmit, container, unmount } = mountInForm(
63+
createElement(Button, { type: undefined }, "Retry"),
64+
);
65+
const button = requireButton(container);
66+
67+
expect(button.getAttribute("type")).toBe("button");
68+
act(() => {
69+
button.click();
70+
});
71+
expect(onSubmit).not.toHaveBeenCalled();
72+
unmount();
73+
});
74+
75+
test('type="submit" still submits the form', () => {
76+
const { onSubmit, container, unmount } = mountInForm(
77+
createElement(Button, { type: "submit" }, "Sign in"),
78+
);
79+
const button = requireButton(container);
80+
81+
expect(button.getAttribute("type")).toBe("submit");
82+
act(() => {
83+
button.click();
84+
});
85+
expect(onSubmit).toHaveBeenCalledTimes(1);
86+
unmount();
87+
});
88+
89+
test("asChild gets no injected type", () => {
90+
const { container, unmount } = mountInForm(
91+
createElement(
92+
Button,
93+
{ asChild: true },
94+
createElement("a", { href: "#docs" }, "Docs"),
95+
),
96+
);
97+
const link = requireAnchor(container);
98+
99+
expect(link.hasAttribute("type")).toBe(false);
100+
unmount();
101+
});
102+
103+
test("asChild keeps a child type that is set before the spread", () => {
104+
function Child(props: ComponentProps<"button">) {
105+
return createElement("button", { type: "button", ...props }, "Child");
106+
}
107+
108+
const { onSubmit, container, unmount } = mountInForm(
109+
createElement(Button, { asChild: true }, createElement(Child)),
110+
);
111+
const button = requireButton(container);
112+
113+
expect(button.getAttribute("type")).toBe("button");
114+
act(() => {
115+
button.click();
116+
});
117+
expect(onSubmit).not.toHaveBeenCalled();
118+
unmount();
119+
});
120+
});

‎src/ui/button.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function Button({
6969
variant,
7070
size,
7171
asChild = false,
72+
type = asChild ? undefined : "button",
7273
...props
7374
}: ButtonProps) {
7475
const Comp = asChild ? Slot : "button";
@@ -77,6 +78,7 @@ export function Button({
7778
data-slot="button"
7879
className={cn(buttonVariants({ variant, size }), className)}
7980
{...props}
81+
{...(type === undefined ? {} : { type })}
8082
/>
8183
);
8284
}

0 commit comments

Comments
 (0)