Skip to content

Commit

Permalink
Add an Animate component and menu appear animation for popovers & sta…
Browse files Browse the repository at this point in the history
…rt work on handbook (#13617)
  • Loading branch information
jasmussen authored and youknowriad committed Mar 6, 2019
1 parent 31a8581 commit 9a25f00
Show file tree
Hide file tree
Showing 28 changed files with 293 additions and 35 deletions.
33 changes: 33 additions & 0 deletions docs/designers-developers/designers/animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Animation

Animation can help reinforce a sense of hierarchy and spatial orientation. This document goes into principles you should follow when you add animation.

## Principles

### Point of Origin

- Animation can help anchor an interface element. For example a menu can scale up from the button that opened it.
- Animation can help give a sense of place; for example a sidebar can animate in from the side, implying it was always hidden off-screen.
- Design your animations as if you're working with real-world materials. Imagine your user interface elements are made of real materials — when not on screen, where are they? Use animation to help express that.

### Speed

- Animations should never block a user interaction. They should be fast, almost always complete in less than 0.2 seconds.
- A user should not have to wait for an animation to finish before they can interact.
- Animations should be performant. Use `transform` CSS properties when you can, these render elements on the GPU, making them smooth.
- If an animation can't be made fast & performant, leave it out.

### Simple

- Don't bounce if the material isn't made of rubber.
- Don't rotate, fold, or animate on a curved path. Keep it simple.

### Consistency

In creating consistent animations, we have to establish physical rules for how elements behave when animated. When all animations follow these rules, they feel consistent, related, and predictable. An animation should match user expectations, if it doesn't, it's probably not the right animation for the job.

Reuse animations if one already exists for your task.

## Inventory of Reused Animations

The generic `Animate` component is used to animate different parts of the interface. See [the component documentation](/packages/components/src/animate/README.md) for more details about the available animations.
12 changes: 12 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/design-resources.md",
"parent": "designers"
},
{
"title": "Animation",
"slug": "animation",
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/animation.md",
"parent": "designers"
},
{
"title": "Contributors Guide",
"slug": "contributors",
Expand Down Expand Up @@ -815,6 +821,12 @@
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/wordcount/README.md",
"parent": "packages"
},
{
"title": "Animate",
"slug": "animate",
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/animate/README.md",
"parent": "components"
},
{
"title": "Autocomplete",
"slug": "autocomplete",
Expand Down
3 changes: 2 additions & 1 deletion docs/toc.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
{"docs/designers-developers/designers/README.md": [
{"docs/designers-developers/designers/block-design.md": []},
{"docs/designers-developers/designers/design-patterns.md": []},
{"docs/designers-developers/designers/design-resources.md": []}
{"docs/designers-developers/designers/design-resources.md": []},
{"docs/designers-developers/designers/animation.md": []}
]}
]},
{"docs/contributors/readme.md": [
Expand Down
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## 7.1.0 (Unreleased)

### New Features

- Added a new `Animate` component.

### Improvements

- `withFilters` has been optimized to avoid binding hook handlers for each mounted instance of the component, instead using a single centralized hook delegator.
Expand Down
39 changes: 39 additions & 0 deletions packages/components/src/animate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Animate

Simple interface to introduce animations to components.

## Usage

```jsx
import { Animate } from '@wordpress/components';

const MyAnimatedNotice = () => (
<Animate todo="Add missing props">
{ ( { className } ) => (
<Notice className={ className } status="success">
<p>Animation finished.</p>
</Notice>
) }
</Animate>
);
```

## Props

Name | Type | Default | Description
--- | --- | --- | ---
`type` | `string` | `undefined` | Type of the animation to use.
`options` | `object` | `{}` | Options of the chosen animation.
`children` | `function` | `undefined` | A callback receiving a list of props ( `className` ) to apply to the DOM element to animate.

## Available Animation Types

### appear

This animation is meant for popover/modal content, such as menus appearing. It shows the height and width of the animated element scaling from 0 to full size, from its point of origin.

#### Options

Name | Type | Default | Description
--- | --- | --- | ---
`origin` | `string` | `top center` | Point of origin (`top`, `bottom`,` middle right`, `left`, `center`).
25 changes: 25 additions & 0 deletions packages/components/src/animate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import classnames from 'classnames';

function Animate( { type, options = {}, children } ) {
if ( type === 'appear' ) {
const { origin = 'top' } = options;
const [ yAxis, xAxis = 'center' ] = origin.split( ' ' );

return children( {
className: classnames(
'components-animate__appear',
{
[ 'is-from-' + xAxis ]: xAxis !== 'center',
[ 'is-from-' + yAxis ]: yAxis !== 'middle',
},
),
} );
}

return children( {} );
}

export default Animate;
28 changes: 28 additions & 0 deletions packages/components/src/animate/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.components-animate__appear {
animation: components-animate__appear-animation 0.1s cubic-bezier(0, 0, 0.2, 1) 0s;
animation-fill-mode: forwards;

&.is-from-top,
&.is-from-top.is-from-left {
transform-origin: top left;
}
&.is-from-top.is-from-right {
transform-origin: top right;
}
&.is-from-bottom,
&.is-from-bottom.is-from-left {
transform-origin: bottom left;
}
&.is-from-bottom.is-from-right {
transform-origin: bottom right;
}
}

@keyframes components-animate__appear-animation {
from {
transform: translateY(-2em) scaleY(0) scaleX(0);
}
to {
transform: translateY(0%) scaleY(1) scaleX(1);
}
}
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Components
export * from './primitives';
// eslint-disable-next-line camelcase
export { default as Animate } from './animate';
export { default as Autocomplete } from './autocomplete';
export { default as BaseControl } from './base-control';
export { default as Button } from './button';
Expand Down
85 changes: 56 additions & 29 deletions packages/components/src/popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import IconButton from '../icon-button';
import ScrollLock from '../scroll-lock';
import IsolatedEventContainer from '../isolated-event-container';
import { Slot, Fill, Consumer } from '../slot-fill';
import Animate from '../animate';

const FocusManaged = withConstrainedTabbing( withFocusReturn( ( { children } ) => children ) );

Expand Down Expand Up @@ -55,6 +56,11 @@ class Popover extends Component {
contentWidth: null,
isMobile: false,
popoverSize: null,

// Delay the animation after the initial render
// because the animation have impact on the height of the popover
// causing the computed position to be wrong.
isReadyToAnimate: false,
};

// Property used keep track of the previous anchor rect
Expand Down Expand Up @@ -150,7 +156,7 @@ class Popover extends Component {
popoverSize.height !== this.state.popoverSize.height
);
if ( didPopoverSizeChange ) {
this.setState( { popoverSize } );
this.setState( { popoverSize, isReadyToAnimate: true } );
}
this.anchorRect = anchorRect;
this.computePopoverPosition( popoverSize, anchorRect );
Expand Down Expand Up @@ -258,6 +264,7 @@ class Popover extends Component {
focusOnMount,
getAnchorRect,
expandOnMobile,
animate = true,
/* eslint-enable no-unused-vars */
...contentProps
} = this.props;
Expand All @@ -270,8 +277,21 @@ class Popover extends Component {
contentWidth,
popoverSize,
isMobile,
isReadyToAnimate,
} = this.state;

// Compute the animation position
const yAxisMapping = {
top: 'bottom',
bottom: 'top',
};
const xAxisMapping = {
left: 'right',
right: 'left',
};
const animateYAxis = yAxisMapping[ yAxis ] || 'middle';
const animateXAxis = xAxisMapping[ xAxis ] || 'center';

const classes = classnames(
'components-popover',
className,
Expand All @@ -289,36 +309,43 @@ class Popover extends Component {
/* eslint-disable jsx-a11y/no-static-element-interactions */
let content = (
<PopoverDetectOutside onClickOutside={ onClickOutside }>
<IsolatedEventContainer
className={ classes }
style={ {
top: ! isMobile && popoverTop ? popoverTop + 'px' : undefined,
left: ! isMobile && popoverLeft ? popoverLeft + 'px' : undefined,
visibility: popoverSize ? undefined : 'hidden',
} }
{ ...contentProps }
onKeyDown={ this.maybeClose }
<Animate
type={ animate && isReadyToAnimate ? 'appear' : null }
options={ { origin: animateYAxis + ' ' + animateXAxis } }
>
{ isMobile && (
<div className="components-popover__header">
<span className="components-popover__header-title">
{ headerTitle }
</span>
<IconButton className="components-popover__close" icon="no-alt" onClick={ onClose } />
</div>
{ ( { className: animateClassName } ) => (
<IsolatedEventContainer
className={ classnames( classes, animateClassName ) }
style={ {
top: ! isMobile && popoverTop ? popoverTop + 'px' : undefined,
left: ! isMobile && popoverLeft ? popoverLeft + 'px' : undefined,
visibility: popoverSize ? undefined : 'hidden',
} }
{ ...contentProps }
onKeyDown={ this.maybeClose }
>
{ isMobile && (
<div className="components-popover__header">
<span className="components-popover__header-title">
{ headerTitle }
</span>
<IconButton className="components-popover__close" icon="no-alt" onClick={ onClose } />
</div>
) }
<div
ref={ this.contentNode }
className="components-popover__content"
style={ {
maxHeight: ! isMobile && contentHeight ? contentHeight + 'px' : undefined,
maxWidth: ! isMobile && contentWidth ? contentWidth + 'px' : undefined,
} }
tabIndex="-1"
>
{ children }
</div>
</IsolatedEventContainer>
) }
<div
ref={ this.contentNode }
className="components-popover__content"
style={ {
maxHeight: ! isMobile && contentHeight ? contentHeight + 'px' : undefined,
maxWidth: ! isMobile && contentWidth ? contentWidth + 'px' : undefined,
} }
tabIndex="-1"
>
{ children }
</div>
</IsolatedEventContainer>
</Animate>
</PopoverDetectOutside>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ exports[`Popover #render() should pass additional props to portaled element 1`]
>
<div>
<div
class="components-popover is-bottom is-center"
class="components-popover is-bottom is-center components-animate__appear is-from-top"
role="tooltip"
style=""
>
Expand All @@ -30,7 +30,7 @@ exports[`Popover #render() should render content 1`] = `
>
<div>
<div
class="components-popover is-bottom is-center"
class="components-popover is-bottom is-center components-animate__appear is-from-top"
style=""
>
<div
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/style.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@import "./animate/style.scss";
@import "./autocomplete/style.scss";
@import "./base-control/style.scss";
@import "./button-group/style.scss";
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/tooltip/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class Tooltip extends Component {
position={ position }
className="components-tooltip"
aria-hidden="true"
animate={ false }
>
{ text }
<Shortcut className="components-tooltip__shortcut" shortcut={ shortcut } />
Expand Down
6 changes: 6 additions & 0 deletions packages/e2e-test-utils/src/click-on-more-menu-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
*/
import { first } from 'lodash';

/**
* Internal dependencies
*/
import { waitForAnimation } from './wait-for-animation';

/**
* Clicks on More Menu item, searches for the button with the text provided and clicks it.
*
Expand All @@ -12,6 +17,7 @@ export async function clickOnMoreMenuItem( buttonLabel ) {
await expect( page ).toClick(
'.edit-post-more-menu [aria-label="Show more tools & options"]'
);
await waitForAnimation();
const moreMenuContainerSelector =
'//*[contains(concat(" ", @class, " "), " edit-post-more-menu__content ")]';
let elementToClick = first( await page.$x(
Expand Down
1 change: 1 addition & 0 deletions packages/e2e-test-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export { toggleScreenOption } from './toggle-screen-option';
export { transformBlockTo } from './transform-block-to';
export { uninstallPlugin } from './uninstall-plugin';
export { visitAdminPage } from './visit-admin-page';
export { waitForAnimation } from './wait-for-animation';
export { waitForWindowDimensions } from './wait-for-window-dimensions';

export * from './mocks';
6 changes: 6 additions & 0 deletions packages/e2e-test-utils/src/search-for-block.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
/**
* Internal dependencies
*/
import { waitForAnimation } from './wait-for-animation';

/**
* Search for block in the global inserter
*
* @param {string} searchTerm The text to search the inserter for.
*/
export async function searchForBlock( searchTerm ) {
await page.click( '.edit-post-header [aria-label="Add block"]' );
await waitForAnimation();
// Waiting here is necessary because sometimes the inserter takes more time to
// render than Puppeteer takes to complete the 'click' action
await page.waitForSelector( '.editor-inserter__menu' );
Expand Down
Loading

0 comments on commit 9a25f00

Please sign in to comment.