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:
Marc-André Bombeck committed 2026-06-11 23:20:37 +02:00
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", () => {
+55 -15
View File
@@ -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>
);
}