fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it

This commit is contained in:
Maurice
2026-11-08 12:15:34 +01:00
parent b73f749880
commit 06c03cbded
3 changed files with 137 additions and 48 deletions
@@ -88,7 +88,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
const touchShell = useIsWide();
const legend =
terrain.canReplay && !props.water ? (
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} compact={fit === 'side'} />
) : 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 && (
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
{formatElevation(stats.summit.elevationMeters, unit)}
</SoftPill>
)}
{stats.steepest && (
<SoftPill
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
>
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
</SoftPill>
)}
</>
);
}
/**
* `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({
<div
ref={panelRef}
id="tour-terrain-grades"
className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3"
className={`absolute bottom-full mb-2 rounded-2xl p-3 ${compact ? 'end-0 w-[240px]' : 'inset-x-0'}`}
style={GLASS}
>
{compact && (
<div className="mb-2 flex flex-wrap gap-1.5">
<LegendFacts stats={stats} unit={unit} />
</div>
)}
<ul className="flex flex-col gap-1">
{GRADE_BANDS.map((band) => (
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
@@ -414,19 +447,7 @@ function GradeLegend({
<span key={band.key} style={{ width: width(band.key), background: band.paint }} />
))}
</span>
{stats.summit && (
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
{formatElevation(stats.summit.elevationMeters, unit)}
</SoftPill>
)}
{stats.steepest && (
<SoftPill
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
>
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
</SoftPill>
)}
{!compact && <LegendFacts stats={stats} unit={unit} />}
<span className="flex h-[26px] w-[26px] items-center justify-center rounded-full text-content-muted">
{open ? <ChevronDown size={14} strokeWidth={2.2} /> : <ChevronUp size={14} strokeWidth={2.2} />}
</span>
+45 -18
View File
@@ -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));
});
});
+56 -15
View File
@@ -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<unknown>;
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<unknown>;
addListener(event: 'keyboardWillHide', listener: () => void): Promise<unknown>;
}
/** 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());
}