259 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|