mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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