Replace headless ui transition with framer motion

Increase animation speed

Cookies

Remove unused import
This commit is contained in:
Luke Vella 2022-04-16 11:02:21 +01:00
parent eee23c1bb5
commit 50f5710bd3
7 changed files with 237 additions and 226 deletions

View file

@ -1,4 +1,4 @@
import { Transition } from "@headlessui/react"; import { AnimatePresence, motion } from "framer-motion";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import Link from "next/link"; import Link from "next/link";
import * as React from "react"; import * as React from "react";
@ -11,16 +11,24 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
const [visible, setVisible] = React.useState(true); const [visible, setVisible] = React.useState(true);
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Transition <AnimatePresence>
show={visible} {visible ? (
appear={true} <motion.div
as="div" variants={{
enter="transition transform delay-1000 duration-1000" enter: {
enterFrom="opacity-0 translate-y-8" opacity: 1,
enterTo="opacity-100 translate-y-0" y: 0,
leave="duration-200" transition: { type: "spring", delay: 2, duration: 1 },
leaveFrom="opacity-100 translate-y-0" },
leaveTo="opacity-0 translate-y-8" exit: {
opacity: 0,
y: 10,
transition: { duration: 0.1 },
},
}}
initial={{ opacity: 0, y: 100 }}
animate="enter"
exit="exit"
className="fixed bottom-8 right-8 z-50 w-60 rounded-lg bg-white p-4 pt-8 text-sm shadow-lg" className="fixed bottom-8 right-8 z-50 w-60 rounded-lg bg-white p-4 pt-8 text-sm shadow-lg"
> >
<CookiesIllustration className="absolute -top-6" /> <CookiesIllustration className="absolute -top-6" />
@ -30,7 +38,9 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
</div> </div>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<Link href="/privacy-policy"> <Link href="/privacy-policy">
<a className="text-slate-400 hover:text-indigo-500">Privacy Policy</a> <a className="text-slate-400 hover:text-indigo-500">
Privacy Policy
</a>
</Link> </Link>
<button <button
onClick={() => { onClick={() => {
@ -42,7 +52,9 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
OK OK
</button> </button>
</div> </div>
</Transition>, </motion.div>
) : null}
</AnimatePresence>,
getPortal(), getPortal(),
); );
}; };

View file

