import { children, createEffect, createSignal, onCleanup, useTransition, type JSX, type ParentComponent, type ResolvedChildren, } from "solid-js"; import "./BottomSheet.css"; import material from "./material.module.css"; import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION, } from "./theme"; import { animateSlideInFromRight, animateSlideOutToRight, } from "~platform/anim"; export type BottomSheetProps = { open?: boolean; bottomUp?: boolean; class?: JSX.HTMLAttributes["class"]; onClose?(reason: "backdrop"): void; }; const MOVE_SPEED = 1600; const BottomSheet: ParentComponent = (props) => { let element: HTMLDialogElement; let animation: Animation | undefined; 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 = () => { element.close(); }; const animatedClose = () => { if (window.innerWidth > 560 && !props.bottomUp) { onClose(); return; } const onAnimationEnd = () => { element.classList.remove("animated"); onClose(); }; element.classList.add("animated"); animation = props.bottomUp ? animateSlideInFromBottom(element, true) : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("cancel", onAnimationEnd); }; const animatedOpen = () => { element.showModal(); if (props.bottomUp) { animateSlideInFromBottom(element); } else if (window.innerWidth <= 560) { element.classList.add("animated"); const onAnimationEnd = () => { element.classList.remove("animated"); }; animation = animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION, }); animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("cancel", onAnimationEnd); } }; const animateSlideInFromBottom = ( element: HTMLElement, reserve?: boolean, ) => { const rect = element.getBoundingClientRect(); const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; element.classList.add("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("animated"); document.body.style.overflow = oldOverflow; animation = undefined; }; animation.addEventListener("cancel", onAnimationEnd); animation.addEventListener("finish", onAnimationEnd); return animation; }; onCleanup(() => { if (animation) { animation.cancel(); } }); const onDialogClick = ( event: MouseEvent & { currentTarget: HTMLDialogElement }, ) => { if (event.target !== event.currentTarget) return; const rect = event.currentTarget.getBoundingClientRect(); const isNotInDialog = event.clientY < rect.top || event.clientY > rect.bottom || event.clientX < rect.left || event.clientX > rect.right; if (isNotInDialog) { props.onClose?.("backdrop"); } }; const onDialogCancel = (event: Event) => { event.preventDefault(); props.onClose?.("backdrop"); }; return ( {ochildren() ?? cache()} ); }; export default BottomSheet;