mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): show what the Coach looked at above the answer
While a turn runs, the header names the read in progress with a soft shimmer; when it ends the list folds into "Looked at N sources". Each row reads domain, window, and the count or the reason nothing was found. Completed steps are announced through a polite status region at most once every 1.5 s, and a reloaded conversation stays quiet. The spinner and shimmer stop under reduced motion.
This commit is contained in:
1 parent
ea2eaf2074
commit
1914764ddc
2 files changed
+580
-6
No files matched your search
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CoachTurnStepList,
|
||||
CoachTurnSteps,
|
||||
currentStep,
|
||||
describeStep,
|
||||
nextAnnouncement,
|
||||
stepLabel,
|
||||
} from "../turn-steps";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Reading Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Reading Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Reading Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Lese Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep / nextAnnouncement", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
|
||||
it("announces only settled steps not yet told, the newest one", () => {
|
||||
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
|
||||
"s2",
|
||||
);
|
||||
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
|
||||
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnSteps>", () => {
|
||||
it("renders nothing without steps", () => {
|
||||
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
|
||||
"coach-turn-steps",
|
||||
);
|
||||
});
|
||||
|
||||
it("while active: the running step in the header, with a stable accessible name", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-active"');
|
||||
expect(html).toContain("Reading Lab results, last 12 months…");
|
||||
expect(html).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// The shimmer comes from the class that stops under reduced motion.
|
||||
expect(html).toContain("skeleton-shimmer");
|
||||
// Collapsed by default.
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("when done: folds into a count of sources, closed", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
expect(html).not.toContain("skeleton-shimmer");
|
||||
expect(html).not.toContain("aria-label=");
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses theme tokens only, and every motion stops under reduced motion", () => {
|
||||
const html =
|
||||
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />) +
|
||||
render(
|
||||
<CoachTurnStepList
|
||||
steps={[
|
||||
BP,
|
||||
SLEEP,
|
||||
LABS_RUNNING,
|
||||
{
|
||||
...SLEEP,
|
||||
id: "s4",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
},
|
||||
]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition")) {
|
||||
throw new Error(`unguarded transition: ${cls}`);
|
||||
}
|
||||
}
|
||||
// The spinner stops under reduced motion (the repo-wide convention).
|
||||
expect(html).toContain("animate-spin motion-reduce:animate-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnStepList>", () => {
|
||||
it("one row per step: domain · window · count or reason, with its status", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
|
||||
);
|
||||
expect(html).toContain('aria-label="What the Coach looked at"');
|
||||
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
|
||||
expect(html).toContain('data-status="done"');
|
||||
expect(html).toContain('data-status="empty"');
|
||||
expect(html).toContain('data-status="running"');
|
||||
const text = html
|
||||
.replace(/<span class="sr-only">, <\/span>/g, "")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
|
||||
expect(text).toContain("Sleep · last 30 days · no readings");
|
||||
expect(text).toContain("Lab results · last 12 months");
|
||||
});
|
||||
|
||||
it("a reloaded list shows the same rows", () => {
|
||||
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
|
||||
const stored = render(
|
||||
<CoachTurnStepList
|
||||
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
|
||||
expect(text(stored)).toBe(text(live));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.39.4 — what the Coach read on a turn: a compact list of steps under
|
||||
* the assistant avatar, live while the turn runs (from the `step` frames)
|
||||
* and restored from `metricSource.steps` on reload.
|
||||
* v1.39.4 — what the Coach read on a turn: a compact list of steps above
|
||||
* the answer, live while the turn runs (from the `step` frames) and
|
||||
* restored from `metricSource.steps` on reload.
|
||||
*
|
||||
* Not built yet: renders nothing.
|
||||
* While the turn runs the header names the step in progress ("Reading
|
||||
* Blood pressure, last 90 days…") with a soft shimmer; once it ends the
|
||||
* header folds into "Looked at 3 sources" and the list closes. The list
|
||||
* itself is one row per step: domain, window, and a count or the reason
|
||||
* nothing was found. Everything shown comes from catalog keys and the
|
||||
* server's enums and counts; a row never shows a value, a tool argument or
|
||||
* model text.
|
||||
*
|
||||
* Screen readers hear each completed step once, through a polite status
|
||||
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
|
||||
* wins). A reloaded conversation announces nothing.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Minus,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
COACH_STEP_REASON_KEYS,
|
||||
COACH_STEP_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachGranularityLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
export interface CoachTurnStepsProps {
|
||||
@@ -15,6 +44,311 @@ export interface CoachTurnStepsProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function CoachTurnSteps(_props: CoachTurnStepsProps) {
|
||||
return null;
|
||||
/** The shortest gap between two screen-reader announcements. */
|
||||
export const ANNOUNCE_THROTTLE_MS = 1500;
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
type TranslateCount = (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
|
||||
function tOr(
|
||||
t: Translate,
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
const value = t(key, params);
|
||||
return value === key ? fallback : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's label in the reader's language: the catalog key rendered with
|
||||
* the domain and window names, or the server's label when the key is not
|
||||
* in the bundle.
|
||||
*/
|
||||
export function stepLabel(step: CoachStep, t: Translate): string {
|
||||
return tOr(t, step.labelKey, step.label, {
|
||||
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
|
||||
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row's text: the domain as the title, then the window, period,
|
||||
* granularity, and either how much was read or why nothing was.
|
||||
*/
|
||||
export function describeStep(
|
||||
step: CoachStep,
|
||||
t: Translate,
|
||||
tCount: TranslateCount,
|
||||
): { title: string; meta: string[] } {
|
||||
const title = step.domain
|
||||
? t(coachDomainLabelKey(step.domain))
|
||||
: stepLabel(step, t);
|
||||
const meta: string[] = [];
|
||||
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
|
||||
if (step.period && step.period !== "current") {
|
||||
meta.push(t(coachPeriodLabelKey(step.period)));
|
||||
}
|
||||
if (step.granularity)
|
||||
meta.push(t(coachGranularityLabelKey(step.granularity)));
|
||||
if (step.status === "done" && step.count !== undefined) {
|
||||
const base =
|
||||
step.tool === "snapshot"
|
||||
? "coach.step.metrics"
|
||||
: step.tool === "get_labs"
|
||||
? "coach.step.rows"
|
||||
: "coach.step.readings";
|
||||
meta.push(tCount(base, step.count));
|
||||
} else if (step.status !== "running" && step.reason) {
|
||||
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
|
||||
}
|
||||
return { title, meta };
|
||||
}
|
||||
|
||||
function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return [title, ...meta].join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The step the active header names: the latest one still running, else the
|
||||
* latest one overall (between two rounds nothing is running).
|
||||
*/
|
||||
export function currentStep(steps: CoachStep[]): CoachStep | null {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
if (steps[i].status === "running") return steps[i];
|
||||
}
|
||||
return steps.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest settled step the reader has not been told about yet, given the
|
||||
* ids already announced (or present when the list mounted). Null when there
|
||||
* is nothing new.
|
||||
*/
|
||||
export function nextAnnouncement(
|
||||
steps: CoachStep[],
|
||||
told: ReadonlySet<string>,
|
||||
): CoachStep | null {
|
||||
let latest: CoachStep | null = null;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running" && !told.has(step.id)) latest = step;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status region's text: each newly settled step, throttled. Steps that
|
||||
* were already settled when the list mounted are never announced, so a
|
||||
* reloaded conversation stays quiet.
|
||||
*/
|
||||
function useStepAnnouncement(
|
||||
steps: CoachStep[],
|
||||
describe: (step: CoachStep) => string,
|
||||
): string {
|
||||
// Mutable on purpose: the ids the reader has been told about, seeded with
|
||||
// whatever was already settled at mount.
|
||||
const [told] = useState(
|
||||
() => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
|
||||
);
|
||||
const lastAt = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const next = nextAnnouncement(steps, told);
|
||||
if (!next) return;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running") told.add(step.id);
|
||||
}
|
||||
const nextText = describe(next);
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
|
||||
);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
lastAt.current = Date.now();
|
||||
setText(nextText);
|
||||
}, wait);
|
||||
}, [steps, describe, told]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
|
||||
const className = "mt-0.5 size-3 shrink-0";
|
||||
if (step.status === "running" && active) {
|
||||
return (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
className,
|
||||
"text-muted-foreground animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "done") {
|
||||
return (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "failed") {
|
||||
return (
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Minus
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows under the header: one per step, in the order they started. */
|
||||
export function CoachTurnStepList({
|
||||
id,
|
||||
steps,
|
||||
active,
|
||||
}: CoachTurnStepsProps & { id?: string }) {
|
||||
const { t, tCount } = useTranslations();
|
||||
return (
|
||||
<ol
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-list"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
|
||||
>
|
||||
{steps.map((step) => {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
data-slot="coach-turn-step"
|
||||
data-status={step.status}
|
||||
className="flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<StepIcon step={step} active={active} />
|
||||
<span className="text-muted-foreground min-w-0">
|
||||
<span className="text-foreground/80">{title}</span>
|
||||
{meta.map((part, i) => (
|
||||
<span key={i}>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span className="sr-only">, </span>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const listId = useId();
|
||||
// Closed by default; the reader's toggle holds until the turn ends, and
|
||||
// the list folds away again when it does.
|
||||
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
setOpenOverride(null);
|
||||
}
|
||||
const announcement = useStepAnnouncement(steps, (step) =>
|
||||
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
|
||||
);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
const open = openOverride ?? false;
|
||||
const current = active ? currentStep(steps) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-turn-steps"
|
||||
className="flex max-w-full flex-col text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-turn-steps-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
// While the turn runs the visible text changes with every step; the
|
||||
// status region below announces completions, so the button keeps a
|
||||
// stable name instead of re-announcing each label.
|
||||
{...(current
|
||||
? {
|
||||
"aria-label": t(
|
||||
open
|
||||
? COACH_STEP_UI_KEYS.toggleHide
|
||||
: COACH_STEP_UI_KEYS.toggleShow,
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
onClick={() => setOpenOverride(!open)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-fit max-w-full items-center gap-1.5",
|
||||
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{current ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-active"
|
||||
className="skeleton-shimmer min-w-0 truncate rounded"
|
||||
>
|
||||
{t(COACH_STEP_UI_KEYS.headerActive, {
|
||||
label: stepLabel(current, t),
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
|
||||
{tCount("coach.step.headerDone", steps.length)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <CoachTurnStepList id={listId} steps={steps} active={active} />}
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user