mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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,121 @@
|
||||
/**
|
||||
* A small text action inside a line of content goes through `InlineAction`
|
||||
* (`src/components/ui/inline-action.tsx`).
|
||||
*
|
||||
* Thirty-odd hand-rolled copies of the same "underlined small text link"
|
||||
* carried three different focus rings, none at all on half of them, and a
|
||||
* 16 px touch target on a phone. The primitive owns the 44 px floor on a
|
||||
* coarse pointer and the ring; this guard keeps a new copy from appearing
|
||||
* beside it.
|
||||
*
|
||||
* Matched: a `<button>`, `<Link>` or `<a>` whose static `className` combines
|
||||
* the inline-action signature — an underline (`underline` / `hover:underline`)
|
||||
* with a small text size (`text-xs` / `text-sm`) and an action tone
|
||||
* (`text-primary` / `text-muted-foreground`). A full-width row link or a
|
||||
* framed button does not have that combination.
|
||||
*
|
||||
* Not matched (stated, not hidden): a class list assembled at runtime, and a
|
||||
* copy without an underline. The guard catches the idiom, not every way to
|
||||
* draw a small link.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
/**
|
||||
* Sites that look like an inline action but are something else, each with
|
||||
* the reason. Keyed by `path:tag`.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
// A full-width footer row of the glucose clinical panel: icon plus a
|
||||
// wrapping sentence under a border, read as part of the card body.
|
||||
"components/insights/glucose/glucose-clinical-panel.tsx:Link":
|
||||
"full-width footer row, not an inline action",
|
||||
};
|
||||
|
||||
const OPENER_RE = /<(button|Link|a)\b((?:[^>{]|\{[^}]*\})*?)>/g;
|
||||
const CLASS_RE = /className=(?:"([^"]*)"|\{cn\(\s*"([^"]*)")/;
|
||||
|
||||
function isInlineActionShape(cls: string): boolean {
|
||||
return (
|
||||
/(?:^|\s)(?:hover:)?underline(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)text-(?:xs|sm)(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)text-(?:primary|muted-foreground)(?:\s|$)/.test(cls)
|
||||
);
|
||||
}
|
||||
|
||||
function findHandRolledInlineActions(
|
||||
files: { path: string; source: string }[],
|
||||
): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const { path, source } of files) {
|
||||
for (const match of source.matchAll(OPENER_RE)) {
|
||||
const cls = CLASS_RE.exec(match[2]);
|
||||
if (!cls) continue;
|
||||
if (!isInlineActionShape(cls[1] ?? cls[2] ?? "")) continue;
|
||||
hits.push(`${path}:${match[1]}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe("small text actions go through InlineAction", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 1200, extensions: [".tsx"] })
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.startsWith("components/") || p.startsWith("app/")) &&
|
||||
!p.startsWith("components/ui/") &&
|
||||
!p.includes("__tests__/"),
|
||||
)
|
||||
.map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
|
||||
|
||||
it("walks the UI tree", () => {
|
||||
expect(files.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("has no hand-rolled copy outside the allowlist", () => {
|
||||
const hits = findHandRolledInlineActions(files).filter(
|
||||
(key) => !(key in ALLOWED),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every allowlist entry live", () => {
|
||||
const hits = new Set(findHandRolledInlineActions(files));
|
||||
for (const key of Object.keys(ALLOWED)) {
|
||||
expect(hits.has(key), key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("recognises the idiom it exists to refuse", () => {
|
||||
const planted = [
|
||||
{
|
||||
path: "components/x.tsx",
|
||||
source:
|
||||
'<Link href="/a" className="text-primary text-xs underline-offset-2 hover:underline">Go</Link>',
|
||||
},
|
||||
{
|
||||
path: "components/y.tsx",
|
||||
source:
|
||||
'<button type="button" onClick={() => go()} className="text-muted-foreground hover:text-foreground text-sm underline">Go</button>',
|
||||
},
|
||||
{
|
||||
path: "components/z.tsx",
|
||||
source: '<InlineAction tone="muted">Go</InlineAction>',
|
||||
},
|
||||
];
|
||||
expect(findHandRolledInlineActions(planted)).toEqual([
|
||||
"components/x.tsx:Link",
|
||||
"components/y.tsx:button",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is used where the copies were", () => {
|
||||
const users = files.filter((f) => f.source.includes("<InlineAction"));
|
||||
expect(users.length).toBeGreaterThanOrEqual(25);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export default function MoodPageClient() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
@@ -197,14 +198,12 @@ export default function MoodPageClient() {
|
||||
here (module-gated — no nav change) rather than a merge: capture
|
||||
vs. screener stay two defensible surfaces, just cross-linked. */}
|
||||
{user?.modules?.mentalHealth === true ? (
|
||||
<Link
|
||||
href="/mental-wellbeing"
|
||||
data-slot="mood-mental-wellbeing-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted" size="sm" className="gap-1.5">
|
||||
<Link href="/mental-wellbeing" data-slot="mood-mental-wellbeing-link">
|
||||
{t("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface CentralCodexStatus {
|
||||
status: string;
|
||||
@@ -228,15 +229,15 @@ export function CentralCodexSection() {
|
||||
{t("settings.ai.deviceCodeStep2")}
|
||||
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
|
||||
{deviceCode.userCode}
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(deviceCode.userCode)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline"
|
||||
>
|
||||
{t("settings.ai.deviceCodeCopy")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</li>
|
||||
<li>{t("settings.ai.deviceCodeStep3")}</li>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* v1.18.0 — operator-side panel for server-wide module availability, the
|
||||
@@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() {
|
||||
onCheckedChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
<Link
|
||||
href="/admin/coach"
|
||||
className="text-primary text-xs underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("admin.section.coach.title")}
|
||||
</Link>
|
||||
<InlineAction asChild>
|
||||
<Link href="/admin/coach">
|
||||
{t("admin.section.coach.title")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
const PREVIEW_LIMIT = 10;
|
||||
|
||||
@@ -61,13 +62,12 @@ export function RecentAuditPreview() {
|
||||
titleId="admin-overview-audit-heading"
|
||||
title={t("admin.overview.auditTitle")}
|
||||
status={
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<InlineAction asChild size="sm">
|
||||
<Link href="/admin/login-overview">
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import Link from "next/link";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
@@ -29,12 +30,11 @@ export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
</p>
|
||||
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/encryption"
|
||||
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("dashboard.keyBackupBanner.action")}
|
||||
</Link>
|
||||
<InlineAction asChild size="sm" className="min-h-11 font-medium">
|
||||
<Link href="/admin/encryption">
|
||||
{t("dashboard.keyBackupBanner.action")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
} from "./source-errors";
|
||||
import { SegmentedChoice } from "./segmented-choice";
|
||||
import { systemName } from "./use-document-sources";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export interface DocumentPickerLink {
|
||||
kind: DocumentPickerLinkKind;
|
||||
@@ -485,19 +486,19 @@ export function DocumentSourcePicker({
|
||||
</div>
|
||||
</div>
|
||||
{hasFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
onClick={() => {
|
||||
startOver();
|
||||
setTag("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
className="min-h-8"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("documents.sourcePicker.clearFilters")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
|
||||
{finished ? (
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
CoachProvenanceMetric,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* Per-message bubble renderer for the Coach thread, split out of
|
||||
@@ -775,13 +776,11 @@ function ChatBubbleImpl({
|
||||
setup instead of inviting an endless retry. */}
|
||||
{(errorCode === "coach.provider.none" ||
|
||||
errorCode === "ai.provider.none") && (
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="coach-no-provider-cta"
|
||||
className="text-primary text-xs font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.coach.errorNoProviderAction")}
|
||||
</Link>
|
||||
<InlineAction asChild className="font-medium">
|
||||
<Link href="/settings/ai" data-slot="coach-no-provider-cta">
|
||||
{t("insights.coach.errorNoProviderAction")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
)}
|
||||
{/* v1.18.1 — one-tap cadence-suggestion action
|
||||
card. Live from the streaming hook, or restored from the
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* The Health Score panel: the hero band's right-hand column.
|
||||
@@ -441,14 +442,15 @@ export function HealthScoreCard({
|
||||
</span>
|
||||
) : null}
|
||||
{/* v1.42 — the way to the score's course over time. */}
|
||||
<Link
|
||||
href="/insights/health-score"
|
||||
data-slot="health-score-history-link"
|
||||
className="text-foreground/80 hover:text-foreground focus-visible:ring-ring/50 -my-3 inline-flex items-center gap-1 rounded-md py-3 text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<History className="size-3.5" aria-hidden="true" />
|
||||
{t("insights.healthScore.historyLink")}
|
||||
</Link>
|
||||
<InlineAction asChild tone="foreground">
|
||||
<Link
|
||||
href="/insights/health-score"
|
||||
data-slot="health-score-history-link"
|
||||
>
|
||||
<History className="size-3.5" aria-hidden="true" />
|
||||
{t("insights.healthScore.historyLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
MedicationEfficacyDTO,
|
||||
EfficacyTargetView,
|
||||
} from "@/lib/medications/efficacy/build-efficacy";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
function primaryWithDelta(
|
||||
dto: MedicationEfficacyDTO,
|
||||
@@ -108,13 +109,14 @@ export function MedicationEfficacySummary({
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/medications/${med.id}?tab=wirkung`}
|
||||
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
data-slot="insights-medication-efficacy-link"
|
||||
>
|
||||
{t("medications.efficacy.insights.link")}
|
||||
</Link>
|
||||
<InlineAction asChild className="shrink-0">
|
||||
<Link
|
||||
href={`/medications/${med.id}?tab=wirkung`}
|
||||
data-slot="insights-medication-efficacy-link"
|
||||
>
|
||||
{t("medications.efficacy.insights.link")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { getTargetSourceLink } from "@/lib/targets/source-link";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
|
||||
@@ -416,19 +417,16 @@ function TargetReferencePanel({
|
||||
panel registers itself as that gear's edit target above. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
|
||||
{sourceLink ? (
|
||||
<a
|
||||
href={sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("targets.sourceLabel", {
|
||||
source: sourceName(target.source, t),
|
||||
})}
|
||||
</span>
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
<InlineAction asChild tone="muted">
|
||||
<a href={sourceLink} target="_blank" rel="noopener noreferrer">
|
||||
<span>
|
||||
{t("targets.sourceLabel", {
|
||||
source: sourceName(target.source, t),
|
||||
})}
|
||||
</span>
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</InlineAction>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("targets.sourceLabel", {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { ChronotypeDto } from "./use-sleep-rhythm";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
|
||||
function clockLabel(minutesOfDay: number): string {
|
||||
@@ -90,12 +91,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
className="font-medium"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="chronotype-advanced"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
@@ -103,7 +104,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.sleep.chronotype.advancedToggle")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
|
||||
{showAdvanced && (
|
||||
<div id="chronotype-advanced" className="space-y-2 border-t pt-3">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type TrendAnnotationStatus,
|
||||
} from "./trend-annotation";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* Trends row — a small chart per metric above a one-sentence
|
||||
@@ -339,15 +340,20 @@ export function TrendsRow({
|
||||
is used but was unreachable from Insights. The link sits below
|
||||
the caption so the chart keeps its own interactions. */}
|
||||
{config.detailHref ? (
|
||||
<Link
|
||||
href={config.detailHref}
|
||||
data-slot="trends-row-detail-link"
|
||||
data-metric={config.metric}
|
||||
className="text-muted-foreground hover:text-foreground mt-auto inline-flex items-center gap-1 self-start text-xs font-medium transition-colors"
|
||||
<InlineAction
|
||||
asChild
|
||||
tone="muted"
|
||||
className="mt-auto self-start font-medium"
|
||||
>
|
||||
{t("insights.trendsRow.viewDetail", { metric: title })}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
href={config.detailHref}
|
||||
data-slot="trends-row-detail-link"
|
||||
data-metric={config.metric}
|
||||
>
|
||||
{t("insights.trendsRow.viewDetail", { metric: title })}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
formatPace,
|
||||
formatDurationMinutes,
|
||||
} from "./format";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface StatTileProps {
|
||||
icon: ReactNode;
|
||||
@@ -199,14 +200,15 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
{workout.previousWorkoutId ? (
|
||||
<Link
|
||||
href={`/insights/workouts/${workout.previousWorkoutId}`}
|
||||
data-slot="workout-detail-previous-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.workouts.detail.previousSessionLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted" className="shrink-0 gap-1.5">
|
||||
<Link
|
||||
href={`/insights/workouts/${workout.previousWorkoutId}`}
|
||||
data-slot="workout-detail-previous-link"
|
||||
>
|
||||
{t("insights.workouts.detail.previousSessionLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { formatPeriod, type PeriodQuery } from "./period-query";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export interface EntityLinkOption {
|
||||
id: string;
|
||||
@@ -546,16 +547,15 @@ export function EntityLinkPicker({
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
data-slot={`${slot}-group-select`}
|
||||
onClick={() =>
|
||||
change(toggleAll(selected, groupIds), group.options)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("links.picker.selectAll")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="space-y-1">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flame, RotateCw } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface MedicationComplianceBarsProps {
|
||||
rate7: number;
|
||||
@@ -286,14 +287,10 @@ export function MedicationComplianceError({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
<InlineAction tone="muted" onClick={onRetry}>
|
||||
<RotateCw className="size-3" aria-hidden="true" />
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</div>
|
||||
<div className="bg-muted/40 h-2 rounded" />
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,7 @@ import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-t
|
||||
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
|
||||
import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
|
||||
// both chart bodies load through `next/dynamic` (client-only) with the
|
||||
@@ -1097,14 +1098,13 @@ function StatusRow({
|
||||
<p className="text-muted-foreground text-xs font-medium">{label}</p>
|
||||
<p className="text-foreground text-sm">{value}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
onClick={onJump}
|
||||
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="shrink-0"
|
||||
data-slot={`${dataSlot}-jump`}
|
||||
>
|
||||
{jumpLabel}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
type InventoryItem,
|
||||
type InventoryState,
|
||||
} from "./inventory-shared";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs";
|
||||
export { invalidateSupplyQueries } from "./inventory-shared";
|
||||
@@ -314,17 +315,18 @@ export function InventorySection({
|
||||
of what was shared" panel. */}
|
||||
{!inSharedRecord && (
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
contextValueLabelKey,
|
||||
} from "@/lib/mood/context-vocabulary";
|
||||
import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
const CONTEXT_NOTE_MAX_LENGTH = 500;
|
||||
|
||||
@@ -683,16 +684,17 @@ export function MoodLinkedDaySection({
|
||||
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
|
||||
{bodyLine}
|
||||
</span>
|
||||
<a
|
||||
href={
|
||||
linked.body.available && linked.body.episodeId
|
||||
? `/illness/${linked.body.episodeId}`
|
||||
: "/illness"
|
||||
}
|
||||
className="text-primary text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("mood.dayContext.openIllness")}
|
||||
</a>
|
||||
<InlineAction asChild className="underline">
|
||||
<a
|
||||
href={
|
||||
linked.body.available && linked.body.episodeId
|
||||
? `/illness/${linked.body.episodeId}`
|
||||
: "/illness"
|
||||
}
|
||||
>
|
||||
{t("mood.dayContext.openIllness")}
|
||||
</a>
|
||||
</InlineAction>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
droppedAnyLinkKeys,
|
||||
type DroppedLinkKeysWire,
|
||||
} from "@/lib/mood/dropped-link-keys";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
// Re-export the score map under the legacy local name to keep the
|
||||
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
|
||||
@@ -1361,16 +1362,15 @@ function MoodNoteText({
|
||||
{note}
|
||||
</p>
|
||||
{(expanded || clamped) && (
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
className="font-medium"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={noteId}
|
||||
data-testid="mood-note-toggle"
|
||||
className="text-primary cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface AiProviderStatus {
|
||||
/** Origin of the provider that would serve this user, if any. */
|
||||
@@ -376,12 +377,9 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.ai.setupCta")}
|
||||
</Link>
|
||||
<InlineAction asChild size="sm" className="font-medium underline">
|
||||
<Link href="/settings/ai">{t("onboarding.ai.setupCta")}</Link>
|
||||
</InlineAction>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -459,20 +457,28 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
new account one honest pointer to Settings → Modules before they
|
||||
leave the wizard. */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
|
||||
<Link
|
||||
href="/settings/export"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
|
||||
<InlineAction
|
||||
asChild
|
||||
tone="muted"
|
||||
size="sm"
|
||||
className="gap-1.5 underline"
|
||||
>
|
||||
<FileUp className="size-3.5" />
|
||||
{t("onboarding.done.importCta")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
|
||||
<Link href="/settings/export">
|
||||
<FileUp className="size-3.5" />
|
||||
{t("onboarding.done.importCta")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
<InlineAction
|
||||
asChild
|
||||
tone="muted"
|
||||
size="sm"
|
||||
className="gap-1.5 underline"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
{t("onboarding.done.modulesCta")}
|
||||
</Link>
|
||||
<Link href="/settings/modules">
|
||||
<Settings2 className="size-3.5" />
|
||||
{t("onboarding.done.modulesCta")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useHandoverDecision,
|
||||
type HandoverDecisionState,
|
||||
} from "@/lib/queries/use-handover-decision";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
type PendingDecision = NonNullable<HandoverDecisionState["pending"]>;
|
||||
|
||||
@@ -191,13 +192,14 @@ export function HandoverNotificationHint() {
|
||||
<p className="text-sm">
|
||||
{t("recordSharing.handoverDecision.notificationsBody")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className="text-primary text-sm underline"
|
||||
data-slot="handover-notification-link"
|
||||
>
|
||||
{t("recordSharing.handoverDecision.notificationsAction")}
|
||||
</Link>
|
||||
<InlineAction asChild size="sm" className="underline">
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
data-slot="handover-notification-link"
|
||||
>
|
||||
{t("recordSharing.handoverDecision.notificationsAction")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { InsightsSettings } from "./shared";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export function CodexProviderForm({
|
||||
settings,
|
||||
@@ -241,15 +242,15 @@ export function CodexProviderForm({
|
||||
{t("settings.ai.deviceCodeStep2")}
|
||||
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
|
||||
{deviceCode.userCode}
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(deviceCode.userCode)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline"
|
||||
tone="muted"
|
||||
className="underline"
|
||||
>
|
||||
{t("settings.ai.deviceCodeCopy")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</div>
|
||||
</li>
|
||||
<li>{t("settings.ai.deviceCodeStep3")}</li>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
uiToLegacyProviderEnum,
|
||||
type UserAIProvider,
|
||||
} from "./shared";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export function OpenAIProviderForm({
|
||||
userProvider,
|
||||
@@ -172,15 +173,15 @@ export function OpenAIProviderForm({
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
className="underline"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline"
|
||||
>
|
||||
{showAdvanced
|
||||
? t("settings.ai.openai.hideAdvanced")
|
||||
: t("settings.ai.openai.showAdvanced")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<Label htmlFor="ai-openai-base-url">
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export type OAuthIntegrationKey =
|
||||
| "withings"
|
||||
@@ -489,16 +490,17 @@ export function OAuthIntegrationCardView({
|
||||
details || planLimited || viewModel?.metricFreshness?.length ? (
|
||||
<>
|
||||
{planLimited ? (
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="withings-plan-link"
|
||||
className="text-primary inline-flex min-h-11 items-center gap-1 text-sm underline-offset-2 hover:underline sm:min-h-0"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
<InlineAction asChild size="sm">
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="withings-plan-link"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
{details}
|
||||
<MetricFreshnessList entries={viewModel?.metricFreshness} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
* v1.16.11 — medication low-stock alert threshold (Settings →
|
||||
@@ -320,13 +321,11 @@ export function LowStockCard({
|
||||
{/* v1.16.11 — back-link to the medications list, the surface the
|
||||
alert is about (the supply tab links here the same way). */}
|
||||
<p>
|
||||
<Link
|
||||
href="/medications"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="low-stock-medications-link"
|
||||
>
|
||||
{t("notifications.lowStock.medicationsLink")}
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href="/medications" data-slot="low-stock-medications-link">
|
||||
{t("notifications.lowStock.medicationsLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export interface ModuleToggleRowProps {
|
||||
/** Stable key — used for the input id + the test query handle. */
|
||||
@@ -112,13 +113,12 @@ export function ModuleToggleRow({
|
||||
<p className="text-muted-foreground text-xs">{disabledReason}</p>
|
||||
) : null}
|
||||
{manageLink ? (
|
||||
<Link
|
||||
href={manageLink.href}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{manageLink.label}
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted">
|
||||
<Link href={manageLink.href}>
|
||||
{manageLink.label}
|
||||
<ChevronRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
shiftDateKey,
|
||||
validTimezoneOr,
|
||||
} from "@/lib/tz/format";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
@@ -310,13 +311,12 @@ export function HealthRecordExportPanel() {
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only). Only rendered when the module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
<InlineAction asChild tone="muted" className="gap-1.5">
|
||||
<Link href={`/documents?year=${new Date().getFullYear()}`}>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface SetupData {
|
||||
otpauthUri: string;
|
||||
@@ -430,13 +431,9 @@ export function TotpCard({
|
||||
{t("settings.security.totp.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetWizard}
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
>
|
||||
<InlineAction tone="muted" onClick={resetWizard}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
@@ -564,9 +561,8 @@ export function TotpCard({
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
onClick={() =>
|
||||
setDisableMethod((m) =>
|
||||
m === "totp" ? "recovery" : "totp",
|
||||
@@ -576,7 +572,7 @@ export function TotpCard({
|
||||
{disableMethod === "totp"
|
||||
? t("settings.security.useRecoveryCode")
|
||||
: t("settings.security.useAuthenticator")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { CatalogPicker } from "./catalog-picker";
|
||||
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
|
||||
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
|
||||
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
|
||||
export const VACCINATION_SITES = [
|
||||
@@ -312,14 +313,14 @@ export function VaccinationForm({
|
||||
</FieldGroup>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<InlineAction
|
||||
tone="muted"
|
||||
size="sm"
|
||||
data-slot="vaccination-series-reveal"
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline-offset-2 hover:underline"
|
||||
onClick={() => setSeriesOpen(true)}
|
||||
>
|
||||
{t("vaccinations.form.seriesToggle")}
|
||||
</button>
|
||||
</InlineAction>
|
||||
)}
|
||||
|
||||
<FieldGroup htmlFor="vaccination-lot" label={t("vaccinations.form.lot")}>
|
||||
|
||||
Reference in new issue
Block a user