feat: Improved UI motion design (#2310)

* feat: Improved UI motion design

* fix: Animation direction when screen placement causes context menu to be flipped
This commit is contained in:
Tom Moor
2021-07-12 11:57:17 -07:00
committed by GitHub
parent 5689d96cc4
commit 8e5a2b85c2
11 changed files with 203 additions and 62 deletions

View File

@ -6,14 +6,16 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { import {
fadeIn, fadeIn,
fadeAndScaleIn, fadeAndSlideUp,
fadeAndSlideIn, fadeAndSlideDown,
mobileContextMenu,
} from "shared/styles/animations"; } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious"; import usePrevious from "hooks/usePrevious";
type Props = {| type Props = {|
"aria-label": string, "aria-label": string,
visible?: boolean, visible?: boolean,
placement?: string,
animating?: boolean, animating?: boolean,
children: React.Node, children: React.Node,
onOpen?: () => void, onOpen?: () => void,
@ -44,13 +46,25 @@ export default function ContextMenu({
return ( return (
<> <>
<Menu hideOnClickOutside preventBodyScroll {...rest}> <Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => ( {(props) => {
<Position {...props}> // kind of hacky, but this is an effective way of telling which way
<Background dir="auto"> // the menu will _actually_ be placed when taking into account screen
{rest.visible || rest.animating ? children : null} // positioning.
</Background> const topAnchor = props.style.top === "0";
</Position> const rightAnchor = props.placement === "bottom-end";
)}
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu> </Menu>
{(rest.visible || rest.animating) && ( {(rest.visible || rest.animating) && (
<Portal> <Portal>
@ -91,7 +105,7 @@ const Position = styled.div`
`; `;
const Background = styled.div` const Background = styled.div`
animation: ${fadeAndSlideIn} 200ms ease; animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%; transform-origin: 50% 100%;
max-width: 100%; max-width: 100%;
background: ${(props) => props.theme.menuBackground}; background: ${(props) => props.theme.menuBackground};
@ -109,9 +123,10 @@ const Background = styled.div`
} }
${breakpoint("tablet")` ${breakpoint("tablet")`
animation: ${fadeAndScaleIn} 200ms ease; animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) => transform-origin: ${(props) =>
props.left !== undefined ? "25%" : "75%"} 0; props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
max-width: 276px; max-width: 276px;
background: ${(props) => props.theme.menuBackground}; background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow}; box-shadow: ${(props) => props.theme.menuShadow};

View File

@ -66,6 +66,9 @@ function DocumentListItem(props: Props, ref) {
!document.isDraft && !document.isArchived && !document.isTemplate; !document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id); const can = policies.abilities(currentTeam.id);
const handleMenuOpen = React.useCallback(() => setMenuOpen(true), []);
const handleMenuClosed = React.useCallback(() => setMenuOpen(false), []);
return ( return (
<DocumentLink <DocumentLink
ref={ref} ref={ref}
@ -143,8 +146,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu <DocumentMenu
document={document} document={document}
showPin={showPin} showPin={showPin}
onOpen={() => setMenuOpen(true)} onOpen={handleMenuOpen}
onClose={() => setMenuOpen(false)} onClose={handleMenuClosed}
modal={false} modal={false}
/> />
</Actions> </Actions>

View File

@ -4,7 +4,7 @@ import { transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import { Portal } from "react-portal"; import { Portal } from "react-portal";
import styled from "styled-components"; import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations"; import { fadeAndSlideDown } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug"; import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument"; import HoverPreviewDocument from "components/HoverPreviewDocument";
@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
} }
const Animate = styled.div` const Animate = styled.div`
animation: ${fadeAndSlideIn} 150ms ease; animation: ${fadeAndSlideDown} 150ms ease;
@media print { @media print {
display: none; display: none;

View File

@ -1,5 +1,4 @@
// @flow // @flow
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import Document from "models/Document"; import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem"; import DocumentListItem from "components/DocumentListItem";
@ -19,24 +18,26 @@ type Props = {|
showTemplate?: boolean, showTemplate?: boolean,
|}; |};
@observer const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
class PaginatedDocumentList extends React.Component<Props> { empty,
render() { heading,
const { empty, heading, documents, fetch, options, ...rest } = this.props; documents,
fetch,
return ( options,
<PaginatedList ...rest
items={documents} }: Props) {
empty={empty} return (
heading={heading} <PaginatedList
fetch={fetch} items={documents}
options={options} empty={empty}
renderItem={(item) => ( heading={heading}
<DocumentListItem key={item.id} document={item} {...rest} /> fetch={fetch}
)} options={options}
/> renderItem={(item) => (
); <DocumentListItem key={item.id} document={item} {...rest} />
} )}
} />
);
});
export default PaginatedDocumentList; export default PaginatedDocumentList;

View File

@ -1,14 +1,26 @@
// @flow // @flow
import { m } from "framer-motion";
import * as React from "react"; 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 styled, { withTheme } from "styled-components";
import { type Theme } from "types"; import { type Theme } from "types";
type Props = { type Props = {
theme: Theme, theme: Theme,
children: React.Node,
}; };
const TabLink = styled(NavLink)` const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -20,19 +32,48 @@ const TabLink = styled(NavLink)`
&:hover { &:hover {
color: ${(props) => props.theme.textSecondary}; 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 = { const activeStyle = {
paddingBottom: "5px",
borderBottom: `3px solid ${theme.textSecondary}`,
color: theme.textSecondary, color: theme.textSecondary,
}; };
return <TabLink {...rest} activeStyle={activeStyle} />; return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
);
} }
export default withTheme(Tab); export default withTheme(Tab);

View File

@ -1,4 +1,5 @@
// @flow // @flow
import { AnimateSharedLayout } from "framer-motion";
import { transparentize } from "polished"; import { transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
@ -79,11 +80,13 @@ const Tabs = ({ children }: {| children: React.Node |}) => {
}, [width, updateShadows]); }, [width, updateShadows]);
return ( return (
<Sticky> <AnimateSharedLayout>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}> <Sticky>
{children} <Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
</Nav> {children}
</Sticky> </Nav>
</Sticky>
</AnimateSharedLayout>
); );
}; };

View File

@ -1,5 +1,6 @@
// @flow // @flow
import "focus-visible"; import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
import { Provider } from "mobx-react"; import { Provider } from "mobx-react";
import * as React from "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) { if (element) {
const App = () => ( const App = () => (
<React.StrictMode> <React.StrictMode>
@ -56,15 +61,17 @@ if (element) {
<Analytics> <Analytics>
<Theme> <Theme>
<ErrorBoundary> <ErrorBoundary>
<Router history={history}> <LazyMotion features={loadFeatures}>
<> <Router history={history}>
<PageTheme /> <>
<ScrollToTop> <PageTheme />
<Routes /> <ScrollToTop>
</ScrollToTop> <Routes />
<Toasts /> </ScrollToTop>
</> <Toasts />
</Router> </>
</Router>
</LazyMotion>
</ErrorBoundary> </ErrorBoundary>
</Theme> </Theme>
</Analytics> </Analytics>

3
app/utils/motion.js Normal file
View File

@ -0,0 +1,3 @@
// @flow
import { domMax } from "framer-motion";
export default domMax;

View File

@ -79,6 +79,7 @@
"flow-typed": "^3.3.1", "flow-typed": "^3.3.1",
"focus-visible": "^5.1.0", "focus-visible": "^5.1.0",
"fractional-index": "^1.0.0", "fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^4.0.2", "fs-extra": "^4.0.2",
"http-errors": "1.4.0", "http-errors": "1.4.0",
"i18next": "^19.8.3", "i18next": "^19.8.3",
@ -211,4 +212,4 @@
"js-yaml": "^3.13.1" "js-yaml": "^3.13.1"
}, },
"version": "0.57.0" "version": "0.57.0"
} }

View File

@ -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 { from {
opacity: 0; opacity: 0;
transform: scale(.98) translateY(10px); 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` export const bounceIn = keyframes`
from, from,
20%, 20%,

View File

@ -1040,7 +1040,7 @@
exec-sh "^0.3.2" exec-sh "^0.3.2"
minimist "^1.2.0" 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" version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
@ -6014,6 +6014,26 @@ fragment-cache@^0.2.1:
dependencies: dependencies:
map-cache "^0.2.2" 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: fresh@~0.5.2:
version "0.5.2" version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 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" referrer-policy "1.2.0"
x-xss-protection "1.3.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: hide-powered-by@1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.1.0.tgz#be3ea9cab4bdb16f8744be873755ca663383fa7a" 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: dependencies:
"@babel/runtime" "^7.9.2" "@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: popper.js@^1.14.7:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" 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" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= 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: styled-components-breakpoint@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/styled-components-breakpoint/-/styled-components-breakpoint-2.1.1.tgz#37c1b92b0e96c1bbc5d293724d7a114daaa15fca" 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" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== 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" version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==