mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern
The composer starts at a single-line height (matching the disclaimer text on the left of the row) and grows with content up to six lines. Past that point the textarea scrolls internally so the rest of the drawer never moves. Implementation is the standard scrollHeight measurement — reset to "auto", read scrollHeight, clamp to [lineHeight + padding, 6 × lineHeight + padding]. The pure `computeAutoGrowHeight` helper backs the effect for unit testing.
This commit is contained in:
1 parent
1332342c59
commit
c7eb226969
2 files changed
+128
-4
No files matched your search
@@ -121,10 +121,57 @@ describe("<CoachInput>", () => {
|
||||
expect(handler).toHaveBeenCalledWith("typed");
|
||||
});
|
||||
|
||||
it("renders the textarea with rows=2 to match the artboard", () => {
|
||||
it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => {
|
||||
// v1.4.25 W5 — Claude-web-style auto-grow. SSR baseline is a
|
||||
// single-line textarea; the client-side `useEffect` measures
|
||||
// `scrollHeight` and grows the height up to ~6 lines. The static
|
||||
// markup must show `rows="1"` so the initial paint matches the
|
||||
// disclaimer text height on the left side of the row.
|
||||
const html = render(
|
||||
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/);
|
||||
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="1"/);
|
||||
// The textarea caps growth via the `max-h-[9.5rem]` class so the
|
||||
// composer never pushes the rest of the drawer off-screen.
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-input-textarea"[^>]*class="[^"]*max-h-\[9\.5rem\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.25 W5 — `computeAutoGrowHeight` is the pure helper backing the
|
||||
// textarea's auto-grow effect. Pin its math so the textarea never
|
||||
// collapses below a single line or grows past the 6-line ceiling.
|
||||
import { computeAutoGrowHeight } from "../coach-input";
|
||||
|
||||
describe("computeAutoGrowHeight", () => {
|
||||
it("clamps to the single-line minimum when scrollHeight is empty", () => {
|
||||
const out = computeAutoGrowHeight({
|
||||
lineHeight: 20,
|
||||
scrollHeight: 0,
|
||||
maxLines: 6,
|
||||
paddingY: 10,
|
||||
});
|
||||
expect(out).toBe(20 + 10);
|
||||
});
|
||||
|
||||
it("returns the natural scrollHeight when below the cap", () => {
|
||||
const out = computeAutoGrowHeight({
|
||||
lineHeight: 20,
|
||||
scrollHeight: 60,
|
||||
maxLines: 6,
|
||||
paddingY: 10,
|
||||
});
|
||||
expect(out).toBe(60);
|
||||
});
|
||||
|
||||
it("caps at maxLines × lineHeight + paddingY", () => {
|
||||
const out = computeAutoGrowHeight({
|
||||
lineHeight: 20,
|
||||
scrollHeight: 999,
|
||||
maxLines: 6,
|
||||
paddingY: 10,
|
||||
});
|
||||
expect(out).toBe(20 * 6 + 10);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, type KeyboardEvent, useCallback } from "react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,6 +46,41 @@ export interface CoachInputProps {
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W5 — Claude-web-style auto-grow.
|
||||
*
|
||||
* The composer textarea starts at 1 line (≈44 px including padding,
|
||||
* matching the disclaimer text height on the sources rail) and grows
|
||||
* with content up to 6 lines (≈144 px). Past 6 lines the textarea
|
||||
* scrolls internally. Implemented as a plain `scrollHeight` measurement
|
||||
* — no external auto-resize library, no `contenteditable` round-trip,
|
||||
* no `field-sizing` (still Chrome-only in 2026-Q2).
|
||||
*
|
||||
* Pure helper so the math can be unit-tested without a DOM:
|
||||
* - `lineHeight`: cached line-height of the textarea (px)
|
||||
* - `scrollHeight`: textarea's natural `scrollHeight` after height
|
||||
* was reset to "auto"
|
||||
* - `maxLines`: hard cap (6); past this the textarea scrolls
|
||||
* Returns the height in px to apply, clamped to [minHeight, maxHeight].
|
||||
*/
|
||||
export function computeAutoGrowHeight(args: {
|
||||
lineHeight: number;
|
||||
scrollHeight: number;
|
||||
maxLines: number;
|
||||
paddingY: number;
|
||||
}): number {
|
||||
const min = args.lineHeight + args.paddingY;
|
||||
const max = args.lineHeight * args.maxLines + args.paddingY;
|
||||
// Honour the existing scrollHeight (which already includes padding)
|
||||
// but clamp it into the [min,max] band. The minimum guarantees the
|
||||
// textarea never collapses below a single line when the value is
|
||||
// empty; the maximum prevents the row from pushing the composer
|
||||
// taller than 6 lines.
|
||||
return Math.max(min, Math.min(args.scrollHeight, max));
|
||||
}
|
||||
|
||||
const AUTO_GROW_MAX_LINES = 6;
|
||||
|
||||
export function CoachInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -49,6 +90,35 @@ export function CoachInput({
|
||||
inputId = "coach-composer-textarea",
|
||||
}: CoachInputProps) {
|
||||
const { t } = useTranslations();
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// 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
|
||||
// value arrives prefilled. We reset `style.height` to "auto" first so
|
||||
// shrinking text (e.g. after the user hits send) also collapses the
|
||||
// textarea — without the reset, `scrollHeight` only ever monotonically
|
||||
// grows. Reads `getComputedStyle` for line-height + padding once per
|
||||
// tick so a CSS-only theme change re-applies the right minimum.
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
// Reset so the browser re-measures the natural content height.
|
||||
el.style.height = "auto";
|
||||
const computed = window.getComputedStyle(el);
|
||||
const rawLineHeight = parseFloat(computed.lineHeight);
|
||||
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 20;
|
||||
const paddingY =
|
||||
(parseFloat(computed.paddingTop) || 0) +
|
||||
(parseFloat(computed.paddingBottom) || 0);
|
||||
const height = computeAutoGrowHeight({
|
||||
lineHeight,
|
||||
scrollHeight: el.scrollHeight,
|
||||
maxLines: AUTO_GROW_MAX_LINES,
|
||||
paddingY,
|
||||
});
|
||||
el.style.height = `${height}px`;
|
||||
}, [value]);
|
||||
|
||||
const canSubmit = !disabled && value.trim().length > 0;
|
||||
|
||||
@@ -85,15 +155,22 @@ export function CoachInput({
|
||||
>
|
||||
<textarea
|
||||
id={inputId}
|
||||
ref={textareaRef}
|
||||
data-slot="coach-input-textarea"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("insights.coach.composerPlaceholder")}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
// 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
|
||||
// the cap the textarea scrolls internally (overflow-auto via
|
||||
// `max-h-[9.5rem]`, ≈6 lines at the current line-height).
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent text-sm leading-relaxed outline-none",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user