Extract components
This commit is contained in:
parent
a48eb945e0
commit
fe46cb3379
30 changed files with 4075 additions and 2610 deletions
|
|
@ -9,9 +9,17 @@ interface VisualViewportState {
|
|||
interface MobileBottomSheetProps {
|
||||
children: ReactNode;
|
||||
legend?: ReactNode;
|
||||
onCoveredHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
function getVisualViewportState(): VisualViewportState {
|
||||
function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
|
||||
if (!avoidKeyboard) {
|
||||
return {
|
||||
height: window.innerHeight,
|
||||
bottomInset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
return {
|
||||
|
|
@ -27,25 +35,36 @@ function getVisualViewportState(): VisualViewportState {
|
|||
};
|
||||
}
|
||||
|
||||
function useVisualViewportState(): VisualViewportState {
|
||||
const [state, setState] = useState(getVisualViewportState);
|
||||
function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
|
||||
const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard));
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
const update = () => setState(getVisualViewportState());
|
||||
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);
|
||||
vv?.addEventListener('resize', update);
|
||||
vv?.addEventListener('scroll', update);
|
||||
if (avoidKeyboard) {
|
||||
vv?.addEventListener('resize', update);
|
||||
vv?.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
vv?.removeEventListener('resize', update);
|
||||
vv?.removeEventListener('scroll', update);
|
||||
if (avoidKeyboard) {
|
||||
vv?.removeEventListener('resize', update);
|
||||
vv?.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [avoidKeyboard]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -54,12 +73,46 @@ function clamp(value: number, min: number, max: number): number {
|
|||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export default function MobileBottomSheet({ children, legend }: MobileBottomSheetProps) {
|
||||
const viewport = useVisualViewportState();
|
||||
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);
|
||||
|
||||
|
|
@ -80,6 +133,10 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee
|
|||
);
|
||||
}, [heightBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset));
|
||||
}, [currentHeight, onCoveredHeightChange, viewport.bottomInset]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -106,30 +163,61 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee
|
|||
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 = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
|
||||
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));
|
||||
window.setTimeout(() => {
|
||||
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);
|
||||
return () => sheet.removeEventListener('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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue