From 06c03cbded1e2476a6f5a6cb467618359b34459f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 12:15:34 +0100 Subject: [PATCH] fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it --- .../Tours/terrain/TourTerrainMap.tsx | 51 +++++++++---- client/src/native/keepFieldInView.test.ts | 63 +++++++++++----- client/src/native/keepFieldInView.ts | 71 +++++++++++++++---- 3 files changed, 137 insertions(+), 48 deletions(-) diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index 64262f027..52d345d48 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -88,7 +88,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { const touchShell = useIsWide(); const legend = terrain.canReplay && !props.water ? ( - + ) : null; const legendAbove = fit === 'stacked' || fit === 'side'; const layerSwitcher = (vertical: boolean) => ( @@ -344,14 +344,42 @@ function PreloadRing({ share, label }: { share: number; label: string }) { } /** The grade mix as a pill like the dock; it unfolds upwards into the bands. */ +/** The highest point and the steepest grade, as the legend's soft pills. */ +function LegendFacts({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) { + const { t } = useTranslation(); + return ( + <> + {stats.summit && ( + }> + {formatElevation(stats.summit.elevationMeters, unit)} + + )} + {stats.steepest && ( + = 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'} + icon={} + > + {t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })} + + )} + + ); +} + +/** + * `compact` (the map's side, on a narrow 3D map): the bar and the chevron + * only; the highest point and the steepest grade move into the opened card. + */ function GradeLegend({ stats, unit, onLayout, + compact = false, }: { stats: TerrainStats; unit: DistanceUnit; onLayout?: (layout: TerrainLegendLayout | null) => void; + compact?: boolean; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -381,9 +409,14 @@ function GradeLegend({
+ {compact && ( +
+ +
+ )}
    {GRADE_BANDS.map((band) => (
  • @@ -414,19 +447,7 @@ function GradeLegend({ ))} - {stats.summit && ( - }> - {formatElevation(stats.summit.elevationMeters, unit)} - - )} - {stats.steepest && ( - = 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'} - icon={} - > - {t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })} - - )} + {!compact && } {open ? : } diff --git a/client/src/native/keepFieldInView.test.ts b/client/src/native/keepFieldInView.test.ts index 864bb44d7..e265ddef5 100644 --- a/client/src/native/keepFieldInView.test.ts +++ b/client/src/native/keepFieldInView.test.ts @@ -1,9 +1,21 @@ -import { describe, expect, it, vi } from 'vitest'; -import { keepFieldInView, revealFocusedField } from './keepFieldInView'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { keepFieldInView, releaseRoom, revealFocusedField } from './keepFieldInView'; -function field(tag: string, top: number, bottom: number): HTMLElement { - const el = document.createElement(tag); +const KEYBOARD = 300; + +function pane(): HTMLElement { + const el = document.createElement('div'); + el.style.overflowY = 'auto'; + Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 2000 }); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: 800 }); + el.scrollBy = vi.fn(); document.body.appendChild(el); + return el; +} + +function field(parent: HTMLElement, tag: string, top: number, bottom: number): HTMLElement { + const el = document.createElement(tag); + parent.appendChild(el); el.getBoundingClientRect = () => ({ top, bottom }) as DOMRect; el.scrollIntoView = vi.fn(); if (tag === 'div') el.tabIndex = 0; @@ -11,26 +23,41 @@ function field(tag: string, top: number, bottom: number): HTMLElement { return el; } +afterEach(() => { + releaseRoom(); + document.body.innerHTML = ''; +}); + describe('keepFieldInView', () => { - it('FE-NATIVE-KBFIELD-001: a field the keyboard covers is scrolled to the middle, one in view or not for typing stays', () => { - Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 }); + it('FE-NATIVE-KBFIELD-001: a field under the keyboard gets room in its pane and is scrolled above it, the room goes with the keyboard', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 }); + const scroller = pane(); + field(scroller, 'input', 760, 800); - const covered = field('input', 620, 660); - revealFocusedField(); - expect(covered.scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + revealFocusedField(KEYBOARD); + expect(scroller.style.paddingBottom).toBe('300px'); + // The keyboard's top is at 700; the field's bottom goes 24px above it. + expect(scroller.scrollBy).toHaveBeenCalledWith({ top: 800 - (700 - 24), behavior: 'smooth' }); - const visible = field('textarea', 100, 160); - revealFocusedField(); - expect(visible.scrollIntoView).not.toHaveBeenCalled(); - - const notTyping = field('div', 620, 660); - revealFocusedField(); - expect(notTyping.scrollIntoView).not.toHaveBeenCalled(); + releaseRoom(); + expect(scroller.style.paddingBottom).toBe(''); }); - it('FE-NATIVE-KBFIELD-002: listens for the keyboard having come up', () => { + it('FE-NATIVE-KBFIELD-003: a field in view, or one not for typing, stays where it is', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 }); + const scroller = pane(); + field(scroller, 'textarea', 100, 160); + revealFocusedField(KEYBOARD); + field(scroller, 'div', 760, 800); + revealFocusedField(KEYBOARD); + expect(scroller.scrollBy).not.toHaveBeenCalled(); + expect(scroller.style.paddingBottom).toBe(''); + }); + + it('FE-NATIVE-KBFIELD-002: listens for the keyboard coming up and going', () => { const addListener = vi.fn(async () => ({ remove: vi.fn() })); - keepFieldInView({ addListener }); + keepFieldInView({ addListener } as never); expect(addListener).toHaveBeenCalledWith('keyboardDidShow', expect.any(Function)); + expect(addListener).toHaveBeenCalledWith('keyboardWillHide', expect.any(Function)); }); }); diff --git a/client/src/native/keepFieldInView.ts b/client/src/native/keepFieldInView.ts index 063f181d1..fcba19067 100644 --- a/client/src/native/keepFieldInView.ts +++ b/client/src/native/keepFieldInView.ts @@ -1,32 +1,73 @@ /** * The field being typed into stays above the keyboard, on every screen. * - * The app resizes the page body for the keyboard, but a field low in a - * scrolling pane (the settings, a long form) is not moved by that: it ends up - * under the keyboard and the user types blind. Once the keyboard is up, the - * focused field is scrolled to the middle of what is left of the window. - * Screens that keep their composer above the keyboard themselves (the chat, - * useKeyboardInset) are unaffected: a field already in view does not move. + * The app is told to resize the page body for the keyboard, but on an iPad the + * panes keep their full height (they are sized to the window, not the body), + * so a field low in a scrolling pane (the settings, the tour editor's breaks, + * a long form) ends up under the keyboard and the user types blind. + * + * Once the keyboard is up, the pane the field scrolls in gets room below its + * content as tall as the keyboard, and is scrolled until the field sits above + * the keyboard with a little air. The room goes again when the keyboard does. + * A field already in view does not move, so screens that keep their composer + * above the keyboard themselves (the chat, useKeyboardInset) are unaffected. */ const TYPING_TARGET = 'input, textarea, select, [contenteditable="true"], [contenteditable=""]'; +/** What is left between the field and the keyboard's top edge. */ +const AIR = 24; -interface KeyboardEvents { - addListener(event: 'keyboardDidShow', listener: () => void): Promise; +interface KeyboardInfo { + keyboardHeight: number; } -/** Scrolls the focused field into view when it is one the user types into and it sits outside the window. */ -export function revealFocusedField(doc: Document = document): void { +interface KeyboardEvents { + addListener(event: 'keyboardDidShow', listener: (info: KeyboardInfo) => void): Promise; + addListener(event: 'keyboardWillHide', listener: () => void): Promise; +} + +/** The pane given room for the keyboard, and what its bottom padding was before. */ +let padded: { pane: HTMLElement; before: string } | null = null; + +function scrollingPane(field: HTMLElement): HTMLElement | null { + for (let node = field.parentElement; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) return node; + } + return null; +} + +/** Gives the field's pane room for the keyboard and scrolls the field above it. */ +export function revealFocusedField(keyboardHeight: number, doc: Document = document): void { const field = doc.activeElement; if (!(field instanceof HTMLElement) || !field.matches(TYPING_TARGET)) return; - const box = field.getBoundingClientRect(); const view = doc.defaultView; - const height = view?.visualViewport?.height ?? view?.innerHeight ?? 0; - if (box.top >= 0 && box.bottom <= height) return; - field.scrollIntoView({ block: 'center', behavior: 'smooth' }); + const visibleBottom = (view?.innerHeight ?? 0) - keyboardHeight; + const box = field.getBoundingClientRect(); + if (box.top >= 0 && box.bottom <= visibleBottom - AIR) return; + + const pane = scrollingPane(field); + if (!pane) { + field.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (!padded || padded.pane !== pane) { + releaseRoom(); + padded = { pane, before: pane.style.paddingBottom }; + pane.style.paddingBottom = `${keyboardHeight}px`; + } + pane.scrollBy({ top: box.bottom - (visibleBottom - AIR), behavior: 'smooth' }); +} + +/** Takes the room for the keyboard away again. */ +export function releaseRoom(): void { + if (!padded) return; + padded.pane.style.paddingBottom = padded.before; + padded = null; } /** Wires it to the keyboard of the app. */ export function keepFieldInView(keyboard: KeyboardEvents): void { - void keyboard.addListener('keyboardDidShow', () => revealFocusedField()); + void keyboard.addListener('keyboardDidShow', ({ keyboardHeight }) => revealFocusedField(keyboardHeight)); + void keyboard.addListener('keyboardWillHide', () => releaseRoom()); }