mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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",
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user