Files
TREK/client/src/mobile/MobileShell.tsx
T
Maurice 4dfccaa2a4 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.
2026-11-07 23:25:14 +01:00

106 lines
5.4 KiB
TypeScript

import { CSSProperties, ReactNode, useRef } from 'react'
import { useMatch } from 'react-router'
import BottomNav from '../components/Layout/BottomNav'
import ErrorBoundary from '../components/shared/ErrorBoundary'
import MBottomNav from './components/MBottomNav'
import MNavRail from './components/MNavRail'
import { useSizeClass } from '../hooks/sizeClass'
import MKeyboardHost from './keyboard/MKeyboardHost'
import MShellShortcuts from './keyboard/MShellShortcuts'
import MToastHost from './components/MToastHost'
import MEdgeBack from './components/MEdgeBack'
import { useNativeCapabilities } from '../native/capabilities'
import { isNativeIos } from '../native/platform'
import { useWindowFold } from '../native/windowFold'
import './mobile.css'
interface MobileShellProps {
isPhone: boolean
children: ReactNode
}
/**
* Shell around every protected page. Below the md breakpoint it is the root
* of the new mobile experience: scopes the --m-* design tokens, paints the
* screen gradient, hosts the bottom dock, the sheet portal target and the
* mobile toast presenter. From 768px up it renders the exact legacy wrapper
* unchanged. Both branches share the div > div > children shape so pages keep
* their state when the viewport crosses the breakpoint (rotation,
* split-screen) instead of remounting.
*
* On the phone the shell deliberately owns no scroll container (#1809): the
* document is the scroller, because that is the only movement iOS Safari
* minimises its address bar for. Screens that want a full-viewport layout
* (map screens, the trip planner) set their own height instead of borrowing
* one from here.
*/
export default function MobileShell({ isPhone, children }: MobileShellProps) {
// The trip planner (/trips/:id) is a full-screen takeover that ships its own
// in-trip dock (MTripShell). The global dock must not render underneath it:
// on iOS Safari a position:fixed nav paints THROUGH the trip overlay's higher
// stacking context, so the wrong (global) bar shows on top of the trip screen.
const inTripPlanner = useMatch('/trips/:id')
// The app's own edge swipe back, where the binary has turned WebKit's off.
const { caps } = useNativeCapabilities()
const pageRef = useRef<HTMLDivElement>(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. 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()
const foldStyle = fold
? ({ '--m-fold-start': `${fold.start}px`, '--m-fold-end': `${fold.end}px` } as CSSProperties)
: undefined
if (!isPhone) {
return (
<div className="flex flex-col h-dvh md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<ErrorBoundary boundaryId="chrome:bottom-nav" fallback={null}><BottomNav /></ErrorBoundary>
</div>
)
}
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.
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" data-size={sizeClass} data-fold={fold ? 'vertical' : undefined} style={foldStyle}>
{/* 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
be stretched over the whole scroll length.
It stays on a NEGATIVE z-index, and the content wrapper stays unpositioned
on purpose. Lifting the layer into the positioned range means the content
has to be positioned too, which makes it a stacking context and clamps
every fixed overlay a screen renders inside it: the vacay view/edit FAB
(z-50) ended up underneath the dock (z-40) that way. For the negative
layer to be visible, body must not paint over it, which index.css handles
for this breakpoint. */}
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
{/* 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 && (
<ErrorBoundary boundaryId="chrome:m-nav-rail" fallback={null}><MNavRail /></ErrorBoundary>
)}
<div ref={pageRef} className="flex-1 wide:ps-[var(--m-rail-w)]">{children}</div>
<MEdgeBack target={pageRef} enabled={edgeBack} />
{!inTripPlanner && !wide && (
<ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>
)}
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
{/* A hardware keyboard (an iPad's, mostly) gets the shortcuts an iPad app has. */}
<ErrorBoundary boundaryId="chrome:m-keyboard" fallback={null}>
<MKeyboardHost />
<MShellShortcuts />
</ErrorBoundary>
<div id="m-sheet-root" />
</div>
)
}