Skip to content

Commit 5dab165

Browse files
authored
Let a button drawn as a link answer the keyboard like a button (#303)
Six controls in the app are buttons that navigate: "New skill", "New agent", the sidebar's new-channel control, the two empty-state returns, and PageShell's back button, which is five routes in every state each of them has. All six draw a router Link through `render`. Base UI defaults `nativeButton` to true, so every one of them was told to expect a native <button> and found an anchor, and said so at render. The warning was the visible half. The rest was that Base UI put type="button" on an anchor, where it means nothing, and withheld the role="button" and Space-to-activate handling it applies to a non-button — so these read as links to a screen reader and ignored the Space key. Replacing the element is exactly the case where the default is wrong, so the default now follows `render`, once in the shared Button rather than at each call site. Passing `render` is not proof the result is a non-button, only that we can no longer assume it is one, so the one call site that draws a real <button> through `render` says so: the combobox trigger, which otherwise trips the inverse warning. SidebarMenuButton, Item and the sidebar's other wrappers call useRender directly rather than useButton, so they have no nativeButton and never warned; DropdownMenuItem already defaults it to false. None of them change here.
1 parent ee0464d commit 5dab165

3 files changed

Lines changed: 110 additions & 0 deletions

File tree

app/src/components/ui/button.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,32 @@ function Button({
4444
className,
4545
variant = "default",
4646
size = "default",
47+
render,
48+
/*
49+
* DIVERGES FROM UPSTREAM SHADCN. Base UI defaults `nativeButton` to `true`, which is right only
50+
* while the element really is a `<button>`. Six call sites here draw a router `Link` through
51+
* `render` instead — "New skill", "New agent", the sidebar's new-channel control, the two
52+
* empty-state returns, and `PageShell`'s back button, which is five routes in every state each of
53+
* them has — and every one of them warned at render that it had been told to expect a native
54+
* button and found an anchor. It was not only noise: Base UI was putting `type="button"` on an
55+
* anchor, which means nothing there, and withholding the `role="button"` and Space-to-activate
56+
* handling a non-button needs in order to behave like one.
57+
*
58+
* Replacing the element is exactly the case where the default is wrong, so the default follows
59+
* `render`, once here rather than at every call site. Passing `render` is not proof the result is
60+
* a non-button, only that we can no longer assume it is one, so a call site drawing a real
61+
* `<button>` through `render` passes `nativeButton` back explicitly — `combobox.tsx` is the one
62+
* that does.
63+
*/
64+
nativeButton = render === undefined,
4765
...props
4866
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
4967
return (
5068
<ButtonPrimitive
5169
data-slot="button"
5270
className={cn(buttonVariants({ variant, size, className }))}
71+
nativeButton={nativeButton}
72+
render={render}
5373
{...props}
5474
/>
5575
)

app/src/components/ui/combobox.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ function ComboboxInput({
7272
size="icon-xs"
7373
variant="ghost"
7474
render={<ComboboxTrigger />}
75+
/* `ComboboxTrigger` draws a real `<button>`, which `Button` cannot tell from the one
76+
* call site here that draws a link. See the note in `button.tsx`. */
77+
nativeButton
7578
data-slot="input-group-button"
7679
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
7780
disabled={disabled}

app/tests/button-native.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test";
2+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
3+
import { cleanup, render } from "@testing-library/react";
4+
import type { ReactElement } from "react";
5+
import { Button } from "@/components/ui/button";
6+
7+
beforeAll(() => GlobalRegistrator.register());
8+
afterEach(cleanup);
9+
afterAll(() => GlobalRegistrator.unregister());
10+
11+
/**
12+
* Base UI reports a button drawn as the wrong element through `console.error`, from an effect, so
13+
* the only way to assert the app renders quietly is to collect what a render logs.
14+
*
15+
* The DOM assertions below carry the real weight. Base UI remembers every message it has already
16+
* printed for the life of the process, so an empty log is evidence only as long as nothing earlier
17+
* said the same thing. `role` and `type` on the rendered element say what `nativeButton` resolved to
18+
* no matter what else has run.
19+
*/
20+
function drawing(element: ReactElement) {
21+
const logged: string[] = [];
22+
const spy = spyOn(console, "error").mockImplementation(
23+
(...args: unknown[]) => {
24+
logged.push(args.map(String).join(" "));
25+
},
26+
);
27+
28+
try {
29+
const { container } = render(element);
30+
return {
31+
complaints: logged.filter((message) => message.startsWith("Base UI:")),
32+
element: container.firstElementChild as HTMLElement,
33+
};
34+
} finally {
35+
spy.mockRestore();
36+
}
37+
}
38+
39+
test("a button with no `render` is still a native button", () => {
40+
const { complaints, element } = drawing(<Button>Save</Button>);
41+
42+
expect(complaints).toEqual([]);
43+
expect(element.tagName).toBe("BUTTON");
44+
expect(element.getAttribute("type")).toBe("button");
45+
expect(element.getAttribute("role")).toBeNull();
46+
});
47+
48+
test("a button drawn as a link takes button semantics rather than `type`", () => {
49+
const { complaints, element } = drawing(
50+
<Button render={<a href="/settings" />}>Settings</Button>,
51+
);
52+
53+
expect(complaints).toEqual([]);
54+
expect(element.tagName).toBe("A");
55+
expect(element.getAttribute("role")).toBe("button");
56+
expect(element.getAttribute("type")).toBeNull();
57+
});
58+
59+
/**
60+
* The shape `PageShell`'s back button and the sidebar's links use: a function, because a router
61+
* `Link` takes its own props alongside the ones Base UI merges in.
62+
*/
63+
test("a button drawn as a link through the function form does the same", () => {
64+
const { complaints, element } = drawing(
65+
<Button render={(props) => <a href="/agents" {...props} />}>Agents</Button>,
66+
);
67+
68+
expect(complaints).toEqual([]);
69+
expect(element.tagName).toBe("A");
70+
expect(element.getAttribute("role")).toBe("button");
71+
});
72+
73+
/**
74+
* `render` that draws a real button is the case the default cannot see, so a call site says so —
75+
* `combobox.tsx` is the one that does.
76+
*/
77+
test("a call site drawing a real button can say so", () => {
78+
const { complaints, element } = drawing(
79+
<Button nativeButton render={<button type="button" />}>
80+
Open
81+
</Button>,
82+
);
83+
84+
expect(complaints).toEqual([]);
85+
expect(element.tagName).toBe("BUTTON");
86+
expect(element.getAttribute("role")).toBeNull();
87+
});

0 commit comments

Comments
 (0)