feat: polish intelligent answer interactions
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user