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 Link from "next/link";
import * as React from "react";
@ -11,16 +11,24 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
const [visible, setVisible] = React.useState(true);
return ReactDOM.createPortal(
<Transition
show={visible}
appear={true}
as="div"
enter="transition transform delay-1000 duration-1000"
enterFrom="opacity-0 translate-y-8"
enterTo="opacity-100 translate-y-0"
leave="duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-8"
<AnimatePresence>
{visible ? (
<motion.div
variants={{
enter: {
opacity: 1,
y: 0,
transition: { type: "spring", delay: 2, duration: 1 },
},
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"
>
<CookiesIllustration className="absolute -top-6" />
@ -30,7 +38,9 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
</div>
<div className="flex items-center space-x-6">
<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>
<button
onClick={() => {
@ -42,7 +52,9 @@ const CookieConsentPopover: React.VoidFunctionComponent = () => {
OK
</button>
</div>
</Transition>,
</motion.div>
) : null}
</AnimatePresence>,
getPortal(),
);
};

View file

@ -1,12 +1,12 @@
import { Transition } from "@headlessui/react";
import { Comment } from "@prisma/client";
import axios from "axios";
import clsx from "clsx";
import { formatRelative } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useList } from "react-use";
import {
createComment,
@ -33,75 +33,12 @@ interface CommentForm {
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> = ({
pollId,
canDelete,
}) => {
const getCommentsQueryKey = ["poll", pollId, "comments"];
const [userName, setUserName] = useUserName();
const [deletedComments, { push }] = useList<string>([]);
const queryClient = useQueryClient();
const { data: comments } = useQuery(
getCommentsQueryKey,
@ -141,8 +78,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`);
},
{
onMutate: ({ commentId }) => {
push(commentId);
onMutate: () => {
plausible("Deleted comment");
},
onSuccess: () => {
@ -179,14 +115,64 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
<div className="border-b bg-white px-4 py-2">
<div className="font-medium">Comments</div>
</div>
{comments.length ? (
<Comments
comments={comments}
canDelete={canDelete}
onDelete={handleDelete}
deletedComments={deletedComments}
<div
className={clsx({
"space-y-3 border-b bg-slate-50 p-4": comments.length > 0,
})}
>
<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}
</div>
<div className="w-fit whitespace-pre-wrap">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</motion.div>
</motion.div>
);
})}
</AnimatePresence>
</div>
<form
className="bg-white p-4"
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 Button, { ButtonProps } from "../button";
@ -32,7 +33,8 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
}) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return (
<Transition appear={true} as={React.Fragment} show={visible}>
<AnimatePresence>
{visible ? (
<Dialog
open={visible}
className="fixed inset-0 z-40 overflow-y-auto"
@ -41,28 +43,24 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
if (overlayClosable) onCancel?.();
}}
>
<div className="flex min-h-screen items-center justify-center">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<motion.div
transition={{ duration: 0.5 }}
className="flex min-h-screen items-center justify-center"
>
<Dialog.Overlay className="fixed inset-0 bg-slate-900 bg-opacity-10" />
</Transition.Child>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="my-8 mx-4 inline-block w-fit transform overflow-hidden rounded-xl bg-white text-left align-middle shadow-xl transition-all"
<Dialog.Overlay
as={motion.div}
transition={{ duration: 0.5 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-0 bg-slate-900 bg-opacity-10"
/>
<motion.div
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
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) => {
e.stopPropagation();
}}
@ -100,11 +98,11 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
) : null}
</div>
)}
</div>
</Transition.Child>
</div>
</motion.div>
</motion.div>
</Dialog>
</Transition>
) : null}
</AnimatePresence>
);
};

View file

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

View file

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

View file

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