@ -1,12 +1,12 @@
import { Transition } from "@headlessui/react";
import { Comment } from "@prisma/client"; import { Comment } from "@prisma/client";
import axios from "axios"; import axios from "axios";
import clsx from "clsx";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { usePlausible } from "next-plausible"; import { usePlausible } from "next-plausible";
import * as React from "react"; import * as React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { useList } from "react-use";
import { import {
createComment, createComment,
@ -33,75 +33,12 @@ interface CommentForm {
content: string; content: string;
} }
const Comments: React.VoidFunctionComponent<{
comments: {
id: string;
authorName: string;
content: string;
createdAt: string;
}[];
deletedComments: string[];
onDelete: (commentId: string) => void;
canDelete?: boolean;
}> = ({ comments, deletedComments, onDelete, canDelete }) => {
return (
<div className="space-y-3 border-b bg-slate-50 p-4">
{comments.map((comment, i) => {
return (
<div className="flex" key={i}>
<Transition
show={!deletedComments.includes(comment.id)}
as="div"
enter="transition transform duration-300"
enterFrom="opacity-0 translate-y-4"
enterTo="opacity-100 translate-y-0"
leave="transition transform duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-fit rounded-xl border bg-white px-3 py-2 shadow-sm"
>
<div className="flex items-center space-x-2">
<UserAvater name={comment.authorName} />
<div className="mb-1">
<span className="mr-1">{comment.authorName}</span>
<span className="mr-1 text-slate-400">&bull;</span>
<span className="text-sm text-slate-500">
{formatRelative(new Date(comment.createdAt), Date.now())}
</span>
</div>
{canDelete ? (
<Dropdown
placement="bottom-start"
trigger={<CompactButton icon={DotsHorizontal} />}
>
<DropdownItem
icon={Trash}
label="Delete comment"
onClick={() => {
onDelete(comment.id);
}}
/>
</Dropdown>
) : null}
</div>
<div className="w-fit whitespace-pre-wrap">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</Transition>
</div>
);
})}
</div>
);
};
const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
pollId, pollId,
canDelete, canDelete,
}) => { }) => {
const getCommentsQueryKey = ["poll", pollId, "comments"]; const getCommentsQueryKey = ["poll", pollId, "comments"];
const [userName, setUserName] = useUserName(); const [userName, setUserName] = useUserName();
const [deletedComments, { push }] = useList<string>([]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: comments } = useQuery( const { data: comments } = useQuery(
getCommentsQueryKey, getCommentsQueryKey,
@ -141,8 +78,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`); await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`);
}, },
{ {
onMutate: ({ commentId }) => { onMutate: () => {
push(commentId);
plausible("Deleted comment"); plausible("Deleted comment");
}, },
onSuccess: () => { onSuccess: () => {
@ -179,14 +115,64 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
<div className="border-b bg-white px-4 py-2"> <div className="border-b bg-white px-4 py-2">
<div className="font-medium">Comments</div> <div className="font-medium">Comments</div>
</div> </div>
{comments.length ? ( <div
<Comments className={clsx({
comments={comments} "space-y-3 border-b bg-slate-50 p-4": comments.length > 0,
canDelete={canDelete} })}
onDelete={handleDelete} >
deletedComments={deletedComments} <AnimatePresence initial={false}>
{comments.map((comment) => {
return (
<motion.div
transition={{ duration: 0.2 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex"
key={comment.id}
>
<motion.div
initial={{ scale: 0.8, y: 10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.8 }}
className="w-fit rounded-xl border bg-white px-3 py-2 shadow-sm"
>
<div className="flex items-center space-x-2">
<UserAvater name={comment.authorName} />
<div className="mb-1">
<span className="mr-1">{comment.authorName}</span>
<span className="mr-1 text-slate-400">&bull;</span>
<span className="text-sm text-slate-500">
{formatRelative(
new Date(comment.createdAt),
Date.now(),
)}
</span>
</div>
{canDelete ? (
<Dropdown
placement="bottom-start"
trigger={<CompactButton icon={DotsHorizontal} />}
>
<DropdownItem
icon={Trash}
label="Delete comment"
onClick={() => {
handleDelete(comment.id);
}}
/> />
</Dropdown>
) : null} ) : null}
</div>
<div className="w-fit whitespace-pre-wrap">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</motion.div>
</motion.div>
);
})}
</AnimatePresence>
</div>
<form <form
className="bg-white p-4" className="bg-white p-4"
onSubmit={handleSubmit((data) => { onSubmit={handleSubmit((data) => {

View file

@ -1,4 +1,5 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog } from "@headlessui/react";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react"; import * as React from "react";
import Button, { ButtonProps } from "../button"; import Button, { ButtonProps } from "../button";
@ -32,7 +33,8 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
}) => { }) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null); const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return ( return (
<Transition appear={true} as={React.Fragment} show={visible}> <AnimatePresence>
{visible ? (
<Dialog <Dialog
open={visible} open={visible}
className="fixed inset-0 z-40 overflow-y-auto" className="fixed inset-0 z-40 overflow-y-auto"
@ -41,28 +43,24 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
if (overlayClosable) onCancel?.(); if (overlayClosable) onCancel?.();
}} }}
> >
<div className="flex min-h-screen items-center justify-center"> <motion.div
<Transition.Child transition={{ duration: 0.5 }}
as={React.Fragment} className="flex min-h-screen items-center justify-center"
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
> >
<Dialog.Overlay className="fixed inset-0 bg-slate-900 bg-opacity-10" /> <Dialog.Overlay
</Transition.Child> as={motion.div}
<Transition.Child transition={{ duration: 0.5 }}
as={React.Fragment} initial={{ opacity: 0 }}
enter="ease-out duration-100" animate={{ opacity: 1 }}
enterFrom="opacity-0 scale-95" exit={{ opacity: 0 }}
enterTo="opacity-100 scale-100" className="fixed inset-0 z-0 bg-slate-900 bg-opacity-10"
leave="ease-in duration-100" />
leaveFrom="opacity-100 scale-100" <motion.div
leaveTo="opacity-0 scale-95" transition={{ duration: 0.1 }}
> initial={{ opacity: 0, scale: 0.9 }}
<div animate={{ opacity: 1, scale: 1 }}
className="my-8 mx-4 inline-block w-fit transform overflow-hidden rounded-xl bg-white text-left align-middle shadow-xl transition-all" exit={{ opacity: 0, scale: 0.9 }}
className="z-50 my-8 mx-4 inline-block w-fit transform overflow-hidden rounded-xl bg-white text-left align-middle shadow-xl transition-all"
onMouseDown={(e) => { onMouseDown={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -100,11 +98,11 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
) : null} ) : null}
</div> </div>
)} )}
</div> </motion.div>
</Transition.Child> </motion.div>
</div>
</Dialog> </Dialog>
</Transition> ) : null}
</AnimatePresence>
); );
}; };

