mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold
The Android shell now reports keyCommands and windowLayout, so the large screen layout no longer assumes an iPad: - The keyboard host hands the shortcuts to the app whenever the shell has the keyCommands feature, whatever the platform. Before it also required iOS, so an Android tablet with a keyboard answered the keys in the page and never listed them in the system's shortcuts. - useWindowFold follows the layout the Android shell reports (feature windowLayout). When a vertical fold separates the window, a foldable half open like a book, MobileShell marks it with data-fold and its place, and the leading pane (the trip's plan, a tab's summary, a list beside its detail) ends at the fold while the trailing one starts there, on either reading direction. A flat fold that does not separate stays one screen. - The split size class starts at 840pt, Android's expanded width, instead of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes like an iPad does. An 11" iPad upright (834pt) stays a single pane. The address screen of the native shell follows the same width. - TrekShell exports NativeWindowLayout for it. Also: the sheet's width and anchor types and the collab board type stay private to their modules, which nothing else imports (knip).
This commit is contained in:
@@ -983,7 +983,7 @@ a.linkbtn {
|
||||
centred in the other half, the way a split sign-in page uses the width.
|
||||
Upright, the photo band grows with the screen instead of stopping at a
|
||||
phone's 440px. Logical sides, so Arabic mirrors it. */
|
||||
@media (min-width: 900px) and (min-height: 600px) and (orientation: landscape) {
|
||||
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
|
||||
body::after {
|
||||
inset: 16px auto 16px 16px;
|
||||
inset-block: 16px;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* The touch shell's size classes, the web's answer to UIKit's compact and
|
||||
* regular widths.
|
||||
* regular widths and to Android's window size classes. They are read off the
|
||||
* window's CSS size, which is the same in the iOS and the Android app (a CSS
|
||||
* pixel is a point there and a dp here), so both apps lay out alike.
|
||||
*
|
||||
* compact every phone, upright or on its side, and the narrow Split View and
|
||||
* Stage Manager windows. The shell as it has always been: the dock at
|
||||
@@ -11,8 +13,10 @@ import { useSyncExternalStore } from 'react';
|
||||
* any orientation or window above a third of the screen, a foldable
|
||||
* unfolded. A navigation rail on the leading edge takes the dock's
|
||||
* place, sheets become centred form sheets, lists get columns.
|
||||
* split from 900pt across: room for two panes, so the trip's plan sits next
|
||||
* to its map and settings show their sections beside the page.
|
||||
* split from 840pt across, Android's expanded width: room for two panes, so
|
||||
* the trip's plan sits next to its map and settings show their
|
||||
* sections beside the page. An Android foldable unfolded on its side
|
||||
* (about 841dp) has it, an 11" iPad upright (834pt) does not.
|
||||
*
|
||||
* 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.
|
||||
@@ -22,7 +26,7 @@ import { useSyncExternalStore } from 'react';
|
||||
* keep the three in step (sizeClass.test.ts holds them together).
|
||||
*/
|
||||
export const WIDE_QUERY = '(min-width: 600px) and (min-height: 501px)';
|
||||
export const SPLIT_QUERY = '(min-width: 900px) and (min-height: 501px)';
|
||||
export const SPLIT_QUERY = '(min-width: 840px) and (min-height: 501px)';
|
||||
|
||||
function matches(query: string): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(query).matches;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { CSSProperties, ReactNode, useRef } from 'react'
|
||||
import { useMatch } from 'react-router'
|
||||
import BottomNav from '../components/Layout/BottomNav'
|
||||
import ErrorBoundary from '../components/shared/ErrorBoundary'
|
||||
@@ -11,6 +11,7 @@ import MToastHost from './components/MToastHost'
|
||||
import MEdgeBack from './components/MEdgeBack'
|
||||
import { useNativeCapabilities } from '../native/capabilities'
|
||||
import { isNativeIos } from '../native/platform'
|
||||
import { useWindowFold } from '../native/windowFold'
|
||||
import './mobile.css'
|
||||
|
||||
interface MobileShellProps {
|
||||
@@ -46,6 +47,12 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
// 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()
|
||||
// A foldable's fold, as the Android app reports it: two-pane screens end their
|
||||
// leading pane there (mobile.css, data-fold).
|
||||
const fold = useWindowFold()
|
||||
const foldStyle = fold
|
||||
? ({ '--m-fold-start': `${fold.start}px`, '--m-fold-end': `${fold.end}px` } as CSSProperties)
|
||||
: undefined
|
||||
|
||||
if (!isPhone) {
|
||||
return (
|
||||
@@ -59,7 +66,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">
|
||||
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" 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
|
||||
|
||||
@@ -47,7 +47,7 @@ const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold
|
||||
* rather than a panel across the screen: compact for a short list of actions,
|
||||
* regular for a form, large for a sheet that holds a page of its own.
|
||||
*/
|
||||
export type MSheetWidth = 'compact' | 'regular' | 'large'
|
||||
type MSheetWidth = 'compact' | 'regular' | 'large'
|
||||
|
||||
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
|
||||
// what iPadOS shows there: a form sheet centred over the dimmed page, as wide
|
||||
@@ -72,7 +72,7 @@ const POSITION: Record<MSheetVariant, string> = {
|
||||
* (the trip's More), the way iPadOS shows a menu next to the button that
|
||||
* opened it rather than in the middle of the screen.
|
||||
*/
|
||||
export type MSheetWideAnchor = 'center' | 'rail-foot'
|
||||
type MSheetWideAnchor = 'center' | 'rail-foot'
|
||||
const WIDE_ANCHOR: Record<MSheetWideAnchor, string> = {
|
||||
center: WIDE_POSITION,
|
||||
'rail-foot': 'wide:top-auto wide:translate-y-0 wide:end-auto wide:bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:start-[calc(var(--m-rail-w)+6px)]',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { isNativeIos } from '../../native/platform';
|
||||
import { reportNativeFailure } from '../../native/reportFailure';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts';
|
||||
@@ -8,14 +7,16 @@ import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts'
|
||||
/**
|
||||
* 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),
|
||||
* which puts them in the overlay a held ⌘ shows and in the iPadOS menu bar;
|
||||
* the page then leaves those keys alone, so nothing runs twice.
|
||||
* 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.
|
||||
*/
|
||||
export default function MKeyboardHost() {
|
||||
const lists = useShortcutRegistry((s) => s.lists);
|
||||
const { caps } = useNativeCapabilities();
|
||||
const native = isNativeIos() && caps.features.includes('keyCommands');
|
||||
const native = caps.features.includes('keyCommands');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -589,6 +589,24 @@
|
||||
--m-pane-trail-w: env(viewport-segment-width 1 0, 50vw);
|
||||
}
|
||||
}
|
||||
/* The Android app reports a foldable's fold itself (native/windowFold.ts):
|
||||
MobileShell marks a vertical fold that separates the window with data-fold
|
||||
and its place in --m-fold-start/--m-fold-end, and the panes meet there. The
|
||||
leading side follows the reading direction, the fold's place does not. */
|
||||
.m-root[data-fold='vertical'] {
|
||||
--m-pane-aside-w: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
|
||||
--m-pane-trail-w: calc(100vw - var(--m-fold-end));
|
||||
}
|
||||
.m-root[data-fold='vertical'] .m-trip {
|
||||
--trip-pane-open: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
|
||||
}
|
||||
:root[dir='rtl'] .m-root[data-fold='vertical'] {
|
||||
--m-pane-aside-w: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
|
||||
--m-pane-trail-w: var(--m-fold-start);
|
||||
}
|
||||
:root[dir='rtl'] .m-root[data-fold='vertical'] .m-trip {
|
||||
--trip-pane-open: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
|
||||
}
|
||||
.m-pane-aside {
|
||||
width: var(--m-pane-aside-w);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MTripCollabTab } from '../MTripShell';
|
||||
|
||||
/** The collab boards that can stand beside the chat: everything but the chat itself. */
|
||||
const BOARD_TABS = ['notes', 'links', 'polls'] as const;
|
||||
export type CollabBoardTab = (typeof BOARD_TABS)[number];
|
||||
type CollabBoardTab = (typeof BOARD_TABS)[number];
|
||||
|
||||
/** Whether a collab tab is one of the boards (not the chat, nor a tab this screen does not know). */
|
||||
export function isCollabBoard(tab: string): tab is CollabBoardTab {
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface SharedFilePayload {
|
||||
* pixels, Android's size classes, and a foldable's posture with the hinge's
|
||||
* place in the window. `posture` is none on a device without a fold.
|
||||
*/
|
||||
interface NativeWindowLayout {
|
||||
export interface NativeWindowLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
widthClass: 'compact' | 'medium' | 'expanded';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// FE-NATIVE-FOLD-001 onwards: the fold an Android foldable reports, as two-pane screens use it.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { NativeWindowLayout } from './trekShell';
|
||||
import { _resetWindowFold, foldOf, useWindowFold } from './windowFold';
|
||||
|
||||
const shell = vi.hoisted(() => ({
|
||||
features: [] as string[],
|
||||
layout: null as unknown,
|
||||
listener: null as null | ((layout: unknown) => void),
|
||||
}));
|
||||
|
||||
vi.mock('./capabilities', () => ({
|
||||
hasNativeFeature: (feature: string) => shell.features.includes(feature),
|
||||
useNativeCapabilities: () => ({
|
||||
caps: { shellApi: 3, platform: 'android', appVersion: '', build: '', features: shell.features },
|
||||
updateRequired: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock('./trekShell', () => ({
|
||||
TrekShell: {
|
||||
getWindowLayout: vi.fn(() => Promise.resolve(shell.layout)),
|
||||
addListener: vi.fn((_event: string, fn: (layout: unknown) => void) => {
|
||||
shell.listener = fn;
|
||||
return Promise.resolve({ remove: () => Promise.resolve() });
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function layout(over: Partial<NativeWindowLayout> = {}): NativeWindowLayout {
|
||||
return {
|
||||
width: 841,
|
||||
height: 701,
|
||||
widthClass: 'expanded',
|
||||
heightClass: 'medium',
|
||||
posture: 'halfOpened',
|
||||
orientation: 'vertical',
|
||||
separating: true,
|
||||
hinge: { left: 420, top: 0, right: 421, bottom: 701 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetWindowFold();
|
||||
shell.features = [];
|
||||
shell.layout = null;
|
||||
shell.listener = null;
|
||||
});
|
||||
|
||||
afterEach(() => _resetWindowFold());
|
||||
|
||||
describe('foldOf', () => {
|
||||
it('FE-NATIVE-FOLD-001: a book posture splits the window at its hinge', () => {
|
||||
expect(foldOf(layout())).toEqual({ start: 420, end: 421 });
|
||||
});
|
||||
|
||||
it('FE-NATIVE-FOLD-002: a seamless flat fold, a horizontal one or none at all splits nothing', () => {
|
||||
expect(foldOf(layout({ posture: 'flat', separating: false }))).toBeNull();
|
||||
expect(foldOf(layout({ orientation: 'horizontal' }))).toBeNull();
|
||||
expect(foldOf(layout({ posture: 'none', hinge: undefined }))).toBeNull();
|
||||
expect(foldOf(null)).toBeNull();
|
||||
// A hinge outside the window is not one to lay panes against.
|
||||
expect(foldOf(layout({ hinge: { left: 0, top: 0, right: 0, bottom: 701 } }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWindowFold', () => {
|
||||
it('FE-NATIVE-FOLD-003: without the shell feature there is no fold to follow', () => {
|
||||
const { result } = renderHook(() => useWindowFold());
|
||||
expect(result.current).toBeNull();
|
||||
expect(shell.listener).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-NATIVE-FOLD-004: follows the layout the Android shell reports, as the device folds and unfolds', async () => {
|
||||
shell.features = ['windowLayout'];
|
||||
shell.layout = layout();
|
||||
const { result } = renderHook(() => useWindowFold());
|
||||
await waitFor(() => expect(result.current).toEqual({ start: 420, end: 421 }));
|
||||
|
||||
act(() => shell.listener?.(layout({ posture: 'flat', separating: false })));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
import { hasNativeFeature, useNativeCapabilities } from './capabilities';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { TrekShell, type NativeWindowLayout } from './trekShell';
|
||||
|
||||
/** A fold down the window, in CSS pixels from its left edge: where it begins and ends. */
|
||||
export interface WindowFold {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fold a two-pane screen should respect: a vertical one that separates the
|
||||
* window into two sides, as a foldable half open like a book has, or a device
|
||||
* whose two screens meet there. A flat fold that does not separate (a Galaxy
|
||||
* Fold opened all the way) is one seamless screen, and a horizontal one runs
|
||||
* across both panes rather than between them.
|
||||
*/
|
||||
export function foldOf(layout: NativeWindowLayout | null): WindowFold | null {
|
||||
if (!layout?.hinge || layout.posture === 'none') return null;
|
||||
if (layout.orientation !== 'vertical' || !layout.separating) return null;
|
||||
const { left, right } = layout.hinge;
|
||||
if (!(left > 0 && right >= left && right < layout.width)) return null;
|
||||
return { start: left, end: right };
|
||||
}
|
||||
|
||||
let layout: NativeWindowLayout | null = null;
|
||||
let listening = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: NativeWindowLayout): void {
|
||||
layout = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Starts following the window layout the Android shell reports (feature windowLayout), once. */
|
||||
function follow(): void {
|
||||
if (listening || !hasNativeFeature('windowLayout')) return;
|
||||
listening = true;
|
||||
TrekShell.getWindowLayout().then(publish, reportNativeFailure);
|
||||
TrekShell.addListener('windowLayout', publish).catch(reportNativeFailure);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
const current = () => layout;
|
||||
|
||||
/**
|
||||
* The fold of the window, while the app's shell reports one (Android, feature
|
||||
* windowLayout); null in a browser, on iOS and on a device without one. Two-pane
|
||||
* screens end their leading pane at it, so no row runs across the hinge.
|
||||
*/
|
||||
export function useWindowFold(): WindowFold | null {
|
||||
const { caps } = useNativeCapabilities();
|
||||
const supported = caps.features.includes('windowLayout');
|
||||
useEffect(() => {
|
||||
if (supported) follow();
|
||||
}, [supported]);
|
||||
const reported = useSyncExternalStore(subscribe, current, current);
|
||||
return supported ? foldOf(reported) : null;
|
||||
}
|
||||
|
||||
/** Test helper: forget the layout and the subscription. */
|
||||
export function _resetWindowFold(): void {
|
||||
layout = null;
|
||||
listening = false;
|
||||
listeners.clear();
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default {
|
||||
// responsive class.
|
||||
({ addVariant }) => {
|
||||
addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')
|
||||
addVariant('split', '@media (min-width: 900px) and (min-height: 501px)')
|
||||
addVariant('split', '@media (min-width: 840px) and (min-height: 501px)')
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { usePluginStore } from '../../../src/store/pluginStore';
|
||||
|
||||
// FE-MOB-MSHELL-001 onwards
|
||||
|
||||
const fold = vi.hoisted(() => ({ current: null as null | { start: number; end: number } }));
|
||||
vi.mock('../../../src/native/windowFold', () => ({ useWindowFold: () => fold.current }));
|
||||
|
||||
beforeEach(() => {
|
||||
fold.current = null;
|
||||
useAddonStore.setState({ addons: [], loaded: true });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
});
|
||||
@@ -117,4 +121,20 @@ describe('MobileShell', () => {
|
||||
expect(content.className).toContain('overflow-y-auto');
|
||||
expect(content.className).toContain('md:overflow-visible');
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-008: a fold the Android app reports is where the panes meet', () => {
|
||||
const { container, rerender } = render(
|
||||
<MobileShell isPhone><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
const root = () => container.querySelector('.m-root') as HTMLElement;
|
||||
expect(root().dataset.fold).toBeUndefined();
|
||||
expect(root().style.getPropertyValue('--m-fold-start')).toBe('');
|
||||
|
||||
fold.current = { start: 420, end: 421 };
|
||||
rerender(<MobileShell isPhone><p>page body</p></MobileShell>);
|
||||
expect(root().dataset.fold).toBe('vertical');
|
||||
expect(root().style.getPropertyValue('--m-fold-start')).toBe('420px');
|
||||
expect(root().style.getPropertyValue('--m-fold-end')).toBe('421px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,9 +193,37 @@ describe('MKeyboardHost', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MKeyboardHost in the Android app', () => {
|
||||
beforeEach(() => {
|
||||
native.ios = false;
|
||||
native.features = [];
|
||||
native.setKeyCommands.mockClear();
|
||||
useShortcutRegistry.setState({ lists: [] });
|
||||
});
|
||||
|
||||
it('FE-MOB-KEYS-010: an Android shell with key commands takes the shortcuts too, the feature decides', async () => {
|
||||
native.features = ['keyCommands'];
|
||||
const run = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<MKeyboardHost />
|
||||
<Probe owner="trip" shortcuts={[{ id: 'tab-plan', title: 'Plan', key: '1', mod: true, run }]} />
|
||||
</>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(native.setKeyCommands).toHaveBeenLastCalledWith({
|
||||
commands: [{ id: 'trip:tab-plan', title: 'Plan', input: '1', modifiers: ['command'] }],
|
||||
})
|
||||
);
|
||||
fireEvent.keyDown(document.body, { key: '1', metaKey: true });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MShellShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
native.ios = false;
|
||||
native.features = [];
|
||||
useShortcutRegistry.setState({ lists: [] });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
useAddonStore.setState({
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('size classes', () => {
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user