feat(coach): tap-friendly composer hint and accurate evidence aria-expanded

- New `<Popover>` primitive (Radix-backed, Dracula surface tokens)
  mirrors the existing `<Tooltip>` wrapper. Tooltips do not
  tap-toggle reliably on touch; popovers do.
- The Coach composer info-icon swaps off `<Tooltip>` onto
  `<Popover>` so the long-form "Enter to send, Shift+Enter for a new
  line" copy is reachable via a tap on mobile while the keyboard
  hover-preview semantic is preserved on desktop. The `aria-label`
  on the trigger keeps screen-reader users announced.
- The composer textarea now carries `enterKeyHint="send"`,
  `autoCapitalize="sentences"`, and an opt-in `autoFocusOnOpen` prop.
  The drawer flips that prop on when it mounts the fallback
  composer, so the textarea claims the cursor the first time the
  drawer opens; subsequent re-renders of an already-open drawer do
  not steal focus back.
- Coach evidence `<details>` is now controlled by local state. The
  summary carries an accurate `aria-expanded` that flips in
  lock-step with the disclosure; the native Enter / Space toggle
  semantics are preserved.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 18:42:15 +02:00
1 parent bba8921ec9
commit bfb5ba7290
5 files changed
+156 -34

No files matched your search

@@ -26,12 +26,15 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + the tooltip-trigger hint", () => {
it("renders the localised placeholder + the popover-trigger hint", () => {
// v1.4.27 F15 — the verbose prose hint footer was replaced by an
// Info icon wrapped in a tooltip. The same `composerHint`
// translation now surfaces as the trigger's `aria-label` (so
// screen-readers still announce it on focus) and as the tooltip
// body for sighted users on hover.
// Info icon button. The same `composerHint` translation surfaces
// as the trigger's `aria-label` (so screen-readers announce it on
// focus) and as the popover body for sighted users on tap.
//
// v1.4.27 MB3 / CF-31 — the disclosure swapped off Radix Tooltip
// (no reliable tap-toggle on touch surfaces) onto Radix Popover,
// so the contract is now "tap to open" instead of "hover".
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
@@ -455,6 +455,12 @@ export function CoachDrawer({
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
// v1.4.27 MB3 / CF-30 — the composer mounts on drawer
// open and unmounts on close (the body collapses with
// the sheet), so a one-shot mount focus matches the
// "drawer just opened" semantic without any re-render
// gymnastics.
autoFocusOnOpen
/>
)
}
@@ -11,11 +11,10 @@ import { Info, Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,6 +49,14 @@ export interface CoachInputProps {
isStreaming?: boolean;
/** Optional textarea ref so the parent can focus on drawer open. */
inputId?: string;
/**
* v1.4.27 MB3 / CF-30 — when set, focuses the composer textarea once
* on mount. The drawer flips this to `true` when the drawer becomes
* the freshly-opened surface so the user can start typing
* immediately. It must stay opt-in (lazy): on every re-render of an
* already-open drawer we do not steal focus back.
*/
autoFocusOnOpen?: boolean;
}
/**
@@ -94,10 +101,29 @@ export function CoachInput({
disabled = false,
isStreaming = false,
inputId = "coach-composer-textarea",
autoFocusOnOpen = false,
}: CoachInputProps) {
const { t } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
// parent flags this surface as the freshly-opened one. The empty
// dependency array keeps this from re-firing on every value change
// (the auto-grow effect handles those). We bail when disabled so we
// do not yank focus into a non-interactive field.
useEffect(() => {
if (!autoFocusOnOpen || disabled) return;
const el = textareaRef.current;
if (!el) return;
// Defer to the next paint so the drawer's own focus-trap finishes
// resolving before we hand the cursor to the composer.
const id = window.requestAnimationFrame(() => {
el.focus();
});
return () => window.cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot on mount
}, []);
// v1.4.25 W5 — auto-grow side effect. Runs on every value change
// (the controlled `value` prop is the source of truth) plus on mount
// so the initial paint already shows the 1-line height even when the
@@ -168,6 +194,13 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
// v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard
// return on iOS / Android instead of the generic newline
// glyph, and lift autocapitalize to "sentences" so a
// question typed on phone reads as one. The submit handler
// still preserves Shift+Enter for explicit newlines.
enterKeyHint="send"
autoCapitalize="sentences"
// v1.4.25 W5 — single-line initial state, Claude-web-style.
// `rows={1}` is the SSR-stable baseline; the `useEffect`
// above grows the textarea up to `AUTO_GROW_MAX_LINES`. Past
@@ -185,31 +218,39 @@ export function CoachInput({
it ate ~140 px of vertical room for an Apple-Health-like
single-message exchange. The hint now sits behind a tiny
Info icon left of the send button; the existing
translation string surfaces as the tooltip body so
screen-reader users still hear it on focus. */}
translation string surfaces as the popover body so
screen-reader users still hear it on focus.
v1.4.27 MB3 / CF-31 — swapped off Radix `<Tooltip>` (which
never tap-toggles reliably on touch) onto `<Popover>` so
mobile users can open the hint via a plain tap on the icon
button. The aria-label still labels the icon for screen
readers; the popover body carries the long-form copy. */}
<div className="mt-1.5 flex items-center justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("insights.coach.composerHint")}
data-slot="coach-input-hint"
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
"items-center justify-center rounded",
"focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.composerHint")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("insights.coach.composerHint")}
data-slot="coach-input-hint"
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
"items-center justify-center rounded",
"focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
data-slot="coach-input-hint-body"
>
{t("insights.coach.composerHint")}
</PopoverContent>
</Popover>
<Button
type="submit"
size="sm"
@@ -318,6 +318,13 @@ function ChatBubble({
// v1.4.27 B7 / L3 — pair the evidence `<details>` and its disclosed
// list explicitly so screen-readers announce the panel relationship.
const evidencePanelId = useId();
// v1.4.27 MB3 / CF-32 — track the disclosure state in React so the
// summary can carry an accurate `aria-expanded`. Native `<details>`
// reflects its open state via the `open` attribute, but that does
// not surface as `aria-expanded` on the summary by default; screen
// readers still need the explicit attribute to announce the panel
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
// avatar matches the Coach avatar in size and visual weight.
@@ -425,11 +432,20 @@ function ChatBubble({
{keyValues.length > 0 && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
onToggle={(e) =>
setEvidenceOpen((e.target as HTMLDetailsElement).open)
}
// v1.4.27 F14 — always closed by default. The `open`
// attribute was previously tied to a per-user pref that
// surfaced raw values unconditionally; that pref is now
// retired and the disclosure is a true progressive-
// disclosure surface — the user clicks to expand.
//
// v1.4.27 MB3 / CF-32 — the `open` attribute is now
// controlled from local state so the summary's
// `aria-expanded` stays in lock-step. The native disclosure
// semantics (Enter / Space toggle) are preserved.
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
@@ -438,6 +454,7 @@ function ChatBubble({
<summary
data-slot="coach-evidence-summary"
aria-controls={evidencePanelId}
aria-expanded={evidenceOpen}
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Popover primitive — Radix `Popover` wrapped with the same Dracula
* surface tokens the rest of the shadcn primitives use.
*
* v1.4.27 MB3 — introduced so the Coach composer hint can swap off
* the `<Tooltip>` primitive. Radix tooltips never tap-toggle reliably
* on touch surfaces; popovers do. The mouse-hover preview that the
* tooltip used to provide on desktop is dropped intentionally —
* tap-to-open is the same gesture on every device.
*/
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground border-border animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-xs origin-(--radix-popover-content-transform-origin) rounded-md border p-3 text-xs shadow-md outline-none",
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };