mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user