mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(mobile): keep form fields at 16 px on every touch screen
iOS and iPadOS Safari zoom the page into any focused field whose text is below 16 px. The fields switched to 14 px at a width breakpoint (`md:text-sm`, `sm:text-sm`), which a phone held sideways (844 px) and every iPad pass, and the native select was 14 px at every width, so the profile settings zoomed on a portrait phone too. A `pointer-fine:` variant (a mouse or trackpad is the primary pointer) now gates the compact size on Input, Textarea, NativeSelect, DateField, TimeField and the Coach composer; touch screens keep 16 px at any width and the desktop look is unchanged.
This commit is contained in:
1 parent
ac212cf6b7
commit
56a3008cf0
8 files changed
+41
-17
No files matched your search
@@ -32,6 +32,20 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields
|
||||
* take the compact 14 px type only under it: iOS and iPadOS Safari zoom the
|
||||
* page into any focused field whose text is smaller than 16 px, and a width
|
||||
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
|
||||
* (844 px) and to every iPad. Touch-first screens keep the 16 px base at
|
||||
* every width.
|
||||
*/
|
||||
@custom-variant pointer-fine {
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
|
||||
default scale starts at sm (640px), which leaves a gap between an iPhone SE
|
||||
|
||||
@@ -380,7 +380,7 @@ export function CoachInput({
|
||||
autoCapitalize="sentences"
|
||||
rows={1}
|
||||
className={cn(
|
||||
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:pointer-fine:text-sm",
|
||||
"px-2 py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
|
||||
|
||||
@@ -4,10 +4,12 @@ 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)", () => {
|
||||
it("pins text-base on touch screens and shrinks to text-sm only under a fine pointer (iOS zoom defence)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain("text-base");
|
||||
expect(html).toContain("sm:text-sm");
|
||||
expect(html).toContain("sm:pointer-fine:text-sm");
|
||||
// A width breakpoint alone handed 14 px to a phone held sideways.
|
||||
expect(html).not.toMatch(/(^|\s)sm:text-sm(\s|")/);
|
||||
});
|
||||
|
||||
it("floors at min-h-11 on mobile and min-h-9 on sm+ (WCAG 2.5.5 tap target)", () => {
|
||||
@@ -62,7 +64,7 @@ describe("<Textarea>", () => {
|
||||
expect(html).toContain("font-mono");
|
||||
// iOS zoom defence still present after merge
|
||||
expect(html).toContain("text-base");
|
||||
expect(html).toContain("sm:text-sm");
|
||||
expect(html).toContain("sm:pointer-fine:text-sm");
|
||||
});
|
||||
|
||||
it("forwards rows / placeholder / maxLength to the underlying element", () => {
|
||||
|
||||
@@ -60,11 +60,13 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
|
||||
// `input-focus` tone, with no ring. The earlier `ring-2 ring-offset-2`
|
||||
// painted a hard 4 px ring OUTSIDE the box — wider than the field's div and
|
||||
// visibly mismatched next to the app's other inputs, and it shifted the
|
||||
// highlight box on focus. `text-base md:text-sm` mirrors `<Input>` too: a
|
||||
// sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a
|
||||
// layout jump; 16 px below `md:` suppresses it.
|
||||
// highlight box on focus. `text-base md:pointer-fine:text-sm` mirrors
|
||||
// `<Input>` too: a sub-16 px input triggers iOS Safari's focus auto-zoom,
|
||||
// which reads as a layout jump; 16 px on every touch screen suppresses it
|
||||
// (`pointer-fine`, globals.css — a width breakpoint alone missed a phone
|
||||
// held sideways and every iPad).
|
||||
const FIELD_BASE_CLASSES =
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
|
||||
|
||||
export interface DateFieldProps {
|
||||
id?: string;
|
||||
|
||||
@@ -76,7 +76,7 @@ function Input({
|
||||
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 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 sm:h-10 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 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 sm:h-10 md:pointer-fine:text-sm",
|
||||
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -56,8 +56,11 @@ import { cn } from "@/lib/utils";
|
||||
// theme's `--muted-foreground` — same colour the shadcn `<SelectTrigger>`
|
||||
// chevron uses.
|
||||
|
||||
// Type size: 16 px on a touch screen, 14 px only under a mouse or trackpad
|
||||
// (`pointer-fine`, globals.css). iOS Safari zooms the page into a focused
|
||||
// `<select>` whose text is smaller than 16 px, the same as into an input.
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-base pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
|
||||
|
||||
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ import { cn } from "@/lib/utils";
|
||||
*
|
||||
* 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.
|
||||
* - `text-base sm:pointer-fine: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 a touch
|
||||
* screen holds the viewport at any width (a phone held sideways is
|
||||
* past `sm`, an iPad past `md`); 14 px only where a mouse or
|
||||
* trackpad drives (`pointer-fine`, globals.css) 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
|
||||
@@ -65,7 +68,7 @@ function Textarea({
|
||||
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",
|
||||
"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:pointer-fine:text-sm",
|
||||
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -45,10 +45,10 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
|
||||
// Focus vocabulary and font-size parity with `<Input>` / `<DateField>`: the
|
||||
// border turns the neutral `input-focus` tone with no ring (not the old hard
|
||||
// `ring-2 ring-offset-2`, which painted 4 px outside the div and jumped on
|
||||
// focus) and `text-base md:text-sm`
|
||||
// focus) and `text-base md:pointer-fine:text-sm`
|
||||
// to keep iOS Safari from auto-zooming a sub-16 px input.
|
||||
const FIELD_BASE_CLASSES =
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:pointer-fine:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
|
||||
Reference in new issue
Block a user