diff --git a/src/ui/button.test.tsx b/src/ui/button.test.tsx new file mode 100644 index 00000000..227cf767 --- /dev/null +++ b/src/ui/button.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, mock, test } from "bun:test"; +import { act, createElement, type ReactElement } from "react"; +import { createRoot } from "react-dom/client"; + +import { Button } from "./button.js"; + +function mountInForm(child: ReactElement) { + const onSubmit = mock((event: Event) => event.preventDefault()); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(createElement("form", { onSubmit }, child)); + }); + return { + onSubmit, + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("Button", () => { + test("a bare Button inside a form does not submit it", () => { + const { onSubmit, container, unmount } = mountInForm( + createElement(Button, null, "Retry"), + ); + const button = container.querySelector("button") as HTMLButtonElement; + + expect(button.getAttribute("type")).toBe("button"); + act(() => { + button.click(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + + test("a forwarded type={undefined} keeps the button default", () => { + const { onSubmit, container, unmount } = mountInForm( + createElement(Button, { type: undefined }, "Retry"), + ); + const button = container.querySelector("button") as HTMLButtonElement; + + expect(button.getAttribute("type")).toBe("button"); + act(() => { + button.click(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + + test('type="submit" still submits the form', () => { + const { onSubmit, container, unmount } = mountInForm( + createElement(Button, { type: "submit" }, "Sign in"), + ); + const button = container.querySelector("button") as HTMLButtonElement; + + expect(button.getAttribute("type")).toBe("submit"); + act(() => { + button.click(); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + unmount(); + }); + + test("asChild gets no injected type", () => { + const { container, unmount } = mountInForm( + createElement( + Button, + { asChild: true }, + createElement("a", { href: "#docs" }, "Docs"), + ), + ); + const link = container.querySelector("a") as HTMLAnchorElement; + + expect(link.hasAttribute("type")).toBe(false); + unmount(); + }); +}); diff --git a/src/ui/button.tsx b/src/ui/button.tsx index 03ace2d3..7360a3e0 100644 --- a/src/ui/button.tsx +++ b/src/ui/button.tsx @@ -69,11 +69,17 @@ export function Button({ variant, size, asChild = false, + // A native button with no type submits its form; default to "button" so a + // Button never submits a host form unasked. A destructured default also + // covers a forwarded `type={undefined}`, which a spread would let through. + // A slotted child may not be a button, so it gets no type. + type = asChild ? undefined : "button", ...props }: ButtonProps) { const Comp = asChild ? Slot : "button"; return (