feat(ui): extract <Textarea> primitive with iOS-zoom + WCAG defences

Six textarea call-sites had been copy-pasting the same className with
the text-base sm:text-sm iOS-zoom defence; the audit flagged it as a
drift risk. New shadcn-style primitive in src/components/ui/textarea.tsx
mirrors <Input>'s shape and bakes in the defence, the WCAG 2.5.5 tap-
target floor (min-h-11 sm:min-h-9), autoCapitalize="sentences" /
spellCheck defaults for prose, and the autoComplete="off" + password-
manager ignore posture from <Input>.

Migrates four call-sites onto the primitive (bugreport, medications
JSON import, side-effect notes, admin feedback note) — saves ~16 LOC
of inline copy-paste and ~850 chars of repeated className strings.
The coach-input textarea stays as-is: it's an auto-grow composer with
a transparent bg wrapped in an outer "fake input" div, so migrating
would require nullifying nearly every primitive default.

11 new unit tests lock the contract (iOS zoom class, tap-target floor,
forwardRef sentinel, override semantics, attribute pass-through).
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 08:54:49 +02:00
1 parent 8d0ae98afd
commit 3a241dbe35
7 files changed
+554 -315

No files matched your search

@@ -0,0 +1,76 @@
# Phase W2 — Textarea primitive extraction (v1.4.47)
## Goal
Close the v1.4.43 mobile-UI audit L3 finding: six call-sites were each maintaining their own copy-pasted className with the `text-base sm:text-sm` iOS-zoom defence, drift risk on every edit. Extract a shadcn-style `<Textarea>` primitive parallel to `<Input>`, migrate the affected call-sites, lock the contract behind unit tests.
## What landed
### New primitive: `src/components/ui/textarea.tsx`
Mirror of `<Input>` for multi-line input. Baked-in defaults:
- **iOS zoom defence** — `text-base sm:text-sm` (16 px on mobile, 14 px on `sm+`). Safari zooms the viewport on focus when the rendered font is below 16 px and yanks the keyboard up; this defends every consumer without each site having to repeat the dance.
- **WCAG 2.5.5 tap-target floor** — `min-h-11 sm:min-h-9` (44 px on touch, 36 px on pointer). Lets `rows={1}` consumers stay above the tap-target floor without manually overriding.
- **`autoCapitalize="sentences"`** — free-text fields read as prose on iOS / Android by default. Caller overrides with `"none"` for code / JSON / IDs.
- **`spellCheck={true}`** — same reasoning; caller opts out for structured input.
- **`autoComplete="off"` + `data-lpignore` / `data-1p-ignore`** — same posture as `<Input>`: HealthLog is a health-data app, so password managers don't get to paste the user's saved password into a free-text note that we then persist server-side.
- **`forwardRef`** so consumers can attach refs (auto-grow, focus management, …).
- **`data-slot="textarea"`** for the shadcn convention.
- **`cn(…)` composition** — caller className wins via tailwind-merge, defaults still apply.
Plus the standard focus / aria-invalid / disabled treatment that mirrors `<Input>`.
### New tests: `src/components/ui/__tests__/textarea.test.tsx`
11 tests, all green. Locks the contract:
- iOS zoom defence class present (`text-base` + `sm:text-sm`).
- WCAG tap-target floor present (`min-h-11` + `sm:min-h-9`).
- `autoComplete` defaults to `"off"` with the password-manager ignore attrs.
- `autoCapitalize` defaults to `"sentences"`.
- `spellCheck` defaults to `true`.
- `data-slot="textarea"` attribute.
- Caller overrides for `autoCapitalize` / `spellCheck` are honoured (JSON-paste case).
- Caller overrides for `autoComplete` drop the ignore attrs.
- Caller className merges via `cn()` without losing defaults.
- `React.forwardRef` sentinel verified (sentinel-symbol check, dependency-free — no testing-library needed).
- `rows` / `placeholder` / `maxLength` forwarded to the underlying element.
## Call-sites migrated
| File | Lines saved | Notes |
| --- | --- | --- |
| `src/app/bugreport/page.tsx:202` | -3 (comment + comment + 211-char className) | Plain free-text bug description; primitive defaults are a 1:1 fit. |
| `src/app/medications/page.tsx:468` | -3 inline className + 2 comment | JSON paste — explicitly disables `autoCapitalize` + `spellCheck`, keeps the `font-mono` override via `className`. |
| `src/components/medications/SideEffectsSection.tsx:467` | -3 inline className + 2 comment + 2 redundant attrs | Compact notes-row inside side-effect modal; `px-2 py-1.5` padding override preserved via `className`. Dropped the redundant explicit `autoCapitalize="sentences"` / `autoComplete="off"` (now the default). |
| `src/components/admin/feedback-inbox-section.tsx:498` | -3 (comment + comment + 211-char className) | Admin internal-note textarea; primitive defaults are a 1:1 fit. |
**Total**: ~16 lines of copy-paste removed; **852 chars** of repeated className strings eliminated.
## Out of scope (intentional)
- **`src/components/insights/coach-panel/coach-input.tsx:188`** — included in the audit's "6+" count, but it's a fundamentally different design (auto-grow composer card wrapped in an outer "fake input" div with its own border + focus-within ring + Dracula-purple ring on focus, transparent textarea bg, custom `max-h-[9.5rem]` cap with internal overflow). Migrating it would require nullifying nearly every primitive default (border, bg, focus-ring, min-h, padding, shadow) — net worse than the inline pattern. Left as-is.
- `<Input>` modifications — separate concern per scope.
## Quality gates
- `pnpm typecheck` — green.
- `pnpm lint` on the touched files — green (one unrelated pre-existing warning in a withings test file remains).
- `pnpm test src/components/ui/__tests__/textarea.test.tsx` — 11/11 green.
- `pnpm test src/components/ui/__tests__/ src/components/medications/__tests__/SideEffectsSection.test.tsx` — 43/43 green; no regression in the suites that touch the migrated files.
- `npx prettier --check` on the touched files — clean after a `--write` pass.
- `npx knip` — clean (no unused exports / files).
- Full `pnpm test` — 5101 / 5104 pass; the 2 failing tests (`/api/dashboard/summary` idempotency + `/api/medications/intake` double-mint) are confirmed pre-existing (fail on origin/develop unchanged, verified via `git stash`). Not in this scope.
## Branch
`worktree-agent-a135dd75a71c47561` — branched from `origin/develop` (HEAD `690cfaf0`).
## Files touched
- **NEW** `src/components/ui/textarea.tsx` (+82 LOC)
- **NEW** `src/components/ui/__tests__/textarea.test.tsx` (+93 LOC)
- **EDIT** `src/app/bugreport/page.tsx` (-3 textarea LOC, +1 import)
- **EDIT** `src/app/medications/page.tsx` (-3 textarea LOC, +1 import, +3 explicit JSON overrides)
- **EDIT** `src/components/medications/SideEffectsSection.tsx` (-5 textarea LOC, +1 import)
- **EDIT** `src/components/admin/feedback-inbox-section.tsx` (-3 textarea LOC, +1 import)
Net: +169 LOC primitive + tests, -14 LOC inline copy-paste, with the audit's drift-risk surface area now closed behind a single contract + test file.
+7 -5
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -199,7 +200,7 @@ export default function BugReportPage() {
<div className="space-y-2">
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
<textarea
<Textarea
id="bug-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -207,9 +208,6 @@ export default function BugReportPage() {
minLength={10}
maxLength={5000}
rows={8}
// text-base on mobile so iOS Safari doesn't zoom on
// focus; text-sm on sm+ keeps the compact desktop look.
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
/>
</div>
@@ -229,7 +227,11 @@ export default function BugReportPage() {
)}
<div className="flex items-center justify-end pt-2">
<Button type="submit" disabled={loading} aria-busy={loading || undefined}>
<Button
type="submit"
disabled={loading}
aria-busy={loading || undefined}
>
{/*
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
the same 16x16 slot as the Bug icon) so the button
+49 -46
View File
@@ -37,6 +37,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
MoreHorizontal,
Loader2,
@@ -279,48 +280,48 @@ export default function MedicationsPage() {
className="sm:max-w-lg"
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MedicationForm
footerSlot={footerEl}
initial={
editingMed
? {
id: editingMed.id,
name: editingMed.name,
dose: editingMed.dose,
category: editingMed.category,
// v1.4.25 W4d — pass through the Prisma treatment-class
// discriminator and the optional dosesPerUnit so the
// form preselects the GLP-1 surfaces correctly when
// editing an existing GLP-1 row.
treatmentClass: editingMed.treatmentClass,
dosesPerUnit: editingMed.dosesPerUnit ?? null,
active: editingMed.active,
notificationsEnabled: editingMed.notificationsEnabled,
schedules: editingMed.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? "",
dose: s.dose ?? "",
...parseScheduleRecurrence(s.daysOfWeek),
})),
}
: undefined
}
editActions={
editingMed
? {
onImportIntakes: () => setImportMedId(editingMed.id),
onApiAccess: () =>
setApiMed({
id: editingMed.id,
name: editingMed.name,
}),
}
: undefined
}
onSuccess={closeDialog}
onCancel={closeDialog}
/>
<MedicationForm
footerSlot={footerEl}
initial={
editingMed
? {
id: editingMed.id,
name: editingMed.name,
dose: editingMed.dose,
category: editingMed.category,
// v1.4.25 W4d — pass through the Prisma treatment-class
// discriminator and the optional dosesPerUnit so the
// form preselects the GLP-1 surfaces correctly when
// editing an existing GLP-1 row.
treatmentClass: editingMed.treatmentClass,
dosesPerUnit: editingMed.dosesPerUnit ?? null,
active: editingMed.active,
notificationsEnabled: editingMed.notificationsEnabled,
schedules: editingMed.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? "",
dose: s.dose ?? "",
...parseScheduleRecurrence(s.daysOfWeek),
})),
}
: undefined
}
editActions={
editingMed
? {
onImportIntakes: () => setImportMedId(editingMed.id),
onApiAccess: () =>
setApiMed({
id: editingMed.id,
name: editingMed.name,
}),
}
: undefined
}
onSuccess={closeDialog}
onCancel={closeDialog}
/>
</ResponsiveSheet>
</div>
);
@@ -465,14 +466,16 @@ function IntakeImportDialog({
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
]`}
</pre>
<textarea
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder={t("medications.importPaste")}
rows={8}
// text-base on mobile so iOS Safari doesn't zoom on
// focus; text-sm on sm+ for the compact import textarea.
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 font-mono text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
// JSON paste — disable sentence-case and spell-check so the
// primitive defaults don't munge structured input.
autoCapitalize="none"
spellCheck={false}
className="font-mono"
/>
{result && (
<p
+91 -94
View File
@@ -28,6 +28,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -86,7 +87,7 @@ export function FeedbackInboxSection() {
instead of squashing the badge text below the legibility
floor. Hide the scrollbar visually but keep the gesture
working (matches the Insights tab-strip pattern). */}
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
<TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
@@ -428,100 +429,96 @@ function FeedbackDetailDialog({
</div>
}
>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
</div>
{(url || locale || userAgent || appVersion) && (
<div>
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h3>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUrl")}
</dt>
<dd className="font-mono break-all">{url}</dd>
</>
)}
{locale && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaLocale")}
</dt>
<dd className="font-mono">{locale}</dd>
</>
)}
{appVersion && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaAppVersion")}
</dt>
<dd className="font-mono">{appVersion}</dd>
</>
)}
{userAgent && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUserAgent")}
</dt>
<dd className="font-mono break-all">{userAgent}</dd>
</>
)}
</dl>
</div>
)}
{item.screenshotBase64 && (
<div>
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h3>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="admin-feedback-note" className="text-xs">
{t("admin.feedback.adminNote")}
</Label>
<textarea
id="admin-feedback-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={5000}
placeholder={t("admin.feedback.adminNotePlaceholder")}
// text-base on mobile so iOS Safari doesn't zoom on
// focus; text-sm on sm+ keeps the compact admin look.
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={update.isPending || note === (item.adminNote ?? "")}
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
</div>
{(url || locale || userAgent || appVersion) && (
<div>
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h3>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUrl")}
</dt>
<dd className="font-mono break-all">{url}</dd>
</>
)}
{locale && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaLocale")}
</dt>
<dd className="font-mono">{locale}</dd>
</>
)}
{appVersion && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaAppVersion")}
</dt>
<dd className="font-mono">{appVersion}</dd>
</>
)}
{userAgent && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUserAgent")}
</dt>
<dd className="font-mono break-all">{userAgent}</dd>
</>
)}
</dl>
</div>
)}
{item.screenshotBase64 && (
<div>
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h3>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="admin-feedback-note" className="text-xs">
{t("admin.feedback.adminNote")}
</Label>
<Textarea
id="admin-feedback-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={5000}
placeholder={t("admin.feedback.adminNotePlaceholder")}
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={update.isPending || note === (item.adminNote ?? "")}
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
</div>
</ResponsiveSheet>
);
}
+162 -170
View File
@@ -16,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
MedicationSideEffectCategory,
@@ -144,14 +145,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
// from the wire payload. The server derives it from the entry
// via `categoryForEntry`. Backwards-compatible with older
// clients during the cut window (route accepts both shapes).
const res = await fetch(
`/api/medications/${medicationId}/side-effects`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const res = await fetch(`/api/medications/${medicationId}/side-effects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
@@ -255,7 +253,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
bodyPaddingY="py-2.5"
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.sideEffects.loading")}</span>
</div>
@@ -269,7 +267,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
{!isLoading && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("medications.sideEffects.recentTitle")}
</p>
<ul className="space-y-1.5">
@@ -333,9 +331,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(
t("medications.sideEffects.deleteConfirm"),
)
window.confirm(t("medications.sideEffects.deleteConfirm"))
) {
deleteMutation.mutate(row.id);
}
@@ -356,165 +352,161 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
title={t("medications.sideEffects.sheetTitle")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
aria-label={t("medications.sideEffects.sheetTitle")}
>
<div className="space-y-1.5">
<Label htmlFor="side-effect-category">
{t("medications.sideEffects.categoryLabel")}
</Label>
<Select
value={category}
onValueChange={(next) => {
setCategory(next as MedicationSideEffectCategory);
// Reset entry — the previously-selected entry probably
// doesn't belong to the new category.
setEntry(null);
}}
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
aria-label={t("medications.sideEffects.sheetTitle")}
>
<div className="space-y-1.5">
<Label htmlFor="side-effect-category">
{t("medications.sideEffects.categoryLabel")}
</Label>
<Select
value={category}
onValueChange={(next) => {
setCategory(next as MedicationSideEffectCategory);
// Reset entry — the previously-selected entry probably
// doesn't belong to the new category.
setEntry(null);
}}
>
<SelectTrigger
id="side-effect-category"
className="w-full"
data-slot="side-effect-category-trigger"
>
<SelectTrigger
id="side-effect-category"
className="w-full"
data-slot="side-effect-category-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
<SelectItem key={c} value={c}>
{t(
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.entryLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.entryLabel")}
>
{filteredEntries.map((candidate) => {
const selected = candidate === entry;
return (
<button
type="button"
key={candidate}
role="radio"
aria-checked={selected}
onClick={() => setEntry(candidate)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
{t(
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
)}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.severityLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.severityLabel")}
>
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
const selected = severity === value;
return (
<button
type="button"
key={label}
role="radio"
aria-checked={selected}
onClick={() => setSeverity(value)}
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
<span aria-hidden className="block text-sm">
{value}
</span>
<span className="block text-[10px] opacity-80">
{t(`medications.sideEffects.severity.${label}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="side-effect-notes">
{t("medications.sideEffects.notesLabel")}
</Label>
<textarea
id="side-effect-notes"
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
placeholder={t("medications.sideEffects.notesPlaceholder")}
rows={3}
maxLength={NOTES_MAX}
enterKeyHint="done"
autoCapitalize="sentences"
autoComplete="off"
// text-base on mobile so iOS Safari doesn't
// zoom-on-focus; text-sm on sm+ keeps the compact
// notes-row visual.
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-base sm:text-sm"
/>
<p className="text-muted-foreground text-right text-[10px]">
{notes.length} / {NOTES_MAX}
</p>
</div>
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
</form>
{footerEl
? createPortal(
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
{t("medications.sideEffects.cancelCta")}
</Button>
<Button
type="submit"
form={formId}
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
<SelectItem key={c} value={c}>
{t(
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
)}
{t("medications.sideEffects.submitCta")}
</Button>
</div>,
footerEl,
)
: null}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.entryLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.entryLabel")}
>
{filteredEntries.map((candidate) => {
const selected = candidate === entry;
return (
<button
type="button"
key={candidate}
role="radio"
aria-checked={selected}
onClick={() => setEntry(candidate)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
{t(
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
)}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.severityLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.severityLabel")}
>
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
const selected = severity === value;
return (
<button
type="button"
key={label}
role="radio"
aria-checked={selected}
onClick={() => setSeverity(value)}
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
<span aria-hidden className="block text-sm">
{value}
</span>
<span className="block text-[10px] opacity-80">
{t(`medications.sideEffects.severity.${label}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="side-effect-notes">
{t("medications.sideEffects.notesLabel")}
</Label>
<Textarea
id="side-effect-notes"
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
placeholder={t("medications.sideEffects.notesPlaceholder")}
rows={3}
maxLength={NOTES_MAX}
enterKeyHint="done"
// Compact notes-row padding override; primitive's
// px-3 py-2 is too tall inside the side-effect modal.
className="px-2 py-1.5"
/>
<p className="text-muted-foreground text-right text-[10px]">
{notes.length} / {NOTES_MAX}
</p>
</div>
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
</form>
{footerEl
? createPortal(
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
{t("medications.sideEffects.cancelCta")}
</Button>
<Button
type="submit"
form={formId}
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("medications.sideEffects.submitCta")}
</Button>
</div>,
footerEl,
)
: null}
</ResponsiveSheet>
</MedicationDetailSection>
);
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Textarea } from "../textarea";
describe("<Textarea>", () => {
it("pins text-base on mobile and shrinks to text-sm on sm+ (iOS zoom defence)", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain("text-base");
expect(html).toContain("sm:text-sm");
});
it("floors at min-h-11 on mobile and min-h-9 on sm+ (WCAG 2.5.5 tap target)", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain("min-h-11");
expect(html).toContain("sm:min-h-9");
});
it("defaults autoComplete to off and tells password managers to skip", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain('autoComplete="off"');
expect(html).toContain('data-lpignore="true"');
expect(html).toContain('data-1p-ignore="true"');
});
it("defaults autoCapitalize to sentences for prose-like free-text input", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain('autoCapitalize="sentences"');
});
it("enables spellCheck by default", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain('spellCheck="true"');
});
it("emits the data-slot attribute (shadcn convention)", () => {
const html = renderToStaticMarkup(<Textarea id="note" />);
expect(html).toContain('data-slot="textarea"');
});
it("allows callers to override autoCapitalize for code / JSON input", () => {
const html = renderToStaticMarkup(
<Textarea id="json" autoCapitalize="none" spellCheck={false} />,
);
expect(html).toContain('autoCapitalize="none"');
expect(html).not.toContain('spellCheck="true"');
});
it("respects an explicit autoComplete value and drops the ignore attrs", () => {
const html = renderToStaticMarkup(<Textarea id="bio" autoComplete="on" />);
expect(html).toContain('autoComplete="on"');
expect(html).not.toContain('data-lpignore="true"');
expect(html).not.toContain('data-1p-ignore="true"');
});
it("merges caller className via cn() without dropping defaults", () => {
const html = renderToStaticMarkup(
<Textarea id="note" className="font-mono" />,
);
expect(html).toContain("font-mono");
// iOS zoom defence still present after merge
expect(html).toContain("text-base");
expect(html).toContain("sm:text-sm");
});
it("is wrapped in React.forwardRef so refs reach the <textarea>", () => {
// React tags forwardRef components with a sentinel symbol on
// `$$typeof`. Verifying the sentinel keeps the assertion
// dependency-free (no testing-library / DOM env needed) while
// still catching a regression that strips the wrapper.
const forwardRefSymbol = Symbol.for("react.forward_ref");
const componentMeta = Textarea as unknown as {
$$typeof: symbol;
render?: unknown;
};
expect(componentMeta.$$typeof).toBe(forwardRefSymbol);
expect(typeof componentMeta.render).toBe("function");
});
it("forwards rows / placeholder / maxLength to the underlying element", () => {
const html = renderToStaticMarkup(
<Textarea
id="note"
rows={8}
placeholder="Tell us more"
maxLength={5000}
/>,
);
expect(html).toContain('rows="8"');
expect(html).toContain('placeholder="Tell us more"');
expect(html).toContain('maxLength="5000"');
});
});
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Base textarea primitive — mirror of `<Input>` for multi-line input.
*
* v1.4.47 W2 — extracted the iOS-zoom defence + tap-target floor +
* autocomplete defaults that the bugreport / medications-import /
* side-effects / admin-feedback textareas were copy-pasting into a
* single primitive. Six call sites previously each maintained their
* own className string; any drift between them was a paper-cut waiting
* to happen.
*
* What it bakes in:
*
* - `text-base sm:text-sm` — iOS Safari zooms the viewport on
* focus when the font-size renders below 16 px, which yanks the
* keyboard up and leaves the user lost. 16 px on mobile holds
* the viewport; 14 px on `sm`+ keeps the compact desktop look.
*
* - `min-h-11 sm:min-h-9` — WCAG 2.5.5 / Apple HIG floor of 44 px
* on touch screens, shrinking to 36 px once we know we have a
* pointer (covers `rows={1}` single-line uses without dropping
* under the tap-target floor).
*
* - `autoCapitalize="sentences"` — free-text fields read as prose
* on iOS / Android by default. Caller can override with `"none"`
* for code / JSON / IDs.
*
* - `spellCheck` defaults to `true` — same reasoning.
*
* - `autoComplete` defaults to `"off"` for the same reason as
* `<Input>`: HealthLog is a health-data app, and we don't want
* a password manager pasting the user's saved password into a
* free-text textarea that we then persist server-side. Caller
* can opt back in (e.g. `"on"`) per field.
*
* Composition is via `cn(…)` so callers can override anything — the
* primitive defaults are the strict pass, not the only pass.
*/
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(function Textarea(
{ className, autoComplete, autoCapitalize, spellCheck, ...props },
ref,
) {
const resolvedAutoComplete = autoComplete ?? "off";
const resolvedAutoCapitalize = autoCapitalize ?? "sentences";
const resolvedSpellCheck = spellCheck ?? true;
const skipAutofill = resolvedAutoComplete === "off";
return (
<textarea
ref={ref}
data-slot="textarea"
autoComplete={resolvedAutoComplete}
autoCapitalize={resolvedAutoCapitalize}
spellCheck={resolvedSpellCheck}
data-lpignore={skipAutofill ? "true" : undefined}
data-1p-ignore={skipAutofill ? "true" : undefined}
className={cn(
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
// primitive's docblock for the full reasoning.
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
});
export { Textarea };