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.
This commit is contained in:
Maurice
2026-11-07 23:20:44 +01:00
parent 18a16d2aa2
commit 4dfccaa2a4
21 changed files with 701 additions and 185 deletions
+4 -1
View File
@@ -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()
@@ -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(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} 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(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
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('')
})
})
@@ -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({
) : (
<span className="absolute inset-0 flex items-center justify-center">
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" className="ml-0.5 -mt-6 opacity-80" />
: <MapPin size={24} strokeWidth={1.8} className="-mt-6 opacity-80" />}
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={emptyIconStyle} className={`ml-0.5 ${emptyIcon}`} />
: <MapPin size={24} strokeWidth={1.8} style={emptyIconStyle} className={emptyIcon} />}
</span>
)}
+1 -1
View File
@@ -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
+16 -11
View File
@@ -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);
+111 -23
View File
@@ -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;
}
+14 -6
View File
@@ -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<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.
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.
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" data-fold={fold ? 'vertical' : undefined} style={foldStyle}>
<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
@@ -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. */}
<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 && (
<ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}>{wide ? <MNavRail /> : <MBottomNav />}</ErrorBoundary>
{!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. */}
@@ -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 (
<div
aria-hidden="true"
className="m-dock-scrim pointer-events-none fixed inset-0 z-[39] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
/>
);
}
+41 -14
View File
@@ -1,49 +1,76 @@
import { useEffect } from 'react';
import { useNativeCapabilities } from '../../native/capabilities';
import { isNativeApp, isNativeIos } from '../../native/platform';
import { reportNativeFailure } from '../../native/reportFailure';
import { TrekShell } from '../../native/trekShell';
import { hasOpenOverlay, useHasOpenOverlay } from '../overlayStack';
import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts';
/**
* Whether a modal is in front of the page: a sheet (it registers on the overlay
* stack) or any other dialog that marks itself aria-modal. The page's shortcuts
* wait behind it. ⌘[ while a reservation was being edited used to leave the trip
* and drop the form without the "discard changes?" a tap on the scrim asks.
*/
function modalOpen(): boolean {
if (hasOpenOverlay()) return true;
// A dialog kept mounted while closed (the assistant's) is hidden, not gone.
return Array.from(document.querySelectorAll('[aria-modal="true"]')).some((el) => !el.closest('[hidden]'));
}
/**
* Answers the touch shell's keyboard shortcuts (useShortcuts), once for every
* screen. In a browser it listens for the keys itself. In the app the binary
* takes them over as native key commands when it can (feature keyCommands):
* on iOS they show in the overlay a held ⌘ shows and in the iPadOS menu bar,
* on Android in the system's keyboard shortcuts list. The page then leaves
* those keys alone, so nothing runs twice. Only a shell that has the feature
* reports it, so the feature alone decides, whatever the platform.
* screen, in the app only. A browser keeps its own keys: there the touch shell
* is a phone layout, and Ctrl+1 is a tab switch and Alt+← is Back.
*
* The binary takes the shortcuts over as native key commands when it can
* (feature keyCommands): on iOS they show in the overlay a held ⌘ shows and in
* the iPadOS menu bar, on Android in the system's keyboard shortcuts list. The
* page then leaves those keys alone, so nothing runs twice. Only a shell that
* has the feature reports it, so the feature alone decides, whatever the
* platform. An older binary without it leaves every key to the page.
*
* While a sheet or dialog is open none of them run: the page listens to
* nothing and the app's list is emptied, so a shortcut can neither navigate
* away under an unsaved form nor open a second editor over it.
*/
export default function MKeyboardHost() {
const lists = useShortcutRegistry((s) => s.lists);
const { caps } = useNativeCapabilities();
const native = caps.features.includes('keyCommands');
const app = isNativeApp();
const native = app && caps.features.includes('keyCommands');
const blocked = useHasOpenOverlay();
useEffect(() => {
if (!app) return;
const apple = isNativeIos();
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return;
if (e.defaultPrevented || e.isComposing || modalOpen()) return;
const all = lists.map((l) => l.shortcuts);
const hit = findShortcut(e, native ? all.map((l) => l.filter((s) => !s.mod && !s.ctrl)) : all);
const hit = findShortcut(e, native ? all.map((l) => l.filter((s) => !s.mod && !s.ctrl)) : all, apple);
if (!hit) return;
e.preventDefault();
hit.run();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [lists, native]);
}, [lists, native, app]);
useEffect(() => {
if (!native) return;
const commands = lists.flatMap((l) =>
l.shortcuts.filter((s) => s.mod || s.ctrl).map((s) => toNativeCommand(l.owner, s))
);
const commands = blocked
? []
: lists.flatMap((l) => l.shortcuts.filter((s) => s.mod || s.ctrl).map((s) => toNativeCommand(l.owner, s)));
TrekShell.setKeyCommands({ commands }).catch(reportNativeFailure);
}, [lists, native]);
}, [lists, native, blocked]);
useEffect(() => {
if (!native) return;
let cancelled = false;
let remove: (() => Promise<void>) | null = null;
TrekShell.addListener('keyCommand', ({ id }) => {
// A command can still arrive from a list sent before a dialog opened.
if (modalOpen()) return;
const at = id.indexOf(':');
const owner = id.slice(0, at);
const list = useShortcutRegistry.getState().lists.find((l) => l.owner === owner);
@@ -8,8 +8,9 @@ import { useShortcuts } from './useShortcuts';
/**
* The app-wide shortcuts of the touch shell: ⌘1 to ⌘9 for the sections in the
* order the rail shows them, ⌘N for what the "+" creates here, ⌘, for
* settings. Inside a trip the numbers belong to the trip's own sections
* (MTripShell), so the shell lets go of them there.
* settings. Inside a trip the numbers and ⌘N belong to the trip (MTripShell):
* its tabs change without a new URL, and this component renders only on one,
* so a ⌘N of its own would still create what the tab the trip opened on does.
*/
export default function MShellShortcuts() {
const { t } = useTranslation();
@@ -30,7 +31,7 @@ export default function MShellShortcuts() {
mod: true,
run: () => void navigate(item.to),
}))),
...(noCreate ? [] : [{ id: 'create', title: create.label, key: 'n', mod: true, run: create.run }]),
...(noCreate || inTrip ? [] : [{ id: 'create', title: create.label, key: 'n', mod: true, run: create.run }]),
{ id: 'settings', title: t('nav.bottomSettings'), key: ',', mod: true, run: () => void navigate('/settings') },
];
useShortcuts('shell', shortcuts);
+25 -13
View File
@@ -11,9 +11,13 @@ export interface Shortcut {
id: string;
/** Its name, for the overlay a held ⌘ shows and the iPadOS menu bar. */
title: string;
/** KeyboardEvent.key, lower case for letters ('n', '1', ',', 'ArrowLeft'). */
/**
* KeyboardEvent.key, lower case for letters ('n', '1', ',', 'ArrowLeft'). A
* digit is matched by the key's place as well (KeyboardEvent.code), since a
* French keyboard types '&' on the 1 key without Shift.
*/
key: string;
/** ⌘ on Apple keyboards, Ctrl elsewhere. */
/** ⌘ on an iPad (and any Apple keyboard), Ctrl on Android. */
mod?: boolean;
shift?: boolean;
alt?: boolean;
@@ -22,17 +26,24 @@ export interface Shortcut {
run: () => void;
}
/** Whether an event is this shortcut. ⌘ and Ctrl both count as the command key. */
export function matchesShortcut(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
s: Shortcut
): boolean {
type KeyEventLike = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'> &
Partial<Pick<KeyboardEvent, 'code'>>;
/**
* Whether an event is this shortcut. The command key is the platform's own: ⌘
* on Apple keyboards, Ctrl elsewhere, never either one. Taking both made Ctrl+N
* in a Mac text field (the "next line" binding) run ⌘N.
*/
export function matchesShortcut(e: KeyEventLike, s: Shortcut, apple = true): boolean {
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (key !== s.key) return false;
const digitAt = /^[0-9]$/.test(s.key) && e.code === `Digit${s.key}`;
if (key !== s.key && !digitAt) return false;
if (s.ctrl) {
if (!e.ctrlKey || !e.metaKey) return false;
} else if (!!s.mod !== (e.metaKey || e.ctrlKey)) {
return false;
} else {
const command = apple ? e.metaKey : e.ctrlKey;
const other = apple ? e.ctrlKey : e.metaKey;
if (!!s.mod !== command || other) return false;
}
return !!s.shift === e.shiftKey && !!s.alt === e.altKey;
}
@@ -47,13 +58,14 @@ export function isTypingTarget(target: EventTarget | null): boolean {
/** The first shortcut of the lists (later ones first) that the event is. */
export function findShortcut(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'target'>,
lists: Shortcut[][]
e: KeyEventLike & Pick<KeyboardEvent, 'target'>,
lists: Shortcut[][],
apple = true
): Shortcut | null {
for (let i = lists.length - 1; i >= 0; i--) {
for (const s of lists[i]) {
if (!s.mod && !s.ctrl && isTypingTarget(e.target)) continue;
if (matchesShortcut(e, s)) return s;
if (matchesShortcut(e, s, apple)) return s;
}
}
return null;
+48 -26
View File
@@ -10,6 +10,9 @@
--m-bg: #F3F2EF;
--m-scr: radial-gradient(120% 80% at 50% -10%, #FBFAF7 0%, #F1EFEA 55%, #E9E7E1 100%);
--m-glass: rgba(255,255,255,.6);
/* The dock's glass: denser than --m-glass, so a photo scrolling under it
* neither turns it muddy nor washes out its icons. */
--m-dock: rgba(255,255,255,.8);
--m-gbr: rgba(255,255,255,.75);
--m-card: rgba(255,255,255,.55);
--m-cbr: rgba(255,255,255,.75);
@@ -58,17 +61,17 @@
--m-rail-w: 0px;
}
/* Wide size class (hooks/sizeClass.ts WIDE_QUERY): the rail on the leading
* edge takes the dock's place, so nothing docks at the bottom any more and
* content only clears the home indicator. */
@media (min-width: 600px) and (min-height: 501px) {
.m-root {
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 92px);
}
:root[dir='rtl'] .m-root {
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 92px);
}
/* Wide size class (hooks/sizeClass.ts, written to the shell's root as
* data-size): the rail on the leading edge takes the dock's place, so nothing
* docks at the bottom any more and content only clears the home indicator.
* Asked from <html>, so a sheet's own .m-root (portalled, perhaps to body)
* reads the same values as the shell. */
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px);
}
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px);
}
.dark .m-root {
@@ -78,6 +81,7 @@
--m-bg: #0A0A0C;
--m-scr: radial-gradient(120% 80% at 50% -10%, #17171C 0%, #0C0C0F 55%, #08080A 100%);
--m-glass: rgba(26,26,31,.6);
--m-dock: rgba(26,26,31,.82);
--m-gbr: rgba(255,255,255,.12);
--m-card: rgba(24,24,29,.55);
--m-cbr: rgba(255,255,255,.1);
@@ -171,15 +175,13 @@
/* A form sheet on a wide window sits mid-screen, so it rises from the bottom
edge of the window rather than from just below its own resting place. */
@media (min-width: 600px) and (min-height: 501px) {
@keyframes m-sheet-up {
from { transform: translateY(calc(50dvh + 50%)); }
to { transform: translateY(0); }
}
@keyframes m-sheet-down {
from { transform: translateY(0); }
to { transform: translateY(calc(50dvh + 50%)); }
}
@keyframes m-sheet-up-wide {
from { transform: translateY(calc(50dvh + 50%)); }
to { transform: translateY(0); }
}
@keyframes m-sheet-down-wide {
from { transform: translateY(0); }
to { transform: translateY(calc(50dvh + 50%)); }
}
.m-sheet-in { animation: m-sheet-in 260ms var(--ease-out-quint) both; }
.m-sheet-out { animation: m-sheet-out 220ms var(--ease-drawer) both; }
@@ -192,12 +194,14 @@
.m-pop-in { animation: m-pop-in 220ms var(--ease-out-quint) both; }
.m-toast-in { animation: m-toast-in 260ms var(--ease-out-quint) both; }
.m-toast-out { animation: m-toast-out 220ms ease both; }
@media (min-width: 600px) and (min-height: 501px) {
.m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
[dir='rtl'] .m-panel-in { animation-name: m-panel-in-rtl; }
@media (prefers-reduced-motion: reduce) {
.m-panel-in, [dir='rtl'] .m-panel-in { animation: m-fade-in 120ms ease-out both; }
}
@media (prefers-reduced-motion: no-preference) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-up { animation-name: m-sheet-up-wide; }
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-down { animation-name: m-sheet-down-wide; }
}
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation-name: m-panel-in-rtl; }
@media (prefers-reduced-motion: reduce) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-fade-in 120ms ease-out both; }
}
/* Reduced motion: gentler, not zero (same rule the desktop keyframes follow). */
@@ -631,6 +635,17 @@ html:has(.m-shell) body {
overflow-x: clip;
background-color: transparent;
}
/* The few routes without a phone screen of their own (a plugin's page, help,
a trip's files) still render through PageShell. Inside the touch shell its
desktop navbar, shown from 768px up, would stand over the rail and repeat the
navigation, and --nav-h would keep 64px of room for it: the shell has its own
navigation, so neither is there. */
html:has(.m-shell) {
--nav-h: 0px;
}
html:has(.m-shell) [data-desktop-navbar] {
display: none;
}
html:has(.m-shell) .leaflet-control-zoom {
display: none !important;
}
@@ -652,6 +667,13 @@ html:has(.m-shell) ::-webkit-scrollbar {
margin-inline-start: var(--trip-map-inset, 0px);
}
/* The strip under a floating dock (components/MDockScrim.tsx): solid across
the gap below the dock, clear from about its middle up. */
.m-dock-scrim {
-webkit-mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
}
/* A horizontal strip with more behind an edge fades out there
(components/useEdgeFade.ts), so a cut chip reads as "the row goes on". */
.m-fade-s,
+10 -1
View File
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useOverlay } from './overlayStack';
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useHasOpenOverlay, useOverlay } from './overlayStack';
beforeEach(() => _resetOverlayStack());
@@ -33,4 +33,13 @@ describe('overlayStack', () => {
expect(hasOpenOverlay()).toBe(false);
expect(closeTopOverlay()).toBe(false);
});
it('tells a component when an overlay opens and when the last one closes', () => {
const watch = renderHook(() => useHasOpenOverlay());
expect(watch.result.current).toBe(false);
const sheet = renderHook(({ open }) => useOverlay(open, () => {}), { initialProps: { open: true } });
expect(watch.result.current).toBe(true);
sheet.rerender({ open: false });
expect(watch.result.current).toBe(false);
});
});
+19 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
/**
* The overlays open on screen, newest last, so a way back closes the one on
@@ -9,6 +9,16 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
type Close = () => void;
const stack: { id: number; close: () => void }[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
function changed(): void {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Closes the overlay on top. False when none is open, so the caller goes back instead. */
export function closeTopOverlay(): boolean {
@@ -22,6 +32,11 @@ export function hasOpenOverlay(): boolean {
return stack.length > 0;
}
/** Whether an overlay is open, for a component that changes with it (the keyboard shortcuts). */
export function useHasOpenOverlay(): boolean {
return useSyncExternalStore(subscribe, hasOpenOverlay, () => false);
}
/** Registers an overlay while `open`, with the way it closes. */
export function useOverlay(open: boolean, close: Close): void {
const closeRef = useRef(close);
@@ -32,9 +47,11 @@ export function useOverlay(open: boolean, close: Close): void {
if (!open) return;
const entry = { id: nextId++, close: () => closeRef.current() };
stack.push(entry);
changed();
return () => {
const i = stack.findIndex((e) => e.id === entry.id);
if (i >= 0) stack.splice(i, 1);
changed();
};
}, [open]);
}
@@ -42,4 +59,5 @@ export function useOverlay(open: boolean, close: Close): void {
/** Test helper. */
export function _resetOverlayStack(): void {
stack.length = 0;
changed();
}
@@ -31,6 +31,8 @@ import MTripRail from './MTripRail'
import { CollabSegment, CompactToggle, DocSyncButton, GlassSegment } from './MTripShellParts'
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
import { useShortcuts } from '../../keyboard/useShortcuts'
import { tripCreateIntent } from '../../../components/Layout/useCreateAction'
import MDockScrim from '../../components/MDockScrim'
/**
* Mobile trip screen frame. Owns the chrome the design shares across every
@@ -489,6 +491,8 @@ export default function MTripShell({
else planner.handleSelectDay(next.id, true)
}
const dayKeys = trTab === 'plan' || trTab === 'roadtrip'
// ⌘N creates what the tab on show creates, as the "+" does (MShellShortcuts lets go of it here).
const createIntent = tripCreateIntent(String(tripId), trTab)
useShortcuts('trip', [
...keyTabs.map((tab, i) => ({ id: `tab-${tab.id}`, title: tab.label, key: String(i + 1), mod: true, run: () => setTrTab(tab.id) })),
...(dayKeys && days.length > 1
@@ -500,6 +504,7 @@ export default function MTripShell({
...(split && MAP_TABS.has(trTab)
? [{ id: 'pane', title: paneOpen ? t('mobileTrip.hidePane') : t('mobileTrip.showPane'), key: 's', ctrl: true, run: trTab === 'roadtrip' ? toggleRtView : toggleView }]
: []),
{ id: 'create', title: t(createIntent.labelKey), key: 'n', mod: true, run: () => void navigate(createIntent.to) },
{ id: 'back', title: t('common.back'), key: '[', mod: true, run: () => void navigate('/dashboard') },
])
@@ -891,7 +896,9 @@ export default function MTripShell({
)}
{/* ── Bottom dock (replaces the global bottom nav on this screen) ── */}
{!wide && <nav className="absolute inset-x-4 z-40 flex h-[62px] items-center justify-around rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
{/* A tab's list fades out under the dock rather than showing in the gap below it; a map runs to the edge. */}
{!wide && !MAP_TABS.has(trTab) && <MDockScrim />}
{!wide && <nav className="absolute inset-x-4 z-40 flex h-[62px] items-center justify-around rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-dock)] px-[14px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.4] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
{dockTabs.map(({ id, icon: Icon }) => {
// While More is open the dock points at More, not at the tab under it.
const active = trTab === id && sheet?.id !== 'mehr'
+10 -8
View File
@@ -121,15 +121,17 @@ export default {
},
plugins: [
// The touch shell's size classes (src/hooks/sizeClass.ts) as the `wide:` and
// `split:` variants. Variants of their own rather than entries in `screens`:
// both need a height bound (a phone on its side stays compact), and a raw
// screen among the min-width ones reorders how Tailwind sorts every
// responsive class.
// `split:` variants. They key on the class MobileShell writes to the shell's
// root (data-size), not on media queries of their own, so the CSS follows the
// hooks exactly: compact in a browser, and unmoved by Android's soft keyboard.
// Asked from <html>, so a sheet portalled outside the shell follows too, and
// inside :where() so a variant weighs what a media query did.
({ addVariant }) => {
addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')
addVariant('split', '@media (min-width: 840px) and (min-height: 501px)')
// The other side of that height bound: a window as short as a phone on its
// side, where a form sheet takes the full height (MSheet's `form`).
addVariant('wide', ':where(html:has(.m-shell[data-size="wide"], .m-shell[data-size="split"])) &')
addVariant('split', ':where(html:has(.m-shell[data-size="split"])) &')
// A window as short as a phone on its side, where a form sheet takes the full
// height (MSheet's `form`). A media query on purpose: a soft keyboard that
// shrinks the window below it changes only CSS, so nothing remounts.
addVariant('short', '@media (max-height: 500px)')
},
],
+16 -19
View File
@@ -1,33 +1,30 @@
import { act } from '@testing-library/react';
import { SPLIT_QUERY, WIDE_QUERY } from '../../src/hooks/sizeClass';
import { afterEach } from 'vitest';
import { _pinSizeClass, _resetSizeClass } from '../../src/hooks/sizeClass';
/**
* The touch shell's size class in tests (src/hooks/sizeClass.ts).
*
* jsdom's window is 1024×768, which the real queries would read as an iPad, so
* every phone test would run the wide layout. tests/setup.ts answers the two
* size-class queries from here instead: compact unless a test sets another,
* and back to compact after each test.
* jsdom is a browser, where the class is always compact, so a test of the
* large-screen layout pins the class it wants instead of faking the app and a
* window size. Importing this helper also hands the class back to the window
* after each test of the file.
*
* Not imported by tests/setup.ts on purpose: sizeClass.ts reads native/platform,
* and a module a setup file imports is cached before a test file's vi.mock of it
* can take effect.
*/
export type TestSizeClass = 'compact' | 'wide' | 'split';
let current: TestSizeClass = 'compact';
export function sizeClassMatches(query: string): boolean | null {
if (query === WIDE_QUERY) return current !== 'compact';
if (query === SPLIT_QUERY) return current === 'split';
return null;
}
/** Switches the size class; the hooks follow as they would a rotation or a fold. */
export function setSizeClass(next: TestSizeClass): void {
act(() => {
current = next;
window.dispatchEvent(new Event('resize'));
});
act(() => _pinSizeClass(next));
}
/** Called by tests/setup.ts after each test. */
/** Back to what the window says (compact in jsdom). */
export function resetSizeClass(): void {
current = 'compact';
_pinSizeClass(null);
_resetSizeClass();
}
afterEach(() => resetSizeClass());
-6
View File
@@ -2,7 +2,6 @@ import '@testing-library/jest-dom/vitest';
import 'fake-indexeddb/auto';
import { cleanup, configure } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resetSizeClass, sizeClassMatches } from './helpers/sizeClass';
import { server } from './helpers/msw/server';
// waitFor/findBy* default to 1s, which is enough on a dev machine but not on a
@@ -36,7 +35,6 @@ beforeAll(() => server.listen({
afterEach(() => {
server.resetHandlers();
cleanup();
resetSizeClass();
localStorage.clear();
sessionStorage.clear();
});
@@ -61,10 +59,6 @@ Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, op
const mediaLists = new Set<{ media: string; matches: boolean; listeners: Set<(e: MediaQueryListEvent) => void> }>()
function widthMatches(query: string): boolean {
// The touch shell's size classes come from tests/helpers/sizeClass.ts, compact
// by default: jsdom's 1024px window would otherwise read as an iPad.
const sizeClass = sizeClassMatches(query)
if (sizeClass != null) return sizeClass
const max = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query)
if (max) return window.innerWidth <= Number(max[1])
const min = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query)
+161 -10
View File
@@ -16,6 +16,7 @@ import { act, fireEvent, render, screen, waitFor } from '../../helpers/render';
// FE-MOB-KEYS-001 onwards
const native = vi.hoisted(() => ({
app: false,
ios: false,
features: [] as string[],
setKeyCommands: vi.fn(() => Promise.resolve()),
@@ -23,8 +24,9 @@ const native = vi.hoisted(() => ({
}));
vi.mock('../../../src/native/platform', () => ({
isNativeApp: () => native.ios,
isNativeIos: () => native.ios,
isNativeApp: () => native.app,
isNativeIos: () => native.app && native.ios,
isNativeAndroid: () => native.app && !native.ios,
nativeServerHost: () => null,
}));
vi.mock('../../../src/native/capabilities', () => ({
@@ -43,8 +45,17 @@ vi.mock('../../../src/native/trekShell', () => ({
},
}));
import MSheet from '../../../src/mobile/components/MSheet';
import MKeyboardHost from '../../../src/mobile/keyboard/MKeyboardHost';
import MShellShortcuts from '../../../src/mobile/keyboard/MShellShortcuts';
import { _resetOverlayStack } from '../../../src/mobile/overlayStack';
/** The iPad app; without the keyCommands feature (an older binary) the page answers every key. */
function asIpadApp(features: string[] = []) {
native.app = true;
native.ios = true;
native.features = features;
}
const key = (k: string, mods: Partial<KeyboardEvent> = {}) => ({
key: k,
@@ -60,9 +71,15 @@ describe('shortcut matching', () => {
const sidebar: Shortcut = { id: 'p', title: 'Sidebar', key: 's', ctrl: true, run: () => {} };
const next: Shortcut = { id: 'n', title: 'Next', key: 'ArrowRight', alt: true, run: () => {} };
it('FE-MOB-KEYS-001: ⌘ and Ctrl both count as the command key, the rest must match exactly', () => {
it("FE-MOB-KEYS-001: the command key is the platform's own, the rest must match exactly", () => {
expect(matchesShortcut(key('s', { metaKey: true }), save)).toBe(true);
expect(matchesShortcut(key('S', { ctrlKey: true }), save)).toBe(true);
expect(matchesShortcut(key('S', { metaKey: true }), save)).toBe(true);
// On an Apple keyboard Ctrl is not ⌘: Ctrl+N in a text field moves the caret.
expect(matchesShortcut(key('s', { ctrlKey: true }), save)).toBe(false);
expect(matchesShortcut(key('s', { metaKey: true, ctrlKey: true }), save)).toBe(false);
// Elsewhere (Android) Ctrl is the command key and the Meta key is not.
expect(matchesShortcut(key('s', { ctrlKey: true }), save, false)).toBe(true);
expect(matchesShortcut(key('s', { metaKey: true }), save, false)).toBe(false);
expect(matchesShortcut(key('s'), save)).toBe(false);
expect(matchesShortcut(key('s', { metaKey: true, shiftKey: true }), save)).toBe(false);
expect(matchesShortcut(key('s', { metaKey: true, ctrlKey: true }), sidebar)).toBe(true);
@@ -70,6 +87,14 @@ describe('shortcut matching', () => {
expect(matchesShortcut(key('ArrowRight', { altKey: true }), next)).toBe(true);
});
it('FE-MOB-KEYS-011: a digit shortcut finds its key on a French keyboard too', () => {
const first: Shortcut = { id: 'one', title: 'One', key: '1', mod: true, run: () => {} };
// AZERTY types '&' on the 1 key without Shift; the key's place is still Digit1.
expect(matchesShortcut({ ...key('&', { metaKey: true }), code: 'Digit1' }, first)).toBe(true);
expect(matchesShortcut({ ...key('é', { metaKey: true }), code: 'Digit2' }, first)).toBe(false);
expect(matchesShortcut({ ...key('1', { metaKey: true }), code: 'Numpad1' }, first)).toBe(true);
});
it('FE-MOB-KEYS-002: a field keeps its own keys, except the ones with ⌘', () => {
const field = document.createElement('input');
expect(isTypingTarget(field)).toBe(true);
@@ -112,15 +137,18 @@ function LocationEcho() {
describe('MKeyboardHost', () => {
beforeEach(() => {
native.app = false;
native.ios = false;
native.features = [];
native.setKeyCommands.mockClear();
native.listener = null;
useShortcutRegistry.setState({ lists: [] });
_resetOverlayStack();
});
afterEach(() => useShortcutRegistry.setState({ lists: [] }));
it('FE-MOB-KEYS-005: in a browser the page answers the keys and runs the newest handler', () => {
it('FE-MOB-KEYS-005: in the app the page answers the keys and runs the newest handler', () => {
asIpadApp();
const first = vi.fn();
const second = vi.fn();
const shortcut = (run: () => void): Shortcut[] => [{ id: 'go', title: 'Go', key: '1', mod: true, run }];
@@ -144,6 +172,7 @@ describe('MKeyboardHost', () => {
});
it('FE-MOB-KEYS-006: a screen that goes away takes its shortcuts with it', () => {
asIpadApp();
const run = vi.fn();
const { rerender } = render(
<>
@@ -158,8 +187,7 @@ describe('MKeyboardHost', () => {
});
it('FE-MOB-KEYS-007: the app takes the ⌘ shortcuts as key commands, names and all, and reports them back', async () => {
native.ios = true;
native.features = ['keyCommands'];
asIpadApp(['keyCommands']);
const run = vi.fn();
const step = vi.fn();
render(
@@ -193,8 +221,126 @@ describe('MKeyboardHost', () => {
});
});
describe('MKeyboardHost and the rest of the screen', () => {
beforeEach(() => {
native.app = false;
native.ios = false;
native.features = [];
native.setKeyCommands.mockClear();
native.listener = null;
useShortcutRegistry.setState({ lists: [] });
_resetOverlayStack();
});
afterEach(() => useShortcutRegistry.setState({ lists: [] }));
it('FE-MOB-KEYS-012: a browser keeps its own keys: Ctrl+1 switches tabs, Alt+Left goes back', () => {
const go = vi.fn();
const back = vi.fn();
render(
<>
<MKeyboardHost />
<Probe
owner="trip"
shortcuts={[
{ id: 'go', title: 'Go', key: '1', mod: true, run: go },
{ id: 'day-prev', title: 'Previous day', key: 'ArrowLeft', alt: true, run: back },
]}
/>
</>
);
const ctrlOne = fireEvent.keyDown(document.body, { key: '1', ctrlKey: true });
const metaOne = fireEvent.keyDown(document.body, { key: '1', metaKey: true });
const altLeft = fireEvent.keyDown(document.body, { key: 'ArrowLeft', altKey: true });
expect(go).not.toHaveBeenCalled();
expect(back).not.toHaveBeenCalled();
// Not prevented: the browser still does what the keys mean to it.
expect([ctrlOne, metaOne, altLeft]).toEqual([true, true, true]);
});
it('FE-MOB-KEYS-013: with a sheet open and its form changed, ⌘[ neither leaves nor drops the form', async () => {
asIpadApp();
const leave = vi.fn();
render(
<>
<MKeyboardHost />
<Probe owner="trip" shortcuts={[{ id: 'back', title: 'Back', key: '[', mod: true, run: leave }]} />
<MSheet open onClose={() => {}} discardGuard={{ title: 'changed' }} ariaLabel="Edit reservation">
<input aria-label="Title" defaultValue="Dinner" />
</MSheet>
</>
);
await screen.findByRole('dialog', { name: 'Edit reservation' });
const pressed = fireEvent.keyDown(screen.getByLabelText('Title'), { key: '[', metaKey: true });
expect(leave).not.toHaveBeenCalled();
expect(pressed).toBe(true);
expect(screen.getByRole('dialog', { name: 'Edit reservation' })).toBeInTheDocument();
});
it('FE-MOB-KEYS-014: the app is handed no key commands while a sheet is open, and gets them back after', async () => {
asIpadApp(['keyCommands']);
const run = vi.fn();
const shortcuts: Shortcut[] = [{ id: 'create', title: 'New trip', key: 'n', mod: true, run }];
const { rerender } = render(
<>
<MKeyboardHost />
<Probe owner="shell" shortcuts={shortcuts} />
<MSheet open onClose={() => {}} ariaLabel="New trip">
<span />
</MSheet>
</>
);
await waitFor(() => expect(native.setKeyCommands).toHaveBeenLastCalledWith({ commands: [] }));
await waitFor(() => expect(native.listener).not.toBeNull());
// A command sent before the sheet opened does not run behind it either.
act(() => native.listener?.({ id: 'shell:create' }));
expect(run).not.toHaveBeenCalled();
rerender(
<>
<MKeyboardHost />
<Probe owner="shell" shortcuts={shortcuts} />
</>
);
await waitFor(() =>
expect(native.setKeyCommands).toHaveBeenLastCalledWith({
commands: [{ id: 'shell:create', title: 'New trip', input: 'n', modifiers: ['command'] }],
})
);
});
it('FE-MOB-KEYS-015: a dialog kept mounted but hidden holds nothing back', () => {
asIpadApp();
const run = vi.fn();
render(
<>
<MKeyboardHost />
<Probe owner="a" shortcuts={[{ id: 'go', title: 'Go', key: '1', mod: true, run }]} />
<section role="dialog" aria-modal="true" hidden />
</>
);
fireEvent.keyDown(document.body, { key: '1', metaKey: true });
expect(run).toHaveBeenCalledOnce();
});
it('FE-MOB-KEYS-016: Android without key commands of its own answers Ctrl, not the Meta key', () => {
native.app = true;
const run = vi.fn();
render(
<>
<MKeyboardHost />
<Probe owner="a" shortcuts={[{ id: 'go', title: 'Go', key: '1', mod: true, run }]} />
</>
);
fireEvent.keyDown(document.body, { key: '1', metaKey: true });
expect(run).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: '1', ctrlKey: true });
expect(run).toHaveBeenCalledOnce();
});
});
describe('MKeyboardHost in the Android app', () => {
beforeEach(() => {
native.app = true;
native.ios = false;
native.features = [];
native.setKeyCommands.mockClear();
@@ -222,8 +368,7 @@ describe('MKeyboardHost in the Android app', () => {
describe('MShellShortcuts', () => {
beforeEach(() => {
native.ios = false;
native.features = [];
asIpadApp();
useShortcutRegistry.setState({ lists: [] });
usePluginStore.setState({ plugins: [], loaded: true });
useAddonStore.setState({
@@ -253,7 +398,7 @@ describe('MShellShortcuts', () => {
expect(at()).toBe('/settings');
});
it('FE-MOB-KEYS-009: inside a trip the numbers belong to the trip', () => {
it('FE-MOB-KEYS-009: inside a trip the numbers and ⌘N belong to the trip', () => {
render(
<>
<MKeyboardHost />
@@ -264,5 +409,11 @@ describe('MShellShortcuts', () => {
);
fireEvent.keyDown(document.body, { key: '2', metaKey: true });
expect(screen.getByTestId('loc').textContent).toBe('/trips/4');
// The trip registers its own ⌘N for the tab on show (MTripShell); the shell's
// would only know the tab the trip opened on.
fireEvent.keyDown(document.body, { key: 'n', metaKey: true });
expect(screen.getByTestId('loc').textContent).toBe('/trips/4');
const shell = useShortcutRegistry.getState().lists.find((l) => l.owner === 'shell');
expect(shell?.shortcuts.map((s) => s.id)).toEqual(['settings']);
});
});
+130 -30
View File
@@ -1,48 +1,76 @@
import { renderHook } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { SPLIT_QUERY, WIDE_QUERY, isWideViewport, useIsSplit, useIsWide } from '../../../src/hooks/sizeClass';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetSizeClass,
isWideViewport,
sizeClassOf,
useIsSplit,
useIsWide,
useSizeClass,
} from '../../../src/hooks/sizeClass';
import { setSizeClass } from '../../helpers/sizeClass';
// FE-MOB-SIZE-001 onwards
const native = vi.hoisted(() => ({ app: false }));
vi.mock('../../../src/native/platform', () => ({
isNativeApp: () => native.app,
isNativeIos: () => false,
isNativeAndroid: () => native.app,
nativeServerHost: () => null,
}));
const root = resolve(__dirname, '../../..');
function resizeTo(width: number, height: number) {
act(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height });
window.dispatchEvent(new Event('resize'));
});
}
describe('size classes', () => {
it('FE-MOB-SIZE-001: the Tailwind variants and the stylesheet use the same queries as the hooks', () => {
const initial = { width: window.innerWidth, height: window.innerHeight };
beforeEach(() => {
native.app = false;
_resetSizeClass();
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: initial.width });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: initial.height });
document.body.innerHTML = '';
});
it('FE-MOB-SIZE-001: the Tailwind variants and the stylesheet follow the class on the shell, not media queries of their own', () => {
const tailwind = readFileSync(resolve(root, 'tailwind.config.js'), 'utf8');
expect(tailwind).toContain(`addVariant('wide', '@media ${WIDE_QUERY}')`);
expect(tailwind).toContain(`addVariant('split', '@media ${SPLIT_QUERY}')`);
expect(tailwind).toContain(
`addVariant('wide', ':where(html:has(.m-shell[data-size="wide"], .m-shell[data-size="split"])) &')`
);
expect(tailwind).toContain(`addVariant('split', ':where(html:has(.m-shell[data-size="split"])) &')`);
const css = readFileSync(resolve(root, 'src/mobile/mobile.css'), 'utf8');
const queries = [...css.matchAll(/@media ([^{]*min-height: 501px[^{]*)\{/g)].map((m) => m[1].trim());
expect(queries.length).toBeGreaterThan(0);
for (const q of queries) expect(q).toBe(WIDE_QUERY);
expect(css).not.toMatch(/min-height: 501px/);
expect(css).toContain(`html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {`);
});
it('FE-MOB-SIZE-002: a phone on its side stays compact, a tablet and an unfolded foldable do not', () => {
// The queries evaluated by hand for the sizes that matter: jsdom has no height feature.
const evaluate = (query: string, w: number, h: number) => {
const minW = Number(/min-width: (\d+)px/.exec(query)?.[1]);
const minH = Number(/min-height: (\d+)px/.exec(query)?.[1]);
return w >= minW && h >= minH;
const sizes: Record<string, [number, number, string]> = {
phone: [402, 874, 'compact'],
phoneLandscape: [956, 440, 'compact'],
ipadMiniPortrait: [744, 1133, 'wide'],
ipad13Landscape: [1376, 1032, 'split'],
ipad11SplitHalf: [590, 834, 'compact'],
ipad13SplitHalf: [683, 1032, 'wide'],
duoUnfoldedLandscape: [1017, 699, 'split'],
duoUnfoldedPortrait: [699, 1017, 'wide'],
ipad11Portrait: [834, 1194, 'wide'],
androidFoldUnfoldedLandscape: [841, 701, 'split'],
androidFoldUnfoldedPortrait: [701, 841, 'wide'],
};
const sizes: Record<string, [number, number, boolean, boolean]> = {
phone: [402, 874, false, false],
phoneLandscape: [956, 440, false, false],
ipadMiniPortrait: [744, 1133, true, false],
ipad13Landscape: [1376, 1032, true, true],
ipad11SplitHalf: [590, 834, false, false],
ipad13SplitHalf: [683, 1032, true, false],
duoUnfoldedLandscape: [1017, 699, true, true],
duoUnfoldedPortrait: [699, 1017, true, false],
ipad11Portrait: [834, 1194, true, false],
androidFoldUnfoldedLandscape: [841, 701, true, true],
androidFoldUnfoldedPortrait: [701, 841, true, false],
};
for (const [name, [w, h, wide, split]] of Object.entries(sizes)) {
expect([name, evaluate(WIDE_QUERY, w, h)]).toEqual([name, wide]);
expect([name, evaluate(SPLIT_QUERY, w, h)]).toEqual([name, split]);
for (const [name, [w, h, expected]] of Object.entries(sizes)) {
expect([name, sizeClassOf(w, h)]).toEqual([name, expected]);
}
});
@@ -64,4 +92,76 @@ describe('size classes', () => {
expect(wide.result.current).toBe(false);
expect(split.result.current).toBe(false);
});
it('FE-MOB-SIZE-004: in a browser the touch shell stays compact at any size', () => {
resizeTo(744, 1133);
const cls = renderHook(() => useSizeClass());
expect(cls.result.current).toBe('compact');
resizeTo(1376, 1032);
expect(cls.result.current).toBe('compact');
expect(isWideViewport()).toBe(false);
});
it('FE-MOB-SIZE-005: in the app the class follows the window', () => {
native.app = true;
resizeTo(402, 874);
const cls = renderHook(() => useSizeClass());
expect(cls.result.current).toBe('compact');
resizeTo(744, 1133);
expect(cls.result.current).toBe('wide');
resizeTo(1133, 744);
expect(cls.result.current).toBe('split');
resizeTo(874, 402);
expect(cls.result.current).toBe('compact');
});
it('FE-MOB-SIZE-006: the soft keyboard taking height from the window leaves the class alone', () => {
native.app = true;
resizeTo(1280, 800);
const cls = renderHook(() => useSizeClass());
expect(cls.result.current).toBe('split');
// A field gets focus and Android's keyboard shrinks the WebView to 400px.
const field = document.createElement('input');
document.body.appendChild(field);
field.focus();
resizeTo(1280, 400);
expect(cls.result.current).toBe('split');
expect(document.activeElement).toBe(field);
// The keyboard goes down: the window is whole again, nothing moved.
resizeTo(1280, 800);
expect(cls.result.current).toBe('split');
// Without a field in focus a shorter window is a new window.
field.blur();
resizeTo(1280, 400);
expect(cls.result.current).toBe('compact');
});
it('FE-MOB-SIZE-007: a turn while typing is still taken, the keyboard only holds the height', () => {
native.app = true;
resizeTo(701, 841);
const cls = renderHook(() => useSizeClass());
expect(cls.result.current).toBe('wide');
const field = document.createElement('textarea');
document.body.appendChild(field);
field.focus();
resizeTo(701, 470);
expect(cls.result.current).toBe('wide');
resizeTo(841, 701);
expect(cls.result.current).toBe('split');
});
it('FE-MOB-SIZE-008: a checkbox in focus brings up no keyboard, so it holds nothing', () => {
native.app = true;
resizeTo(1280, 800);
const cls = renderHook(() => useSizeClass());
const box = document.createElement('input');
box.type = 'checkbox';
document.body.appendChild(box);
box.focus();
resizeTo(1280, 400);
expect(cls.result.current).toBe('compact');
});
});
@@ -17,6 +17,13 @@ import { seedStore } from '../../../helpers/store';
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }));
// The shortcuts are the app's (MKeyboardHost): an iPad without native key commands.
vi.mock('../../../../src/native/platform', () => ({
isNativeApp: () => true,
isNativeIos: () => true,
isNativeAndroid: () => false,
nativeServerHost: () => null,
}));
vi.mock('../../../../src/pages/tripPlanner/useTripPlanner', () => ({
useTripPlanner: () => mocks.planner,
}));
@@ -187,7 +194,18 @@ describe('MTripShell on a wide window', () => {
expect(mocks.planner.navigate).toHaveBeenCalledWith('/dashboard');
});
it('FE-MOB-SHELLW-010: ⌘N creates what the tab on show creates, and says so', () => {
setSizeClass('split');
renderShell({ activeTab: 'finanzplan' } as Partial<TripPlanner>);
const trip = useShortcutRegistry.getState().lists.find((l) => l.owner === 'trip');
expect(trip?.shortcuts.find((s) => s.id === 'create')?.title).toBe('costs.addExpense');
fireEvent.keyDown(document.body, { key: 'n', metaKey: true });
expect(mocks.planner.navigate).toHaveBeenCalledWith('/trips/1?create=expense');
});
it('FE-MOB-SHELLW-006: the phone keeps its dock and its full-screen plan', () => {
// Pinned: in the app jsdom's 1024x768 window would read as a tablet.
setSizeClass('compact');
renderShell();
expect(screen.queryByRole('navigation', { name: 'Trip sections' })).not.toBeInTheDocument();
expect(shellApi().paneOpen).toBe(false);