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());
}