Files
HealthLog/src/components/ui/input.tsx
T
Marc-André Bombeck 1768b1d971 ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.

i18n leaks (3 primitives):
  - Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
    so a German user heard 'Close, button' on every modal. Both lift
    `useTranslations()` and read `common.close`.
  - ChartSkeleton's sr-only loading announcement was English-only.
    The component is already a client island; reads `charts.loadingLabel`
    from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
  - Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
    was hardcoded. Routes through `t("nav.breadcrumb")` with a new
    key in each locale.

Focus rings (mobile + tablet nav):
  - Bottom-nav primary links + the More overflow trigger relied on a
    `text-muted-foreground` → `text-primary` colour transition only;
    keyboard / switch-control users saw no focus indicator at all.
    Added `focus-visible:ring-ring/50 focus-visible:ring-2
    focus-visible:outline-none focus-visible:ring-offset-2` matching
    the v1.4.33 sidebar treatment.
  - Mobile top-bar trigger had `focus:outline-none` without a
    `focus-visible` replacement. Same treatment applied.

Tap-target floor (WCAG 2.5.5):
  - Input / NativeSelect / Select / DateTimeInput were locked to
    h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
    mobile clears the 44 px floor while desktop keeps the 40 px norm
    that the Button primitive already converged on in v1.4.33.

Admin chip-strip auto-scroll-into-view:
  - The admin shell shipped 13 chips but lacked the
    `scrollIntoView({inline: 'center'})` effect the settings shell
    got in v1.4.33. Admins tapping a chip past the right edge landed
    on the new page with the strip scrolled to the left and the
    active chip off-screen. Copied the settings-shell pattern verbatim.

List-page subtitles on mobile:
  - Measurements / Mood / Medications pages hid their descriptive
    subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
    the subtitle on mobile because it doubles as contextual help.
    Dropped the hide rule; the H1 still anchors the visual.

Insights empty-state CTA vocabulary:
  - `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
    `TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
    `MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
    empty-state CTAs emitted those mismatched tokens and the dialog
    silently failed to open. Replaced with a single source of truth
    derived from `MEASUREMENT_TYPES` plus a normalisation map for
    legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
    New unit test walks every `?add=` token emitted under
    `src/app/insights/**` and asserts it resolves to a real form type.
2026-05-17 00:34:00 +02:00

83 lines
3.2 KiB
TypeScript

import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Base input primitive.
*
* v1.4 hardening: defaults `autoComplete` to `"off"` and tells
* LastPass / 1Password to skip the field when the caller does not
* opt in to a semantic autocomplete value. HealthLog is a health-data
* app — letting browsers / password managers autofill measurement,
* medication, mood, and AI-token inputs with the user's last-saved
* email or account password is at best confusing and at worst leaks
* credentials into a free-text field that gets persisted server-side.
*
* Auth and profile forms (login, register, change-email) keep working
* because they already pass an explicit `autoComplete` value —
* `"username"`, `"email"`, `"current-password"`, `"new-password"`. When
* `autoComplete` is anything other than `"off"`, the LastPass /
* 1Password ignore attrs are dropped so password managers fill the
* field normally.
*
* v1.4.27 mobile pass: derive a sensible `inputMode` default from the
* `type` prop so every numeric / email / tel / url / search field
* surfaces the right on-screen keyboard on iOS Safari and Android
* Chrome without each call site having to repeat the attribute. Most
* `type="number"` fields in HealthLog accept decimals (kg, mmol/L,
* sleep hours), so the default is `"decimal"`; integer-only call
* sites can still pass `inputMode="numeric"` explicitly.
*/
function deriveInputMode(
type: React.HTMLInputTypeAttribute | undefined,
): React.HTMLAttributes<HTMLInputElement>["inputMode"] | undefined {
switch (type) {
case "number":
return "decimal";
case "tel":
return "tel";
case "email":
return "email";
case "url":
return "url";
case "search":
return "search";
default:
return undefined;
}
}
function Input({
className,
type,
autoComplete,
inputMode,
...props
}: React.ComponentProps<"input">) {
const resolvedAutoComplete = autoComplete ?? "off";
const skipAutofill = resolvedAutoComplete === "off";
const resolvedInputMode = inputMode ?? deriveInputMode(type);
return (
<input
type={type}
data-slot="input"
autoComplete={resolvedAutoComplete}
inputMode={resolvedInputMode}
data-lpignore={skipAutofill ? "true" : undefined}
data-1p-ignore={skipAutofill ? "true" : undefined}
className={cn(
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
// shrink to 40 px on sm+ where pointer precision is higher.
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 sm:h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md: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 { Input };