feat(ui): InlineAction for small text actions, with a 44 px touch floor

Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:03:15 +02:00
1 parent ce8074ad08
commit 60be63bcd1
33 files changed
+484 -222

No files matched your search

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { InlineAction } from "../inline-action";
describe("<InlineAction>", () => {
it("is a non-submitting button with the touch floor and a focus ring", () => {
const html = renderToStaticMarkup(
<InlineAction>Open schedule</InlineAction>,
);
expect(html).toMatch(/^<button[^>]*type="button"/);
expect(html).toContain("pointer-coarse:min-h-11");
expect(html).toContain("focus-visible:ring-[3px]");
expect(html).toContain("text-primary");
expect(html).toContain("text-xs");
});
it("keeps an explicit submit type", () => {
const html = renderToStaticMarkup(
<InlineAction type="submit">Send</InlineAction>,
);
expect(html).toContain('type="submit"');
});
it("maps the tones and sizes", () => {
const muted = renderToStaticMarkup(
<InlineAction tone="muted" size="sm">
x
</InlineAction>,
);
expect(muted).toContain("text-muted-foreground");
expect(muted).toContain("hover:text-foreground");
expect(muted).toContain("text-sm");
expect(muted).not.toContain("text-primary");
});
it("renders a link through asChild without a button type", () => {
const html = renderToStaticMarkup(
<InlineAction asChild tone="foreground">
<a href="/insights/health-score" data-slot="history-link">
History
</a>
</InlineAction>,
);
expect(html).toMatch(/^<a /);
expect(html).not.toContain("type=");
expect(html).toContain('data-slot="history-link"');
expect(html).toContain("pointer-coarse:min-h-11");
});
});
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* A small text action that sits inside a line of content: "Open schedule"
* beside a value, "Select all" beside a group label, "View all" in a card
* header, a "Learn more" link under a hint. Not a button with a frame (that
* is `Button`), not a content link inside prose.
*
* It owns three things the hand-rolled copies kept getting wrong:
*
* - **The touch target.** On a coarse pointer the action is at least 44 px
* tall (`pointer-coarse:min-h-11`, the same floor `Button` carries), so a
* 12 px "Edit" link is not a 16 px target on a phone. A fine pointer keeps
* the line height, so the desktop layout does not move.
* - **The focus ring**, which several copies had none of.
* - **The tone vocabulary**: `primary` for the one forward action of a row,
* `muted` for a secondary way out, `foreground` for an action that reads as
* part of the content.
*
* Render a `Link` or `<a>` through `asChild`. A plain button gets
* `type="button"` so it never submits the form it sits in.
*/
const inlineActionVariants = cva(
"inline-flex items-center gap-1 rounded-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 pointer-coarse:min-h-11 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
tone: {
primary: "text-primary",
muted: "text-muted-foreground hover:text-foreground",
foreground: "text-foreground/80 hover:text-foreground",
},
size: {
xs: "text-xs",
sm: "text-sm",
},
},
defaultVariants: {
tone: "primary",
size: "xs",
},
},
);
type InlineActionProps = React.ComponentProps<"button"> &
VariantProps<typeof inlineActionVariants> & {
asChild?: boolean;
};
function InlineAction({
className,
tone,
size,
asChild = false,
type,
...props
}: InlineActionProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="inline-action"
type={asChild ? undefined : (type ?? "button")}
className={cn(inlineActionVariants({ tone, size }), className)}
{...props}
/>
);
}
export { InlineAction, inlineActionVariants };
+13 -14
View File
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
import { learnLinkForMetric, learnUrl } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
import { InlineAction } from "@/components/ui/inline-action";
import { cn } from "@/lib/utils";
/**
@@ -40,20 +41,18 @@ export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
const label = t("common.learnMore");
return (
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline",
className,
)}
>
{label}
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
<InlineAction asChild tone="muted" className={className}>
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
>
{label}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
);
}