73 lines
3.5 KiB
TypeScript
73 lines
3.5 KiB
TypeScript
import { forwardRef, useCallback, useLayoutEffect, useRef, useState, type ComponentProps, type ReactElement, type ReactNode } from "react";
|
|
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
|
import { agentEase } from "../components/agent-motion";
|
|
|
|
import { AnswerMotionContext, useAnswerMotion } from "./answer-motion-context";
|
|
|
|
/** Initial rendering is instant; subsequent transitions follow pointer or keyboard modality. */
|
|
export function AnswerMotionScope({ children, ...props }: ComponentProps<"div">) {
|
|
const pointer = useRef(false);
|
|
const reducedMotion = useReducedMotion();
|
|
const shouldAnimate = useCallback(() => pointer.current && !reducedMotion, [reducedMotion]);
|
|
return <AnswerMotionContext.Provider value={shouldAnimate}>
|
|
<div {...props} onPointerDownCapture={() => { pointer.current = true; }}
|
|
onKeyDownCapture={() => { pointer.current = false; }}
|
|
onClickCapture={(event) => { if (event.detail === 0) pointer.current = false; }}>{children}</div>
|
|
</AnswerMotionContext.Provider>;
|
|
}
|
|
|
|
/** Keep the same mounted editors; retiring content is immediately inert and hidden from AT. */
|
|
export const AnswerReveal = forwardRef<HTMLDivElement, ComponentProps<typeof motion.div> & { open: boolean }>(
|
|
function AnswerReveal({ open, children, ...props }, ref) {
|
|
const shouldAnimate = useAnswerMotion();
|
|
const reducedMotion = useReducedMotion();
|
|
const controls = useAnimationControls();
|
|
const previous = useRef(open);
|
|
const [retained, setRetained] = useState(open);
|
|
useLayoutEffect(() => {
|
|
const changed = previous.current !== open;
|
|
previous.current = open;
|
|
controls.stop();
|
|
if (!changed || !shouldAnimate() || reducedMotion) {
|
|
controls.set({ opacity: 1, y: 0 });
|
|
setRetained(open);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
if (open) {
|
|
setRetained(true);
|
|
controls.set({ opacity: 0, y: -4 });
|
|
}
|
|
void controls.start(open ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
|
|
duration: 0.18, ease: agentEase
|
|
}).then(() => { if (!cancelled) setRetained(open); });
|
|
return () => { cancelled = true; controls.stop(); };
|
|
}, [controls, open, reducedMotion, shouldAnimate]);
|
|
return <motion.div {...props} ref={ref} data-answer-reveal data-open={open}
|
|
hidden={!open && !retained} inert={!open} aria-hidden={!open || undefined}
|
|
initial={false} animate={controls}>{children}</motion.div>;
|
|
}
|
|
);
|
|
|
|
export function AnswerDisclosure({ open, onOpenChange, trigger, children, contentClassName, ...props }: {
|
|
open: boolean; onOpenChange: (open: boolean) => void; trigger: ReactElement; children: ReactNode;
|
|
contentClassName?: string;
|
|
} & Omit<ComponentProps<typeof Collapsible>, "children" | "open" | "onOpenChange">) {
|
|
return <Collapsible {...props} open={open} onOpenChange={onOpenChange}>
|
|
<CollapsibleTrigger asChild>{trigger}</CollapsibleTrigger>
|
|
<CollapsibleContent forceMount asChild>
|
|
<AnswerReveal open={open} className={contentClassName}>{children}</AnswerReveal>
|
|
</CollapsibleContent>
|
|
</Collapsible>;
|
|
}
|
|
|
|
export function AnswerDisclosureChevron({ open }: { open: boolean }) {
|
|
const shouldAnimate = useAnswerMotion();
|
|
return <motion.span aria-hidden className="inline-flex shrink-0" initial={false}
|
|
animate={{ rotate: open ? 180 : 0 }} transition={{ duration: shouldAnimate() ? 0.18 : 0, ease: agentEase }}>
|
|
<ChevronDown size={14} />
|
|
</motion.span>;
|
|
}
|