mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
style: one spacing, title and skeleton language across the app
Icons inside buttons drop their manual margins in favour of the button's built-in gap (two hundred call sites), form field stacks settle on one spacing step, card titles on exactly two sizes, inline field errors on one text size, and a dozen hand-rolled loading pulses move onto the shared skeleton primitive while keeping their silhouettes. The intake dialog wording joins the rest of the app on 'übersprungen', and the UI guidelines now document the two deliberate footer and touch-target systems.
This commit is contained in:
1 parent
4ca3de5ff3
commit
04765ea8ac
92 files changed
+1433
-1445
No files matched your search
+135
-137
@@ -248,15 +248,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={loading || !mood}
|
||||
>
|
||||
<Button type="submit" form={formId} disabled={loading || !mood}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
@@ -328,145 +324,150 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</div>
|
||||
|
||||
{moodPicked && (
|
||||
<div className="space-y-4 border-t pt-4" data-slot="mood-annotate-panel">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="space-y-4 border-t pt-4"
|
||||
data-slot="mood-annotate-panel"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.toLowerCase() !== label.toLowerCase());
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) => p.toLowerCase() !== label.toLowerCase(),
|
||||
);
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
|
||||
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
|
||||
to the free-text input above; an entry can carry both axes. The
|
||||
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
|
||||
controls, lifted here as `ratedFactors`. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no longer
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no longer
|
||||
truncates silently. Turns destructive (warns) as the input
|
||||
approaches the limit. */}
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -497,10 +498,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={resetForm}
|
||||
>
|
||||
<AlertDialogAction variant="destructive" onClick={resetForm}>
|
||||
{t("mood.formResetConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
+328
-338
@@ -62,15 +62,8 @@ import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
MOOD_LABEL_KEYS,
|
||||
MOOD_SCORE_BY_ENUM,
|
||||
} from "@/lib/mood/labels";
|
||||
import {
|
||||
invalidateKeys,
|
||||
moodDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels";
|
||||
import { invalidateKeys, moodDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { moodSourceEnum } from "@/lib/validations/moodlog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -146,9 +139,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// v1.15.13 — page-scoped multi-select selection (current page ids only).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [editing, setEditing] = useState<MoodEntry | null>(null);
|
||||
const [editMood, setEditMood] = useState("");
|
||||
@@ -173,9 +164,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
|
||||
const editFormId = useId();
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// v1.15.13 — every filter / page / sort change resets pagination AND
|
||||
// clears the page-scoped selection.
|
||||
@@ -460,7 +449,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<SelectValue placeholder={t("dataList.allSources")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">{t("dataList.allSources")}</SelectItem>
|
||||
<SelectItem value="ALL">
|
||||
{t("dataList.allSources")}
|
||||
</SelectItem>
|
||||
{MOOD_SOURCE_OPTIONS.map((src) => (
|
||||
<SelectItem key={src} value={src}>
|
||||
{formatMoodSource(src, t)}
|
||||
@@ -472,7 +463,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
|
||||
{/* v1.15.13 — optional date range, wired to from/to. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="mood-from" className="text-muted-foreground text-xs">
|
||||
<Label
|
||||
htmlFor="mood-from"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("dataList.dateFrom")}
|
||||
</Label>
|
||||
<DateInput
|
||||
@@ -484,7 +478,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="mood-to" className="text-muted-foreground text-xs">
|
||||
<Label
|
||||
htmlFor="mood-to"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("dataList.dateTo")}
|
||||
</Label>
|
||||
<DateInput
|
||||
@@ -537,7 +534,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
) : onAddFirst ? (
|
||||
<Button size="sm" onClick={onAddFirst}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.emptyAddFirst")}
|
||||
</Button>
|
||||
) : undefined
|
||||
@@ -598,77 +595,79 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
{data.entries.map((entry) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{entry.score}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
|
||||
<span className="block truncate">
|
||||
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
|
||||
</span>
|
||||
{entry.note && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.source !== "MANUAL" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMoodSource(entry.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{entry.score}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
|
||||
<span className="block truncate">
|
||||
{entry.tags.length > 0
|
||||
? entry.tags.join(", ")
|
||||
: "-"}
|
||||
</span>
|
||||
{entry.note && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.source !== "MANUAL" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMoodSource(entry.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
@@ -688,87 +687,87 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
{data.entries.map((entry) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-testid="mood-row"
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border flex items-center justify-between rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
|
||||
<div
|
||||
key={entry.id}
|
||||
data-testid="mood-row"
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
|
||||
`<button role=checkbox>`; a wrapping `<label>` does
|
||||
NOT forward taps to a button, so the effective tap
|
||||
target was 16px (fails WCAG 2.5.5). The 44px button
|
||||
owns the whole hit area and is the single toggle
|
||||
source; the inner Checkbox is a `pointer-events-none`
|
||||
controlled visual, so a tap fires the handler once. */}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
onClick={() => onToggleRow(entry.id)}
|
||||
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<span
|
||||
data-testid="mood-row-score"
|
||||
className="text-lg font-bold tabular-nums"
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
onClick={() => onToggleRow(entry.id)}
|
||||
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{entry.score}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-semibold">
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
</span>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<span
|
||||
data-testid="mood-row-score"
|
||||
className="text-lg font-bold tabular-nums"
|
||||
>
|
||||
{entry.score}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-semibold">
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
</span>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{entry.tags.join(", ")}
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</p>
|
||||
)}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 truncate text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
{entry.tags.length > 0 && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{entry.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 truncate text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* Phase A5 mobile audit: bumped from h-8 w-8 (32px)
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* Phase A5 mobile audit: bumped from h-8 w-8 (32px)
|
||||
to min-h-11 min-w-11 (44px) so the per-row edit
|
||||
and delete actions meet WCAG 2.5.5 on touch
|
||||
devices. The desktop table keeps its denser
|
||||
h-8 w-8 since pointer targets allow it. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
iconClassName="h-4 w-4"
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
iconClassName="h-4 w-4"
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -833,205 +832,196 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
title={t("mood.editEntry")}
|
||||
footer={<div ref={setEditFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{editing && (
|
||||
<form
|
||||
id={editFormId}
|
||||
onSubmit={submitEdit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label id="edit-mood-level-label">{t("mood.moodLevel")}</Label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby="edit-mood-level-label"
|
||||
className="grid grid-cols-5 gap-2"
|
||||
>
|
||||
{MOOD_LEVELS_LIST.map((level, index) => {
|
||||
const isSelected = editMood === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => setEditMood(level)}
|
||||
{...getEditMoodRadioProps(index)}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary border-2"
|
||||
: "border-border hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{MOOD_SCORES[level]}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight sm:text-xs">
|
||||
{t(MOOD_LABEL_KEYS[level])}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editing && (
|
||||
<form id={editFormId} onSubmit={submitEdit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label id="edit-mood-level-label">{t("mood.moodLevel")}</Label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby="edit-mood-level-label"
|
||||
className="grid grid-cols-5 gap-2"
|
||||
>
|
||||
{MOOD_LEVELS_LIST.map((level, index) => {
|
||||
const isSelected = editMood === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => setEditMood(level)}
|
||||
{...getEditMoodRadioProps(index)}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary border-2"
|
||||
: "border-border hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{MOOD_SCORES[level]}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight sm:text-xs">
|
||||
{t(MOOD_LABEL_KEYS[level])}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-logged-at">
|
||||
{t("mood.timestamp")}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="edit-mood-logged-at"
|
||||
value={editMoodLoggedAt}
|
||||
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="edit-tags">
|
||||
{t("mood.tags")} ({t("common.optional")})
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="edit-mood-logged-at"
|
||||
value={editMoodLoggedAt}
|
||||
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="edit-tags"
|
||||
value={editTagsInput}
|
||||
onChange={(e) => setEditTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="edit-tags">
|
||||
{t("mood.tags")} ({t("common.optional")})
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="edit-tags"
|
||||
value={editTagsInput}
|
||||
onChange={(e) => setEditTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
/>
|
||||
{/* v1.8.5 — structured-tag taxonomy picker. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")} ({t("common.optional")})
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={editTagKeys}
|
||||
onToggle={toggleEditTagKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
{t("mood.note")} ({t("common.optional")})
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-mood-note"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.8.5 — structured-tag taxonomy picker. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")} ({t("common.optional")})
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={editTagKeys}
|
||||
onToggle={toggleEditTagKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
{t("mood.note")} ({t("common.optional")})
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-mood-note"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{editFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={editFormId}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
editFooterEl,
|
||||
)
|
||||
: null}
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog
|
||||
open={editDeleteDialogOpen}
|
||||
onOpenChange={setEditDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("mood.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("mood.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={deleteEditingEntry}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={editFormId}
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
editFooterEl,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AlertDialog
|
||||
open={editDeleteDialogOpen}
|
||||
onOpenChange={setEditDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("mood.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("mood.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={deleteEditingEntry}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user