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 (