perfect-postcode/frontend/src/components/map/MobileBottomSheet.tsx
2026-05-12 22:00:56 +01:00

259 lines
7.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
interface VisualViewportState {
height: number;
bottomInset: number;
}
interface MobileBottomSheetProps {
children: ReactNode;
legend?: ReactNode;
onCoveredHeightChange?: (height: number) => void;
}
function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
if (!avoidKeyboard) {
return {
height: window.innerHeight,
bottomInset: 0,
};
}
const vv = window.visualViewport;
if (!vv) {
return {
height: window.innerHeight,
bottomInset: 0,
};
}
const bottomInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
return {
height: vv.height,
bottomInset,
};
}
function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard));
useEffect(() => {
const vv = window.visualViewport;
const update = () => {
const next = getVisualViewportState(avoidKeyboard);
setState((prev) =>
prev.height === next.height && prev.bottomInset === next.bottomInset ? prev : next
);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
if (avoidKeyboard) {
vv?.addEventListener('resize', update);
vv?.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
if (avoidKeyboard) {
vv?.removeEventListener('resize', update);
vv?.removeEventListener('scroll', update);
}
};
}, [avoidKeyboard]);
return state;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function isKeyboardEditableElement(element: HTMLElement): boolean {
if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement) {
return ![
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
].includes(element.type);
}
return element.isContentEditable;
}
function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
const element = target.closest('input, textarea, [contenteditable]');
if (!(element instanceof HTMLElement)) return null;
return isKeyboardEditableElement(element) ? element : null;
}
export default function MobileBottomSheet({
children,
legend,
onCoveredHeightChange,
}: MobileBottomSheetProps) {
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
const viewport = useVisualViewportState(keyboardAvoidanceActive);
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const dragStartYRef = useRef(0);
const dragStartHeightRef = useRef(0);
const scrollIntoViewTimerRef = useRef<number | null>(null);
const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const heightBounds = useMemo(() => {
const available = viewport.height;
return {
min: Math.min(132, Math.max(104, available * 0.22)),
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
max: Math.max(300, available - 12),
};
}, [viewport.height]);
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);
useEffect(() => {
setHeight((value) =>
value == null ? value : clamp(value, heightBounds.min, heightBounds.max)
);
}, [heightBounds]);
useEffect(() => {
onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset));
}, [currentHeight, onCoveredHeightChange, viewport.bottomInset]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragStartYRef.current = e.clientY;
dragStartHeightRef.current = currentHeight;
setIsDragging(true);
},
[currentHeight]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (dragStartHeightRef.current === 0) return;
const nextHeight = dragStartHeightRef.current + dragStartYRef.current - e.clientY;
setHeight(clamp(nextHeight, heightBounds.min, heightBounds.max));
},
[heightBounds]
);
const handlePointerUp = useCallback(() => {
if (dragStartHeightRef.current === 0) return;
dragStartHeightRef.current = 0;
setIsDragging(false);
}, []);
const handleSheetPointerDown = useCallback((event: React.PointerEvent) => {
if (getKeyboardEditableElement(event.target)) return;
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
sheetRef.current?.contains(activeElement) &&
isKeyboardEditableElement(activeElement)
) {
activeElement.blur();
}
}, []);
useEffect(() => {
const sheet = sheetRef.current;
if (!sheet) return;
const handleFocusIn = (event: FocusEvent) => {
const target = getKeyboardEditableElement(event.target);
if (!target || !sheet.contains(target)) return;
setKeyboardAvoidanceActive(true);
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
if (scrollIntoViewTimerRef.current != null) {
window.clearTimeout(scrollIntoViewTimerRef.current);
}
scrollIntoViewTimerRef.current = window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 120);
};
const handleFocusOut = (event: FocusEvent) => {
const nextTarget = getKeyboardEditableElement(event.relatedTarget);
if (nextTarget && sheet.contains(nextTarget)) return;
setKeyboardAvoidanceActive(false);
};
sheet.addEventListener('focusin', handleFocusIn);
sheet.addEventListener('focusout', handleFocusOut);
return () => {
sheet.removeEventListener('focusin', handleFocusIn);
sheet.removeEventListener('focusout', handleFocusOut);
if (scrollIntoViewTimerRef.current != null) {
window.clearTimeout(scrollIntoViewTimerRef.current);
}
};
}, [heightBounds.initial, heightBounds.max, viewport.height]);
return (
<section
ref={sheetRef}
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
onPointerDownCapture={handleSheetPointerDown}
style={{
bottom: viewport.bottomInset,
height: currentHeight,
paddingBottom: 'env(safe-area-inset-bottom)',
transition:
isDragging || viewport.bottomInset > 0
? undefined
: 'height 140ms ease, bottom 180ms ease',
}}
>
<div className="relative shrink-0 px-4 py-2">
<div
className="absolute inset-x-0 top-1/2 z-10 h-11 -translate-y-1/2 touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
<div
className="pointer-events-none flex w-full items-center justify-center"
role="presentation"
>
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
</div>
</div>
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y"
>
{children}
</div>
</section>
);
}