forked from treehouse/mastodon
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 asyncsignup-info-prompt
parent
aa803153e2
commit
50d38d7605
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
@ -9,16 +10,23 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
isUserTouching: PropTypes.func,
|
||||||
|
isModalOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalOpen: PropTypes.func,
|
||||||
|
onModalClose: PropTypes.func,
|
||||||
icon: PropTypes.string.isRequired,
|
icon: PropTypes.string.isRequired,
|
||||||
items: PropTypes.array.isRequired,
|
items: PropTypes.array.isRequired,
|
||||||
size: PropTypes.number.isRequired,
|
size: PropTypes.number.isRequired,
|
||||||
direction: PropTypes.string,
|
direction: PropTypes.string,
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
ariaLabel: PropTypes.string,
|
ariaLabel: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
ariaLabel: 'Menu',
|
ariaLabel: 'Menu',
|
||||||
|
isModalOpen: false,
|
||||||
|
isUserTouching: () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -34,6 +42,10 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
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.onModalClose();
|
||||||
|
}
|
||||||
|
|
||||||
// Don't call e.preventDefault() when the item uses 'href' property.
|
// Don't call e.preventDefault() when the item uses 'href' property.
|
||||||
// ex. "Edit profile" on the account action bar
|
// ex. "Edit profile" on the account action bar
|
||||||
|
|
||||||
|
@ -48,7 +60,17 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
this.dropdown.hide();
|
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 })
|
handleHide = () => this.setState({ expanded: false })
|
||||||
|
|
||||||
|
@ -71,6 +93,7 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
|
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
|
||||||
const { expanded } = this.state;
|
const { expanded } = this.state;
|
||||||
|
const isUserTouching = this.props.isUserTouching();
|
||||||
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
|
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
|
||||||
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
|
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
|
||||||
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
|
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
|
||||||
|
@ -89,15 +112,21 @@ export default class DropdownMenu extends React.PureComponent {
|
||||||
</ul>
|
</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 (
|
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}>
|
<DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}>
|
||||||
<i className={iconClassname} aria-hidden />
|
<i className={iconClassname} aria-hidden />
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
|
|
||||||
<DropdownContent className={directionClass}>
|
{dropdownContent}
|
||||||
{dropdownItems}
|
|
||||||
</DropdownContent>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import IconButton from './icon_button';
|
import IconButton from './icon_button';
|
||||||
import DropdownMenu from './dropdown_menu';
|
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
@ -156,7 +156,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
||||||
{shareButton}
|
{shareButton}
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
<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>
|
||||||
</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 React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
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 Link from 'react-router-dom/Link';
|
||||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
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'>
|
||||||
<div className='account__action-bar-dropdown'>
|
<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>
|
||||||
|
|
||||||
<div className='account__action-bar-links'>
|
<div className='account__action-bar-links'>
|
||||||
|
|
|
@ -24,6 +24,10 @@ const iconStyle = {
|
||||||
export default class PrivacyDropdown extends React.PureComponent {
|
export default class PrivacyDropdown extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
isUserTouching: PropTypes.func,
|
||||||
|
isModalOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalOpen: PropTypes.func,
|
||||||
|
onModalClose: PropTypes.func,
|
||||||
value: PropTypes.string.isRequired,
|
value: PropTypes.string.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
@ -34,7 +38,25 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleToggle = () => {
|
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) => {
|
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 () {
|
componentDidMount () {
|
||||||
window.addEventListener('click', this.onGlobalClick);
|
window.addEventListener('click', this.onGlobalClick);
|
||||||
window.addEventListener('touchstart', this.onGlobalClick);
|
window.addEventListener('touchstart', this.onGlobalClick);
|
||||||
|
@ -68,25 +101,18 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||||
const { value, intl } = this.props;
|
const { value, intl } = this.props;
|
||||||
const { open } = this.state;
|
const { open } = this.state;
|
||||||
|
|
||||||
const options = [
|
const valueOption = this.options.find(item => item.value === value);
|
||||||
{ 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
|
<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__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'>
|
<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 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__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
|
||||||
<div className='privacy-dropdown__option__content'>
|
<div className='privacy-dropdown__option__content'>
|
||||||
<strong>{item.shortText}</strong>
|
<strong>{item.text}</strong>
|
||||||
{item.longText}
|
{item.meta}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PrivacyDropdown from '../components/privacy_dropdown';
|
import PrivacyDropdown from '../components/privacy_dropdown';
|
||||||
import { changeComposeVisibility } from '../../../actions/compose';
|
import { changeComposeVisibility } from '../../../actions/compose';
|
||||||
|
import { openModal, closeModal } from '../../../actions/modal';
|
||||||
|
import { isUserTouching } from '../../../is_mobile';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
isModalOpen: state.get('modal').modalType === 'ACTIONS',
|
||||||
value: state.getIn(['compose', 'privacy']),
|
value: state.getIn(['compose', 'privacy']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -12,6 +15,10 @@ const mapDispatchToProps = dispatch => ({
|
||||||
dispatch(changeComposeVisibility(value));
|
dispatch(changeComposeVisibility(value));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isUserTouching,
|
||||||
|
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
|
||||||
|
onModalClose: () => dispatch(closeModal()),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
|
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
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';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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__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'>
|
<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>
|
||||||
</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 BundleContainer from '../containers/bundle_container';
|
||||||
import BundleModalError from './bundle_modal_error';
|
import BundleModalError from './bundle_modal_error';
|
||||||
import ModalLoading from './modal_loading';
|
import ModalLoading from './modal_loading';
|
||||||
|
import ActionsModal from '../components/actions_modal';
|
||||||
import {
|
import {
|
||||||
MediaModal,
|
MediaModal,
|
||||||
OnboardingModal,
|
OnboardingModal,
|
||||||
|
@ -21,6 +22,7 @@ const MODAL_COMPONENTS = {
|
||||||
'BOOST': BoostModal,
|
'BOOST': BoostModal,
|
||||||
'CONFIRM': ConfirmationModal,
|
'CONFIRM': ConfirmationModal,
|
||||||
'REPORT': ReportModal,
|
'REPORT': ReportModal,
|
||||||
|
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ModalRoot extends React.PureComponent {
|
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;
|
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() {
|
export function isIOS() {
|
||||||
return iOS;
|
return iOS;
|
||||||
|
|
|
@ -214,16 +214,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown--active::after {
|
.dropdown--active::after {
|
||||||
content: "";
|
@media screen and (min-width: 1025px) {
|
||||||
display: block;
|
content: "";
|
||||||
position: absolute;
|
display: block;
|
||||||
width: 0;
|
position: absolute;
|
||||||
height: 0;
|
width: 0;
|
||||||
border-style: solid;
|
height: 0;
|
||||||
border-width: 0 4.5px 7.8px;
|
border-style: solid;
|
||||||
border-color: transparent transparent $ui-secondary-color;
|
border-width: 0 4.5px 7.8px;
|
||||||
bottom: 8px;
|
border-color: transparent transparent $ui-secondary-color;
|
||||||
right: 104px;
|
bottom: 8px;
|
||||||
|
right: 104px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.invisible {
|
.invisible {
|
||||||
|
@ -3402,7 +3404,8 @@ button.icon-button.active i.fa-retweet {
|
||||||
|
|
||||||
.boost-modal,
|
.boost-modal,
|
||||||
.confirmation-modal,
|
.confirmation-modal,
|
||||||
.report-modal {
|
.report-modal,
|
||||||
|
.actions-modal {
|
||||||
background: lighten($ui-secondary-color, 8%);
|
background: lighten($ui-secondary-color, 8%);
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
border-radius: 8px;
|
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__action-bar {
|
||||||
.confirmation-modal__cancel-button {
|
.confirmation-modal__cancel-button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
|
@ -5,16 +5,24 @@ import React from 'react';
|
||||||
import DropdownMenu from '../../../app/javascript/mastodon/components/dropdown_menu';
|
import DropdownMenu from '../../../app/javascript/mastodon/components/dropdown_menu';
|
||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||||
|
|
||||||
|
const isTrue = () => true;
|
||||||
|
|
||||||
describe('<DropdownMenu />', () => {
|
describe('<DropdownMenu />', () => {
|
||||||
const icon = 'my-icon';
|
const icon = 'my-icon';
|
||||||
const size = 123;
|
const size = 123;
|
||||||
const action = sinon.spy();
|
let items;
|
||||||
|
let wrapper;
|
||||||
|
let action;
|
||||||
|
|
||||||
const items = [
|
beforeEach(() => {
|
||||||
{ text: 'first item', action: action, href: '/some/url' },
|
action = sinon.spy();
|
||||||
{ text: 'second item', action: 'noop' },
|
|
||||||
];
|
items = [
|
||||||
const wrapper = shallow(<DropdownMenu icon={icon} items={items} size={size} />);
|
{ 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 />', () => {
|
it('contains one <Dropdown />', () => {
|
||||||
expect(wrapper).to.have.exactly(1).descendants(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);
|
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', () => {
|
it('uses props.size for <DropdownTrigger /> style values', () => {
|
||||||
['font-size', 'width', 'line-height'].map((property) => {
|
['font-size', 'width', 'line-height'].map((property) => {
|
||||||
expect(wrapper.find(DropdownTrigger)).to.have.style(property, `${size}px`);
|
expect(wrapper.find(DropdownTrigger)).to.have.style(property, `${size}px`);
|
||||||
|
@ -53,6 +71,23 @@ describe('<DropdownMenu />', () => {
|
||||||
expect(wrapper.state('expanded')).to.be.equal(true);
|
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
|
// Error: ReactWrapper::state() can only be called on the root
|
||||||
/*it('sets expanded to false when clicking outside', () => {
|
/*it('sets expanded to false when clicking outside', () => {
|
||||||
const wrapper = mount((
|
const wrapper = mount((
|
||||||
|
|
Loading…
Reference in New Issue