mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(coach): move focus to the composer after a chip or a clarification choice
A tapped follow-up chip goes away and a clarification choice disables as the turn starts, which dropped focus to the page. Both now hand focus to the composer, which stays focusable while a reply streams (read-only with aria-disabled rather than disabled). The clarification question is announced through a live region that is mounted before the question arrives; the card itself no longer carries one.
This commit is contained in:
1 parent
1dd74bfa20
commit
9913734961
8 files changed
+95
-11
No files matched your search
@@ -14,6 +14,11 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const focusComposer = vi.fn();
|
||||
vi.mock("../composer-focus", () => ({
|
||||
focusCoachComposer: () => focusComposer(),
|
||||
}));
|
||||
|
||||
import { CoachClarificationCard } from "../clarification-card";
|
||||
import type { CoachClarification } from "@/lib/ai/coach/types";
|
||||
|
||||
@@ -58,9 +63,11 @@ function choiceButtons(
|
||||
}
|
||||
|
||||
describe("<CoachClarificationCard>", () => {
|
||||
it("announces politely and takes no focus", () => {
|
||||
it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
|
||||
const { html } = card(METRIC);
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
// The card mounts with its content, so a live region on the card itself
|
||||
// is never heard; the conversation keeps one mounted for it.
|
||||
expect(html).not.toContain("aria-live");
|
||||
expect(html).toContain('aria-label="The Coach has a question"');
|
||||
expect(html).not.toContain("autofocus");
|
||||
expect(html).not.toContain("autoFocus");
|
||||
@@ -78,8 +85,11 @@ describe("<CoachClarificationCard>", () => {
|
||||
const { tree, onChoose } = card(METRIC);
|
||||
const buttons = choiceButtons(tree);
|
||||
expect(buttons).toHaveLength(2);
|
||||
focusComposer.mockClear();
|
||||
(buttons[1].props.onClick as () => void)();
|
||||
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
|
||||
// The choices disable while the answer runs: focus goes to the composer.
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the choices while a turn is in flight", () => {
|
||||
|
||||
@@ -113,6 +113,18 @@ describe("<CoachConversation> page deep-link (#67)", () => {
|
||||
expect(html).not.toContain('data-slot="coach-hero"');
|
||||
});
|
||||
|
||||
it("keeps the clarification live region mounted before any question arrives", () => {
|
||||
// A region that mounts together with its content is not announced; the
|
||||
// empty region has to be in the page first.
|
||||
const html = render(
|
||||
<CoachConversation surface="page" initialConversationId="older" />,
|
||||
makeClient(),
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<div[^>]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-opens the most-recent thread when none is pinned", () => {
|
||||
const html = render(
|
||||
<CoachConversation surface="page" autoOpenMostRecent />,
|
||||
|
||||
@@ -111,6 +111,23 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("keeps the textarea focusable while a reply streams: read-only, not disabled", () => {
|
||||
const html = render(
|
||||
<CoachInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
disabled
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
const textarea = html.match(/<textarea[^>]*>/)?.[0];
|
||||
expect(textarea).toContain('id="coach-composer-textarea"');
|
||||
expect(textarea).toMatch(/\sreadonly(=""|\s|>)/i);
|
||||
expect(textarea).toContain('aria-disabled="true"');
|
||||
expect(textarea).not.toMatch(/\sdisabled(=""|\s|>)/);
|
||||
});
|
||||
|
||||
it("swaps the send button for a Stop control while streaming with onCancel", () => {
|
||||
// v1.11.3 D1 — while a reply streams the composer must surface a
|
||||
// visible Stop affordance bound to the abort handler so the user
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* `clarification: { messageId, choiceId }`; a typed answer goes out with
|
||||
* `clarification: { messageId }`.
|
||||
*
|
||||
* Announced politely when it appears and never takes focus: the person may
|
||||
* already be typing.
|
||||
* Announced politely when it appears, through the live region the
|
||||
* conversation keeps mounted beside it (a region that arrives with its
|
||||
* content is never heard), and never takes focus on its own: the person may
|
||||
* already be typing. A tapped choice hands focus to the composer.
|
||||
*/
|
||||
import { MessageCircleQuestionMark } from "lucide-react";
|
||||
|
||||
@@ -22,6 +24,8 @@ import type {
|
||||
CoachClarificationChoice,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
|
||||
export interface CoachClarificationCardProps {
|
||||
clarification: CoachClarification;
|
||||
/** The assistant message that asked. */
|
||||
@@ -56,7 +60,6 @@ export function CoachClarificationCard({
|
||||
<Card
|
||||
data-slot="coach-clarification-card"
|
||||
data-message-id={messageId}
|
||||
aria-live="polite"
|
||||
aria-label={t(COACH_CLARIFY_UI_KEYS.cardLabel)}
|
||||
role="region"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
@@ -85,7 +88,10 @@ export function CoachClarificationCard({
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled}
|
||||
data-choice-id={choice.id}
|
||||
onClick={() => onChoose(choice)}
|
||||
onClick={() => {
|
||||
onChoose(choice);
|
||||
focusCoachComposer();
|
||||
}}
|
||||
>
|
||||
{choiceLabel(choice)}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
@@ -861,6 +862,17 @@ export function CoachConversation({
|
||||
{t("insights.coach.attach.indexingHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* v1.39.4 — mounted empty before any question, so the question is
|
||||
announced when it arrives (a live region that mounts with its
|
||||
content is not heard). Visually hidden, so it takes no room in the
|
||||
stack; the card below carries the choices. */}
|
||||
<div
|
||||
data-slot="coach-clarification-live"
|
||||
aria-live="polite"
|
||||
className="sr-only"
|
||||
>
|
||||
{openClarification ? t(COACH_CLARIFY_UI_KEYS.cardLabel) : ""}
|
||||
</div>
|
||||
{openClarification ? (
|
||||
<CoachClarificationCard
|
||||
clarification={openClarification.clarification}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { COACH_COMPOSER_ID } from "./composer-focus";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — Coach composer.
|
||||
*
|
||||
@@ -172,7 +174,7 @@ export function CoachInput({
|
||||
onCancel,
|
||||
disabled = false,
|
||||
isStreaming = false,
|
||||
inputId = "coach-composer-textarea",
|
||||
inputId = COACH_COMPOSER_ID,
|
||||
autoFocusOnOpen = false,
|
||||
placeholder,
|
||||
showHub = false,
|
||||
@@ -446,7 +448,11 @@ export function CoachInput({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
|
||||
disabled={disabled}
|
||||
// Read-only rather than disabled while a reply streams: a disabled
|
||||
// field drops focus, and a follow-up chip or a clarification choice
|
||||
// hands focus here right as the turn starts.
|
||||
readOnly={disabled}
|
||||
aria-disabled={disabled || undefined}
|
||||
enterKeyHint="send"
|
||||
autoCapitalize="sentences"
|
||||
rows={1}
|
||||
@@ -455,7 +461,7 @@ export function CoachInput({
|
||||
"px-2 py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
"placeholder:text-muted-foreground aria-disabled:opacity-60",
|
||||
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* v1.39.4 — the Coach composer's id, and handing focus to it.
|
||||
*
|
||||
* A follow-up chip or a clarification choice sends a message and then goes
|
||||
* away (chips hide while the turn runs, choices disable), which would drop
|
||||
* keyboard and screen-reader focus to the page. Focus moves to the composer
|
||||
* instead: that is where the person continues. The composer stays focusable
|
||||
* while a reply streams (it is read-only then, not disabled).
|
||||
*/
|
||||
export const COACH_COMPOSER_ID = "coach-composer-textarea";
|
||||
|
||||
export function focusCoachComposer(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.getElementById(COACH_COMPOSER_ID)?.focus();
|
||||
}
|
||||
@@ -9,13 +9,16 @@
|
||||
* re-rendered from `labelKey` here: a related-metric chip names its metric
|
||||
* in the label, and the key alone does not carry it. Hidden while a turn is
|
||||
* in flight, so a chip can never be tapped against a reply that is about to
|
||||
* stop being the latest.
|
||||
* stop being the latest. The tapped chip goes away with the rest, so focus
|
||||
* moves to the composer.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
|
||||
/** At most this many chips render, whatever the payload carries. */
|
||||
const MAX_CHIPS = 3;
|
||||
|
||||
@@ -54,7 +57,10 @@ export function CoachFollowUpChips({
|
||||
className="min-h-11"
|
||||
data-follow-up-id={chip.id}
|
||||
data-follow-up-kind={chip.kind}
|
||||
onClick={() => onSelect(chip, messageId)}
|
||||
onClick={() => {
|
||||
onSelect(chip, messageId);
|
||||
focusCoachComposer();
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user