Let a button drawn as a link answer the keyboard like a button - #303
Open
jerelvelarde wants to merge 1 commit into
Open
Let a button drawn as a link answer the keyboard like a button#303jerelvelarde wants to merge 1 commit into
jerelvelarde wants to merge 1 commit into
Conversation
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.
jerelvelarde
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 31, 2026 11:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Six controls in this app are buttons that navigate: "New skill", "New agent", the sidebar's
new-channel control, the two empty-state returns from a component that no longer ships, and
PageShell's back button — one call site that draws on five routes, in every state each of them has.All six hand a router
Linkto the sharedButtonthrough Base UI'srenderprop.Base UI's
nativeButtondefaults totrue, and nothing in the app had ever set it. So on each ofthose renders it looked at the element it had been given, found an
<a>where it had been promised a<button>, and said so:The console noise is the half that gets noticed. The half that matters is what the wrong answer buys.
Believing it has a native button, Base UI writes
type="button"onto the element, which meansnothing on an anchor, and skips the work it does for a non-button: the
role="button"that tells ascreen reader what the control is, and the Space-key activation a
<button>gets from the browserand an anchor does not. The back button on every plugin, component and connected-account screen
announced itself as a link and ignored Space.
The approach
The prop is per-call-site, but the mistake is not. It is the same mistake at all six, and the seventh
link somebody draws would make it again. So the default moves into the shared
Buttonand followsrender:Why the prop and not the element it produces. There is no reading of
renderthat answers thequestion. It is a function at four of the six sites —
PageShellpasses(props) => <Link {...props} />— and at the other two it is an element whose type is a component, not an intrinsic tag. Neither
form says what tag comes out the end. What we can know is narrower and enough: with no
render, BaseUI draws its own
<button>, and that is the only case wheretrueis certain.What it costs. Passing
renderis not proof the result is a non-button, only that we can nolonger assume it is one. One call site replaces the element with another real
<button>: thecombobox's trigger. Left to the new default it trips the inverse warning, so it passes
nativeButtonback explicitly. That is the price of putting the decision in one place — a site that swaps in
another button has to say so — and the failure mode is loud rather than silent, because Base UI warns
in the other direction just as readily.
The change worth naming. Those six anchors now carry
role="button". A screen reader announcesthem as buttons rather than links, and they drop out of a links list. That is the right answer for
controls drawn, sized and labelled as buttons, and it is what
type="button"on an anchor wasfailing to say — but it is a change in how they are announced, not only a quieter console.
app/src/components/uitracks its own shadcn upstream, so both edits are the smallest that work andboth carry a comment saying why they diverge from it.
What is not covered
SidebarMenuButton,Item,SidebarMenuSubButtonand the rest ofsidebar.tsxcalluseRenderrather thanuseButton, sothey have no
nativeButtonand never warned — which covers every sidebar and settings link andevery plugin and connected-account row.
DropdownMenuItemdoes go throughuseButton, but Base UIalready defaults it to
falsethere.Button, not the six screens. MountingPageShellneedsa router, and what that would prove is what the
Buttontest already proves.nativeButtonhas no test. Reaching it needs aCombobox.Rootand a popup thathappy-dom does not lay out. It was checked by hand instead: removing that one prop produces the
inverse warning, restoring it silences it.
Verification
bun testinapp/: 206 pass, 0 fail across 32 files. Baseline onmainis 202 across 31.bun run typecheck: clean across app, server and worker.bun run format:checkandbun run lint: clean over every tracked source directory. (Both fail inmy checkout before reaching a file, on stale gitignored worktrees under
.claude/that Biomerefuses to traverse. A clean checkout, which is what CI runs, has none.)
app/tests/button-native.test.tsx— four tests, one perrendershape: absent, an element, afunction, and an explicit
nativeButton. Each asserts the resultingtagName,roleandtypeonthe rendered DOM rather than only a quiet console, because Base UI remembers each warning for the
life of the process — that dedupe masked one of the two failures on the first red run.