From b7a91fc84e19839ea85d4bd07879002d9bc8311b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 23:19:40 +0100 Subject: [PATCH] fix(mobile): one form sheet for every dialog, opaque and placed beside the rail Every sheet now stands on the opaque sheet surface: the frosting the translucent one relied on is dropped while the scrim fades, so the page read through the fields as sharp ghost text. On a wide window a sheet is centred in the content beside the navigation rail instead of on the whole window, so it never lies on the rail, and it grows to the window's height instead of stopping at 880px. On a phone the card stands between the status bar and the dock rather than across the dock. A sheet marked as a form fills the height of a window as short as a phone on its side, with Cancel and Save beside the title (new `short:` variant, m-form-sheet grid in mobile.css). The form chrome gains a scrolling body that fades at the edges with more behind them (useEdgeFade on the vertical axis), a date field whose keyboard entry moves into the picker, a field wrapper for amounts with a currency, and two-line titles. The focused sheet no longer draws the app-wide focus frame or loses its corners, and the desktop's dark field paint stays out of the touch shell, which painted a dark box inside every rounded field. --- client/src/hooks/useAutoGrow.ts | 30 +++ client/src/index.css | 8 +- client/src/mobile/components/MSheet.tsx | 220 ++++++++++-------- client/src/mobile/components/useEdgeFade.ts | 40 ++-- client/src/mobile/mobile.css | 74 ++++++ .../screens/trip/sheets/PlSheetChrome.tsx | 78 ++++++- client/tailwind.config.js | 3 + client/tests/unit/mobile/MSheet.test.tsx | 76 +++++- client/tests/unit/mobile/formSheet.test.tsx | 188 +++++++++++++++ client/tests/unit/mobile/largeScreen.test.tsx | 34 +-- 10 files changed, 618 insertions(+), 133 deletions(-) create mode 100644 client/src/hooks/useAutoGrow.ts create mode 100644 client/tests/unit/mobile/formSheet.test.tsx diff --git a/client/src/hooks/useAutoGrow.ts b/client/src/hooks/useAutoGrow.ts new file mode 100644 index 000000000..0916542c6 --- /dev/null +++ b/client/src/hooks/useAutoGrow.ts @@ -0,0 +1,30 @@ +import { type RefObject, useLayoutEffect } from 'react'; + +/** + * Grows a textarea with what is written in it, so its last line is never + * sliced through the glyphs at the bottom edge the way a fixed `rows` height + * cuts a fourth line. `rows` stays the least it shows. It measures again when + * the text changes and when the field's width does (a rotation, a fold), since + * both decide how many lines the text wraps to. + */ +export function useAutoGrow(ref: RefObject, value: string): void { + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let width = -1; + const fit = () => { + width = el.clientWidth; + el.style.height = 'auto'; + // scrollHeight leaves the border out; the box is border-box. + const border = el.offsetHeight - el.clientHeight; + el.style.height = `${el.scrollHeight + border}px`; + }; + fit(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + if (el.clientWidth !== width) fit(); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref, value]); +} diff --git a/client/src/index.css b/client/src/index.css index 2bf9bee9b..278666809 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1307,12 +1307,16 @@ img[alt="TREK"] { .dark .bg-slate-100 { background-color: #27272a !important; } .dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; } .dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; } -.dark input, .dark textarea, .dark select { +/* Not inside the touch shell (.m-root): its fields state their own fill, a + transparent input inside a rounded wrapper above all, and this rule outweighs + every utility class, so it painted a dark box inside each of them. The + :where() keeps the desktop's weight exactly as it was. */ +.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) { background-color: #1c1c21; color: #f4f4f5; border-color: #27272a; } -.dark input::placeholder, .dark textarea::placeholder { color: #71717a; } +.dark input:not(:where(.m-root *))::placeholder, .dark textarea:not(:where(.m-root *))::placeholder { color: #71717a; } .dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; } .dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; } diff --git a/client/src/mobile/components/MSheet.tsx b/client/src/mobile/components/MSheet.tsx index c2daea612..8b9a948cc 100644 --- a/client/src/mobile/components/MSheet.tsx +++ b/client/src/mobile/components/MSheet.tsx @@ -17,11 +17,17 @@ interface MSheetProps { /** card = centred floating card, bottom = docked panel above the nav, drawer = left drawer */ variant?: MSheetVariant /** - * glass = translucent sheet (--m-sheet), bar-glass = lighter bar glass - * (--m-glass, default for the bottom variant), opaque = solid (--m-sheetop) + * @deprecated Every sheet stands on the one opaque surface now (--m-sheetop), + * whatever is asked for here; see SURFACE. Kept so the callers that still + * name a material compile. */ material?: MSheetMaterial - /** Transparent scrim (the "Mehr" sheet sits on the UI without dimming it) */ + /** + * Transparent scrim (the "Mehr" sheet sits on the UI without dimming it). Not + * on a window as short as a phone on its side: there the sheet takes most of + * the height, and the strips of page left above and below it read as part of + * it unless they are dimmed. + */ dimTransparent?: boolean ariaLabel?: string className?: string @@ -36,6 +42,14 @@ interface MSheetProps { wideWidth?: MSheetWidth /** Where it stands on a wide window (default centre). */ wideAnchor?: MSheetWideAnchor + /** + * A form: FormSheetHeader, FormSheetBody and FormSheetFooter (PlSheetChrome) + * as its three children. On a window as short as a phone on its side it + * fills the height instead of floating as a card a third of the screen + * tall, and its actions move up beside the title (mobile.css, m-form-sheet), + * so the fields get every line in between. + */ + form?: boolean } const EXIT_MS = 300 @@ -48,22 +62,10 @@ const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold * regular for a form, large for a sheet that holds a page of its own. */ type MSheetWidth = 'compact' | 'regular' | 'large' - -// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes -// what iPadOS shows there: a form sheet centred over the dimmed page, as wide -// as its content needs and no wider, instead of a band across a 13" screen. -// Centred with auto margins between zero insets, so it needs no physical side. -const WIDE_POSITION = 'wide:inset-x-0 wide:mx-auto wide:bottom-auto wide:top-1/2 wide:-translate-y-1/2' const WIDE_WIDTH: Record = { - compact: 'wide:w-[min(320px,calc(100%-48px))]', - regular: 'wide:w-[min(540px,calc(100%-48px))]', - large: 'wide:w-[min(720px,calc(100%-48px))]', -} - -const POSITION: Record = { - card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2', - bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]', - drawer: 'absolute left-0 top-0 bottom-0 w-[78%] max-w-[320px]', + compact: 'wide:w-[min(320px,100%)]', + regular: 'wide:w-[min(540px,100%)]', + large: 'wide:w-[min(720px,100%)]', } /** @@ -73,15 +75,54 @@ const POSITION: Record = { * opened it rather than in the middle of the screen. */ type MSheetWideAnchor = 'center' | 'rail-foot' -const WIDE_ANCHOR: Record = { - center: WIDE_POSITION, - 'rail-foot': 'wide:top-auto wide:translate-y-0 wide:end-auto wide:bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:start-[calc(var(--m-rail-w)+6px)]', + +/* + * The region a sheet may take: a box over the screen that ignores the pointer + * (a tap on it reaches the scrim) and lays the sheet out inside itself, so a + * tall sheet is held by the region's edges instead of running past them. + * + * On a phone the card stands between the status bar and the dock, and the + * docked sheet just above the dock; neither lies across the dock. + */ +const REGION: Record = { + card: 'inset-x-[14px] top-[var(--m-safe-top,12px)] bottom-[calc(var(--bottom-nav-h,84px)+12px)] justify-center', + // On a phone on its side the gap it keeps at the top would leave it a strip + // of a few rows, so there it may rise nearly to the top. + bottom: + 'inset-x-4 top-[calc(var(--m-safe-top,12px)+56px)] bottom-[calc(var(--bottom-nav-h,84px)+16px)] justify-end short:top-[calc(var(--m-safe-top,12px)+8px)]', + drawer: 'start-0 top-0 bottom-0 w-[78%] max-w-[320px]', } -/** The box the panel sits in: its place on a phone, and on a wide window. */ -function frameClass(variant: MSheetVariant, width: MSheetWidth = 'regular', anchor: MSheetWideAnchor = 'center'): string { - if (variant === 'drawer') return POSITION.drawer - return `${POSITION[variant]} ${WIDE_WIDTH[width]} ${WIDE_ANCHOR[anchor]}` +// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes +// what iPadOS shows there: a form sheet over the dimmed page, centred in the +// content beside the navigation rail (never over the rail), as wide as its +// content needs and as tall as the window allows. The region starts after the +// rail on the leading edge, so it needs no physical side. +const WIDE_REGION: Record = { + center: + 'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+24px)] wide:pe-6 wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+24px)] wide:items-center wide:justify-center', + 'rail-foot': + 'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+6px)] wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:items-start wide:justify-end', +} + +// A form on a window as short as a phone on its side: a sheet the window's +// full height, over the dock, as wide as a form reads well and no wider. +const SHORT_FORM_REGION = + 'short:inset-x-4 short:top-[max(env(safe-area-inset-top,0px),8px)] short:bottom-0 short:items-center short:justify-stretch' +const SHORT_FORM_BOX = 'short:max-w-[640px] short:flex-1' + +/** The region the sheet stands in, on a phone and on a wide window. */ +function regionClass(variant: MSheetVariant, anchor: MSheetWideAnchor = 'center', form = false): string { + const base = 'pointer-events-none absolute flex flex-col' + if (variant === 'drawer') return `${base} ${REGION.drawer}` + return `${base} ${REGION[variant]} ${WIDE_REGION[anchor]}${form ? ` ${SHORT_FORM_REGION}` : ''}` +} + +/** The sheet's own box inside the region: its width, and the arrow when it has one. */ +function boxClass(variant: MSheetVariant, width: MSheetWidth = 'regular', form = false): string { + const base = 'pointer-events-auto relative flex min-h-0 w-full flex-col' + if (variant === 'drawer') return `${base} h-full` + return `${base} max-h-full ${WIDE_WIDTH[width]}${form ? ` ${SHORT_FORM_BOX}` : ''}` } /** The popover's arrow, pointing at the More button at the foot of the rail (wide windows only). */ @@ -90,30 +131,29 @@ function PopoverArrow({ variant, anchor }: { variant: MSheetVariant; anchor?: MS return (