mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(a11y): distinguish errors from empty states and close naming gaps
Add isError branches with a retry affordance to the mood, illness, and vorsorge lists so a failed read no longer renders as "no entries yet" and invites duplicate re-entry, matching the labs and cycle pattern. Make the admin backups table reachable on a phone with a card fallback, name the admin language and timezone selects and the AI raw-data switch for screen readers, and replace the touch-unreachable title= help on the glucose clinical panels with a tap-capable info popover. Route the password-strength label through the AA-tuned semantic tokens, underline the register login link so it is not distinguished by colour alone, give the labs load error alert semantics, and explain why the target editor Save button is disabled with aria-invalid and a live alert.
This commit is contained in:
1 parent
b02e3e4072
commit
c38271bc44
13 files changed
+350
-88
No files matched your search
@@ -194,7 +194,13 @@ export default function RegisterPage() {
|
||||
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.hasAccount")}{" "}
|
||||
<Link href="/auth/login" className="text-primary hover:underline">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
// axe-core `link-in-text-block` requires a visible distinction
|
||||
// beyond colour alone (WCAG 1.4.1). The underline stays on
|
||||
// always, matching the login page's register link.
|
||||
className="text-primary underline"
|
||||
>
|
||||
{t("auth.login")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -467,74 +467,134 @@ export function BackupsSection() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.section.backups.colUser")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.section.backups.colType")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colSize")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colCreatedAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{row.username}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatBackupType(row.type, t)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
|
||||
{formatBytes(row.sizeBytes, fmt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{fmt.dateTime(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
username: row.username,
|
||||
})}
|
||||
className="min-h-11"
|
||||
>
|
||||
{downloadingId === row.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.download")}
|
||||
</Button>
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending && restore.variables?.id === row.id
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<div className="mt-4">
|
||||
{/* Wide layout: the canonical table. Hidden below `md`, where the
|
||||
five columns push Download / Restore off-screen behind a
|
||||
horizontal scroll; the card list below takes over there. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.section.backups.colUser")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.section.backups.colType")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colSize")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colCreatedAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{row.username}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatBackupType(row.type, t)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
|
||||
{formatBytes(row.sizeBytes, fmt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{fmt.dateTime(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
username: row.username,
|
||||
})}
|
||||
className="min-h-11"
|
||||
>
|
||||
{downloadingId === row.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.download")}
|
||||
</Button>
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.id === row.id
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Narrow layout: each backup as a self-contained card with the
|
||||
meta line + a flex-wrapping action row, so Download / Restore
|
||||
stay reachable on a phone instead of scrolling off-screen. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="admin-backups-mobile-list"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{row.username}</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{formatBackupType(row.type, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.section.backups.colSize")}:{" "}
|
||||
{formatBytes(row.sizeBytes, fmt)} ·{" "}
|
||||
{fmt.dateTime(row.createdAt)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
username: row.username,
|
||||
})}
|
||||
className="min-h-11"
|
||||
>
|
||||
{downloadingId === row.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.download")}
|
||||
</Button>
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending && restore.variables?.id === row.id
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("admin.section.backups.retentionLabel", {
|
||||
days: data!.retentionDays,
|
||||
|
||||
@@ -68,6 +68,7 @@ export function GeneralSettingsSection() {
|
||||
</p>
|
||||
</div>
|
||||
<NativeSelect
|
||||
aria-label={t("admin.defaultLanguage")}
|
||||
value={settings?.defaultLocale ?? "de"}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultLocale: e.target.value })
|
||||
@@ -94,6 +95,7 @@ export function GeneralSettingsSection() {
|
||||
</p>
|
||||
</div>
|
||||
<NativeSelect
|
||||
aria-label={t("admin.defaultUserTimezone")}
|
||||
value={currentDefaultTz}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultUserTimezone: e.target.value })
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
*/
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Stethoscope,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -308,7 +314,12 @@ function EpisodeGroup({
|
||||
|
||||
export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const { prefs } = useModuleListPrefs("illness");
|
||||
|
||||
@@ -418,6 +429,24 @@ export function IllnessView() {
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="illness-list-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("illness.listLoadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : hasEpisodes ? (
|
||||
<>
|
||||
{/* Retrospective summary sits above the list so a long history
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { InfoHint } from "@/components/ui/info-hint";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
@@ -146,8 +147,9 @@ function AdvancedStat({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs" title={help}>
|
||||
<dt className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
{label}
|
||||
<InfoHint label={help} />
|
||||
</dt>
|
||||
<dd className="text-foreground text-base font-semibold tabular-nums">
|
||||
{value}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InfoHint } from "@/components/ui/info-hint";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { LearnMoreLink } from "@/components/ui/learn-more-link";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -195,11 +196,9 @@ export function GlucoseClinicalPanel() {
|
||||
) : null}
|
||||
{cv ? (
|
||||
<div className="space-y-0.5" data-slot="glucose-cv">
|
||||
<span
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs"
|
||||
title={t("insights.bloodGlucose.clinical.cv.help")}
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
{t("insights.bloodGlucose.clinical.cv.label")}
|
||||
<InfoHint label={t("insights.bloodGlucose.clinical.cv.help")} />
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
@@ -283,12 +282,10 @@ function Metric({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<span
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs"
|
||||
title={help}
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
{icon}
|
||||
{label}
|
||||
{help ? <InfoHint label={help} /> : null}
|
||||
</span>
|
||||
<span className="text-foreground flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||
|
||||
@@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
<p role="alert" className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -219,7 +220,12 @@ export function VorsorgeSection({
|
||||
variant?: "settings" | "page";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
|
||||
const {
|
||||
data: reminders,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useMeasurementReminders(enabled);
|
||||
const { create, update, remove, satisfy } = useMeasurementReminderMutations();
|
||||
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
|
||||
// client-side; the settings page writes them, the page reads them.
|
||||
@@ -662,7 +668,27 @@ export function VorsorgeSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (reminders?.length ?? 0) === 0 && (
|
||||
{!isLoading && isError && (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="vorsorge-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("measurementReminders.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (reminders?.length ?? 0) === 0 && (
|
||||
<EmptyState
|
||||
icon={<Plus className="size-6" />}
|
||||
title={t("measurementReminders.empty.title")}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Smile,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
@@ -219,7 +220,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const toParam = toDay || undefined;
|
||||
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodEntriesList({
|
||||
mood: moodFilter === "ALL" ? undefined : moodFilter,
|
||||
source: sourceParam,
|
||||
@@ -492,6 +493,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="mood-list-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("mood.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : !data?.entries?.length ? (
|
||||
// v1.4.15 phase-C5: replace bare-text empty rectangle with
|
||||
// EmptyState. Filter-aware copy splits "no mood entries yet"
|
||||
|
||||
@@ -245,6 +245,7 @@ export function RuntimeActionsRow({
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t("settings.rawData")}
|
||||
checked={privacyMode === "raw"}
|
||||
onCheckedChange={togglePrivacy}
|
||||
/>
|
||||
|
||||
@@ -307,6 +307,52 @@ function TargetEditSheetBody({
|
||||
|
||||
const canSave = !isDerivedMetric && primaryValid && secondaryValid && !busy;
|
||||
|
||||
// Per-field validity so the inputs can carry `aria-invalid` and the
|
||||
// disabled Save button gets a spoken explanation instead of greying out
|
||||
// silently. The order check (min < max) flags both ends of the pair.
|
||||
const primaryOrderInvalid =
|
||||
Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= maxNum;
|
||||
const primaryMinInvalid =
|
||||
primaryBounds != null &&
|
||||
(!Number.isFinite(minNum) ||
|
||||
minNum < primaryBounds.min ||
|
||||
minNum > primaryBounds.max ||
|
||||
primaryOrderInvalid);
|
||||
const primaryMaxInvalid =
|
||||
primaryBounds != null &&
|
||||
(!Number.isFinite(maxNum) ||
|
||||
maxNum < primaryBounds.min ||
|
||||
maxNum > primaryBounds.max ||
|
||||
primaryOrderInvalid);
|
||||
|
||||
const secondaryOrderInvalid =
|
||||
isBp &&
|
||||
Number.isFinite(diaMinNum) &&
|
||||
Number.isFinite(diaMaxNum) &&
|
||||
diaMinNum >= diaMaxNum;
|
||||
const secondaryMinInvalid =
|
||||
isBp &&
|
||||
secondaryBounds != null &&
|
||||
(!Number.isFinite(diaMinNum) ||
|
||||
diaMinNum < secondaryBounds.min ||
|
||||
diaMinNum > secondaryBounds.max ||
|
||||
secondaryOrderInvalid);
|
||||
const secondaryMaxInvalid =
|
||||
isBp &&
|
||||
secondaryBounds != null &&
|
||||
(!Number.isFinite(diaMaxNum) ||
|
||||
diaMaxNum < secondaryBounds.min ||
|
||||
diaMaxNum > secondaryBounds.max ||
|
||||
secondaryOrderInvalid);
|
||||
|
||||
let validationMessage: string | null = null;
|
||||
if (!isDerivedMetric && primaryBounds && (!primaryValid || !secondaryValid)) {
|
||||
validationMessage =
|
||||
primaryOrderInvalid || secondaryOrderInvalid
|
||||
? t("targets.edit.validationOrder")
|
||||
: t("targets.edit.validationBounds");
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return;
|
||||
if (isBp) {
|
||||
@@ -426,6 +472,7 @@ function TargetEditSheetBody({
|
||||
value={displayMin}
|
||||
onChange={(e) => setMinStr(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-invalid={primaryMinInvalid}
|
||||
data-slot="target-edit-min-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -444,6 +491,7 @@ function TargetEditSheetBody({
|
||||
value={displayMax}
|
||||
onChange={(e) => setMaxStr(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-invalid={primaryMaxInvalid}
|
||||
data-slot="target-edit-max-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -467,6 +515,7 @@ function TargetEditSheetBody({
|
||||
value={displayDiaMin}
|
||||
onChange={(e) => setDiaMinStr(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-invalid={secondaryMinInvalid}
|
||||
data-slot="target-edit-dia-min-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -483,6 +532,7 @@ function TargetEditSheetBody({
|
||||
value={displayDiaMax}
|
||||
onChange={(e) => setDiaMaxStr(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-invalid={secondaryMaxInvalid}
|
||||
data-slot="target-edit-dia-max-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -496,6 +546,16 @@ function TargetEditSheetBody({
|
||||
unit: primaryBounds.unit,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{validationMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="target-edit-validation"
|
||||
className="text-destructive text-xs"
|
||||
>
|
||||
{validationMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A small, tap-reachable help affordance. Replaces the bare `title=`
|
||||
* attribute, which never surfaces on touch. The trigger is a real button
|
||||
* with an accessible name, so keyboard and screen-reader users reach the
|
||||
* same hint a pointer user gets on hover; tapping the button focuses it,
|
||||
* which opens the tooltip on touch devices.
|
||||
*/
|
||||
export function InfoHint({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
/** The hint text shown inside the tooltip. */
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="info-hint"
|
||||
aria-label={t("common.moreInfo")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 inline-flex min-h-6 min-w-6 shrink-0 items-center justify-center rounded-full p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-slot="info-hint-body"
|
||||
align="start"
|
||||
className="max-w-xs leading-relaxed"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,16 @@ interface PasswordStrengthProps {
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
// The bar fill stays vivid (it's a non-text indicator), but the label text
|
||||
// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones
|
||||
// failed contrast against the white card. Weakest is destructive, the middle
|
||||
// band warning, the strong band success.
|
||||
const SCORE_COLORS = [
|
||||
{ color: "bg-red-500", textColor: "text-red-500" },
|
||||
{ color: "bg-orange-500", textColor: "text-orange-500" },
|
||||
{ color: "bg-yellow-500", textColor: "text-yellow-500" },
|
||||
{ color: "bg-green-500", textColor: "text-green-500" },
|
||||
{ color: "bg-emerald-600", textColor: "text-emerald-600" },
|
||||
{ color: "bg-red-500", textColor: "text-destructive" },
|
||||
{ color: "bg-orange-500", textColor: "text-destructive" },
|
||||
{ color: "bg-yellow-500", textColor: "text-warning" },
|
||||
{ color: "bg-green-500", textColor: "text-success" },
|
||||
{ color: "bg-emerald-600", textColor: "text-success" },
|
||||
];
|
||||
|
||||
export function PasswordStrength({
|
||||
|
||||
Reference in new issue
Block a user