* 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 asyncmaster
@@ -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> | |||
); | |||
} | |||
@@ -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> | |||
); | |||
@@ -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); |
@@ -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'> | |||
@@ -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> | |||
)} | |||
@@ -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); |
@@ -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> | |||
); | |||
@@ -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> | |||
); | |||
} | |||
} |
@@ -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 { | |||
@@ -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; | |||
@@ -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; | |||
@@ -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(( | |||