🐛 Render no type attribute on DS buttons used as links (#12709)

* 🐛 Render no type attribute on DS buttons used as links

With `to`, button* renders an <a href> but still passed type="button". On
an anchor, type is a MIME type hint, so "button" is invalid HTML. The
type now applies only when the element is a <button> (still "button" by
default).

New "As Link" story with two tests: the link has its href and no type,
and a plain button keeps type="button".

AI-assisted-by: claude-opus-5-5

* 📚 Document the DS button as a link

The Semantics section only said that button* accepts `to`. It now says
when to use it (the control goes to another URL), what it renders (an
<a href> with the button style and no type), how to keep the in-app
navigation on a plain click, and shows the "As Link" story.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-29 18:10:16 +01:00 committed by GitHub
parent 12dd4d19d0
commit 0fa1077b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 51 additions and 3 deletions

View File

@ -36,7 +36,8 @@
:button-destructive (= variant "destructive"))
props (mf/spread-props props {:class [class internal-class]
:href to
:type (d/nilv type "button")
;; On an anchor, `type` is a MIME type hint.
:type (when-not to (d/nilv type "button"))
:ref (fn [node]
(when on-ref
(on-ref node)))})]

View File

@ -6,6 +6,7 @@
import * as React from "react";
import Components from "@target/components";
import { within, expect } from "storybook/test";
const { Button } = Components;
const { icons } = Components.meta;
@ -67,3 +68,34 @@ export const Destructive = {
variant: "destructive",
},
};
// With `to`, the button renders an anchor: it keeps the button look but it is
// a real link (href, open in a new tab, announced as a link).
export const AsLink = {
args: {
variant: "secondary",
to: "https://penpot.app",
children: "Go to Penpot",
},
};
export const TestAsLinkRendersAnchor = {
args: AsLink.args,
play: async ({ canvasElement }) => {
const link = within(canvasElement).getByRole("link", {
name: "Go to Penpot",
});
await expect(link).toHaveAttribute("href", "https://penpot.app");
// `type` on an anchor is a MIME type hint, not a button type.
await expect(link).not.toHaveAttribute("type");
},
};
export const TestButtonDefaultsToTypeButton = {
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole("button", {
name: "Lorem ipsum",
});
await expect(button).toHaveAttribute("type", "button");
},
};

View File

@ -71,8 +71,23 @@ Action buttons are a variant of `icon-button*` (`"action"`) but smaller.
### Semantics
In some cases, a button needs to be semantically an 'a' tag, but visually maintain the button style.
In that case, the button accepts a 'to' property with the expected 'href'. It will then be rendered as an anchor element.
When the control takes the user to another page or URL, it must be a link, even if it looks like a button.
Pass the destination in `to`: `button*` then renders an `<a href>` with the button style, so it can be
opened in a new tab and assistive technologies announce it as a link. It renders no `type` attribute,
which on an anchor would mean a MIME type.
```clj
[:> button* {:variant "secondary"
:to (rt/resolve-uri router :auth-login {})}
(tr "profile.recovery.go-to-login")]
```
To keep the in-app navigation on a plain click, add an `on-click` that calls `prevent-default` (unless
Ctrl/Cmd is pressed) and emits the navigation event. Other anchor attributes, such as `target`, pass through.
<Canvas of={ButtonStories.AsLink} />
Use a plain `button*` (without `to`) for actions that do not change the URL, such as opening a modal.
### Accessibility