feat: polish intelligent answer interactions

This commit is contained in:
2026-10-29 18:39:50 +08:00
parent 9474788db6
commit 1ee21d1c7f
26 changed files with 1762 additions and 327 deletions
@@ -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>;
}