chore(ui): align TrendCard heading weight, account form rhythm, and MoodChart tick margin with siblings

Three single-class polish swaps to retire P0 / P1 drifts the UI-
conformity review caught:

- TrendCard headline number was the only dashboard tile using
  font-bold for its hero value; every sibling renders the same
  numeric role at font-semibold. Align so the trend strip reads
  uniformly.
- account-section password form was the only form in the codebase
  on space-y-3 outer rhythm; every other form uses space-y-4. Lift
  to match.
- mood-chart YAxis tickMargin sat at 6 while every other line chart
  passes 10 from the same canonical recipe. Match so mood labels
  sit at the same distance from the axis as siblings.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 21:17:54 +02:00
1 parent 267868c013
commit bfb1335158
8 files changed
+311 -321

No files matched your search

+104 -107
View File
@@ -25,13 +25,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateTime } from "@/lib/format";
@@ -329,38 +323,110 @@ function FeedbackDetailDialog({
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</DialogTitle>
<DialogDescription>
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</span>
}
description={
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
}
className="sm:max-w-2xl"
footer={
<div className="flex w-full flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
</span>
</DialogDescription>
</DialogHeader>
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
}
>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
@@ -450,76 +516,7 @@ function FeedbackDetailDialog({
</div>
</div>
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
</div>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
+1 -1
View File
@@ -738,7 +738,7 @@ export function MoodChart({
tickLine={false}
axisLine={false}
width={65}
tickMargin={6}
tickMargin={10}
tickFormatter={formatMoodTick}
/>
<Tooltip
+1 -1
View File
@@ -240,7 +240,7 @@ export function TrendCard({
className="mt-2 flex min-w-0 items-baseline gap-x-1.5"
data-slot="trend-card-value-row"
>
<span className="min-w-0 truncate text-3xl leading-none font-bold tracking-tight tabular-nums">
<span className="min-w-0 truncate text-3xl leading-none font-semibold tracking-tight tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
@@ -42,14 +42,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -131,27 +124,44 @@ export function ResearchModeAcknowledgmentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="research-mode-acknowledgment-dialog"
className="flex max-h-[90vh] flex-col sm:max-w-xl"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.researchMode.dialog.intro")}
</DialogDescription>
</DialogHeader>
{/* Only the body scrolls; header + footer stay pinned so the
Acknowledge / Cancel CTAs are always reachable without
scrolling on small viewports (the previous markup scrolled
the entire DialogContent, pushing the footer below the
fold on iPhone-class heights). */}
<div className="-mr-2 flex-1 space-y-4 overflow-y-auto pr-2 text-sm">
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</span>
}
description={t("medications.researchMode.dialog.intro")}
className="sm:max-w-xl"
footer={
<div className="flex w-full gap-2 sm:justify-end sm:gap-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={mutation.isPending}
data-slot="research-mode-cancel"
>
{t("medications.researchMode.dialog.cancelCta")}
</Button>
<Button
onClick={handleAcknowledge}
disabled={mutation.isPending || !currentDisclaimerVersion}
data-slot="research-mode-acknowledge"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.researchMode.dialog.acknowledgeCta")}
</Button>
</div>
}
>
<div
data-slot="research-mode-acknowledgment-dialog"
className="space-y-4 text-sm"
>
<section
aria-labelledby="research-mode-what-it-is"
data-slot="research-mode-what-it-is"
@@ -253,28 +263,6 @@ export function ResearchModeAcknowledgmentDialog({
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={mutation.isPending}
data-slot="research-mode-cancel"
>
{t("medications.researchMode.dialog.cancelCta")}
</Button>
<Button
onClick={handleAcknowledge}
disabled={mutation.isPending || !currentDisclaimerVersion}
data-slot="research-mode-acknowledge"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.researchMode.dialog.acknowledgeCta")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -60,37 +60,38 @@ vi.mock("sonner", () => ({
},
}));
// Radix Dialog uses Portals which `renderToStaticMarkup` does not
// realise. We collapse the primitives to plain wrappers so the body of
// the dialog is reachable in the static markup.
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({
// v1.4.27 R4 RC2 — the dialog now mounts <ResponsiveSheet>. Its
// internals (Sheet on `<md`, Dialog on `md+`) both portal at runtime;
// we collapse the primitive to a plain wrapper so the body of the
// dialog stays reachable in the static markup.
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
title,
description,
footer,
children,
}: {
open: boolean;
title: React.ReactNode;
description?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
DialogContent: ({
children,
...rest
}: React.ComponentProps<"div"> & { children: React.ReactNode }) => (
<div data-slot="mock-dialog-content" {...rest}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-slot="mock-dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-slot="mock-dialog-description">{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog-footer">{children}</div>
),
}) =>
open ? (
<div data-slot="mock-responsive-sheet">
<div data-slot="mock-responsive-sheet-header">
<h2 data-slot="mock-responsive-sheet-title">{title}</h2>
{description ? (
<p data-slot="mock-responsive-sheet-description">{description}</p>
) : null}
</div>
<div data-slot="mock-responsive-sheet-body">{children}</div>
{footer ? (
<div data-slot="mock-responsive-sheet-footer">{footer}</div>
) : null}
</div>
) : null,
}));
import { ResearchModeAcknowledgmentDialog } from "../ResearchModeAcknowledgmentDialog";
@@ -2,14 +2,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, RotateCcw } from "lucide-react";
@@ -160,15 +153,46 @@ export function PhaseConfigDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.phaseConfig")}</DialogTitle>
<DialogDescription>
{t("medications.phaseConfigDescription")}
</DialogDescription>
</DialogHeader>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.phaseConfig")}
description={t("medications.phaseConfigDescription")}
className="sm:max-w-md"
footer={
<div className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
{t("medications.phaseResetDefaults")}
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -238,39 +262,6 @@ export function PhaseConfigDialog({
{statusMessage}
</div>
)}
<DialogFooter className="flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
{t("medications.phaseResetDefaults")}
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
+1 -1
View File
@@ -610,7 +610,7 @@ export function AccountSection() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-3">
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
+95 -82
View File
@@ -1,18 +1,11 @@
"use client";
import { useId, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { RotateCcw } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -106,11 +99,13 @@ export function TargetEditSheet(props: TargetEditSheetProps) {
// test-friendly: existing tests can render the card without
// standing up a QueryClientProvider, and the hooks only execute
// once the user actually clicks the cog.
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
{props.open ? <TargetEditSheetBody {...props} /> : null}
</Dialog>
);
//
// v1.4.27 R4 RC2 — migrated raw <Dialog> → <ResponsiveSheet> so the
// bottom-sheet branch sticky-pins reset + cancel + save above the
// soft keyboard on phones. The lazy-mount toggle stays at this layer
// so closed renders never instantiate the TanStack Query hooks.
if (!props.open) return null;
return <TargetEditSheetBody {...props} />;
}
function TargetEditSheetBody({
@@ -184,10 +179,25 @@ function TargetEditSheetBody({
const displayDiaMin = diaMinStr ?? (secondary ? String(secondary.min) : "");
const displayDiaMax = diaMaxStr ?? (secondary ? String(secondary.max) : "");
// Focus management — the first input gets focus when the dialog
// Focus management — the first input gets focus when the sheet
// opens, matching the Dashboard chart cog's open-on-focus pattern.
// v1.4.27 R4 RC2 — under the ResponsiveSheet primitive we no longer
// hook Radix's `onOpenAutoFocus`; a mount effect lands focus on the
// primary input as soon as the portal paints.
const firstInputRef = useRef<HTMLInputElement | null>(null);
const dialogContentId = useId();
useEffect(() => {
// Defer one frame so Radix has finished placing the portal before
// we steal focus — otherwise the focus jumps to the close-X on
// some browsers.
const handle = window.requestAnimationFrame(() => {
if (firstInputRef.current) {
firstInputRef.current.focus();
firstInputRef.current.select();
}
});
return () => window.cancelAnimationFrame(handle);
}, []);
const updateMutation = useMutation({
mutationFn: async (
@@ -301,36 +311,73 @@ function TargetEditSheetBody({
? Boolean(thresholdsData?.overrides?.[metric])
: false;
// v1.4.27 R4 RC2 — focus the first numeric input on mount instead of
// relying on Radix's <Dialog>-specific onOpenAutoFocus. The body
// only mounts once `open=true` (lazy mount above), so the effect
// runs exactly when the surface comes into view.
// Note: ResponsiveSheet does not currently expose onOpenAutoFocus —
// we use a tiny mount effect to steal focus.
return (
<DialogContent
<ResponsiveSheet
open
onOpenChange={onOpenChange}
title={t("targets.edit.title", { metric: targetLabel })}
description={
isDerivedMetric
? t("targets.edit.derivedHint")
: t("targets.edit.description", { unit })
}
className="sm:max-w-md"
data-slot="target-edit-sheet"
data-target-type={targetType}
onOpenAutoFocus={(event) => {
// Defer to the first input ref so the user lands on the min
// field rather than the close X (default Radix behaviour
// focuses the first focusable child).
if (firstInputRef.current) {
event.preventDefault();
firstInputRef.current.focus();
firstInputRef.current.select();
}
}}
aria-describedby={dialogContentId}
footer={
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{hasOverride ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="target-edit-reset"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("targets.edit.resetToDefault")}
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
data-slot="target-edit-save"
>
{t("common.save")}
</Button>
</div>
</div>
}
>
<DialogHeader>
<DialogTitle>
{t("targets.edit.title", { metric: targetLabel })}
</DialogTitle>
<DialogDescription id={dialogContentId}>
<div
data-slot="target-edit-sheet"
data-target-type={targetType}
aria-describedby={dialogContentId}
>
<p id={dialogContentId} className="sr-only">
{isDerivedMetric
? t("targets.edit.derivedHint")
: t("targets.edit.description", { unit })}
</DialogDescription>
</DialogHeader>
</p>
{!isDerivedMetric && primaryBounds && (
<div className="space-y-4">
{!isDerivedMetric && primaryBounds && (
<div className="space-y-4">
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-primary-row"
@@ -414,50 +461,16 @@ function TargetEditSheetBody({
</div>
)}
<p className="text-muted-foreground text-xs">
{t("targets.edit.boundsHint", {
min: String(primaryBounds.min),
max: String(primaryBounds.max),
unit: primaryBounds.unit,
})}
</p>
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{hasOverride ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="target-edit-reset"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("targets.edit.resetToDefault")}
</Button>
) : (
<span aria-hidden="true" />
<p className="text-muted-foreground text-xs">
{t("targets.edit.boundsHint", {
min: String(primaryBounds.min),
max: String(primaryBounds.max),
unit: primaryBounds.unit,
})}
</p>
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
data-slot="target-edit-save"
>
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</div>
</ResponsiveSheet>
);
}