mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains.
This commit is contained in:
1 parent
a2a995e429
commit
95f21bfa4d
29 files changed
+703
-137
No files matched your search
@@ -83,24 +83,40 @@ function render(locale: "en" | "de" = "de") {
|
||||
}
|
||||
|
||||
describe("MoodList — full-text note affordance", () => {
|
||||
it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => {
|
||||
it("renders the note as a plain clamped paragraph with the COMPLETE text", () => {
|
||||
const html = render("de");
|
||||
// One toggle per surface (desktop table + mobile card) for the noted row;
|
||||
// the note-less row renders none.
|
||||
const toggles = html.match(/data-testid="mood-note-toggle"/g);
|
||||
expect(toggles?.length).toBe(2);
|
||||
// Collapsed by default: clamped preview + expand affordance label.
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// One note paragraph per surface (desktop table + mobile card) for the
|
||||
// noted row; the note-less row renders none. The note is a PARAGRAPH
|
||||
// sibling of the toggle — not button content — so the accessible name
|
||||
// of the toggle never swallows the whole note.
|
||||
const notes = html.match(/data-testid="mood-note-text"/g);
|
||||
expect(notes?.length).toBe(2);
|
||||
expect(html).toContain("line-clamp-2");
|
||||
expect(html).toContain("Notiz vollständig anzeigen");
|
||||
// The complete text is in the row (the clamp is visual), both paragraphs.
|
||||
expect(html).toContain("Erster Absatz der Notiz");
|
||||
expect(html).toContain("Zweiter Absatz mit weiteren Details");
|
||||
// The toggle only mounts once the client measures an actual overflow
|
||||
// (`scrollHeight > clientHeight`); static SSR has no layout, so no
|
||||
// toggle — a short note never gets a dangling "show more" control.
|
||||
expect(html).not.toContain('data-testid="mood-note-toggle"');
|
||||
});
|
||||
|
||||
it("localises the affordance label", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain("Show full note");
|
||||
it("wires the toggle as a small sibling button with state + target", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/mood/mood-list.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// The toggle carries the disclosure contract: aria-expanded for the
|
||||
// state, aria-controls pointing at the paragraph's id.
|
||||
expect(src).toContain("aria-expanded={expanded}");
|
||||
expect(src).toContain("aria-controls={noteId}");
|
||||
expect(src).toContain('data-testid="mood-note-toggle"');
|
||||
// Clamp detection: measured overflow, re-checked on resize.
|
||||
expect(src).toContain("scrollHeight > el.clientHeight");
|
||||
expect(src).toContain("new ResizeObserver(updateClamped)");
|
||||
// Rendered only when the text actually overflows the clamp (or while
|
||||
// expanded, so it can collapse again).
|
||||
expect(src).toContain("{(expanded || clamped) && (");
|
||||
});
|
||||
|
||||
it("expanded branch preserves line breaks and renders plain text children", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -997,9 +997,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/**
|
||||
* v1.16.8 — expandable free-text note. Collapsed: a two-line clamped
|
||||
* preview. Expanded: the COMPLETE text, line breaks preserved, rendered as
|
||||
* plain text children. The whole block is one toggle button so the full
|
||||
* note is readable in place — previously the only full-text surface was
|
||||
* the edit sheet (plus a hover tooltip on desktop, unreachable on touch).
|
||||
* plain text children. The full note is readable in place — previously the
|
||||
* only full-text surface was the edit sheet (plus a hover tooltip on
|
||||
* desktop, unreachable on touch).
|
||||
*
|
||||
* Structure: the note is a plain paragraph with an `id`; the toggle is a
|
||||
* SMALL sibling button carrying `aria-expanded` + `aria-controls`. The
|
||||
* earlier cut wrapped the whole paragraph in the button, which made the
|
||||
* entire note text the button's accessible name and put block content
|
||||
* inside a button (invalid content model).
|
||||
*
|
||||
* The toggle only renders when the collapsed paragraph actually
|
||||
* overflows its two-line clamp (`scrollHeight > clientHeight`, re-checked
|
||||
* on resize) — a one-line note gets no dangling "show more" control.
|
||||
*/
|
||||
function MoodNoteText({
|
||||
note,
|
||||
@@ -1011,15 +1021,36 @@ function MoodNoteText({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const noteId = useId();
|
||||
const paragraphRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [clamped, setClamped] = useState(false);
|
||||
|
||||
const updateClamped = useCallback(() => {
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
// +1 tolerates sub-pixel rounding on fractional zoom levels.
|
||||
setClamped(el.scrollHeight > el.clientHeight + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only the collapsed paragraph carries the clamp; measuring the
|
||||
// expanded one would always read "fits" and drop the collapse
|
||||
// affordance while it is needed most.
|
||||
if (expanded) return;
|
||||
updateClamped();
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(updateClamped);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [expanded, note, updateClamped]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
data-testid="mood-note-toggle"
|
||||
className="mt-0.5 block w-full min-w-0 cursor-pointer text-left"
|
||||
>
|
||||
<div className="mt-0.5 min-w-0">
|
||||
<p
|
||||
id={noteId}
|
||||
ref={paragraphRef}
|
||||
data-testid="mood-note-text"
|
||||
className={
|
||||
expanded
|
||||
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
|
||||
@@ -1028,10 +1059,19 @@ function MoodNoteText({
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
<span className="text-dracula-purple text-[11px] font-medium">
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</span>
|
||||
</button>
|
||||
{(expanded || clamped) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={noteId}
|
||||
data-testid="mood-note-toggle"
|
||||
className="text-dracula-purple cursor-pointer text-[11px] font-medium"
|
||||
>
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user