diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 28631f4638..1cfa7b5a2d 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -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
;
+ renderItem (option, i) {
+ if (option === null) {
+ return ;
}
- const { text, href = '#' } = item;
+ const { text, href = '#' } = option;
return (
-
-
+
+
{text}
@@ -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 (
-
-
-
- );
- }
-
- const dropdownItems = expanded && (
-
- {items.map(this.renderItem)}
-
- );
-
- // No need to render the actual dropdown if we use the modal. If we
- // don't render anything breaks, so we just put an empty div.
- const dropdownContent = !isUserTouching ? (
-
- {dropdownItems}
-
- ) : ;
+ const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
return (
-
-
-
-
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
- {dropdownContent}
-
+
+ {items.map((option, i) => this.renderItem(option, i))}
+
+
+ )}
+
+ );
+ }
+
+}
+
+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 (
+
+
+
+
+
+
+
);
}
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
index 3d40033be1..79a5a20ef6 100644
--- a/app/javascript/mastodon/features/ui/components/actions_modal.js
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -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 ;
+ return ;
}
const { icon = null, text, meta = null, active = false, href = '#' } = action;
return (
-
+
{icon && }
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 30a52a4482..2a55cfb4c4 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -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;
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index a5d104cc98..e0a310b6ca 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -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;
diff --git a/package.json b/package.json
index 228dd1f257..8894835cdb 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,7 @@
"react-intl": "^2.4.0",
"react-motion": "^0.5.0",
"react-notification": "^6.7.1",
+ "react-overlays": "^0.8.1",
"react-redux": "^5.0.4",
"react-redux-loading-bar": "^2.9.2",
"react-router-dom": "^4.1.1",
diff --git a/spec/javascript/components/dropdown_menu.test.js b/spec/javascript/components/dropdown_menu.test.js
deleted file mode 100644
index a5af730efe..0000000000
--- a/spec/javascript/components/dropdown_menu.test.js
+++ /dev/null
@@ -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('', () => {
- 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();
- });
-
- it('contains one ', () => {
- expect(wrapper).to.have.exactly(1).descendants(Dropdown);
- });
-
- it('contains one ', () => {
- expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownTrigger);
- });
-
- it('contains one ', () => {
- expect(wrapper.find(Dropdown)).to.have.exactly(1).descendants(DropdownContent);
- });
-
- it('does not contain a if isUserTouching', () => {
- const touchingWrapper = shallow();
- expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent);
- });
-
- it('does not contain a if isUserTouching', () => {
- const touchingWrapper = shallow();
- expect(touchingWrapper.find(Dropdown)).to.have.exactly(0).descendants(DropdownContent);
- });
-
- it('uses props.size for 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();
- 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();
- 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();
- 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((
-
-
-
-
- ));
-
- 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();
- 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();
- wrapper.find(DropdownTrigger).first().simulate('click');
- wrapper.find(DropdownContent).find('li a').first().simulate('click');
- expect(action.calledOnce).to.equal(true);
- });
-});
diff --git a/yarn.lock b/yarn.lock
index c1c27a615c..1abf6a3264 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1234,6 +1234,10 @@ chai@^4.1.0:
pathval "^1.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:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -1972,7 +1976,7 @@ doctrine@^2.0.0:
esutils "^2.0.2"
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"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
@@ -5131,6 +5135,12 @@ promise@^7.1.1:
dependencies:
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:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
@@ -5329,6 +5339,17 @@ react-notification@^6.7.1:
dependencies:
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:
version "2.9.2"
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:
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:
version "9.9.0"
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e"