View file

@ -1,6 +1,7 @@
import { Listbox } from "@headlessui/react"; import { Listbox } from "@headlessui/react";
import { Participant, Vote } from "@prisma/client"; import { Participant, Vote } from "@prisma/client";
import clsx from "clsx"; import clsx from "clsx";
import { motion } from "framer-motion";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@ -19,7 +20,6 @@ import Trash from "../../icons/trash.svg";
import { styleMenuItem } from "../../menu-styles"; import { styleMenuItem } from "../../menu-styles";
import NameInput from "../../name-input"; import NameInput from "../../name-input";
import TimeZonePicker from "../../time-zone-picker"; import TimeZonePicker from "../../time-zone-picker";
import { TransitionPopInOut } from "../../transitions";
import { useUserName } from "../../user-name-context"; import { useUserName } from "../../user-name-context";
import { import {
useAddParticipantMutation, useAddParticipantMutation,
@ -145,8 +145,15 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
</div> </div>
<ChevronDown className="h-5" /> <ChevronDown className="h-5" />
</Listbox.Button> </Listbox.Button>
<TransitionPopInOut> <Listbox.Options
<Listbox.Options className="menu-items max-h-72 w-full overflow-auto"> as={motion.div}
transition={{
duration: 0.1,
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}> <Listbox.Option value={undefined} className={styleMenuItem}>
Show all Show all
</Listbox.Option> </Listbox.Option>
@ -163,7 +170,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
</Listbox.Option> </Listbox.Option>
))} ))}
</Listbox.Options> </Listbox.Options>
</TransitionPopInOut>
</div> </div>
</Listbox> </Listbox>
{!poll.closed ? ( {!poll.closed ? (

View file

@ -1,4 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
@ -12,7 +13,6 @@ import ArrowLeft from "../icons/arrow-left.svg";
import ArrowRight from "../icons/arrow-right.svg"; import ArrowRight from "../icons/arrow-right.svg";
import PlusCircle from "../icons/plus-circle.svg"; import PlusCircle from "../icons/plus-circle.svg";
import TimeZonePicker from "../time-zone-picker"; import TimeZonePicker from "../time-zone-picker";
import { TransitionPopInOut } from "../transitions";
import { usePoll } from "../use-poll"; import { usePoll } from "../use-poll";
import { useAddParticipantMutation } from "./mutations"; import { useAddParticipantMutation } from "./mutations";
import ParticipantRow from "./participant-row"; import ParticipantRow from "./participant-row";
@ -26,6 +26,8 @@ if (typeof window !== "undefined") {
smoothscroll.polyfill(); smoothscroll.polyfill();
} }
const MotionButton = motion(Button);
// There's a bug in Safari 15.4 that causes `scroll` to no work as intended // There's a bug in Safari 15.4 that causes `scroll` to no work as intended
const isSafariV154 = const isSafariV154 =
typeof window !== "undefined" typeof window !== "undefined"
@ -214,11 +216,20 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
<div className="flex h-full grow items-end"> <div className="flex h-full grow items-end">
{t("participantCount", { count: participants.length })} {t("participantCount", { count: participants.length })}
</div> </div>
<TransitionPopInOut show={scrollPosition > 0}> <AnimatePresence initial={false}>
<Button rounded={true} onClick={goToPreviousPage}> {scrollPosition > 0 ? (
<MotionButton
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
rounded={true}
onClick={goToPreviousPage}
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </MotionButton>
</TransitionPopInOut> ) : null}
</AnimatePresence>
</div> </div>
<ControlledScrollDiv> <ControlledScrollDiv>
{options.map((option) => { {options.map((option) => {
@ -271,8 +282,14 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
className="flex items-center py-3 px-2" className="flex items-center py-3 px-2"
style={{ width: actionColumnWidth }} style={{ width: actionColumnWidth }}
> >
<TransitionPopInOut show={scrollPosition < maxScrollPosition}> {maxScrollPosition > 0 ? (
<Button <AnimatePresence initial={false}>
{scrollPosition < maxScrollPosition ? (
<MotionButton
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="text-xs" className="text-xs"
rounded={true} rounded={true}
onClick={() => { onClick={() => {
@ -285,8 +302,10 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
) : ( ) : (
`+${numberOfInvisibleColumns} more…` `+${numberOfInvisibleColumns} more…`
)} )}
</Button> </MotionButton>
</TransitionPopInOut> ) : null}
</AnimatePresence>
) : null}
</div> </div>
</div> </div>
{shouldShowNewParticipantForm ? ( {shouldShowNewParticipantForm ? (

View file

@ -1,6 +1,6 @@
import { Transition } from "@headlessui/react";
import { Placement } from "@popperjs/core"; import { Placement } from "@popperjs/core";
import clsx from "clsx"; import clsx from "clsx";
import { motion } from "framer-motion";
import * as React from "react"; import * as React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
@ -16,7 +16,7 @@ export interface TooltipProps {
} }
const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
placement, placement = "bottom",
className, className,
children, children,
disabled, disabled,
@ -29,7 +29,10 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
const [arrowElement, setArrowElement] = const [arrowElement, setArrowElement] =
React.useState<HTMLDivElement | null>(null); React.useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes, update } = usePopper(
referenceElement,
popperElement,
{
placement, placement,
modifiers: [ modifiers: [
{ {
@ -40,14 +43,16 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
}, },
{ name: "arrow", options: { element: arrowElement, padding: 5 } }, { name: "arrow", options: { element: arrowElement, padding: 5 } },
], ],
}); },
);
const [isVisible, setIsVisible] = React.useState(false); const [isVisible, setIsVisible] = React.useState(false);
const [debouncedValue, setDebouncedValue] = React.useState(false); const [debouncedValue, setDebouncedValue] = React.useState(false);
const [, cancel] = useDebounce( const [, cancel] = useDebounce(
() => { async () => {
await update?.();
setDebouncedValue(isVisible); setDebouncedValue(isVisible);
}, },
300, 300,
@ -98,13 +103,20 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
style={styles.popper} style={styles.popper}
{...attributes.popper} {...attributes.popper}
> >
<Transition <motion.div
className="pointer-events-none rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md" className="pointer-events-none rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
as={"div"} initial="hidden"
show={debouncedValue} transition={{
enter="transition transform duration-100" duration: 0.1,
enterFrom="opacity-0 -translate-y-2" }}
enterTo="opacity-100 translate-y-0" variants={{
hidden: {
opacity: 0,
translateY: placement === "bottom" ? -4 : 4,
},
show: { opacity: 1, translateY: 0 },
}}
animate={debouncedValue ? "show" : "hidden"}
> >
<div <div
ref={setArrowElement} ref={setArrowElement}
@ -113,7 +125,7 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
data-popper-arrow data-popper-arrow
></div> ></div>
{typeof content === "string" ? preventWidows(content) : content} {typeof content === "string" ? preventWidows(content) : content}
</Transition> </motion.div>
</div>, </div>,
portal, portal,
) )

View file

@ -1,22 +0,0 @@
import { Transition } from "@headlessui/react";
import * as React from "react";
export const TransitionPopInOut: React.VoidFunctionComponent<{
children: React.ReactNode;
show?: boolean;
}> = ({ children, show }) => {
return (
<Transition
show={show}
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{children}
</Transition>
);
};