Make dropdowns render into portal, expand animation (#5018)
* Make dropdowns render into portal, expand animation * Improve actions modal style
This commit is contained in:
parent
0df6442636
commit
034fab39ab
|
@ -1,53 +1,55 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
|
||||||
import PropTypes from 'prop-types';
|
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 = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
isUserTouching: PropTypes.func,
|
|
||||||
isModalOpen: PropTypes.bool.isRequired,
|
|
||||||
onModalOpen: PropTypes.func,
|
|
||||||
onModalClose: PropTypes.func,
|
|
||||||
icon: PropTypes.string.isRequired,
|
|
||||||
items: PropTypes.array.isRequired,
|
items: PropTypes.array.isRequired,
|
||||||
size: PropTypes.number.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
direction: PropTypes.string,
|
style: PropTypes.object,
|
||||||
status: ImmutablePropTypes.map,
|
placement: PropTypes.string,
|
||||||
ariaLabel: PropTypes.string,
|
arrowOffsetLeft: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
arrowOffsetTop: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
ariaLabel: 'Menu',
|
style: {},
|
||||||
isModalOpen: false,
|
placement: 'bottom',
|
||||||
isUserTouching: () => false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
handleDocumentClick = e => {
|
||||||
direction: 'left',
|
if (this.node && !this.node.contains(e.target)) {
|
||||||
expanded: false,
|
this.props.onClose();
|
||||||
};
|
}
|
||||||
|
|
||||||
setRef = (c) => {
|
|
||||||
this.dropdown = c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 i = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
const { action, to } = this.props.items[i];
|
const { action, to } = this.props.items[i];
|
||||||
|
|
||||||
if (this.props.isModalOpen) {
|
this.props.onClose();
|
||||||
this.props.onModalClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't call e.preventDefault() when the item uses 'href' property.
|
|
||||||
// ex. "Edit profile" on the account action bar
|
|
||||||
|
|
||||||
if (typeof action === 'function') {
|
if (typeof action === 'function') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -56,46 +58,18 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.context.router.history.push(to);
|
this.context.router.history.push(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dropdown.hide();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShow = () => {
|
renderItem (option, i) {
|
||||||
if (this.props.isUserTouching()) {
|
if (option === null) {
|
||||||
this.props.onModalOpen({
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
status: this.props.status,
|
|
||||||
actions: this.props.items,
|
|
||||||
onClick: this.handleClick,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({ expanded: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHide = () => this.setState({ expanded: false })
|
const { text, href = '#' } = option;
|
||||||
|
|
||||||
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' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { text, href = '#' } = item;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className='dropdown__content-list-item' key={`${text}-${i}`}>
|
<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} className='dropdown__content-list-link'>
|
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -103,43 +77,130 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
|
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = 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 (
|
return (
|
||||||
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
|
<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 }) }}>
|
||||||
<i className={iconClassname} aria-hidden />
|
{({ opacity, scaleX, scaleY }) => (
|
||||||
</div>
|
<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 }} />
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownItems = expanded && (
|
<ul>
|
||||||
<ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
|
{items.map((option, i) => this.renderItem(option, i))}
|
||||||
{items.map(this.renderItem)}
|
|
||||||
</ul>
|
</ul>
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
// No need to render the actual dropdown if we use the modal. If we
|
</Motion>
|
||||||
// don't render anything <Dropdow /> breaks, so we just put an empty div.
|
);
|
||||||
const dropdownContent = !isUserTouching ? (
|
}
|
||||||
<DropdownContent className={directionClass} >
|
|
||||||
{dropdownItems}
|
}
|
||||||
</DropdownContent>
|
|
||||||
) : <div />;
|
export default class Dropdown extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
static contextTypes = {
|
||||||
<Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
|
router: PropTypes.object,
|
||||||
<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>
|
static propTypes = {
|
||||||
|
icon: PropTypes.string.isRequired,
|
||||||
{dropdownContent}
|
items: PropTypes.array.isRequired,
|
||||||
</Dropdown>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,32 +1,35 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import Avatar from '../../../components/avatar';
|
import Avatar from '../../../components/avatar';
|
||||||
import RelativeTimestamp from '../../../components/relative_timestamp';
|
import RelativeTimestamp from '../../../components/relative_timestamp';
|
||||||
import DisplayName from '../../../components/display_name';
|
import DisplayName from '../../../components/display_name';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
export default class ActionsModal extends ImmutablePureComponent {
|
export default class ActionsModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
actions: PropTypes.array,
|
actions: PropTypes.array,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
renderAction = (action, i) => {
|
renderAction = (action, i) => {
|
||||||
if (action === null) {
|
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;
|
const { icon = null, text, meta = null, active = false, href = '#' } = action;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={`${text}-${i}`}>
|
<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' />}
|
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
|
||||||
<div>
|
<div>
|
||||||
<div>{text}</div>
|
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||||
<div>{meta}</div>
|
<div>{meta}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -52,7 +52,7 @@ export default class UI extends React.PureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
router: PropTypes.object.isRequired,
|
router: PropTypes.object.isRequired,
|
||||||
}
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
@ -183,14 +183,18 @@ export default class UI extends React.PureComponent {
|
||||||
document.removeEventListener('dragend', this.handleDragEnd);
|
document.removeEventListener('dragend', this.handleDragEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = (c) => {
|
setRef = c => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
setColumnsAreaRef = (c) => {
|
setColumnsAreaRef = c => {
|
||||||
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOverlayRef = c => {
|
||||||
|
this.overlay = c;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { width, draggingOver } = this.state;
|
const { width, draggingOver } = this.state;
|
||||||
const { children } = this.props;
|
const { children } = this.props;
|
||||||
|
|
|
@ -213,6 +213,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown--active .icon-button {
|
.dropdown--active .icon-button {
|
||||||
color: $ui-highlight-color;
|
color: $ui-highlight-color;
|
||||||
}
|
}
|
||||||
|
@ -694,8 +698,8 @@
|
||||||
|
|
||||||
.status__action-bar-dropdown {
|
.status__action-bar-dropdown {
|
||||||
float: left;
|
float: left;
|
||||||
height: 18px;
|
height: 23.15px;
|
||||||
width: 18px;
|
width: 23.15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-status__action-bar-dropdown {
|
.detailed-status__action-bar-dropdown {
|
||||||
|
@ -704,26 +708,6 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
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 {
|
.detailed-status {
|
||||||
|
@ -1254,10 +1238,80 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown__sep {
|
.dropdown-menu__separator {
|
||||||
border-bottom: 1px solid darken($ui-secondary-color, 8%);
|
border-bottom: 1px solid darken($ui-secondary-color, 8%);
|
||||||
margin: 5px 7px 6px;
|
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 {
|
.dropdown--active .dropdown__content {
|
||||||
|
@ -3472,6 +3526,10 @@ button.icon-button.active i.fa-retweet {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu__separator {
|
||||||
|
border-bottom-color: $ui-secondary-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.boost-modal__container {
|
.boost-modal__container {
|
||||||
|
@ -3549,6 +3607,10 @@ button.icon-button.active i.fa-retweet {
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
max-width: 80vw;
|
max-width: 80vw;
|
||||||
|
|
||||||
|
.actions-modal__item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -3561,11 +3623,20 @@ button.icon-button.active i.fa-retweet {
|
||||||
a {
|
a {
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 10px;
|
padding: 12px 16px;
|
||||||
|
font-size: 15px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&.active {
|
&,
|
||||||
|
button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
&,
|
&,
|
||||||
button {
|
button {
|
||||||
background: $ui-highlight-color;
|
background: $ui-highlight-color;
|
||||||
|
|
|
@ -88,6 +88,7 @@
|
||||||
"react-intl": "^2.4.0",
|
"react-intl": "^2.4.0",
|
||||||
"react-motion": "^0.5.0",
|
"react-motion": "^0.5.0",
|
||||||
"react-notification": "^6.7.1",
|
"react-notification": "^6.7.1",
|
||||||
|
"react-overlays": "^0.8.1",
|
||||||
"react-redux": "^5.0.4",
|
"react-redux": "^5.0.4",
|
||||||
"react-redux-loading-bar": "^2.9.2",
|
"react-redux-loading-bar": "^2.9.2",
|
||||||
"react-router-dom": "^4.1.1",
|
"react-router-dom": "^4.1.1",
|
||||||
|
|
|
@ -1,132 +0,0 @@
|
||||||
import { expect } from 'chai';
|
|
||||||
import { shallow, mount } from 'enzyme';
|
|
||||||
import sinon from 'sinon';
|
|
||||||
import React from 'react';
|
|
||||||
import DropdownMenu from '../../../app/javascript/mastodon/components/dropdown_menu';
|
|
||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
|
||||||
|
|
||||||
const isTrue = () => true;
|
|
||||||
|
|
||||||
describe('<DropdownMenu />', () => {
|
|
||||||
const icon = 'my-icon';
|
|
||||||
const size = 123;
|
|
||||||
let items;
|
|
||||||
let wrapper;
|
|
||||||
let action;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
action = sinon.spy();
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{ text: 'first item', action: action, href: '/some/url' },
|
|
||||||
{ text: 'second item', action: 'noop' },
|
|
||||||
];
|
|
||||||
wrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('contains one <Dropdown />', () => {
|
|
||||||
expect(wrapper).to.have.exactly(1).descendants(Dropdown);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('contains one <DropdownTrigger />', () => {
|
|
||||||
expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownTrigger);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('contains one <DropdownContent />', () => {
|
|
||||||
expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not contain a <DropdownContent /> if isUserTouching', () => {
|
|
||||||
const touchingWrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} isUserTouching={isTrue} />);
|
|
||||||
expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not contain a <DropdownContent /> if isUserTouching', () => {
|
|
||||||
const touchingWrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} isUserTouching={isTrue} />);
|
|
||||||
expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses props.size for <DropdownTrigger /> style values', () => {
|
|
||||||
['font-size', 'width', 'line-height'].map((property) => {
|
|
||||||
expect(wrapper.find(DropdownTrigger)).to.have.style(property, `${size}px`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses props.icon as icon class name', () => {
|
|
||||||
expect(wrapper.find(DropdownTrigger).find('i')).to.have.className(`fa-${icon}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is not expanded by default', () => {
|
|
||||||
expect(wrapper.state('expanded')).to.be.equal(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render the list elements if not expanded', () => {
|
|
||||||
const lis = wrapper.find(DropdownContent).find('li');
|
|
||||||
expect(lis.length).to.be.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets expanded to true when clicking the trigger', () => {
|
|
||||||
const wrapper = mount(<DropdownMenu icon={icon} items={items} size={size} />);
|
|
||||||
wrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
expect(wrapper.state('expanded')).to.be.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onModalOpen when clicking the trigger if isUserTouching', () => {
|
|
||||||
const onModalOpen = sinon.spy();
|
|
||||||
const touchingWrapper = mount(<DropdownMenu icon={icon} items={items} status={3.14} size={size} onModalOpen={onModalOpen} isUserTouching={isTrue} />);
|
|
||||||
touchingWrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
expect(onModalOpen.calledOnce).to.be.equal(true);
|
|
||||||
expect(onModalOpen.args[0][0]).to.be.deep.equal({ status: 3.14, actions: items, onClick: touchingWrapper.node.handleClick });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onModalClose when clicking an action if isUserTouching and isModalOpen', () => {
|
|
||||||
const onModalOpen = sinon.spy();
|
|
||||||
const onModalClose = sinon.spy();
|
|
||||||
const touchingWrapper = mount(<DropdownMenu icon={icon} items={items} status={3.14} size={size} isModalOpen onModalOpen={onModalOpen} onModalClose={onModalClose} isUserTouching={isTrue} />);
|
|
||||||
touchingWrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
touchingWrapper.node.handleClick({ currentTarget: { getAttribute: () => '0' }, preventDefault: () => null });
|
|
||||||
expect(onModalClose.calledOnce).to.be.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error: ReactWrapper::state() can only be called on the root
|
|
||||||
/*it('sets expanded to false when clicking outside', () => {
|
|
||||||
const wrapper = mount((
|
|
||||||
<div>
|
|
||||||
<DropdownMenu icon={icon} items={items} size={size} />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
wrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
expect(wrapper.find(DropdownMenu).first().state('expanded')).to.be.equal(true);
|
|
||||||
|
|
||||||
wrapper.find('span').first().simulate('click');
|
|
||||||
expect(wrapper.find(DropdownMenu).first().state('expanded')).to.be.equal(false);
|
|
||||||
})*/
|
|
||||||
|
|
||||||
it('renders list elements for each props.items if expanded', () => {
|
|
||||||
const wrapper = mount(<DropdownMenu icon={icon} items={items} size={size} />);
|
|
||||||
wrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
const lis = wrapper.find(DropdownContent).find('li');
|
|
||||||
expect(lis.length).to.be.equal(items.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the href passed in via props.items', () => {
|
|
||||||
wrapper
|
|
||||||
.find(DropdownContent).find('li a')
|
|
||||||
.forEach((a, i) => expect(a).to.have.attr('href', items[i].href));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the text passed in via props.items', () => {
|
|
||||||
wrapper
|
|
||||||
.find(DropdownContent).find('li a')
|
|
||||||
.forEach((a, i) => expect(a).to.have.text(items[i].text));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the action passed in via props.items as click handler', () => {
|
|
||||||
const wrapper = mount(<DropdownMenu icon={icon} items={items} size={size} />);
|
|
||||||
wrapper.find(DropdownTrigger).first().simulate('click');
|
|
||||||
wrapper.find(DropdownContent).find('li a').first().simulate('click');
|
|
||||||
expect(action.calledOnce).to.equal(true);
|
|
||||||
});
|
|
||||||
});
|
|
34
yarn.lock
34
yarn.lock
|
@ -1234,6 +1234,10 @@ chai@^4.1.0:
|
||||||
pathval "^1.0.0"
|
pathval "^1.0.0"
|
||||||
type-detect "^4.0.0"
|
type-detect "^4.0.0"
|
||||||
|
|
||||||
|
chain-function@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
|
||||||
|
|
||||||
chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
|
chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
|
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
|
||||||
|
@ -1972,7 +1976,7 @@ doctrine@^2.0.0:
|
||||||
esutils "^2.0.2"
|
esutils "^2.0.2"
|
||||||
isarray "^1.0.0"
|
isarray "^1.0.0"
|
||||||
|
|
||||||
"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.0.0, dom-helpers@^3.2.1:
|
"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.0.0, dom-helpers@^3.2.0, dom-helpers@^3.2.1:
|
||||||
version "3.2.1"
|
version "3.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
|
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
|
||||||
|
|
||||||
|
@ -5131,6 +5135,12 @@ promise@^7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
asap "~2.0.3"
|
asap "~2.0.3"
|
||||||
|
|
||||||
|
prop-types-extra@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.0.1.tgz#a57bd4810e82d27a3ff4317ecc1b4ad005f79a82"
|
||||||
|
dependencies:
|
||||||
|
warning "^3.0.0"
|
||||||
|
|
||||||
prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8:
|
prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8:
|
||||||
version "15.5.10"
|
version "15.5.10"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
|
||||||
|
@ -5329,6 +5339,17 @@ react-notification@^6.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prop-types "^15.5.10"
|
prop-types "^15.5.10"
|
||||||
|
|
||||||
|
react-overlays@^0.8.1:
|
||||||
|
version "0.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.1.tgz#26e480003c2fd6f581a4a66c0c86cb3dff17e626"
|
||||||
|
dependencies:
|
||||||
|
classnames "^2.2.5"
|
||||||
|
dom-helpers "^3.2.1"
|
||||||
|
prop-types "^15.5.10"
|
||||||
|
prop-types-extra "^1.0.1"
|
||||||
|
react-transition-group "^2.0.0-beta.0"
|
||||||
|
warning "^3.0.0"
|
||||||
|
|
||||||
react-redux-loading-bar@^2.9.2:
|
react-redux-loading-bar@^2.9.2:
|
||||||
version "2.9.2"
|
version "2.9.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-2.9.2.tgz#f0e604ee35af5ecb25addb10bf24ca3d478c95a8"
|
resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-2.9.2.tgz#f0e604ee35af5ecb25addb10bf24ca3d478c95a8"
|
||||||
|
@ -5430,6 +5451,17 @@ react-toggle@^4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.2.5"
|
classnames "^2.2.5"
|
||||||
|
|
||||||
|
react-transition-group@^2.0.0-beta.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.0.tgz#793bf8cb15bfe91b3101b24bce1c1d2891659575"
|
||||||
|
dependencies:
|
||||||
|
chain-function "^1.0.0"
|
||||||
|
classnames "^2.2.5"
|
||||||
|
dom-helpers "^3.2.0"
|
||||||
|
loose-envify "^1.3.1"
|
||||||
|
prop-types "^15.5.8"
|
||||||
|
warning "^3.0.0"
|
||||||
|
|
||||||
react-virtualized@^9.7.4:
|
react-virtualized@^9.7.4:
|
||||||
version "9.9.0"
|
version "9.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e"
|
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e"
|
||||||
|
|
Reference in a new issue