import { children, createEffect, createSignal, onCleanup, useTransition, type ParentComponent, type ResolvedChildren, } from "solid-js"; import styles from "./BottomSheet.module.css"; import { useHeroSignal } from "../platform/anim"; export type BottomSheetProps = { open?: boolean; bottomUp?: boolean; onClose?(reason: "backdrop"): void; }; export const HERO = Symbol("BottomSheet Hero Symbol"); function composeAnimationFrame( { top, left, height, width, }: Record<"top" | "left" | "height" | "width", number>, x: Record, ) { return { top: `${top}px`, left: `${left}px`, height: `${height}px`, width: `${width}px`, ...x, }; } const MOVE_SPEED = 1200; const BottomSheet: ParentComponent = (props) => { let element: HTMLDialogElement; let animation: Animation | undefined; const [hero, setHero] = useHeroSignal(HERO); const [cache, setCache] = createSignal(); const ochildren = children(() => props.children); const [pending] = useTransition(); createEffect(() => { if (props.open) { if (!element.open && !pending()) { requestAnimationFrame(animatedOpen); setCache(ochildren()); } } else { if (element.open) { animatedClose(); setCache(undefined); } } }); const onClose = () => { const srcElement = hero(); if (srcElement) { srcElement.style.visibility = "unset"; } element.close(); setHero(); }; const animatedClose = () => { const srcElement = hero(); const endRect = srcElement?.getBoundingClientRect(); if (endRect) { const startRect = element.getBoundingClientRect(); const animation = animateHero(startRect, endRect, element, true); animation.addEventListener("finish", onClose); animation.addEventListener("cancel", onClose); } else { if (window.innerWidth > 560 && !props.bottomUp) { onClose(); return; } const animation = props.bottomUp ? animateSlideInFromBottom(element, true) : animateSlideInFromRight(element, true); animation.addEventListener("finish", onClose); animation.addEventListener("cancel", onClose); } }; const animatedOpen = () => { element.showModal(); const srcElement = hero(); const startRect = srcElement?.getBoundingClientRect(); if (!startRect) { console.debug("no source element") } if (startRect) { srcElement!.style.visibility = "hidden"; const endRect = element.getBoundingClientRect(); animateHero(startRect, endRect, element); } else if (props.bottomUp) { animateSlideInFromBottom(element); } else if (window.innerWidth <= 560) { animateSlideInFromRight(element); } }; const animateSlideInFromRight = (element: HTMLElement, reserve?: boolean) => { const rect = element.getBoundingClientRect(); const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; element.classList.add(styles.animated); const oldOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const distance = Math.abs(rect.left - window.innerWidth); const duration = (distance / MOVE_SPEED) * 1000; animation = element.animate( { left: reserve ? [`${rect.left}px`, `${window.innerWidth}px`] : [`${window.innerWidth}px`, `${rect.left}px`], }, { easing, duration }, ); const onAnimationEnd = () => { element.classList.remove(styles.animated); document.body.style.overflow = oldOverflow; animation = undefined; }; animation.addEventListener("cancel", onAnimationEnd); animation.addEventListener("finish", onAnimationEnd); return animation; }; const animateSlideInFromBottom = ( element: HTMLElement, reserve?: boolean, ) => { const rect = element.getBoundingClientRect(); const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; element.classList.add(styles.animated); const oldOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const distance = Math.abs(rect.top - window.innerHeight); const duration = (distance / MOVE_SPEED) * 1000; animation = element.animate( { top: reserve ? [`${rect.top}px`, `${window.innerHeight}px`] : [`${window.innerHeight}px`, `${rect.top}px`], }, { easing, duration }, ); const onAnimationEnd = () => { element.classList.remove(styles.animated); document.body.style.overflow = oldOverflow; animation = undefined; }; animation.addEventListener("cancel", onAnimationEnd); animation.addEventListener("finish", onAnimationEnd); return animation; }; const animateHero = ( startRect: DOMRect, endRect: DOMRect, element: HTMLElement, reserve?: boolean, ) => { const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; element.classList.add(styles.animated); const distance = Math.sqrt( Math.pow(Math.abs(startRect.top - endRect.top), 2) + Math.pow(Math.abs(startRect.left - startRect.top), 2), ); const duration = (distance / MOVE_SPEED) * 1000; animation = element.animate( [ composeAnimationFrame(startRect, { transform: "none" }), composeAnimationFrame(endRect, { transform: "none" }), ], { easing, duration }, ); const onAnimationEnd = () => { element.classList.remove(styles.animated); animation = undefined; }; animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("cancel", onAnimationEnd); return animation; }; onCleanup(() => { if (animation) { animation.cancel(); } }); const onDialogClick = ( event: MouseEvent & { currentTarget: HTMLDialogElement }, ) => { const rect = event.currentTarget.getBoundingClientRect(); const isInDialog = rect.top <= event.clientY && event.clientY <= rect.top + rect.height && rect.left <= event.clientX && event.clientX <= rect.left + rect.width; if (!isInDialog) { props.onClose?.("backdrop"); } }; return ( {ochildren() ?? cache()} ); }; export default BottomSheet;