0
0
Fork 0

Make dropdowns render into portal, expand animation (#5018)

* Make dropdowns render into portal, expand animation

* Improve actions modal style
This commit is contained in:
Eugen Rochko 2017-09-22 04:59:17 +02:00 committed by GitHub
parent 0df6442636
commit 034fab39ab
7 changed files with 303 additions and 263 deletions

View file

@ -1,53 +1,55 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import { Overlay } from 'react-overlays';
import { Motion, spring } from 'react-motion';
export default class DropdownMenu extends React.PureComponent {
class DropdownMenu extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired,
direction: PropTypes.string,
status: ImmutablePropTypes.map,
ariaLabel: PropTypes.string,
disabled: PropTypes.bool,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
};
static defaultProps = {
ariaLabel: 'Menu',
isModalOpen: false,
isUserTouching: () => false,
style: {},
placement: 'bottom',
};
state = {
direction: 'left',
expanded: false,
};
setRef = (c) => {
this.dropdown = c;
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleClick = (e) => {
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, false);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, false);
}
setRef = c => {
this.node = c;
}
handleClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
if (this.props.isModalOpen) {
this.props.onModalClose();
}
// Don't call e.preventDefault() when the item uses 'href' property.
// ex. "Edit profile" on the account action bar
this.props.onClose();
if (typeof action === 'function') {
e.preventDefault();
@ -56,46 +58,18 @@ export default class DropdownMenu extends React.PureComponent {
e.preventDefault();
this.context.router.history.push(to);
}
this.dropdown.hide();
}
handleShow = () => {
if (this.props.isUserTouching()) {
this.props.onModalOpen({
status: this.props.status,
actions: this.props.items,
onClick: this.handleClick,
});
} else {
this.setState({ expanded: true });
}
}
handleHide = () => this.setState({ expanded: false })
handleToggle = (e) => {
if (e.key === 'Enter') {
if (this.props.isUserTouching()) {
this.handleShow();
} else {
this.setState({ expanded: !this.state.expanded });
}
} else if (e.key === 'Escape') {
this.setState({ expanded: false });
}
}
renderItem = (item, i) => {
if (item === null) {
return <li key={`sep-${i}`} className='dropdown__sep' />;
renderItem (option, i) {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#' } = item;
const { text, href = '#' } = option;
return (
<li className='dropdown__content-list-item' key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
{text}
</a>
</li>
@ -103,43 +77,130 @@ export default class DropdownMenu extends React.PureComponent {
}
render () {
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
const { expanded } = this.state;
const isUserTouching = this.props.isUserTouching();
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
if (disabled) {
return (
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</div>
);
}
const dropdownItems = expanded && (
<ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
{items.map(this.renderItem)}
</ul>
);
// No need to render the actual dropdown if we use the modal. If we
// don't render anything <Dropdow /> breaks, so we just put an empty div.
const dropdownContent = !isUserTouching ? (
<DropdownContent className={directionClass} >
{dropdownItems}
</DropdownContent>
) : <div />;
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
return (
<Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
<DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</DropdownTrigger>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
{dropdownContent}
</Dropdown>
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
export default class Dropdown extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired,
ariaLabel: PropTypes.string,
disabled: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
};
static defaultProps = {
ariaLabel: 'Menu',
};
state = {
expanded: false,
};
handleClick = () => {
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
const { status, items } = this.props;
this.props.onModalOpen({
status,
actions: items,
onClick: this.handleItemClick,
});
return;
}
this.setState({ expanded: !this.state.expanded });
}
handleClose = () => {
if (this.props.onModalClose) {
this.props.onModalClose();
}
this.setState({ expanded: false });
}
handleKeyDown = e => {
switch(e.key) {
case 'Enter':
this.handleClick();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
this.handleClose();
if (typeof action === 'function') {
e.preventDefault();
action();
} else if (to) {
e.preventDefault();
this.context.router.history.push(to);
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render () {
const { icon, items, size, ariaLabel, disabled } = this.props;
const { expanded } = this.state;
return (
<div onKeyDown={this.handleKeyDown}>
<IconButton
icon={icon}
title={ariaLabel}
active={expanded}
disabled={disabled}
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
/>
<Overlay show={expanded} placement='bottom' target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} />
</Overlay>
</div>
);
}

View file

@ -1,32 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusContent from '../../../components/status_content';
import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';
import IconButton from '../../../components/icon_button';
import classNames from 'classnames';
export default class ActionsModal extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
actions: PropTypes.array,
onClick: PropTypes.func,
};
renderAction = (action, i) => {
if (action === null) {
return <li key={`sep-${i}`} className='dropdown__sep' />;
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { icon = null, text, meta = null, active = false, href = '#' } = action;
return (
<li key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
<div>
<div>{text}</div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>
</div>
</a>

View file

@ -52,7 +52,7 @@ export default class UI extends React.PureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
}
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
@ -183,14 +183,18 @@ export default class UI extends React.PureComponent {
document.removeEventListener('dragend', this.handleDragEnd);
}
setRef = (c) => {
setRef = c => {
this.node = c;
}
setColumnsAreaRef = (c) => {
setColumnsAreaRef = c => {
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
}
setOverlayRef = c => {
this.overlay = c;
}
render () {
const { width, draggingOver } = this.state;
const { children } = this.props;

View file

@ -213,6 +213,10 @@
}
}
.dropdown-menu {
position: absolute;
}
.dropdown--active .icon-button {
color: $ui-highlight-color;
}
@ -694,8 +698,8 @@
.status__action-bar-dropdown {
float: left;
height: 18px;
width: 18px;
height: 23.15px;
width: 23.15px;
}
.detailed-status__action-bar-dropdown {
@ -704,26 +708,6 @@
align-items: center;
justify-content: center;
position: relative;
.dropdown {
display: block;
width: 18px;
height: 18px;
}
.dropdown--active {
.dropdown__content.dropdown__left {
left: 20px;
right: initial;
}
&::after {
bottom: initial;
margin-left: 7px;
margin-top: -7px;
right: initial;
}
}
}
.detailed-status {
@ -1254,10 +1238,80 @@
position: absolute;
}
.dropdown__sep {
.dropdown-menu__separator {
border-bottom: 1px solid darken($ui-secondary-color, 8%);
margin: 5px 7px 6px;
padding-top: 1px;
height: 0;
}
.dropdown-menu {
background: $ui-secondary-color;
padding: 4px 0;
border-radius: 4px;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
ul {
list-style: none;
}
}
.dropdown-menu__arrow {
position: absolute;
width: 0;
height: 0;
border: 0 solid transparent;
&.left {
right: -5px;
margin-top: -5px;
border-width: 5px 0 5px 5px;
border-left-color: $ui-secondary-color;
}
&.top {
bottom: -5px;
margin-left: -13px;
border-width: 5px 5px 0;
border-top-color: $ui-secondary-color;
}
&.bottom {
top: -5px;
margin-left: -13px;
border-width: 0 5px 5px;
border-bottom-color: $ui-secondary-color;
}
&.right {
left: -5px;
margin-top: -5px;
border-width: 5px 5px 5px 0;
border-right-color: $ui-secondary-color;
}
}
.dropdown-menu__item {
a {
font-size: 13px;
line-height: 18px;
display: block;
padding: 4px 14px;
box-sizing: border-box;
text-decoration: none;
background: $ui-secondary-color;
color: $ui-base-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:focus,
&:hover,
&:active {
background: $ui-highlight-color;
color: $ui-secondary-color;
outline: 0;
}
}
}
.dropdown--active .dropdown__content {
@ -3472,6 +3526,10 @@ button.icon-button.active i.fa-retweet {
padding-top: 10px;
padding-bottom: 10px;
}
.dropdown-menu__separator {
border-bottom-color: $ui-secondary-color;
}
}
.boost-modal__container {
@ -3549,6 +3607,10 @@ button.icon-button.active i.fa-retweet {
max-height: 80vh;
max-width: 80vw;
.actions-modal__item-label {
font-weight: 500;
}
ul {
overflow-y: auto;
flex-shrink: 0;
@ -3561,11 +3623,20 @@ button.icon-button.active i.fa-retweet {
a {
color: $ui-base-color;
display: flex;
padding: 10px;
padding: 12px 16px;
font-size: 15px;
align-items: center;
text-decoration: none;
&.active {
&,
button {
transition: none;
}
&.active,
&:hover,
&:active,
&:focus {
&,
button {
background: $ui-highlight-color;