mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user