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