9 changed files with 485 additions and 0 deletions
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
import { ReactNode, useEffect, useRef } from "react"; |
||||
|
||||
export function createFloatingAnchorEvent(id: string): string { |
||||
return `__floating::anchor::${id}`; |
||||
} |
||||
|
||||
interface Props { |
||||
for: string; |
||||
children?: ReactNode; |
||||
} |
||||
|
||||
export function FloatingAnchor(props: Props) { |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const old = useRef<string | null>(null); |
||||
|
||||
useEffect(() => { |
||||
if (!ref.current) return; |
||||
|
||||
let cancelled = false; |
||||
function render() { |
||||
if (cancelled) return; |
||||
|
||||
if (ref.current) { |
||||
const current = old.current; |
||||
const newer = ref.current.getBoundingClientRect(); |
||||
const newerStr = JSON.stringify(newer); |
||||
if (current !== newerStr) { |
||||
old.current = newerStr; |
||||
const evtStr = createFloatingAnchorEvent(props.for); |
||||
(window as any)[evtStr] = newer; |
||||
const evObj = new CustomEvent(createFloatingAnchorEvent(props.for), { |
||||
detail: newer, |
||||
}); |
||||
document.dispatchEvent(evObj); |
||||
} |
||||
} |
||||
window.requestAnimationFrame(render); |
||||
} |
||||
|
||||
window.requestAnimationFrame(render); |
||||
return () => { |
||||
cancelled = true; |
||||
}; |
||||
}, [props]); |
||||
|
||||
return <div ref={ref}>{props.children}</div>; |
||||
} |
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition"; |
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition"; |
||||
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||
import { useSpringValue, animated, easings } from "@react-spring/web"; |
||||
import { ReactNode, useCallback, useEffect, useRef } from "react"; |
||||
|
||||
interface FloatingCardProps { |
||||
children?: ReactNode; |
||||
onClose?: () => void; |
||||
id: string; |
||||
} |
||||
|
||||
interface RootFloatingCardProps extends FloatingCardProps { |
||||
className?: string; |
||||
} |
||||
|
||||
function CardBase(props: { children: ReactNode }) { |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const { isMobile } = useIsMobile(); |
||||
const height = useSpringValue(0, { |
||||
config: { easing: easings.easeInOutSine, duration: 300 }, |
||||
}); |
||||
const width = useSpringValue(0, { |
||||
config: { easing: easings.easeInOutSine, duration: 300 }, |
||||
}); |
||||
|
||||
const getNewHeight = useCallback(() => { |
||||
if (!ref.current) return; |
||||
const children = ref.current.querySelectorAll( |
||||
":scope > *[data-floating-page='true']" |
||||
); |
||||
if (children.length === 0) { |
||||
height.start(0); |
||||
width.start(0); |
||||
return; |
||||
} |
||||
const lastChild = children[children.length - 1]; |
||||
const rect = lastChild.getBoundingClientRect(); |
||||
if (height.get() === 0) { |
||||
height.set(rect.height); |
||||
width.set(rect.width); |
||||
} else { |
||||
height.start(rect.height); |
||||
width.start(rect.width); |
||||
} |
||||
}, [height, width]); |
||||
|
||||
useEffect(() => { |
||||
if (!ref.current) return; |
||||
getNewHeight(); |
||||
const observer = new MutationObserver(() => { |
||||
getNewHeight(); |
||||
}); |
||||
observer.observe(ref.current, { |
||||
attributes: false, |
||||
childList: true, |
||||
subtree: false, |
||||
}); |
||||
return () => { |
||||
observer.disconnect(); |
||||
}; |
||||
}, [getNewHeight]); |
||||
|
||||
return ( |
||||
<animated.div |
||||
ref={ref} |
||||
style={{ |
||||
height, |
||||
width: isMobile ? "100%" : width, |
||||
}} |
||||
className="relative flex items-center justify-center overflow-hidden" |
||||
> |
||||
{props.children} |
||||
</animated.div> |
||||
); |
||||
} |
||||
|
||||
export function FloatingCard(props: RootFloatingCardProps) { |
||||
const { isMobile } = useIsMobile(); |
||||
const content = <CardBase>{props.children}</CardBase>; |
||||
|
||||
if (isMobile) |
||||
return ( |
||||
<FloatingCardMobilePosition |
||||
className={props.className} |
||||
onClose={props.onClose} |
||||
> |
||||
{content} |
||||
</FloatingCardMobilePosition> |
||||
); |
||||
|
||||
return ( |
||||
<FloatingCardAnchorPosition id={props.id} className={props.className}> |
||||
{content} |
||||
</FloatingCardAnchorPosition> |
||||
); |
||||
} |
||||
|
||||
export function PopoutFloatingCard(props: FloatingCardProps) { |
||||
return <FloatingCard className="rounded-md bg-ash-400 p-2" {...props} />; |
||||
} |
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
import { Transition } from "@/components/Transition"; |
||||
import React, { ReactNode, useCallback, useEffect, useRef } from "react"; |
||||
import { createPortal } from "react-dom"; |
||||
|
||||
interface Props { |
||||
children?: ReactNode; |
||||
onClose?: () => void; |
||||
show?: boolean; |
||||
darken?: boolean; |
||||
} |
||||
|
||||
export function FloatingContainer(props: Props) { |
||||
const target = useRef<Element | null>(null); |
||||
|
||||
useEffect(() => { |
||||
function listen(e: MouseEvent) { |
||||
target.current = e.target as Element; |
||||
} |
||||
document.addEventListener("mousedown", listen); |
||||
return () => { |
||||
document.removeEventListener("mousedown", listen); |
||||
}; |
||||
}); |
||||
|
||||
const click = useCallback( |
||||
(e: React.MouseEvent) => { |
||||
const startedTarget = target.current; |
||||
target.current = null; |
||||
if (e.currentTarget !== e.target) return; |
||||
if (!startedTarget) return; |
||||
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return; |
||||
if (props.onClose) props.onClose(); |
||||
}, |
||||
[props] |
||||
); |
||||
|
||||
return createPortal( |
||||
<Transition show={props.show} animation="none"> |
||||
<div className="pointer-events-auto fixed inset-0"> |
||||
<Transition animation="fade" isChild> |
||||
<div |
||||
onClick={click} |
||||
className={[ |
||||
"absolute inset-0", |
||||
props.darken ? "bg-black opacity-90" : "", |
||||
].join(" ")} |
||||
/> |
||||
</Transition> |
||||
<Transition animation="slide-up" className="h-0" isChild> |
||||
{props.children} |
||||
</Transition> |
||||
</div> |
||||
</Transition>, |
||||
document.body |
||||
); |
||||
} |
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
import { Transition } from "@/components/Transition"; |
||||
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||
import { ReactNode } from "react"; |
||||
|
||||
interface Props { |
||||
children?: ReactNode; |
||||
show?: boolean; |
||||
className?: string; |
||||
height: number; |
||||
width: number; |
||||
} |
||||
|
||||
export function FloatingView(props: Props) { |
||||
const { isMobile } = useIsMobile(); |
||||
if (!props.show) return null; |
||||
return ( |
||||
<div |
||||
className={[props.className ?? "", "absolute"].join(" ")} |
||||
data-floating-page="true" |
||||
style={{ |
||||
height: `${props.height}px`, |
||||
width: !isMobile ? `${props.width}px` : "100%", |
||||
}} |
||||
> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
return ( |
||||
<Transition animation="slide-up" show={props.show}> |
||||
<div data-floating-page="true" className={props.className}> |
||||
{props.children} |
||||
</div> |
||||
</Transition> |
||||
); |
||||
} |
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor"; |
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; |
||||
|
||||
interface AnchorPositionProps { |
||||
children?: ReactNode; |
||||
id: string; |
||||
className?: string; |
||||
} |
||||
|
||||
export function FloatingCardAnchorPosition(props: AnchorPositionProps) { |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const [left, setLeft] = useState<number>(0); |
||||
const [top, setTop] = useState<number>(0); |
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null); |
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null); |
||||
|
||||
const calculateAndSetCoords = useCallback( |
||||
(anchor: DOMRect, card: DOMRect) => { |
||||
const buttonCenter = anchor.left + anchor.width / 2; |
||||
const bottomReal = window.innerHeight - anchor.bottom; |
||||
|
||||
setTop( |
||||
window.innerHeight - bottomReal - anchor.height - card.height - 30 |
||||
); |
||||
setLeft( |
||||
Math.min( |
||||
buttonCenter - card.width / 2, |
||||
window.innerWidth - card.width - 30 |
||||
) |
||||
); |
||||
}, |
||||
[] |
||||
); |
||||
|
||||
useEffect(() => { |
||||
if (!anchorRect || !cardRect) return; |
||||
calculateAndSetCoords(anchorRect, cardRect); |
||||
}, [anchorRect, calculateAndSetCoords, cardRect]); |
||||
|
||||
useEffect(() => { |
||||
if (!ref.current) return; |
||||
function checkBox() { |
||||
const divRect = ref.current?.getBoundingClientRect(); |
||||
setCardRect(divRect ?? null); |
||||
} |
||||
checkBox(); |
||||
const observer = new ResizeObserver(checkBox); |
||||
observer.observe(ref.current); |
||||
return () => { |
||||
observer.disconnect(); |
||||
}; |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
const evtStr = createFloatingAnchorEvent(props.id); |
||||
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]); |
||||
function listen(ev: CustomEvent<DOMRect>) { |
||||
setAnchorRect(ev.detail); |
||||
} |
||||
document.addEventListener(evtStr, listen as any); |
||||
return () => { |
||||
document.removeEventListener(evtStr, listen as any); |
||||
}; |
||||
}, [props.id]); |
||||
|
||||
return ( |
||||
<div |
||||
ref={ref} |
||||
style={{ |
||||
transform: `translateX(${left}px) translateY(${top}px)`, |
||||
}} |
||||
className={[ |
||||
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden", |
||||
props.className ?? "", |
||||
].join(" ")} |
||||
> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
import { useSpring, animated, config } from "@react-spring/web"; |
||||
import { useDrag } from "@use-gesture/react"; |
||||
import { ReactNode, useEffect, useRef, useState } from "react"; |
||||
|
||||
interface MobilePositionProps { |
||||
children?: ReactNode; |
||||
className?: string; |
||||
onClose?: () => void; |
||||
} |
||||
|
||||
export function FloatingCardMobilePosition(props: MobilePositionProps) { |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const height = 500; |
||||
const closing = useRef<boolean>(false); |
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null); |
||||
const [{ y }, api] = useSpring(() => ({ |
||||
y: 0, |
||||
onRest() { |
||||
if (!closing.current) return; |
||||
if (props.onClose) props.onClose(); |
||||
}, |
||||
})); |
||||
|
||||
const bind = useDrag( |
||||
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { |
||||
if (closing.current) return; |
||||
if (last) { |
||||
// if past half height downwards
|
||||
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
|
||||
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) { |
||||
api.start({ |
||||
y: height * 1.2, |
||||
immediate: false, |
||||
config: { ...config.wobbly, velocity: vy, clamp: true }, |
||||
}); |
||||
closing.current = true; |
||||
} else { |
||||
api.start({ |
||||
y: 0, |
||||
immediate: false, |
||||
config: config.wobbly, |
||||
}); |
||||
} |
||||
} else { |
||||
api.start({ y: my, immediate: true }); |
||||
} |
||||
}, |
||||
{ |
||||
from: () => [0, y.get()], |
||||
filterTaps: true, |
||||
bounds: { top: 0 }, |
||||
rubberband: true, |
||||
} |
||||
); |
||||
|
||||
useEffect(() => { |
||||
if (!ref.current) return; |
||||
function checkBox() { |
||||
const divRect = ref.current?.getBoundingClientRect(); |
||||
setCardRect(divRect ?? null); |
||||
} |
||||
checkBox(); |
||||
const observer = new ResizeObserver(checkBox); |
||||
observer.observe(ref.current); |
||||
return () => { |
||||
observer.disconnect(); |
||||
}; |
||||
}, []); |
||||
|
||||
return ( |
||||
<div |
||||
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none" |
||||
style={{ |
||||
transform: `translateY(${ |
||||
window.innerHeight - (cardRect?.height ?? 0) + 200 |
||||
}px)`,
|
||||
}} |
||||
> |
||||
<animated.div |
||||
ref={ref} |
||||
className={[props.className ?? "", "touch-none"].join(" ")} |
||||
style={{ |
||||
y, |
||||
}} |
||||
{...bind()} |
||||
> |
||||
<div className="mx-auto my-2 mb-4 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" /> |
||||
{props.children} |
||||
<div className="h-[200px]" /> |
||||
</animated.div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
import { Button } from "@/components/Button"; |
||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor"; |
||||
import { PopoutFloatingCard } from "@/components/popout/FloatingCard"; |
||||
import { FloatingContainer } from "@/components/popout/FloatingContainer"; |
||||
import { FloatingView } from "@/components/popout/FloatingView"; |
||||
import { useEffect, useRef, useState } from "react"; |
||||
|
||||
// simple empty view, perfect for putting in tests
|
||||
export function TestView() { |
||||
const [show, setShow] = useState(false); |
||||
const [page, setPage] = useState("main"); |
||||
const [left, setLeft] = useState(600); |
||||
const direction = useRef(1); |
||||
|
||||
useEffect(() => { |
||||
const step = 0; |
||||
const interval = setInterval(() => { |
||||
setLeft((v) => { |
||||
const newVal = v + direction.current * step; |
||||
if (newVal > window.innerWidth || newVal < 0) { |
||||
direction.current *= -1; |
||||
} |
||||
return v + direction.current * step; |
||||
}); |
||||
}, 10); |
||||
|
||||
return () => { |
||||
clearInterval(interval); |
||||
}; |
||||
}, []); |
||||
|
||||
return ( |
||||
<div className="relative h-[800px] w-full rounded border border-white"> |
||||
<FloatingContainer show={show} onClose={() => setShow(false)}> |
||||
<PopoutFloatingCard id="test" onClose={() => setShow(false)}> |
||||
<FloatingView |
||||
show={page === "main"} |
||||
height={400} |
||||
width={400} |
||||
className="bg-ash-200" |
||||
> |
||||
<p>Hello world</p> |
||||
<Button onClick={() => setPage("second")}>Next</Button> |
||||
</FloatingView> |
||||
<FloatingView |
||||
show={page === "second"} |
||||
height={300} |
||||
width={500} |
||||
className="bg-ash-200" |
||||
> |
||||
<Button onClick={() => setPage("main")}>Previous</Button> |
||||
</FloatingView> |
||||
</PopoutFloatingCard> |
||||
</FloatingContainer> |
||||
<div |
||||
className="absolute bottom-0" |
||||
style={{ |
||||
left: `${left}px`, |
||||
}} |
||||
> |
||||
<FloatingAnchor for="test"> |
||||
<div |
||||
className="h-8 w-8 bg-white" |
||||
onClick={() => setShow((v) => !v)} |
||||
/> |
||||
</FloatingAnchor> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
Loading…
Reference in new issue