Extract components
Some checks failed
CI / Check (push) Failing after 3m35s
Build and publish Docker image / build-and-push (push) Failing after 3m49s

This commit is contained in:
Andras Schmelczer 2026-05-09 10:21:32 +01:00
parent a48eb945e0
commit fe46cb3379
30 changed files with 4075 additions and 2610 deletions

View file

@ -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,