mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-27 01:25:52 -05:00
Merge pull request #2073 from LiFaytheGoblin/2005/animate-add-to-studio-buttons
Animate add-to-studio-buttons on preview project page
This commit is contained in:
commit
42f0ed16e5
16 changed files with 184 additions and 169 deletions
|
@ -75,7 +75,10 @@ class Login extends React.Component {
|
|||
key="submitButton"
|
||||
type="submit"
|
||||
>
|
||||
<Spinner />
|
||||
<Spinner
|
||||
className="spinner"
|
||||
color="blue"
|
||||
/>
|
||||
</Button>
|
||||
] : [
|
||||
<Button
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
.spinner {
|
||||
margin: 0 .8rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
|
|
48
src/components/modal/addtostudio/animate-hoc.jsx
Normal file
48
src/components/modal/addtostudio/animate-hoc.jsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
/**
|
||||
* Higher-order component for building an animated studio button
|
||||
* it is used to decorate the onToggleStudio function with noticing
|
||||
* when the button has first been clicked.
|
||||
* This is needed so the buttons don't play the animation when they are
|
||||
* first rendered but when they are first clicked.
|
||||
* @param {React.Component} Component a studio button component
|
||||
* @return {React.Component} a wrapped studio button component
|
||||
*/
|
||||
|
||||
const AnimateHOC = Component => {
|
||||
class WrappedComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
wasClicked: false
|
||||
};
|
||||
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
handleClick () {
|
||||
this.setState({ // else tell the state that the button has been clicked
|
||||
wasClicked: true
|
||||
}, () => this.props.onClick(this.props.id)); // callback after state has been updated
|
||||
}
|
||||
render () {
|
||||
const {wasClicked} = this.state;
|
||||
return (<Component
|
||||
{...this.props}
|
||||
wasClicked={wasClicked}
|
||||
onClick={this.handleClick}
|
||||
/>);
|
||||
}
|
||||
}
|
||||
|
||||
WrappedComponent.propTypes = {
|
||||
id: PropTypes.number,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
return WrappedComponent;
|
||||
};
|
||||
|
||||
module.exports = AnimateHOC;
|
|
@ -89,10 +89,10 @@
|
|||
pointer-events: none; /* pass clicks through to buttons underneath */
|
||||
}
|
||||
|
||||
|
||||
.studio-selector-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transition: all .5s;
|
||||
margin: .21875rem .21875rem;
|
||||
border-radius: .5rem;
|
||||
background-color: $ui-white;
|
||||
|
@ -102,6 +102,7 @@
|
|||
height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
|
||||
}
|
||||
|
||||
.studio-selector-button-text {
|
||||
|
@ -164,30 +165,30 @@
|
|||
background-color: $ui-blue;
|
||||
}
|
||||
|
||||
.studio-status-icon-plus-img {
|
||||
.studio-status-icon-plus-img,
|
||||
.studio-status-icon-checkmark-img {
|
||||
animation-direction: normal;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.studio-status-icon--img {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
.studio-status-icon-with-animation {
|
||||
animation-name: bump;
|
||||
animation-duration: .25s;
|
||||
animation-timing-function: cubic-bezier(.3, -3, .6, 3);
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
.action-button-text .spinner-smooth {
|
||||
margin: .2125rem auto;
|
||||
width: 1.875rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.studio-status-icon .spinner-smooth {
|
||||
position: unset; /* don't understand why neither relative nor absolute work */
|
||||
}
|
||||
|
||||
.studio-status-icon .spinner-smooth .circle {
|
||||
/* overlay spinner on circle */
|
||||
position: absolute;
|
||||
margin: .1875rem; /* stay within boundaries of circle */
|
||||
width: 75%; /* stay within boundaries of circle */
|
||||
height: 75%; /* stay within boundaries of circle */
|
||||
@keyframes bump {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ const AddToStudioModalPresentation = ({
|
|||
includesProject={studio.includesProject}
|
||||
key={studio.id}
|
||||
title={studio.title}
|
||||
onToggleStudio={onToggleStudio}
|
||||
onClick={onToggleStudio}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@ -83,7 +83,7 @@ const AddToStudioModalPresentation = ({
|
|||
type="submit"
|
||||
>
|
||||
<div className="action-button-text">
|
||||
<Spinner mode="smooth" />
|
||||
<Spinner />
|
||||
<FormattedMessage id="addToStudio.finishing" />
|
||||
</div>
|
||||
</Button>
|
||||
|
|
|
@ -1,28 +1,36 @@
|
|||
const PropTypes = require('prop-types');
|
||||
const React = require('react');
|
||||
const classNames = require('classnames');
|
||||
|
||||
const Spinner = require('../../spinner/spinner.jsx');
|
||||
const AnimateHOC = require('./animate-hoc.jsx');
|
||||
|
||||
require('./modal.scss');
|
||||
|
||||
const StudioButton = ({
|
||||
hasRequestOutstanding,
|
||||
id,
|
||||
includesProject,
|
||||
title,
|
||||
onToggleStudio
|
||||
onClick,
|
||||
wasClicked
|
||||
}) => {
|
||||
const checkmark = (
|
||||
<img
|
||||
alt="checkmark-icon"
|
||||
className="studio-status-icon-checkmark-img"
|
||||
className={classNames(
|
||||
'studio-status-icon-checkmark-img',
|
||||
{'studio-status-icon-with-animation': wasClicked}
|
||||
)}
|
||||
src="/svgs/modal/confirm.svg"
|
||||
/>
|
||||
);
|
||||
const plus = (
|
||||
<img
|
||||
alt="plus-icon"
|
||||
className="studio-status-icon-plus-img"
|
||||
className={classNames(
|
||||
'studio-status-icon-plus-img',
|
||||
{'studio-status-icon-with-animation': wasClicked}
|
||||
)}
|
||||
src="/svgs/modal/add.svg"
|
||||
/>
|
||||
);
|
||||
|
@ -34,8 +42,7 @@ const StudioButton = ({
|
|||
{'studio-selector-button-selected':
|
||||
includesProject && !hasRequestOutstanding}
|
||||
)}
|
||||
data-id={id}
|
||||
onClick={onToggleStudio}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
|
@ -50,11 +57,11 @@ const StudioButton = ({
|
|||
<div
|
||||
className={classNames(
|
||||
'studio-status-icon',
|
||||
{'studio-status-icon-unselected': !includesProject}
|
||||
{'studio-status-icon-unselected': !includesProject && !hasRequestOutstanding}
|
||||
)}
|
||||
>
|
||||
{(hasRequestOutstanding ?
|
||||
(<Spinner mode="smooth" />) :
|
||||
<Spinner /> :
|
||||
(includesProject ? checkmark : plus))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -63,10 +70,10 @@ const StudioButton = ({
|
|||
|
||||
StudioButton.propTypes = {
|
||||
hasRequestOutstanding: PropTypes.bool,
|
||||
id: PropTypes.number,
|
||||
includesProject: PropTypes.bool,
|
||||
onToggleStudio: PropTypes.func,
|
||||
title: PropTypes.string
|
||||
onClick: PropTypes.func,
|
||||
title: PropTypes.string,
|
||||
wasClicked: PropTypes.bool
|
||||
};
|
||||
|
||||
module.exports = StudioButton;
|
||||
module.exports = AnimateHOC(StudioButton);
|
||||
|
|
|
@ -224,7 +224,7 @@ class ReportModal extends React.Component {
|
|||
>
|
||||
{isWaiting ? (
|
||||
<div className="action-button-text">
|
||||
<Spinner mode="smooth" />
|
||||
<Spinner />
|
||||
<FormattedMessage id="report.sending" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
@ -1,29 +1,28 @@
|
|||
const range = require('lodash.range');
|
||||
const PropTypes = require('prop-types');
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classNames = require('classnames');
|
||||
|
||||
require('./spinner.scss');
|
||||
|
||||
// Adapted from http://tobiasahlin.com/spinkit/
|
||||
const Spinner = ({
|
||||
mode
|
||||
}) => {
|
||||
const spinnerClassName = (mode === 'smooth' ? 'spinner-smooth' : 'spinner');
|
||||
const spinnerDivCount = (mode === 'smooth' ? 24 : 12);
|
||||
return (
|
||||
<div className={spinnerClassName}>
|
||||
{range(1, spinnerDivCount + 1).map(id => (
|
||||
<div
|
||||
className={`circle${id} circle`}
|
||||
key={`circle${id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
className,
|
||||
color
|
||||
}) => (
|
||||
<img
|
||||
alt="loading animation"
|
||||
className={classNames('studio-status-icon-spinner', className)}
|
||||
src={`/svgs/modal/spinner-${color}.svg`}
|
||||
/>
|
||||
);
|
||||
|
||||
Spinner.defaultProps = {
|
||||
color: 'white'
|
||||
};
|
||||
|
||||
Spinner.propTypes = {
|
||||
mode: PropTypes.string
|
||||
className: PropTypes.string,
|
||||
color: PropTypes.oneOf(['white', 'blue', 'transparent-gray'])
|
||||
};
|
||||
|
||||
module.exports = Spinner;
|
||||
|
|
|
@ -1,118 +1,44 @@
|
|||
@import "../../colors";
|
||||
|
||||
.spinner {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:before {
|
||||
display: block;
|
||||
animation: circleFadeDelay 1.2s infinite ease-in-out both;
|
||||
margin: 0 auto;
|
||||
border-radius: 100%;
|
||||
background-color: $ui-gray;
|
||||
width: 15%;
|
||||
height: 15%;
|
||||
content: "";
|
||||
|
||||
.white & {
|
||||
background-color: $ui-blue-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 12 {
|
||||
$rotation: 30deg * ($i - 1);
|
||||
$delay: -1.3s + $i * .1;
|
||||
|
||||
.circle#{$i} {
|
||||
transform: rotate($rotation);
|
||||
|
||||
&:before {
|
||||
animation-delay: $delay;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.studio-status-icon-spinner {
|
||||
/* This class can be used on an icon that should spin.
|
||||
It first plays the intro animation, then spins forever. */
|
||||
animation-name: intro, spin;
|
||||
animation-duration: .25s, .5s;
|
||||
animation-timing-function: cubic-bezier(.3, -3, .6, 3), linear;
|
||||
animation-delay: 0s, .25s;
|
||||
animation-iteration-count: 1, infinite;
|
||||
animation-direction: normal;
|
||||
width: 1.4rem; /* standard is 1.4 rem but can be overwritten by parent */
|
||||
height: 1.4rem;
|
||||
-webkit-animation-name: intro, spin;
|
||||
-webkit-animation-duration: .25s, .5s;
|
||||
-webkit-animation-iteration-count: 1, infinite;
|
||||
-webkit-animation-delay: 0s, .25s;
|
||||
-webkit-animation-timing-function: cubic-bezier(.3, -3, .6, 3), linear;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes circleFadeDelay {
|
||||
0%,
|
||||
39%,
|
||||
@keyframes intro {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*********************/
|
||||
/* type === "smooth" */
|
||||
/*********************/
|
||||
|
||||
.spinner-smooth {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:before {
|
||||
display: block;
|
||||
animation: circleFadeDelaySmooth 1.8s infinite ease-in-out both;
|
||||
margin: 0 auto;
|
||||
border-radius: 100%;
|
||||
background-color: $ui-white;
|
||||
width: 30%;
|
||||
height: 20%;
|
||||
content: "";
|
||||
|
||||
.white & {
|
||||
background-color: darken($ui-blue, 8%);
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
-webkit-transform: rotate(0);
|
||||
}
|
||||
|
||||
@for $i from 1 through 24 {
|
||||
$rotation: 15deg * ($i - 1);
|
||||
$delay: -1.9s + $i * .075;
|
||||
|
||||
.circle#{$i} {
|
||||
transform: rotate($rotation);
|
||||
|
||||
&:before {
|
||||
animation-delay: $delay;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes circleFadeDelaySmooth {
|
||||
0%,
|
||||
35% {
|
||||
opacity: 0;
|
||||
},
|
||||
40% {
|
||||
opacity: 1;
|
||||
100% {
|
||||
transform: rotate(359deg);
|
||||
-webkit-transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,8 +35,10 @@ const Components = () => (
|
|||
<Box title="Carousel component in a box!">
|
||||
<Carousel />
|
||||
</Box>
|
||||
<h1>This is a Spinner</h1>
|
||||
<Spinner />
|
||||
<h1>This is a blue Spinner</h1>
|
||||
<Spinner
|
||||
color="blue"
|
||||
/>
|
||||
<h1>Colors</h1>
|
||||
<div className="colors">
|
||||
<span className="ui-blue">$ui-blue</span>
|
||||
|
|
|
@ -208,8 +208,8 @@ class Preview extends React.Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
handleToggleStudio (event) {
|
||||
const studioId = parseInt(event.currentTarget.dataset.id, 10);
|
||||
handleToggleStudio (id) {
|
||||
const studioId = parseInt(id, 10);
|
||||
if (isNaN(studioId)) { // sanity check in case event had no integer data-id
|
||||
return;
|
||||
}
|
||||
|
|
10
static/svgs/modal/spinner-blue.svg
Normal file
10
static/svgs/modal/spinner-blue.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>spinner-blue</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="spinner-blue" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15,10 C15,7.23857625 12.7614237,5 10,5 C7.23857625,5 5,7.23857625 5,10 C5,12.7614237 7.23857625,15 10,15" id="Oval-2" stroke="#4D97FF" stroke-width="2.5"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 686 B |
10
static/svgs/modal/spinner-transparent-gray.svg
Normal file
10
static/svgs/modal/spinner-transparent-gray.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>spinner-transparent-gray</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="spinner-transparent-gray" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.15">
|
||||
<path d="M15,10 C15,7.23857625 12.7614237,5 10,5 C7.23857625,5 5,7.23857625 5,10 C5,12.7614237 7.23857625,15 10,15" id="Oval-2" stroke="#000000" stroke-width="2.5"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 732 B |
10
static/svgs/modal/spinner-white.svg
Normal file
10
static/svgs/modal/spinner-white.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>spinner-white</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="spinner-white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15,10 C15,7.23857625 12.7614237,5 10,5 C7.23857625,5 5,7.23857625 5,10 C5,12.7614237 7.23857625,15 10,15" id="Oval-2" stroke="#FFFFFF" stroke-width="2.5"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 688 B |
|
@ -1 +0,0 @@
|
|||
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M15 10a5 5 0 1 0-5 5" stroke="#FFF" stroke-width="2.5" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 213 B |
Loading…
Reference in a new issue