mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
267868c013
commit
bfb1335158
8 files changed
+311
-321
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -738,7 +738,7 @@ export function MoodChart({
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={65}
|
||||
tickMargin={6}
|
||||
tickMargin={10}
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
<Tooltip
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user