From 4dfccaa2a412f400b2447d715f796478f862a40d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 23:20:44 +0100 Subject: [PATCH] fix(native): one size class the keyboard cannot move, shortcuts that wait for sheets From the large-screen review (code findings H1, M1, M2, M3, M4, M7, L1, L3, L4, L6, L7): - The size class is one value in JavaScript (hooks/sizeClass.ts) and the stylesheet follows it: MobileShell writes it to the shell as data-size, the wide:/split: variants and mobile.css key on that instead of media queries of their own. A window that only grows shorter at the same width while a field has focus is Android's soft keyboard, not a new window, so the rail, the form sheets and the panes no longer fold away (and take the focused field with them) while one types. - The large-screen layout is the app's only. In a browser the touch shell is the phone's below 768px and stays compact there, as before this branch. - Shortcuts are the app's only, so a browser keeps Ctrl+1 and Alt+Left. The command key is the platform's own (Cmd on Apple, Ctrl elsewhere), digits are matched by the key's place as well (AZERTY), and nothing runs while a sheet or another modal dialog is open: the page listens to nothing and the app is handed no key commands, so Cmd+[ can no longer drop an unsaved form. - Inside a trip Cmd+N belongs to the trip shell and creates what the tab on show creates, named after it. - Inside the touch shell the desktop navbar of the few PageShell routes is hidden and --nav-h is 0. - The rail comes before the page in the DOM, so a keyboard reaches it first. - The phone's deep day colour for a journey card without a photo stays the phone's; the desktop and shared timelines keep their faint wash. Its card is opaque, a suggestion included, so the map's names do not read through. - The trip widget's iPad mini layout keys on the iPad mini's width alone. - The trip dock gets the scroll-edge strip and the denser dock glass the global dock has. - Duplicate test id FE-MOB-PANES-016 renumbered. --- client/ios/App/TREKWidgets/TripContent.swift | 5 +- .../Journey/JourneyEntryCover.test.tsx | 23 ++- .../components/Journey/JourneyEntryCover.tsx | 32 +++- client/src/components/Layout/Navbar.tsx | 2 +- .../src/components/Layout/useCreateAction.ts | 27 +-- client/src/hooks/sizeClass.ts | 134 +++++++++++--- client/src/mobile/MobileShell.tsx | 20 +- client/src/mobile/components/MDockScrim.tsx | 21 +++ client/src/mobile/keyboard/MKeyboardHost.tsx | 55 ++++-- .../src/mobile/keyboard/MShellShortcuts.tsx | 7 +- client/src/mobile/keyboard/shortcuts.ts | 38 ++-- client/src/mobile/mobile.css | 74 +++++--- client/src/mobile/overlayStack.test.ts | 11 +- client/src/mobile/overlayStack.ts | 20 +- client/src/mobile/screens/trip/MTripShell.tsx | 9 +- client/tailwind.config.js | 18 +- client/tests/helpers/sizeClass.ts | 35 ++-- client/tests/setup.ts | 6 - client/tests/unit/mobile/keyboard.test.tsx | 171 +++++++++++++++++- client/tests/unit/mobile/sizeClass.test.ts | 160 +++++++++++++--- .../unit/mobile/trip/MTripShell.wide.test.tsx | 18 ++ 21 files changed, 701 insertions(+), 185 deletions(-) create mode 100644 client/src/mobile/components/MDockScrim.tsx diff --git a/client/ios/App/TREKWidgets/TripContent.swift b/client/ios/App/TREKWidgets/TripContent.swift index 961f9a761..5ef5ffa7e 100644 --- a/client/ios/App/TREKWidgets/TripContent.swift +++ b/client/ios/App/TREKWidgets/TripContent.swift @@ -232,8 +232,11 @@ struct TripContent: View { // 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad // mini's medium widget is narrower, so the panel gives up some width // and the weather keeps to its chip, and the day still reads whole. + // The line is drawn by the iPad mini alone: its medium widget is + // 305.5 pt wide, 273.5 inside the content margins, while the smallest + // iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout. GeometryReader { geo in - let narrow = geo.size.width < 300 + let narrow = geo.size.width < 281 HStack(spacing: 14) { VStack(alignment: .leading, spacing: 0) { Text(trip.title).trekEyebrowOnPhoto() diff --git a/client/src/components/Journey/JourneyEntryCover.test.tsx b/client/src/components/Journey/JourneyEntryCover.test.tsx index 9f4a98815..46245f7e9 100644 --- a/client/src/components/Journey/JourneyEntryCover.test.tsx +++ b/client/src/components/Journey/JourneyEntryCover.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-COVER-001 to FE-JRN-COVER-010 +// FE-JRN-COVER-001 to FE-JRN-COVER-011 import { describe, it, expect, vi } from 'vitest' import userEvent from '@testing-library/user-event' @@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => { expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail') expect(container.querySelector('svg.lucide-play')).toBeNull() }) + + it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => { + const entry = buildEntry({ photos: [], type: 'skeleton' }) + const phone = render( {}} tone="mobile" />) + const phoneCard = phone.getByRole('button') + // d9, the deep wash (jsdom spells the colour out as rgba) + expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/) + expect(phoneCard.className).not.toContain('opacity-90') + expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6') + phone.unmount() + + // The desktop and shared timelines keep their faint wash and the day-coloured pin. + const desk = render( {}} />) + const deskCard = desk.getByRole('button') + // 2e, the faint one + expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/) + expect(deskCard.className).toContain('opacity-90') + const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement + expect(pin.getAttribute('class')).not.toContain('-mt-6') + expect(pin.style.color).not.toBe('') + }) }) diff --git a/client/src/components/Journey/JourneyEntryCover.tsx b/client/src/components/Journey/JourneyEntryCover.tsx index 38ea2bab3..7ccf70bdd 100644 --- a/client/src/components/Journey/JourneyEntryCover.tsx +++ b/client/src/components/Journey/JourneyEntryCover.tsx @@ -64,9 +64,10 @@ interface Props { tone?: 'app' | 'mobile' /** * large: the card a wide window shows (an iPad, a foldable unfolded), with - * the room for a two-line name and the whole place under it. + * the room for a two-line name and the whole place under it. compact: a phone + * on its side, where a regular card left the map a strip 30px tall. */ - size?: 'regular' | 'large' + size?: 'compact' | 'regular' | 'large' } export default function JourneyEntryCover({ @@ -81,6 +82,7 @@ export default function JourneyEntryCover({ size = 'regular', }: Props) { const large = size === 'large' + const box = large ? 'h-[196px] w-[176px]' : size === 'compact' ? 'h-[112px] w-[124px]' : 'h-[152px] w-[136px]' const { t, locale } = useTranslation() const isSuggestion = entry.type === 'skeleton' @@ -97,7 +99,12 @@ export default function JourneyEntryCover({ const mood = showMood ? moodMeta(entry.mood) : undefined const weather = showWeather ? weatherMeta(entry.weather) : undefined - const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800' + const mobile = tone === 'mobile' + const emptyGround = mobile ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800' + // The mark of a card without a photo: white and raised clear of the name on the + // phone's deep day colour, the day's own colour on the faint wash elsewhere. + const emptyIcon = mobile ? '-mt-6 opacity-80' : 'opacity-60' + const emptyIconStyle = mobile ? undefined : { color: dayColor } // One ground for every corner mark. Dark rather than a white veil: these sit on // whatever photograph the traveller took, and a bright sky swallowed the white one. const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]' @@ -117,9 +124,9 @@ export default function JourneyEntryCover({ // room for it with their gap and top padding). // White on the root: every mark and line on the card is white, over a photo or // over the day's colour alike. - className={`relative text-white ${large ? 'h-[196px] w-[176px]' : 'h-[152px] w-[136px]'} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${ + className={`relative text-white ${box} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${ isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]' - } ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`} + } ${src ? '' : emptyGround} ${isSuggestion && !mobile ? 'opacity-90' : ''}`} // backgroundImage, not background: the shorthand would drop the opaque colour // the class supplies and leave the map showing through a card that is only a // faint wash of the day's colour. @@ -131,9 +138,14 @@ export default function JourneyEntryCover({ // ring lost its top edge to that clip. style={{ boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`, - // Without a photograph the card is the day's colour, deep enough to carry - // the white name like a photo does, rather than a grey that read as empty. - ...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)` }), + // Without a photograph the phone's card is the day's colour, deep enough to + // carry the white name like a photo does, rather than a grey that read as + // empty. Its ground stays opaque (no dimming for a suggestion either): it + // floats over the map, whose labels read through anything less. The desktop + // and shared timelines keep their faint wash of the day. + ...(src ? null : { backgroundImage: mobile + ? `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)` + : `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }), }} > {src ? ( @@ -141,8 +153,8 @@ export default function JourneyEntryCover({ ) : ( {posterless - ? - : } + ? + : } )} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 69781dce2..caf687d29 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -123,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings paddingTop: 'env(safe-area-inset-top, 0px)', height: 'var(--nav-h)', transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)', - }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]"> + }} data-desktop-navbar="" className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]"> {/* Left side. flex-1 basis-0, matching the action cluster on the right, so the tab pill between them sits in the middle of the bar rather than being centred on top of both (#1983). Both columns keep at least their diff --git a/client/src/components/Layout/useCreateAction.ts b/client/src/components/Layout/useCreateAction.ts index 6501e3204..0169744d4 100644 --- a/client/src/components/Layout/useCreateAction.ts +++ b/client/src/components/Layout/useCreateAction.ts @@ -31,18 +31,10 @@ export function useCreateAction(phone: boolean): CreateAction { const inCollection = useMatch('/collections/:id'); if (inTrip) { - // The "+" is context-aware per active tab: Bookings → reservation, - // Transports → transport, Costs → expense. Tabs without a create modal - // (lists / files / collab) fall through to adding a place. #1349 - const id = inTrip.params.id; + const id = inTrip.params.id ?? ''; const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null; - if (tripTab === 'finanzplan') - return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) }; - if (tripTab === 'buchungen') - return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) }; - if (tripTab === 'transports') - return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) }; - return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }; + const intent = tripCreateIntent(id, tripTab); + return { label: t(intent.labelKey), run: () => navigate(intent.to) }; } if (inJourney) { // Context-aware per tab, like the trip's "+": the Gallery holds photos, so @@ -69,6 +61,19 @@ export function useCreateAction(phone: boolean): CreateAction { return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }; } +/** + * What the "+" creates inside a trip, by its active tab: Bookings a reservation, + * Transports a transport, Costs an expense. Tabs without a create modal (lists, + * files, collab) fall through to adding a place (#1349). Pure, so a caller that + * knows the tab itself (the trip's own ⌘N, MTripShell) asks with that one. + */ +export function tripCreateIntent(tripId: string, tab: string | null): { labelKey: string; to: string } { + if (tab === 'finanzplan') return { labelKey: 'costs.addExpense', to: `/trips/${tripId}?create=expense` }; + if (tab === 'buchungen') return { labelKey: 'reservations.addManual', to: `/trips/${tripId}?create=reservation` }; + if (tab === 'transports') return { labelKey: 'transport.addManual', to: `/trips/${tripId}?create=transport` }; + return { labelKey: 'places.addPlace', to: `/trips/${tripId}?create=place` }; +} + /** Whether a nav item is the current route: the dashboard only on its exact path, the rest by prefix. */ export function isNavItemActive(pathname: string, to: string): boolean { return to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to); diff --git a/client/src/hooks/sizeClass.ts b/client/src/hooks/sizeClass.ts index 3475bae96..ee60b4e90 100644 --- a/client/src/hooks/sizeClass.ts +++ b/client/src/hooks/sizeClass.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react'; +import { isNativeApp } from '../native/platform'; /** * The touch shell's size classes, the web's answer to UIKit's compact and @@ -21,43 +22,130 @@ import { useSyncExternalStore } from 'react'; * The height bound keeps a phone on its side compact: no phone is taller than * 500pt in landscape, no tablet or unfolded foldable is shorter. * - * `tailwind.config.js` registers the same two queries as the `wide:` and - * `split:` variants, and `index.css` reads them for the shell's own variables; - * keep the three in step (sizeClass.test.ts holds them together). + * Only the app lays out for a large screen. In a browser the touch shell is + * the phone's, below 768px (mobile/useIsPhone.ts), and stays compact there: an + * iPad mini upright in Safari or a narrowed desktop window keeps the dock it + * always had. + * + * The class is one value in JavaScript, and the stylesheet follows it rather + * than asking media queries of its own: MobileShell writes it to the shell's + * root as data-size, and the `wide:` and `split:` variants (tailwind.config.js) + * and the shell's variables (mobile.css) key on that attribute. So the hooks + * and the CSS never disagree, and the guard against the soft keyboard below + * holds for both. */ -export const WIDE_QUERY = '(min-width: 600px) and (min-height: 501px)'; -export const SPLIT_QUERY = '(min-width: 840px) and (min-height: 501px)'; +export type SizeClass = 'compact' | 'wide' | 'split'; -function matches(query: string): boolean { - return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(query).matches; +const WIDE_MIN_WIDTH = 600; +const SPLIT_MIN_WIDTH = 840; +const MIN_HEIGHT = 501; + +/** The class of a window of this size, in CSS pixels. */ +export function sizeClassOf(width: number, height: number): SizeClass { + if (width < WIDE_MIN_WIDTH || height < MIN_HEIGHT) return 'compact'; + return width >= SPLIT_MIN_WIDTH ? 'split' : 'wide'; } -function subscribe(query: string) { - return (onChange: () => void) => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}; - const mq = window.matchMedia(query); - mq.addEventListener('change', onChange); - return () => mq.removeEventListener('change', onChange); - }; +/** Input types that bring up no soft keyboard. */ +const NO_KEYBOARD = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', +]); + +/** Whether the focused element is one the soft keyboard comes up for. */ +function typing(): boolean { + const el = document.activeElement; + if (!(el instanceof HTMLElement)) return false; + if (el.isContentEditable || el.tagName === 'TEXTAREA') return true; + return el instanceof HTMLInputElement && !NO_KEYBOARD.has(el.type); } -const subscribeWide = subscribe(WIDE_QUERY); -const subscribeSplit = subscribe(SPLIT_QUERY); -const readWide = () => matches(WIDE_QUERY); -const readSplit = () => matches(SPLIT_QUERY); -const never = () => false; +let seen: { width: number; height: number } | null = null; +let pinned: SizeClass | null = null; +let listening = false; +const listeners = new Set<() => void>(); + +/** + * Takes in the window's size, except a height lost to the soft keyboard. + * + * Android resizes the WebView when the keyboard comes up (adjustResize), so a + * tablet on its side keeps about 400px of height while a field has focus. Read + * as it is, that made the class compact for as long as one typed: the rail + * turned into the dock, form sheets into bottom sheets, and a pane that + * remounted on the way took the focused field with it, which closed the + * keyboard, which brought the class back, in a loop. A window that only grew + * shorter, at the same width, while a field has focus is the keyboard, not a + * new window: the height seen before stays. The keyboard going down, a turn or + * any other resize is taken as it comes. + */ +function measure(): boolean { + const next = { width: window.innerWidth, height: window.innerHeight }; + const prev = seen; + if (prev && next.width === prev.width && next.height < prev.height && typing()) return false; + seen = next; + return !prev || prev.width !== next.width || prev.height !== next.height; +} + +function follow(): void { + if (listening || typeof window === 'undefined') return; + listening = true; + window.addEventListener('resize', () => { + if (measure()) for (const listener of listeners) listener(); + }); +} + +function read(): SizeClass { + if (pinned) return pinned; + if (typeof window === 'undefined' || !isNativeApp()) return 'compact'; + follow(); + if (!seen) measure(); + return seen ? sizeClassOf(seen.width, seen.height) : 'compact'; +} + +function subscribe(listener: () => void): () => void { + follow(); + listeners.add(listener); + return () => listeners.delete(listener); +} + +const serverClass = (): SizeClass => 'compact'; /** Whether the touch shell is laid out for a wide window right now, outside React. */ export function isWideViewport(): boolean { - return readWide(); + return read() !== 'compact'; } -/** True while the window is in the wide size class. Follows rotation, folding and resizing. */ +/** The size class. Follows rotation, folding and resizing, not the soft keyboard. */ +export function useSizeClass(): SizeClass { + return useSyncExternalStore(subscribe, read, serverClass); +} + +/** True while the window is in the wide size class (split included). */ export function useIsWide(): boolean { - return useSyncExternalStore(subscribeWide, readWide, never); + return useSizeClass() !== 'compact'; } /** True while the window has room for two panes side by side. */ export function useIsSplit(): boolean { - return useSyncExternalStore(subscribeSplit, readSplit, never); + return useSizeClass() === 'split'; +} + +/** Test helper: pins the size class, or with null hands it back to the window. */ +export function _pinSizeClass(next: SizeClass | null): void { + pinned = next; + for (const listener of listeners) listener(); +} + +/** Test helper: forgets the window size seen so far. */ +export function _resetSizeClass(): void { + pinned = null; + seen = null; } diff --git a/client/src/mobile/MobileShell.tsx b/client/src/mobile/MobileShell.tsx index 67ada03b9..e353c57ef 100644 --- a/client/src/mobile/MobileShell.tsx +++ b/client/src/mobile/MobileShell.tsx @@ -4,7 +4,7 @@ import BottomNav from '../components/Layout/BottomNav' import ErrorBoundary from '../components/shared/ErrorBoundary' import MBottomNav from './components/MBottomNav' import MNavRail from './components/MNavRail' -import { useIsWide } from '../hooks/sizeClass' +import { useSizeClass } from '../hooks/sizeClass' import MKeyboardHost from './keyboard/MKeyboardHost' import MShellShortcuts from './keyboard/MShellShortcuts' import MToastHost from './components/MToastHost' @@ -45,8 +45,10 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) { const pageRef = useRef(null) const edgeBack = isNativeIos() && caps.features.includes('edgeBack') // On a wide window (an iPad, a foldable unfolded) the rail on the leading edge - // replaces the dock, and the page column starts after it. - const wide = useIsWide() + // replaces the dock, and the page column starts after it. The class goes on the + // root as data-size: the wide:/split: variants and mobile.css key on it. + const sizeClass = useSizeClass() + const wide = sizeClass !== 'compact' // A foldable's fold, as the Android app reports it: two-pane screens end their // leading pane there (mobile.css, data-fold). const fold = useWindowFold() @@ -66,7 +68,7 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) { return ( // min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a // dvh page length would chase the animation it just triggered. -
+
{/* Background and screen gradient live on their own viewport-sized layer. The shell root is as tall as the document now, and --m-scr is a radial gradient measured against its element box, so on it the light cone would @@ -80,10 +82,16 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) { layer to be visible, body must not paint over it, which index.css handles for this breakpoint. */}
+ {/* The rail comes before the page in the document, as it does on screen, + so a keyboard (Full Keyboard Access on an iPad) reaches the navigation + first. It is fixed, so where it sits in the DOM moves nothing. */} + {!inTripPlanner && wide && ( + + )}
{children}
- {!inTripPlanner && ( - {wide ? : } + {!inTripPlanner && !wide && ( + )} {/* A hardware keyboard (an iPad's, mostly) gets the shortcuts an iPad app has. */} diff --git a/client/src/mobile/components/MDockScrim.tsx b/client/src/mobile/components/MDockScrim.tsx new file mode 100644 index 000000000..f7e0ea914 --- /dev/null +++ b/client/src/mobile/components/MDockScrim.tsx @@ -0,0 +1,21 @@ +/** + * The screen's own background under a floating dock, fading out upwards. The + * dock floats 12px above the screen's foot, and in that gap scrolled content + * used to stay sharp: a pill, a name and an avatar peeking out under the bar. + * Now the page ends where the dock begins, the way an iOS bar's scroll edge + * does. + * + * The same layer as the shell's background (MobileShell), the colour and the + * screen gradient over the whole window, masked down to the strip at the foot, + * so the strip is exactly the colour behind it and shows no band. Above the + * dock's middle the mask is clear, so nothing that sits above the dock (a stats + * card, a carousel) is touched. + */ +export default function MDockScrim() { + return ( +