[Glitch] Fix dropdown menu positions when scrolling

Port fd33bcb3b2 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
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

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

View File

@ -1,7 +1,6 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
@ -10,15 +9,8 @@ import Icon from 'flavours/glitch/components/icon';
// Utils.
import { withPassive } from 'flavours/glitch/utils/dom_helpers';
import Motion from '../../ui/util/optional_motion';
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.
export default class ComposerOptionsDropdownContent extends React.PureComponent {
@ -44,7 +36,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
};
state = {
mounted: false,
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`.
handleRef = (node) => {
setRef = (node) => {
this.node = node;
}
@ -69,7 +60,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} else {
this.node.firstChild.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
// On unmounting, we remove our listeners.
@ -191,7 +181,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// Rendering.
render () {
const { mounted } = this.state;
const {
items,
onChange,
@ -201,36 +190,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
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>
<div style={{ ...style }} role='listbox' ref={this.setRef}>
{!!items && items.map((item, i) => this.renderItem(item, i))}
</div>
);
}

View File

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

View File

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

View File

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