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,38 +11,50 @@ 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: {
className="fixed bottom-8 right-8 z-50 w-60 rounded-lg bg-white p-4 pt-8 text-sm shadow-lg" opacity: 0,
> y: 10,
<CookiesIllustration className="absolute -top-6" /> transition: { duration: 0.1 },
<div className="mb-3"> },
Your privacy is important to us. We only use cookies to improve the
browsing experience on this website.
</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>
</Link>
<button
onClick={() => {
Cookies.set("rallly_cookie_consent", "1", { expires: 365 });
setVisible(false);
}} }}
className="grow rounded-md bg-indigo-500 px-5 py-1 font-semibold text-white shadow-sm transition-all hover:bg-indigo-500/90 focus:ring-2 focus:ring-indigo-200 active:bg-indigo-600/90" 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"
> >
OK <CookiesIllustration className="absolute -top-6" />
</button> <div className="mb-3">
</div> Your privacy is important to us. We only use cookies to improve the
</Transition>, browsing experience on this website.
</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>
</Link>
<button
onClick={() => {
Cookies.set("rallly_cookie_consent", "1", { expires: 365 });
setVisible(false);
}}
className="grow rounded-md bg-indigo-500 px-5 py-1 font-semibold text-white shadow-sm transition-all hover:bg-indigo-500/90 focus:ring-2 focus:ring-indigo-200 active:bg-indigo-600/90"
>
OK
</button>
</div>
</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) => {
) : null} 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 <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,37 +33,34 @@ 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>
<Dialog {visible ? (
open={visible} <Dialog
className="fixed inset-0 z-40 overflow-y-auto" open={visible}
initialFocus={initialFocusRef} className="fixed inset-0 z-40 overflow-y-auto"
onClose={() => { initialFocus={initialFocusRef}
if (overlayClosable) onCancel?.(); onClose={() => {
}} if (overlayClosable) onCancel?.();
> }}
<div className="flex min-h-screen items-center justify-center"> >
<Transition.Child <motion.div
as={React.Fragment} transition={{ duration: 0.5 }}
enter="ease-out duration-200" className="flex min-h-screen items-center justify-center"
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> ) : null}
</Transition> </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,25 +145,31 @@ 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}
<Listbox.Option value={undefined} className={styleMenuItem}> transition={{
Show all 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>
{participants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvater name={participant.name} />
<span>{participant.name}</span>
</div>
</Listbox.Option> </Listbox.Option>
{participants.map((participant) => ( ))}
<Listbox.Option </Listbox.Options>
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvater name={participant.name} />
<span>{participant.name}</span>
</div>
</Listbox.Option>
))}
</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 ? (
<ArrowLeft className="h-4 w-4" /> <MotionButton
</Button> transition={{ duration: 0.1 }}
</TransitionPopInOut> 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" />
</MotionButton>
) : null}
</AnimatePresence>
</div> </div>
<ControlledScrollDiv> <ControlledScrollDiv>
{options.map((option) => { {options.map((option) => {
@ -271,22 +282,30 @@ 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}>
className="text-xs" {scrollPosition < maxScrollPosition ? (
rounded={true} <MotionButton
onClick={() => { transition={{ duration: 0.1 }}
setDidUsePagination(true); initial={{ opacity: 0, scale: 0.9 }}
goToNextPage(); animate={{ opacity: 1, scale: 1 }}
}} exit={{ opacity: 0, scale: 0.8 }}
> className="text-xs"
{didUsePagination ? ( rounded={true}
<ArrowRight className="h-4 w-4" /> onClick={() => {
) : ( setDidUsePagination(true);
`+${numberOfInvisibleColumns} more…` goToNextPage();
)} }}
</Button> >
</TransitionPopInOut> {didUsePagination ? (
<ArrowRight className="h-4 w-4" />
) : (
`+${numberOfInvisibleColumns} more…`
)}
</MotionButton>
) : 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,25 +29,30 @@ 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(
placement, referenceElement,
modifiers: [ popperElement,
{ {
name: "offset", placement,
options: { modifiers: [
offset: [0, 14], {
name: "offset",
options: {
offset: [0, 14],
},
}, },
}, { 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>
);
};