From 8e5a2b85c2fcd018d778ba74a7cb231fd79bb144 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 12 Jul 2021 11:57:17 -0700 Subject: [PATCH] feat: Improved UI motion design (#2310) * feat: Improved UI motion design * fix: Animation direction when screen placement causes context menu to be flipped --- app/components/ContextMenu/index.js | 39 +++++++++++------ app/components/DocumentListItem.js | 7 ++- app/components/HoverPreview.js | 4 +- app/components/PaginatedDocumentList.js | 41 +++++++++--------- app/components/Tab.js | 57 +++++++++++++++++++++---- app/components/Tabs.js | 13 +++--- app/index.js | 25 +++++++---- app/utils/motion.js | 3 ++ package.json | 3 +- shared/styles/animations.js | 26 ++++++++++- yarn.lock | 47 +++++++++++++++++++- 11 files changed, 203 insertions(+), 62 deletions(-) create mode 100644 app/utils/motion.js diff --git a/app/components/ContextMenu/index.js b/app/components/ContextMenu/index.js index 70c31c55..5a3ec9cf 100644 --- a/app/components/ContextMenu/index.js +++ b/app/components/ContextMenu/index.js @@ -6,14 +6,16 @@ import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { fadeIn, - fadeAndScaleIn, - fadeAndSlideIn, + fadeAndSlideUp, + fadeAndSlideDown, + mobileContextMenu, } from "shared/styles/animations"; import usePrevious from "hooks/usePrevious"; type Props = {| "aria-label": string, visible?: boolean, + placement?: string, animating?: boolean, children: React.Node, onOpen?: () => void, @@ -44,13 +46,25 @@ export default function ContextMenu({ return ( <> - {(props) => ( - - - {rest.visible || rest.animating ? children : null} - - - )} + {(props) => { + // kind of hacky, but this is an effective way of telling which way + // the menu will _actually_ be placed when taking into account screen + // positioning. + const topAnchor = props.style.top === "0"; + const rightAnchor = props.placement === "bottom-end"; + + return ( + + + {rest.visible || rest.animating ? children : null} + + + ); + }} {(rest.visible || rest.animating) && ( @@ -91,7 +105,7 @@ const Position = styled.div` `; const Background = styled.div` - animation: ${fadeAndSlideIn} 200ms ease; + animation: ${mobileContextMenu} 200ms ease; transform-origin: 50% 100%; max-width: 100%; background: ${(props) => props.theme.menuBackground}; @@ -109,9 +123,10 @@ const Background = styled.div` } ${breakpoint("tablet")` - animation: ${fadeAndScaleIn} 200ms ease; + animation: ${(props) => + props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease; transform-origin: ${(props) => - props.left !== undefined ? "25%" : "75%"} 0; + props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0; max-width: 276px; background: ${(props) => props.theme.menuBackground}; box-shadow: ${(props) => props.theme.menuShadow}; diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js index 5e0b8a6f..08986eb8 100644 --- a/app/components/DocumentListItem.js +++ b/app/components/DocumentListItem.js @@ -66,6 +66,9 @@ function DocumentListItem(props: Props, ref) { !document.isDraft && !document.isArchived && !document.isTemplate; const can = policies.abilities(currentTeam.id); + const handleMenuOpen = React.useCallback(() => setMenuOpen(true), []); + const handleMenuClosed = React.useCallback(() => setMenuOpen(false), []); + return ( setMenuOpen(true)} - onClose={() => setMenuOpen(false)} + onOpen={handleMenuOpen} + onClose={handleMenuClosed} modal={false} /> diff --git a/app/components/HoverPreview.js b/app/components/HoverPreview.js index ff1df8bd..8db9781a 100644 --- a/app/components/HoverPreview.js +++ b/app/components/HoverPreview.js @@ -4,7 +4,7 @@ import { transparentize } from "polished"; import * as React from "react"; import { Portal } from "react-portal"; import styled from "styled-components"; -import { fadeAndSlideIn } from "shared/styles/animations"; +import { fadeAndSlideDown } from "shared/styles/animations"; import parseDocumentSlug from "shared/utils/parseDocumentSlug"; import DocumentsStore from "stores/DocumentsStore"; import HoverPreviewDocument from "components/HoverPreviewDocument"; @@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) { } const Animate = styled.div` - animation: ${fadeAndSlideIn} 150ms ease; + animation: ${fadeAndSlideDown} 150ms ease; @media print { display: none; diff --git a/app/components/PaginatedDocumentList.js b/app/components/PaginatedDocumentList.js index f6fabd9a..17234a15 100644 --- a/app/components/PaginatedDocumentList.js +++ b/app/components/PaginatedDocumentList.js @@ -1,5 +1,4 @@ // @flow -import { observer } from "mobx-react"; import * as React from "react"; import Document from "models/Document"; import DocumentListItem from "components/DocumentListItem"; @@ -19,24 +18,26 @@ type Props = {| showTemplate?: boolean, |}; -@observer -class PaginatedDocumentList extends React.Component { - render() { - const { empty, heading, documents, fetch, options, ...rest } = this.props; - - return ( - ( - - )} - /> - ); - } -} +const PaginatedDocumentList = React.memo(function PaginatedDocumentList({ + empty, + heading, + documents, + fetch, + options, + ...rest +}: Props) { + return ( + ( + + )} + /> + ); +}); export default PaginatedDocumentList; diff --git a/app/components/Tab.js b/app/components/Tab.js index 5d771744..ece4546a 100644 --- a/app/components/Tab.js +++ b/app/components/Tab.js @@ -1,14 +1,26 @@ // @flow +import { m } from "framer-motion"; import * as React from "react"; -import { NavLink } from "react-router-dom"; +import { NavLink, Route } from "react-router-dom"; import styled, { withTheme } from "styled-components"; import { type Theme } from "types"; type Props = { theme: Theme, + children: React.Node, }; -const TabLink = styled(NavLink)` +const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => ( + + {({ match }) => ( + + {children(match)} + + )} + +); + +const TabLink = styled(NavLinkWithChildrenFunc)` position: relative; display: inline-flex; align-items: center; @@ -20,19 +32,48 @@ const TabLink = styled(NavLink)` &:hover { color: ${(props) => props.theme.textSecondary}; - border-bottom: 3px solid ${(props) => props.theme.divider}; - padding-bottom: 5px; } `; -function Tab({ theme, ...rest }: Props) { +const Active = styled(m.div)` + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + width: 100%; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + background: ${(props) => props.theme.textSecondary}; +`; + +const transition = { + type: "spring", + stiffness: 500, + damping: 30, +}; + +function Tab({ theme, children, ...rest }: Props) { const activeStyle = { - paddingBottom: "5px", - borderBottom: `3px solid ${theme.textSecondary}`, color: theme.textSecondary, }; - return ; + return ( + + {(match) => ( + <> + {children} + {match && ( + + )} + + )} + + ); } export default withTheme(Tab); diff --git a/app/components/Tabs.js b/app/components/Tabs.js index 7eec5868..2d37ec70 100644 --- a/app/components/Tabs.js +++ b/app/components/Tabs.js @@ -1,4 +1,5 @@ // @flow +import { AnimateSharedLayout } from "framer-motion"; import { transparentize } from "polished"; import * as React from "react"; import styled from "styled-components"; @@ -79,11 +80,13 @@ const Tabs = ({ children }: {| children: React.Node |}) => { }, [width, updateShadows]); return ( - - - + + + + + ); }; diff --git a/app/index.js b/app/index.js index 48c15f6b..36a2f629 100644 --- a/app/index.js +++ b/app/index.js @@ -1,5 +1,6 @@ // @flow import "focus-visible"; +import { LazyMotion } from "framer-motion"; import { createBrowserHistory } from "history"; import { Provider } from "mobx-react"; import * as React from "react"; @@ -49,6 +50,10 @@ if ("serviceWorker" in window.navigator) { }); } +// Make sure to return the specific export containing the feature bundle. +const loadFeatures = () => + import("./utils/motion.js").then((res) => res.default); + if (element) { const App = () => ( @@ -56,15 +61,17 @@ if (element) { - - <> - - - - - - - + + + <> + + + + + + + + diff --git a/app/utils/motion.js b/app/utils/motion.js new file mode 100644 index 00000000..069e3f09 --- /dev/null +++ b/app/utils/motion.js @@ -0,0 +1,3 @@ +// @flow +import { domMax } from "framer-motion"; +export default domMax; diff --git a/package.json b/package.json index 22ae5141..6dbbbac4 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "flow-typed": "^3.3.1", "focus-visible": "^5.1.0", "fractional-index": "^1.0.0", + "framer-motion": "^4.1.17", "fs-extra": "^4.0.2", "http-errors": "1.4.0", "i18next": "^19.8.3", @@ -211,4 +212,4 @@ "js-yaml": "^3.13.1" }, "version": "0.57.0" -} \ No newline at end of file +} diff --git a/shared/styles/animations.js b/shared/styles/animations.js index 00dbb52a..d1472d15 100644 --- a/shared/styles/animations.js +++ b/shared/styles/animations.js @@ -18,7 +18,19 @@ export const fadeAndScaleIn = keyframes` } `; -export const fadeAndSlideIn = keyframes` +export const fadeAndSlideDown = keyframes` + from { + opacity: 0; + transform: scale(.98) translateY(-10px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0px); + } +`; + +export const fadeAndSlideUp = keyframes` from { opacity: 0; transform: scale(.98) translateY(10px); @@ -30,6 +42,18 @@ export const fadeAndSlideIn = keyframes` } `; +export const mobileContextMenu = keyframes` + from { + opacity: 0; + transform: scale(.98) translateY(10vh); + } + + to { + opacity: 1; + transform: scale(1) translateY(0px); + } +`; + export const bounceIn = keyframes` from, 20%, diff --git a/yarn.lock b/yarn.lock index 0c454873..742204d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1040,7 +1040,7 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@emotion/is-prop-valid@^0.8.8": +"@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -6014,6 +6014,26 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^4.1.17: + version "4.1.17" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-4.1.17.tgz#4029469252a62ea599902e5a92b537120cc89721" + integrity sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw== + dependencies: + framesync "5.3.0" + hey-listen "^1.0.8" + popmotion "9.3.6" + style-value-types "4.1.4" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + +framesync@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-5.3.0.tgz#0ecfc955e8f5a6ddc8fdb0cc024070947e1a0d9b" + integrity sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA== + dependencies: + tslib "^2.1.0" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -6611,6 +6631,11 @@ helmet@^3.21.1: referrer-policy "1.2.0" x-xss-protection "1.3.0" +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + hide-powered-by@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.1.0.tgz#be3ea9cab4bdb16f8744be873755ca663383fa7a" @@ -10352,6 +10377,16 @@ polished@3.6.5: dependencies: "@babel/runtime" "^7.9.2" +popmotion@9.3.6: + version "9.3.6" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.3.6.tgz#b5236fa28f242aff3871b9e23721f093133248d1" + integrity sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw== + dependencies: + framesync "5.3.0" + hey-listen "^1.0.8" + style-value-types "4.1.4" + tslib "^2.1.0" + popper.js@^1.14.7: version "1.16.1" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" @@ -12627,6 +12662,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +style-value-types@4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-4.1.4.tgz#80f37cb4fb024d6394087403dfb275e8bb627e75" + integrity sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + styled-components-breakpoint@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/styled-components-breakpoint/-/styled-components-breakpoint-2.1.1.tgz#37c1b92b0e96c1bbc5d293724d7a114daaa15fca" @@ -13103,7 +13146,7 @@ tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.2.0: +tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==