mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)')
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user