[Glitch] Fix dropdown menu positions when scrolling

Port fd33bcb3b2 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
main
Peter Simonsson 2023-01-11 21:58:46 +01:00 committed by Claire
parent 3e63fcd4f0
commit a36dfbb2aa
14 changed files with 231 additions and 253 deletions

View File

@ -1,8 +1,8 @@
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
export function openDropdownMenu(id, placement, keyboard, scroll_key) { export function openDropdownMenu(id, keyboard, scroll_key) {
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key }; return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key };
} }
export function closeDropdownMenu(id) { export function closeDropdownMenu(id) {

View File

@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { CircularProgress } from 'flavours/glitch/components/loading_indicator'; import { CircularProgress } from 'flavours/glitch/components/loading_indicator';
@ -24,9 +22,6 @@ class DropdownMenu extends React.PureComponent {
scrollable: PropTypes.bool, scrollable: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
renderHeader: PropTypes.func, renderHeader: PropTypes.func,
@ -35,11 +30,6 @@ class DropdownMenu extends React.PureComponent {
static defaultProps = { static defaultProps = {
style: {}, style: {},
placement: 'bottom',
};
state = {
mounted: false,
}; };
handleDocumentClick = e => { handleDocumentClick = e => {
@ -56,8 +46,6 @@ class DropdownMenu extends React.PureComponent {
if (this.focusedItem && this.props.openedViaKeyboard) { if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true }); this.focusedItem.focus({ preventScroll: true });
} }
this.setState({ mounted: true });
} }
componentWillUnmount () { componentWillUnmount () {
@ -139,40 +127,28 @@ class DropdownMenu extends React.PureComponent {
} }
render () { render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props; const { items, scrollable, renderHeader, loading } = this.props;
const { mounted } = this.state;
let renderItem = this.props.renderItem || this.renderItem; let renderItem = this.props.renderItem || this.renderItem;
return ( return (
<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 }) }}> <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })} ref={this.setRef}>
{({ opacity, scaleX, scaleY }) => ( {loading && (
// It should not be transformed when mounting because the resulting <CircularProgress size={30} strokeWidth={3.5} />
// size will be used to determine the coordinate of the menu by )}
// react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}> {!loading && renderHeader && (
{loading && ( <div className='dropdown-menu__container__header'>
<CircularProgress size={30} strokeWidth={3.5} /> {renderHeader(items)}
)}
{!loading && renderHeader && (
<div className='dropdown-menu__container__header'>
{renderHeader(items)}
</div>
)}
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
</div> </div>
)} )}
</Motion>
{!loading && (
<ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}>
{items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))}
</ul>
)}
</div>
); );
} }
@ -197,7 +173,6 @@ export default class Dropdown extends React.PureComponent {
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
onOpen: PropTypes.func.isRequired, onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number, openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
renderItem: PropTypes.func, renderItem: PropTypes.func,
@ -213,13 +188,11 @@ export default class Dropdown extends React.PureComponent {
id: id++, id: id++,
}; };
handleClick = ({ target, type }) => { handleClick = ({ type }) => {
if (this.state.id === this.props.openDropdownId) { if (this.state.id === this.props.openDropdownId) {
this.handleClose(); this.handleClose();
} else { } else {
const { top } = target.getBoundingClientRect(); this.props.onOpen(this.state.id, this.handleItemClick, type !== 'click');
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
} }
} }
@ -303,7 +276,6 @@ export default class Dropdown extends React.PureComponent {
disabled, disabled,
loading, loading,
scrollable, scrollable,
dropdownPlacement,
openDropdownId, openDropdownId,
openedViaKeyboard, openedViaKeyboard,
children, children,
@ -314,7 +286,6 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId; const open = this.state.id === openDropdownId;
const button = children ? React.cloneElement(React.Children.only(children), { const button = children ? React.cloneElement(React.Children.only(children), {
ref: this.setTargetRef,
onClick: this.handleClick, onClick: this.handleClick,
onMouseDown: this.handleMouseDown, onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown, onKeyDown: this.handleButtonKeyDown,
@ -326,7 +297,6 @@ export default class Dropdown extends React.PureComponent {
active={open} active={open}
disabled={disabled} disabled={disabled}
size={size} size={size}
ref={this.setTargetRef}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown} onKeyDown={this.handleButtonKeyDown}
@ -336,19 +306,27 @@ export default class Dropdown extends React.PureComponent {
return ( return (
<React.Fragment> <React.Fragment>
{button} <span ref={this.setTargetRef}>
{button}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> </span>
<DropdownMenu <Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
items={items} {({ props, arrowProps, placement }) => (
loading={loading} <div {...props}>
scrollable={scrollable} <div className={`dropdown-animation dropdown-menu ${placement}`}>
onClose={this.handleClose} <div className={`dropdown-menu__arrow ${placement}`} {...arrowProps} />
openedViaKeyboard={openedViaKeyboard} <DropdownMenu
renderItem={renderItem} items={items}
renderHeader={renderHeader} loading={loading}
onItemClick={this.handleItemClick} scrollable={scrollable}
/> onClose={this.handleClose}
openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem}
renderHeader={renderHeader}
onItemClick={this.handleItemClick}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</React.Fragment> </React.Fragment>
); );

View File

@ -4,7 +4,6 @@ import { fetchHistory } from 'flavours/glitch/actions/history';
import DropdownMenu from 'flavours/glitch/components/dropdown_menu'; import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
items: state.getIn(['history', statusId, 'items']), items: state.getIn(['history', statusId, 'items']),
@ -13,9 +12,9 @@ const mapStateToProps = (state, { statusId }) => ({
const mapDispatchToProps = (dispatch, { statusId }) => ({ const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, dropdownPlacement, keyboard) { onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId)); dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu(id, dropdownPlacement, keyboard)); dispatch(openDropdownMenu(id, keyboard));
}, },
onClose (id) { onClose (id) {

View File

@ -5,18 +5,17 @@ import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
import { isUserTouching } from '../is_mobile'; import { isUserTouching } from '../is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
}); });
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, dropdownPlacement, keyboard) { onOpen(id, onItemClick, keyboard) {
dispatch(isUserTouching() ? openModal('ACTIONS', { dispatch(isUserTouching() ? openModal('ACTIONS', {
status, status,
actions: items, actions: items,
onClick: onItemClick, onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey)); }) : openDropdownMenu(id, keyboard, scrollKey));
}, },
onClose(id) { onClose(id) {

View File

@ -2,7 +2,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
// Components. // Components.
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
@ -45,7 +45,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
}; };
// Toggles opening and closing the dropdown. // Toggles opening and closing the dropdown.
handleToggle = ({ target, type }) => { handleToggle = ({ type }) => {
const { onModalOpen } = this.props; const { onModalOpen } = this.props;
const { open } = this.state; const { open } = this.state;
@ -59,11 +59,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
} }
} }
} else { } else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
} }
} }
@ -158,6 +156,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
}; };
} }
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
}
// Rendering. // Rendering.
render () { render () {
const { const {
@ -179,6 +189,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
<div <div
className={classNames('privacy-dropdown', placement, { active: open })} className={classNames('privacy-dropdown', placement, { active: open })}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
ref={this.setTargetRef}
> >
<div className={classNames('privacy-dropdown__value', { active })}> <div className={classNames('privacy-dropdown__value', { active })}>
<IconButton <IconButton
@ -204,18 +215,26 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
containerPadding={20} containerPadding={20}
placement={placement} placement={placement}
show={open} show={open}
target={this} flip
target={this.findTarget}
container={container} container={container}
popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
> >
<DropdownMenu {({ props, placement }) => (
items={items} <div {...props} style={{ ...props.style, width: 350, maxWidth: '100vw' }}>
renderItemContents={renderItemContents} <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
onChange={onChange} <DropdownMenu
onClose={this.handleClose} items={items}
value={value} renderItemContents={renderItemContents}
openedViaKeyboard={this.state.openedViaKeyboard} onChange={onChange}
closeOnChange={closeOnChange} onClose={this.handleClose}
/> value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
closeOnChange={closeOnChange}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -1,7 +1,6 @@
// Package imports. // Package imports.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames'; import classNames from 'classnames';
@ -10,15 +9,8 @@ import Icon from 'flavours/glitch/components/icon';
// Utils. // Utils.
import { withPassive } from 'flavours/glitch/utils/dom_helpers'; import { withPassive } from 'flavours/glitch/utils/dom_helpers';
import Motion from '../../ui/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/utils/react_helpers'; import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component. // The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent { export default class ComposerOptionsDropdownContent extends React.PureComponent {
@ -44,7 +36,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
}; };
state = { state = {
mounted: false,
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
}; };
@ -56,7 +47,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} }
// Stores our node in `this.node`. // Stores our node in `this.node`.
handleRef = (node) => { setRef = (node) => {
this.node = node; this.node = node;
} }
@ -69,7 +60,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} else { } else {
this.node.firstChild.focus({ preventScroll: true }); this.node.firstChild.focus({ preventScroll: true });
} }
this.setState({ mounted: true });
} }
// On unmounting, we remove our listeners. // On unmounting, we remove our listeners.
@ -191,7 +181,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// Rendering. // Rendering.
render () { render () {
const { mounted } = this.state;
const { const {
items, items,
onChange, onChange,
@ -201,36 +190,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// The result. // The result.
return ( return (
<Motion <div style={{ ...style }} role='listbox' ref={this.setRef}>
defaultStyle={{ {!!items && items.map((item, i) => this.renderItem(item, i))}
opacity: 0, </div>
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className='privacy-dropdown__dropdown'
ref={this.handleRef}
role='listbox'
style={{
...style,
opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}}
>
{!!items && items.map((item, i) => this.renderItem(item, i))}
</div>
)}
</Motion>
); );
} }

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
@ -155,9 +155,6 @@ class EmojiPickerMenu extends React.PureComponent {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
@ -326,14 +323,13 @@ class EmojiPickerDropdown extends React.PureComponent {
state = { state = {
active: false, active: false,
loading: false, loading: false,
placement: null,
}; };
setRef = (c) => { setRef = (c) => {
this.dropdown = c; this.dropdown = c;
} }
onShowDropdown = ({ target }) => { onShowDropdown = () => {
this.setState({ active: true }); this.setState({ active: true });
if (!EmojiPicker) { if (!EmojiPicker) {
@ -348,9 +344,6 @@ class EmojiPickerDropdown extends React.PureComponent {
this.setState({ loading: false, active: false }); this.setState({ loading: false, active: false });
}); });
} }
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
} }
onHideDropdown = () => { onHideDropdown = () => {
@ -384,7 +377,7 @@ class EmojiPickerDropdown extends React.PureComponent {
render () { render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state; const { active, loading } = this.state;
return ( return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
@ -396,16 +389,22 @@ class EmojiPickerDropdown extends React.PureComponent {
/>} />}
</div> </div>
<Overlay show={active} placement={placement} target={this.findTarget}> <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<EmojiPickerMenu {({ props, placement })=> (
custom_emojis={this.props.custom_emojis} <div {...props} style={{ ...props.style, width: 299 }}>
loading={loading} <div className={`dropdown-animation ${placement}`}>
onClose={this.onHideDropdown} <EmojiPickerMenu
onPick={onPickEmoji} custom_emojis={this.props.custom_emojis}
onSkinTone={onSkinTone} loading={loading}
skinTone={skinTone} onClose={this.onHideDropdown}
frequentlyUsedEmojis={frequentlyUsedEmojis} onPick={onPickEmoji}
/> onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import TextIconButton from './text_icon_button'; import TextIconButton from './text_icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state'; import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
@ -22,10 +20,8 @@ const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent { class LanguageDropdownMenu extends React.PureComponent {
static propTypes = { static propTypes = {
style: PropTypes.object,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
@ -37,7 +33,6 @@ class LanguageDropdownMenu extends React.PureComponent {
}; };
state = { state = {
mounted: false,
searchValue: '', searchValue: '',
}; };
@ -50,7 +45,6 @@ class LanguageDropdownMenu extends React.PureComponent {
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
this.setState({ mounted: true });
// Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
// to wait for a frame before focusing // to wait for a frame before focusing
@ -222,29 +216,22 @@ class LanguageDropdownMenu extends React.PureComponent {
} }
render () { render () {
const { style, placement, intl } = this.props; const { intl } = this.props;
const { mounted, searchValue } = this.state; const { searchValue } = this.state;
const isSearching = searchValue !== ''; const isSearching = searchValue !== '';
const results = this.search(); const results = this.search();
return ( return (
<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 }) }}> <div ref={this.setRef}>
{({ opacity, scaleX, scaleY }) => ( <div className='emoji-mart-search'>
// It should not be transformed when mounting because the resulting <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
// size will be used to determine the coordinate of the menu by <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
// react-overlays </div>
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)} {results.map(this.renderItem)}
</div> </div>
</div> </div>
)}
</Motion>
); );
} }
@ -266,14 +253,11 @@ class LanguageDropdown extends React.PureComponent {
placement: 'bottom', placement: 'bottom',
}; };
handleToggle = ({ target }) => { handleToggle = () => {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open }); this.setState({ open: !this.state.open });
} }
@ -293,13 +277,25 @@ class LanguageDropdown extends React.PureComponent {
onChange(value); onChange(value);
} }
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
}
render () { render () {
const { value, intl, frequentlyUsedLanguages } = this.props; const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
return ( return (
<div className={classNames('privacy-dropdown', { active: open })}> <div className={classNames('privacy-dropdown', placement, { active: open })}>
<div className='privacy-dropdown__value'> <div className='privacy-dropdown__value' ref={this.setTargetRef} >
<TextIconButton <TextIconButton
className='privacy-dropdown__value-icon' className='privacy-dropdown__value-icon'
label={value && value.toUpperCase()} label={value && value.toUpperCase()}
@ -309,15 +305,20 @@ class LanguageDropdown extends React.PureComponent {
/> />
</div> </div>
<Overlay show={open} placement={placement} target={this}> <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<LanguageDropdownMenu {({ props, placement }) => (
value={value} <div {...props} style={{ ...props.style, width: 280 }}>
frequentlyUsedLanguages={frequentlyUsedLanguages} <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
onClose={this.handleClose} <LanguageDropdownMenu
onChange={this.handleChange} value={value}
placement={placement} frequentlyUsedLanguages={frequentlyUsedLanguages}
intl={intl} onClose={this.handleClose}
/> onChange={this.handleChange}
intl={intl}
/>
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -3,13 +3,12 @@ import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { import {
injectIntl, injectIntl,
FormattedMessage, FormattedMessage,
defineMessages, defineMessages,
} from 'react-intl'; } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/Overlay';
// Components. // Components.
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
@ -17,7 +16,6 @@ import Icon from 'flavours/glitch/components/icon';
// Utils. // Utils.
import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
import { searchEnabled } from 'flavours/glitch/initial_state'; import { searchEnabled } from 'flavours/glitch/initial_state';
import Motion from '../../ui/util/optional_motion';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
@ -26,31 +24,20 @@ const messages = defineMessages({
class SearchPopout extends React.PureComponent { class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () { render () {
const { style } = this.props;
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />; const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
return ( return (
<div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}> <div className='search-popout'>
<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 }) }}> <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
{({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul> <ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul> </ul>
{extraInformation} {extraInformation}
</div>
)}
</Motion>
</div> </div>
); );
} }
@ -136,6 +123,10 @@ class Search extends React.PureComponent {
} }
} }
findTarget = () => {
return this.searchForm;
}
render () { render () {
const { intl, value, submitted } = this.props; const { intl, value, submitted } = this.props;
const { expanded } = this.state; const { expanded } = this.state;
@ -161,8 +152,14 @@ class Search extends React.PureComponent {
<Icon id='search' className={hasValue ? '' : 'active'} /> <Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} /> <Icon id='times-circle' className={hasValue ? 'active' : ''} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}> <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<SearchPopout /> {({ props, placement }) => (
<div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
<div className={`dropdown-animation ${placement}`}>
<SearchPopout />
</div>
</div>
)}
</Overlay> </Overlay>
</div> </div>
); );

View File

@ -4,12 +4,12 @@ import {
DROPDOWN_MENU_CLOSE, DROPDOWN_MENU_CLOSE,
} from '../actions/dropdown_menu'; } from '../actions/dropdown_menu';
const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false, scroll_key: null }); const initialState = Immutable.Map({ openId: null, keyboard: false, scroll_key: null });
export default function dropdownMenu(state = initialState, action) { export default function dropdownMenu(state = initialState, action) {
switch (action.type) { switch (action.type) {
case DROPDOWN_MENU_OPEN: case DROPDOWN_MENU_OPEN:
return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard, scroll_key: action.scroll_key }); return state.merge({ openId: action.id, keyboard: action.keyboard, scroll_key: action.scroll_key });
case DROPDOWN_MENU_CLOSE: case DROPDOWN_MENU_CLOSE:
return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state; return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state;
default: default:

View File

@ -586,7 +586,6 @@
} }
.privacy-dropdown__dropdown { .privacy-dropdown__dropdown {
position: absolute;
border-radius: 4px; border-radius: 4px;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
background: $simple-background-color; background: $simple-background-color;
@ -653,7 +652,6 @@
.language-dropdown { .language-dropdown {
&__dropdown { &__dropdown {
position: absolute;
background: $simple-background-color; background: $simple-background-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
border-radius: 4px; border-radius: 4px;

View File

@ -346,9 +346,8 @@
} }
} }
.dropdown-menu { body > [data-popper-placement] {
position: absolute; z-index: 3;
transform-origin: 50% 0;
} }
.invisible { .invisible {
@ -532,6 +531,42 @@
} }
} }
.dropdown-animation {
animation: dropdown 300ms cubic-bezier(0.1, 0.7, 0.1, 1);
@keyframes dropdown {
from {
opacity: 0;
transform: scaleX(0.85) scaleY(0.75);
}
to {
opacity: 1;
transform: scaleX(1) scaleY(1);
}
}
&.top {
transform-origin: bottom;
}
&.right {
transform-origin: left;
}
&.bottom {
transform-origin: top;
}
&.left {
transform-origin: right;
}
.reduce-motion & {
animation: none;
}
}
.dropdown { .dropdown {
display: inline-block; display: inline-block;
} }
@ -600,36 +635,42 @@
.dropdown-menu__arrow { .dropdown-menu__arrow {
position: absolute; position: absolute;
width: 0;
height: 0;
border: 0 solid transparent;
&.left { &::before {
right: -5px; content: '';
margin-top: -5px; display: block;
border-width: 5px 0 5px 5px; width: 14px;
border-left-color: $ui-secondary-color; height: 5px;
background-color: $ui-secondary-color;
mask-image: url("data:image/svg+xml;utf8,<svg width='14' height='5' xmlns='http://www.w3.org/2000/svg'><path d='M7 0L0 5h14L7 0z' fill='white'/></svg>");
} }
&.top { &.top {
bottom: -5px; bottom: -5px;
margin-left: -7px;
border-width: 5px 7px 0; &::before {
border-top-color: $ui-secondary-color; transform: rotate(180deg);
}
}
&.right {
left: -9px;
&::before {
transform: rotate(-90deg);
}
} }
&.bottom { &.bottom {
top: -5px; top: -5px;
margin-left: -7px;
border-width: 0 7px 5px;
border-bottom-color: $ui-secondary-color;
} }
&.right { &.left {
left: -5px; right: -9px;
margin-top: -5px;
border-width: 5px 5px 5px 0; &::before {
border-right-color: $ui-secondary-color; transform: rotate(90deg);
}
} }
} }

View File

@ -37,7 +37,6 @@
.modal-root__modal { .modal-root__modal {
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
z-index: 9999;
} }
.media-modal__zoom-button { .media-modal__zoom-button {

View File

@ -285,22 +285,8 @@ html {
.dropdown-menu { .dropdown-menu {
background: $white; background: $white;
&__arrow { &__arrow::before {
&.left { background-color: $white;
border-left-color: $white;
}
&.top {
border-top-color: $white;
}
&.bottom {
border-bottom-color: $white;
}
&.right {
border-right-color: $white;
}
} }
&__item { &__item {