commit: 50d38d7605b8998463b1428b8da886f33e0714da
parent: aa803153e2161f4462d9d26ecd021fe0d2396cc5
Author: Sorin Davidoi <sorin.davidoi@gmail.com>
Date: Thu, 27 Jul 2017 22:31:59 +0200
fix(dropdown_menu): Open as modal on mobile (#4295)
* fix(dropdown_menu): Open as modal on mobile
* fix(dropdown_menu): Open modal on touch
* fix(dropdown_menu): Show status
* fix(dropdown_menu): Max dimensions and reduce padding
* chore(dropdown_menu): Test new functionality
* refactor: Use DropdownMenuContainer instead of DropdownMenu
* feat(privacy_dropdown): Open as modal on touch devices
* feat(modal_root): Do not load actions-modal async
Diffstat:
12 files changed, 277 insertions(+), 41 deletions(-)
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
@@ -1,4 +1,5 @@
import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types';
@@ -9,16 +10,23 @@ export default class DropdownMenu extends React.PureComponent {
};
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,
};
static defaultProps = {
ariaLabel: 'Menu',
+ isModalOpen: false,
+ isUserTouching: () => false,
};
state = {
@@ -34,6 +42,10 @@ export default class DropdownMenu extends React.PureComponent {
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
@@ -48,7 +60,17 @@ export default class DropdownMenu extends React.PureComponent {
this.dropdown.hide();
}
- handleShow = () => this.setState({ expanded: true })
+ 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 })
@@ -71,6 +93,7 @@ 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`;
@@ -89,15 +112,21 @@ export default class DropdownMenu extends React.PureComponent {
</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 />;
+
return (
- <Dropdown ref={this.setRef} onShow={this.handleShow} onHide={this.handleHide}>
+ <Dropdown ref={this.setRef} active={isUserTouching ? false : undefined} onShow={this.handleShow} onHide={this.handleHide}>
<DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</DropdownTrigger>
- <DropdownContent className={directionClass}>
- {dropdownItems}
- </DropdownContent>
+ {dropdownContent}
</Dropdown>
);
}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
-import DropdownMenu from './dropdown_menu';
+import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -156,7 +156,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
{shareButton}
<div className='status__action-bar-dropdown'>
- <DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+ <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
</div>
</div>
);
diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js
@@ -0,0 +1,16 @@
+import { openModal, closeModal } from '../actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from '../components/dropdown_menu';
+import { isUserTouching } from '../is_mobile';
+
+const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
+});
+
+const mapDispatchToProps = dispatch => ({
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -1,7 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
-import DropdownMenu from '../../../components/dropdown_menu';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import Link from 'react-router-dom/Link';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
@@ -96,7 +96,7 @@ export default class ActionBar extends React.PureComponent {
<div className='account__action-bar'>
<div className='account__action-bar-dropdown'>
- <DropdownMenu items={menu} icon='bars' size={24} direction='right' />
+ <DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' />
</div>
<div className='account__action-bar-links'>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -24,6 +24,10 @@ const iconStyle = {
export default class PrivacyDropdown extends React.PureComponent {
static propTypes = {
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@@ -34,7 +38,25 @@ export default class PrivacyDropdown extends React.PureComponent {
};
handleToggle = () => {
- this.setState({ open: !this.state.open });
+ if (this.props.isUserTouching()) {
+ if (this.state.open) {
+ this.props.onModalClose();
+ } else {
+ this.props.onModalOpen({
+ actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+ onClick: this.handleModalActionClick,
+ });
+ }
+ } else {
+ this.setState({ open: !this.state.open });
+ }
+ }
+
+ handleModalActionClick = (e) => {
+ e.preventDefault();
+ const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+ this.props.onModalClose();
+ this.props.onChange(value);
}
handleClick = (e) => {
@@ -50,6 +72,17 @@ export default class PrivacyDropdown extends React.PureComponent {
}
}
+ componentWillMount () {
+ const { intl: { formatMessage } } = this.props;
+
+ this.options = [
+ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+ { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+ { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+ ];
+ }
+
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
@@ -68,25 +101,18 @@ export default class PrivacyDropdown extends React.PureComponent {
const { value, intl } = this.props;
const { open } = this.state;
- const options = [
- { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
- { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
- { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
- { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) },
- ];
-
- const valueOption = options.find(item => item.value === value);
+ const valueOption = this.options.find(item => item.value === value);
return (
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
<div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
<div className='privacy-dropdown__dropdown'>
- {open && options.map(item =>
+ {open && this.options.map(item =>
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
<div className='privacy-dropdown__option__content'>
- <strong>{item.shortText}</strong>
- {item.longText}
+ <strong>{item.text}</strong>
+ {item.meta}
</div>
</div>
)}
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -1,8 +1,11 @@
import { connect } from 'react-redux';
import PrivacyDropdown from '../components/privacy_dropdown';
import { changeComposeVisibility } from '../../../actions/compose';
+import { openModal, closeModal } from '../../../actions/modal';
+import { isUserTouching } from '../../../is_mobile';
const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'privacy']),
});
@@ -12,6 +15,10 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeComposeVisibility(value));
},
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import DropdownMenu from '../../../components/dropdown_menu';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
@@ -84,7 +84,7 @@ export default class ActionBar extends React.PureComponent {
<div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div className='detailed-status__action-bar-dropdown'>
- <DropdownMenu size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
+ <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
</div>
</div>
);
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+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';
+
+export default class ReportModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ actions: PropTypes.array,
+ onClick: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ renderAction = (action, i) => {
+ if (action === null) {
+ return <li key={`sep-${i}`} className='dropdown__sep' />;
+ }
+
+ 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'}>
+ {icon && <IconButton title={text} icon={icon} />}
+ <div>
+ <div>{text}</div>
+ <div>{meta}</div>
+ </div>
+ </a>
+ </li>
+ );
+ }
+
+ render () {
+ const status = this.props.status && (
+ <div className='status light'>
+ <div className='boost-modal__status-header'>
+ <div className='boost-modal__status-time'>
+ <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+ <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+ </a>
+ </div>
+
+ <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
+ <div className='status__avatar'>
+ <Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} />
+ </div>
+
+ <DisplayName account={this.props.status.get('account')} />
+ </a>
+ </div>
+
+ <StatusContent status={this.props.status} />
+ </div>
+ );
+
+ return (
+ <div className='modal-root__modal actions-modal'>
+ {status}
+
+ <ul>
+ {this.props.actions.map(this.renderAction)}
+ </ul>
+ </div>
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -5,6 +5,7 @@ import spring from 'react-motion/lib/spring';
import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error';
import ModalLoading from './modal_loading';
+import ActionsModal from '../components/actions_modal';
import {
MediaModal,
OnboardingModal,
@@ -21,6 +22,7 @@ const MODAL_COMPONENTS = {
'BOOST': BoostModal,
'CONFIRM': ConfirmationModal,
'REPORT': ReportModal,
+ 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
};
export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
@@ -5,6 +5,15 @@ export function isMobile(width) {
};
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+let userTouching = false;
+
+window.addEventListener('touchstart', () => {
+ userTouching = true;
+}, { once: true });
+
+export function isUserTouching() {
+ return userTouching;
+}
export function isIOS() {
return iOS;
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
@@ -214,16 +214,18 @@
}
.dropdown--active::after {
- content: "";
- display: block;
- position: absolute;
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 0 4.5px 7.8px;
- border-color: transparent transparent $ui-secondary-color;
- bottom: 8px;
- right: 104px;
+ @media screen and (min-width: 1025px) {
+ content: "";
+ display: block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 4.5px 7.8px;
+ border-color: transparent transparent $ui-secondary-color;
+ bottom: 8px;
+ right: 104px;
+ }
}
.invisible {
@@ -3402,7 +3404,8 @@ button.icon-button.active i.fa-retweet {
.boost-modal,
.confirmation-modal,
-.report-modal {
+.report-modal,
+.actions-modal {
background: lighten($ui-secondary-color, 8%);
color: $ui-base-color;
border-radius: 8px;
@@ -3493,6 +3496,43 @@ button.icon-button.active i.fa-retweet {
}
}
+.actions-modal {
+ .status {
+ overflow-y: auto;
+ max-height: 300px;
+ }
+
+ max-height: 80vh;
+ max-width: 80vw;
+
+ ul {
+ overflow-y: auto;
+ flex-shrink: 0;
+
+ li:not(:empty) {
+ a {
+ color: $ui-base-color;
+ display: flex;
+ padding: 10px;
+ align-items: center;
+ text-decoration: none;
+
+ &.active {
+ &,
+ button {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ }
+ }
+
+ button:first-child {
+ margin-right: 10px;
+ }
+ }
+ }
+ }
+}
+
.confirmation-modal__action-bar {
.confirmation-modal__cancel-button {
background-color: transparent;
diff --git a/spec/javascript/components/dropdown_menu.test.js b/spec/javascript/components/dropdown_menu.test.js
@@ -5,16 +5,24 @@ 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;
- const action = sinon.spy();
-
- const items = [
- { text: 'first item', action: action, href: '/some/url' },
- { text: 'second item', action: 'noop' },
- ];
- const wrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} />);
+ 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);
@@ -28,6 +36,16 @@ describe('<DropdownMenu />', () => {
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`);
@@ -53,6 +71,23 @@ describe('<DropdownMenu />', () => {
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((