mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-26 16:52:06 +05:30
#305: scroll to meaningful content on multistep forms on devices with smalls screen resolution
This commit is contained in:
parent
3b726e4547
commit
44b9f2ba55
8
package-lock.json
generated
8
package-lock.json
generated
@ -8749,9 +8749,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"raf": {
|
"raf": {
|
||||||
"version": "3.3.2",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.0.tgz",
|
||||||
"integrity": "sha1-DBO+C1tJtG921maSSNUnzysC/ic=",
|
"integrity": "sha512-pDP/NMRAXoTfrhCfyfSEwJAKLaxBU9eApMeBPB1TkDouZmvPerIClV8lTAd+uF8ZiTaVl69e1FCxQrAd/VTjGw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"performance-now": "2.1.0"
|
"performance-now": "2.1.0"
|
||||||
},
|
},
|
||||||
@ -8924,7 +8924,7 @@
|
|||||||
"requires": {
|
"requires": {
|
||||||
"performance-now": "0.2.0",
|
"performance-now": "0.2.0",
|
||||||
"prop-types": "15.5.10",
|
"prop-types": "15.5.10",
|
||||||
"raf": "3.3.2"
|
"raf": "3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"react-proxy": {
|
"react-proxy": {
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
"intl-format-cache": "^2.0.4",
|
"intl-format-cache": "^2.0.4",
|
||||||
"intl-messageformat": "^2.1.0",
|
"intl-messageformat": "^2.1.0",
|
||||||
"promise.prototype.finally": "3.0.1",
|
"promise.prototype.finally": "3.0.1",
|
||||||
|
"raf": "^3.4.0",
|
||||||
"raven-js": "^3.8.1",
|
"raven-js": "^3.8.1",
|
||||||
"react": "^15.0.0",
|
"react": "^15.0.0",
|
||||||
"react-dom": "^15.0.0",
|
"react-dom": "^15.0.0",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
import { omit, rAF, debounce } from 'functions';
|
import { omit, debounce } from 'functions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MeasureHeight is a component that allows you to measure the height of elements wrapped.
|
* MeasureHeight is a component that allows you to measure the height of elements wrapped.
|
||||||
@ -61,6 +61,6 @@ export default class MeasureHeight extends PureComponent<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
measure = debounce(() => {
|
measure = debounce(() => {
|
||||||
rAF(() => this.el && this.props.onMeasure(this.el.offsetHeight));
|
requestAnimationFrame(() => this.el && this.props.onMeasure(this.el.offsetHeight));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { FormattedMessage as Message } from 'react-intl';
|
|||||||
import Helmet from 'react-helmet';
|
import Helmet from 'react-helmet';
|
||||||
|
|
||||||
import { ScrollMotion } from 'components/ui/motion';
|
import { ScrollMotion } from 'components/ui/motion';
|
||||||
|
import { ScrollIntoView } from 'components/ui/scroll';
|
||||||
import { Input, Button, Form, FormModel, FormError } from 'components/ui/form';
|
import { Input, Button, Form, FormModel, FormError } from 'components/ui/form';
|
||||||
import { BackButton } from 'components/profile/ProfileForm';
|
import { BackButton } from 'components/profile/ProfileForm';
|
||||||
import styles from 'components/profile/profileForm.scss';
|
import styles from 'components/profile/profileForm.scss';
|
||||||
@ -98,6 +99,8 @@ export default class ChangeEmail extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
|
{activeStep > 0 ? <ScrollIntoView /> : null}
|
||||||
|
|
||||||
{this.renderStepForms()}
|
{this.renderStepForms()}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@ -5,6 +5,7 @@ import { Button, FormModel } from 'components/ui/form';
|
|||||||
import styles from 'components/profile/profileForm.scss';
|
import styles from 'components/profile/profileForm.scss';
|
||||||
import Stepper from 'components/ui/stepper';
|
import Stepper from 'components/ui/stepper';
|
||||||
import { ScrollMotion } from 'components/ui/motion';
|
import { ScrollMotion } from 'components/ui/motion';
|
||||||
|
import { ScrollIntoView } from 'components/ui/scroll';
|
||||||
import logger from 'services/logger';
|
import logger from 'services/logger';
|
||||||
import mfa from 'services/api/mfa';
|
import mfa from 'services/api/mfa';
|
||||||
|
|
||||||
@ -81,6 +82,8 @@ export default class MfaEnable extends Component<Props, {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
|
{activeStep > 0 ? <ScrollIntoView /> : null}
|
||||||
|
|
||||||
{this.renderStepForms()}
|
{this.renderStepForms()}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||||||
import { FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
import styles from 'components/profile/profileForm.scss';
|
import styles from 'components/profile/profileForm.scss';
|
||||||
|
import { ScrollIntoView } from 'components/ui/scroll';
|
||||||
import icons from 'components/ui/icons.scss';
|
import icons from 'components/ui/icons.scss';
|
||||||
|
|
||||||
import messages from '../MultiFactorAuth.intl.json';
|
import messages from '../MultiFactorAuth.intl.json';
|
||||||
@ -13,6 +14,8 @@ export default function MfaStatus({onProceed}: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.formBody}>
|
<div className={styles.formBody}>
|
||||||
|
<ScrollIntoView />
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={mfaStyles.bigIcon}>
|
<div className={mfaStyles.bigIcon}>
|
||||||
<span className={icons.lock} />
|
<span className={icons.lock} />
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { rAF as requestAnimationFrame } from 'functions';
|
|
||||||
|
|
||||||
import Box from './Box';
|
import Box from './Box';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
35
src/components/ui/scroll/ScrollIntoView.js
Normal file
35
src/components/ui/scroll/ScrollIntoView.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { restoreScroll } from './scroll';
|
||||||
|
|
||||||
|
class ScrollIntoView extends React.Component<{
|
||||||
|
location: string,
|
||||||
|
top?: bool, // do not touch any DOM and simply scroll to top on location change
|
||||||
|
}> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.onPageUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.location !== prevProps.location) {
|
||||||
|
this.onPageUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageUpdate() {
|
||||||
|
if (this.props.top) {
|
||||||
|
restoreScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.props.top) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span ref={(el) => el && restoreScroll(el)} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(ScrollIntoView);
|
3
src/components/ui/scroll/index.js
Normal file
3
src/components/ui/scroll/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
|
||||||
|
export { default as ScrollIntoView } from './ScrollIntoView';
|
147
src/components/ui/scroll/scroll.js
Normal file
147
src/components/ui/scroll/scroll.js
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// @flow
|
||||||
|
/**
|
||||||
|
* Implements scroll to animation with momentum effect
|
||||||
|
*
|
||||||
|
* @see http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TIME_CONSTANT = 100; // higher numbers - slower animation
|
||||||
|
const SCROLL_ANCHOR_OFFSET = 80; // 50 + 30 (header height + some spacing)
|
||||||
|
// Первый скролл выполняется сразу после загрузки страницы, так что чтобы снизить
|
||||||
|
// нагрузку на рендеринг мы откладываем первый скрол на 200ms
|
||||||
|
let isFirstScroll = true;
|
||||||
|
let scrollJob = null;
|
||||||
|
|
||||||
|
export function scrollTo(y: number) {
|
||||||
|
if (scrollJob) {
|
||||||
|
// we already scrolling, so simply change the coordinates we are scrolling to
|
||||||
|
if (scrollJob.hasAmplitude) {
|
||||||
|
const delta = y - scrollJob.y;
|
||||||
|
scrollJob.amplitude += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollJob.y = y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let scrollWasTouched = false;
|
||||||
|
scrollJob = {
|
||||||
|
// NOTE: we may use some sort of debounce to wait till we catch all the
|
||||||
|
// scroll requests after app state changes, but the way with hasAmplitude
|
||||||
|
// seems to be more reliable
|
||||||
|
hasAmplitude: false,
|
||||||
|
start,
|
||||||
|
y,
|
||||||
|
amplitude: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => { // wrap in requestAnimationFrame to optimize initial reading of scrollTop
|
||||||
|
if (!scrollJob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = normalizeScrollPosition(scrollJob.y);
|
||||||
|
|
||||||
|
scrollJob.hasAmplitude = true;
|
||||||
|
scrollJob.y = y;
|
||||||
|
scrollJob.amplitude = y - getScrollTop();
|
||||||
|
|
||||||
|
(function animateScroll() {
|
||||||
|
if (!scrollJob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, y, amplitude } = scrollJob;
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
let delta = -amplitude * Math.exp(-elapsed / TIME_CONSTANT);
|
||||||
|
|
||||||
|
if (Math.abs(delta) > 0.5 && !scrollWasTouched) {
|
||||||
|
requestAnimationFrame(animateScroll);
|
||||||
|
} else {
|
||||||
|
// the last animation frame
|
||||||
|
delta = 0;
|
||||||
|
scrollJob = null;
|
||||||
|
document.removeEventListener('mousewheel', markScrollTouched);
|
||||||
|
document.removeEventListener('touchstart', markScrollTouched);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollWasTouched) {
|
||||||
|
// block any animation visualisation in case, when user touched scroll
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newScrollTop = y + delta;
|
||||||
|
window.scrollTo(0, newScrollTop);
|
||||||
|
}());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousewheel', markScrollTouched);
|
||||||
|
document.addEventListener('touchstart', markScrollTouched);
|
||||||
|
function markScrollTouched() {
|
||||||
|
scrollWasTouched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures, that `y` is the coordinate, that can be physically scrolled to
|
||||||
|
*
|
||||||
|
* @param {number} y
|
||||||
|
*
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
function normalizeScrollPosition(y: number): number {
|
||||||
|
const contentHeight = (document.documentElement
|
||||||
|
&& document.documentElement.scrollHeight) || 0;
|
||||||
|
const windowHeight: number = window.innerHeight;
|
||||||
|
const maxY = contentHeight - windowHeight;
|
||||||
|
|
||||||
|
return Math.min(y, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to page's top or #anchor link, if any
|
||||||
|
*
|
||||||
|
* @param {?HTMLElement} targetEl - the element to scroll to
|
||||||
|
*/
|
||||||
|
export function restoreScroll(targetEl: ?HTMLElement = null) {
|
||||||
|
const {hash} = location;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isFirstScroll = false;
|
||||||
|
const id = hash.replace('#', '');
|
||||||
|
const el = id ? document.getElementById(id) : targetEl;
|
||||||
|
const viewPort = document.body;
|
||||||
|
|
||||||
|
if (!viewPort) {
|
||||||
|
console.log('Can not find viewPort element'); // eslint-disable-line
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
const {top} = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTo(y);
|
||||||
|
}, isFirstScroll ? 200 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* http://stackoverflow.com/a/3464890/5184751
|
||||||
|
*
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
export function getScrollTop(): number {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
|
||||||
|
if (doc) {
|
||||||
|
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
105
src/functions.js
105
src/functions.js
@ -44,12 +44,6 @@ export function loadScript(src: string): Promise<*> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rAF = window.requestAnimationFrame
|
|
||||||
|| window.mozRequestAnimationFrame
|
|
||||||
|| window.webkitRequestAnimationFrame
|
|
||||||
|| window.msRequestAnimationFrame
|
|
||||||
|| ((cb: Function) => setTimeout(cb, 1000 / 60));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a function, that, as long as it continues to be invoked, will not
|
* Returns a function, that, as long as it continues to be invoked, will not
|
||||||
* be triggered. The function will be called after it stops being called for
|
* be triggered. The function will be called after it stops being called for
|
||||||
@ -85,102 +79,3 @@ export function getJwtPayload(jwt: string): Object {
|
|||||||
throw new Error('Can not decode jwt token');
|
throw new Error('Can not decode jwt token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* http://stackoverflow.com/a/3464890/5184751
|
|
||||||
*
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
export function getScrollTop(): number {
|
|
||||||
const doc = document.documentElement;
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implements scroll to animation with momentum effect
|
|
||||||
*
|
|
||||||
* @see http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TIME_CONSTANT = 100; // higher numbers - slower animation
|
|
||||||
export function scrollTo(y: number) {
|
|
||||||
const start = Date.now();
|
|
||||||
let scrollWasTouched = false;
|
|
||||||
rAF(() => { // wrap in rAF to optimize initial reading of scrollTop
|
|
||||||
if (!document.documentElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentHeight = document.documentElement.scrollHeight || 0;
|
|
||||||
const windowHeight: number = window.innerHeight;
|
|
||||||
if (contentHeight < y + windowHeight) {
|
|
||||||
y = contentHeight - windowHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amplitude = y - getScrollTop();
|
|
||||||
|
|
||||||
(function animateScroll() {
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
|
|
||||||
let delta = -amplitude * Math.exp(-elapsed / TIME_CONSTANT);
|
|
||||||
|
|
||||||
if (Math.abs(delta) > 0.5 && !scrollWasTouched) {
|
|
||||||
rAF(animateScroll);
|
|
||||||
} else {
|
|
||||||
delta = 0;
|
|
||||||
document.removeEventListener('mousewheel', markScrollTouched);
|
|
||||||
document.removeEventListener('touchstart', markScrollTouched);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollWasTouched) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newScrollTop = y + delta;
|
|
||||||
window.scrollTo(0, newScrollTop);
|
|
||||||
}());
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mousewheel', markScrollTouched);
|
|
||||||
document.addEventListener('touchstart', markScrollTouched);
|
|
||||||
function markScrollTouched() {
|
|
||||||
scrollWasTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCROLL_ANCHOR_OFFSET = 80; // 50 + 30 (header height + some spacing)
|
|
||||||
// Первый скролл выполняется сразу после загрузки страницы, так что чтобы снизить
|
|
||||||
// нагрузку на рендеринг мы откладываем первый скрол на 200ms
|
|
||||||
let isFirstScroll = true;
|
|
||||||
/**
|
|
||||||
* Scrolls to page's top or #anchor link, if any
|
|
||||||
*/
|
|
||||||
export function restoreScroll() {
|
|
||||||
const {hash} = location;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isFirstScroll = false;
|
|
||||||
const id = hash.replace('#', '');
|
|
||||||
const el = id ? document.getElementById(id) : null;
|
|
||||||
const viewPort = document.body;
|
|
||||||
|
|
||||||
if (!viewPort) {
|
|
||||||
console.log('Can not find viewPort element'); // eslint-disable-line
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let y = 0;
|
|
||||||
if (el) {
|
|
||||||
const {top} = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollTo(y);
|
|
||||||
}, isFirstScroll ? 200 : 0);
|
|
||||||
}
|
|
||||||
|
@ -13,7 +13,7 @@ import ProfilePage from 'pages/profile/ProfilePage';
|
|||||||
import RulesPage from 'pages/rules/RulesPage';
|
import RulesPage from 'pages/rules/RulesPage';
|
||||||
import PageNotFound from 'pages/404/PageNotFound';
|
import PageNotFound from 'pages/404/PageNotFound';
|
||||||
|
|
||||||
import { restoreScroll } from 'functions';
|
import { ScrollIntoView } from 'components/ui/scroll';
|
||||||
import PrivateRoute from 'containers/PrivateRoute';
|
import PrivateRoute from 'containers/PrivateRoute';
|
||||||
import AuthFlowRoute from 'containers/AuthFlowRoute';
|
import AuthFlowRoute from 'containers/AuthFlowRoute';
|
||||||
import Userbar from 'components/userbar/Userbar';
|
import Userbar from 'components/userbar/Userbar';
|
||||||
@ -42,7 +42,6 @@ class RootPage extends Component<{
|
|||||||
|
|
||||||
onPageUpdate() {
|
onPageUpdate() {
|
||||||
loader.hide();
|
loader.hide();
|
||||||
restoreScroll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -59,6 +58,9 @@ class RootPage extends Component<{
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<html lang={user.lang} />
|
<html lang={user.lang} />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
|
<ScrollIntoView top />
|
||||||
|
|
||||||
<div id="view-port" className={classNames(styles.viewPort, {
|
<div id="view-port" className={classNames(styles.viewPort, {
|
||||||
[styles.isPopupActive]: isPopupActive
|
[styles.isPopupActive]: isPopupActive
|
||||||
})}>
|
})}>
|
||||||
|
@ -2,7 +2,9 @@ import 'babel-polyfill';
|
|||||||
import 'url-search-params-polyfill';
|
import 'url-search-params-polyfill';
|
||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
import { shim as shimPromiseFinaly } from 'promise.prototype.finally';
|
import { shim as shimPromiseFinaly } from 'promise.prototype.finally';
|
||||||
|
import { polyfill as rafPolyfill } from 'raf';
|
||||||
|
|
||||||
|
rafPolyfill();
|
||||||
shimPromiseFinaly();
|
shimPromiseFinaly();
|
||||||
|
|
||||||
// allow :active styles in mobile Safary
|
// allow :active styles in mobile Safary
|
||||||
|
Loading…
Reference in New Issue
Block a user