Skip to content

Let a button drawn as a link answer the keyboard like a button - #303

Open
jerelvelarde wants to merge 1 commit into
CopilotKit:mainfrom
jerelvelarde:jerel/base-ui-native-button
Open

Let a button drawn as a link answer the keyboard like a button#303
jerelvelarde wants to merge 1 commit into
CopilotKit:mainfrom
jerelvelarde:jerel/base-ui-native-button

Conversation

@jerelvelarde

Copy link
Copy Markdown
Contributor

Let a button drawn as a link answer the keyboard like a button

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 Link to the shared Button through Base UI's render prop.

Base UI's nativeButton defaults to true, and nothing in the app had ever set it. So on each of
those renders it looked at the element it had been given, found an <a> where it had been promised a
<button>, and said so:

Base UI: A component that acts as a button expected a native <button> because the nativeButton
prop is true. Rendering a non-<button> removes native button semantics, which can impact forms
and accessibility.

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 means
nothing on an anchor, and skips the work it does for a non-button: the role="button" that tells a
screen reader what the control is, and the Space-key activation a <button> gets from the browser
and 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 Button and follows
render:

nativeButton = render === undefined,

Why the prop and not the element it produces. There is no reading of render that answers the
question. It is a function at four of the six sites — PageShell passes (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, Base
UI draws its own <button>, and that is the only case where true is certain.

What it costs. Passing render is not proof the result is a non-button, only that we can no
longer assume it is one. One call site replaces the element with another real <button>: the
combobox's trigger. Left to the new default it trips the inverse warning, so it passes nativeButton
back 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 announces
them 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 was
failing to say — but it is a change in how they are announced, not only a quieter console.

app/src/components/ui tracks its own shadcn upstream, so both edits are the smallest that work and
both carry a comment saying why they diverge from it.

What is not covered

  • The other wrappers do not need this, and do not change. SidebarMenuButton, Item,
    SidebarMenuSubButton and the rest of sidebar.tsx call useRender rather than useButton, so
    they have no nativeButton and never warned — which covers every sidebar and settings link and
    every plugin and connected-account row. DropdownMenuItem does go through useButton, but Base UI
    already defaults it to false there.
  • The regression test covers the shared Button, not the six screens. Mounting PageShell needs
    a router, and what that would prove is what the Button test already proves.
  • The combobox's nativeButton has no test. Reaching it needs a Combobox.Root and a popup that
    happy-dom does not lay out. It was checked by hand instead: removing that one prop produces the
    inverse warning, restoring it silences it.
  • No visual change. Same variants, same classes, same layout.

Verification

  • bun test in app/: 206 pass, 0 fail across 32 files. Baseline on main is 202 across 31.
  • bun run typecheck: clean across app, server and worker.
  • bun run format:check and bun run lint: clean over every tracked source directory. (Both fail in
    my checkout before reaching a file, on stale gitignored worktrees under .claude/ that Biome
    refuses to traverse. A clean checkout, which is what CI runs, has none.)
  • app/tests/button-native.test.tsx — four tests, one per render shape: absent, an element, a
    function, and an explicit nativeButton. Each asserts the resulting tagName, role and type on
    the 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant