sforkowany z mirror/soapbox
Merge branch 'filter-bar-animation' into 'develop'
Filter bar tab indicator animation See merge request soapbox-pub/soapbox-fe!954strip-front-mentions
commit
afa1ae2fc1
|
@ -0,0 +1,156 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export default class FilterBar extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
active: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
|
||||
const { left, width } = this.getActiveTabIndicationSize();
|
||||
this.setState({ mounted: true, left, width });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown, false);
|
||||
document.removeEventListener('resize', this.handleResize, false);
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
this.setState(this.getActiveTabIndicationSize());
|
||||
}, 300, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.active !== prevProps.active) {
|
||||
this.setState(this.getActiveTabIndicationSize());
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement);
|
||||
let element = null;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowRight':
|
||||
element = items[index+1] || items[0];
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
element = items[index-1] || items[items.length-1];
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
handleItemKeyPress = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTabIndicationSize() {
|
||||
const { active, items } = this.props;
|
||||
|
||||
if (!active || !this.node) return { width: null };
|
||||
|
||||
const index = items.findIndex(({ name }) => name === active);
|
||||
const elements = Array.from(this.node.getElementsByTagName('a'));
|
||||
const element = elements[index];
|
||||
|
||||
if (!element) return { width: null };
|
||||
|
||||
const left = element.offsetLeft;
|
||||
const { width } = element.getBoundingClientRect();
|
||||
|
||||
return { left, width };
|
||||
}
|
||||
|
||||
renderActiveTabIndicator() {
|
||||
const { left, width } = this.state;
|
||||
|
||||
return (
|
||||
<div className='filter-bar__active' style={{ left, width }} />
|
||||
);
|
||||
}
|
||||
|
||||
renderItem(option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { name, text, href, to, title } = option;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={name}
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onKeyPress={this.handleItemKeyPress}
|
||||
data-index={i}
|
||||
title={title}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, items } = this.props;
|
||||
const { mounted } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('filter-bar', className)} ref={this.setRef}>
|
||||
{mounted && this.renderActiveTabIndicator()}
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -2,18 +2,26 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import AccountContainer from '../../../containers/account_container';
|
||||
import StatusContainer from '../../../containers/status_container';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Hashtag from '../../../components/hashtag';
|
||||
import FilterBar from '../../search/components/filter_bar';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
|
||||
import Pullable from 'soapbox/components/pullable';
|
||||
import FilterBar from 'soapbox/components/filter_bar';
|
||||
|
||||
export default class SearchResults extends ImmutablePureComponent {
|
||||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class SearchResults extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
|
@ -25,12 +33,37 @@ export default class SearchResults extends ImmutablePureComponent {
|
|||
features: PropTypes.object.isRequired,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
trends: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter);
|
||||
|
||||
handleSelectFilter = newActiveFilter => this.props.selectFilter(newActiveFilter);
|
||||
|
||||
renderFilterBar() {
|
||||
const { intl, selectedFilter } = this.props;
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => this.handleSelectFilter('accounts'),
|
||||
name: 'accounts',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.statuses),
|
||||
action: () => this.handleSelectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => this.handleSelectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
];
|
||||
|
||||
return <FilterBar className='search__filter-bar' items={items} active={selectedFilter} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, results, submitted, selectedFilter, suggestions, trends } = this.props;
|
||||
|
||||
|
@ -105,7 +138,7 @@ export default class SearchResults extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<>
|
||||
<FilterBar selectedFilter={selectedFilter} selectFilter={this.handleSelectFilter} />
|
||||
{this.renderFilterBar()}
|
||||
|
||||
{noResultsMessage || (
|
||||
<Pullable>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import FilterBar from 'soapbox/components/filter_bar';
|
||||
|
||||
const tooltips = defineMessages({
|
||||
const messages = defineMessages({
|
||||
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
|
||||
|
@ -14,7 +16,7 @@ const tooltips = defineMessages({
|
|||
});
|
||||
|
||||
export default @injectIntl
|
||||
class FilterBar extends React.PureComponent {
|
||||
class NotificationFilterBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
selectFilter: PropTypes.func.isRequired,
|
||||
|
@ -30,90 +32,67 @@ class FilterBar extends React.PureComponent {
|
|||
|
||||
render() {
|
||||
const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props;
|
||||
const renderedElement = !advancedMode ? (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.mentions'
|
||||
defaultMessage='Mentions'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
title={intl.formatMessage(tooltips.mentions)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/at.svg')} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'favourite' ? 'active' : ''}
|
||||
onClick={this.onClick('favourite')}
|
||||
title={intl.formatMessage(tooltips.favourites)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/thumb-up.svg')} />
|
||||
</button>
|
||||
{supportsEmojiReacts && <button
|
||||
className={selectedFilter === 'pleroma:emoji_reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('pleroma:emoji_reaction')}
|
||||
title={intl.formatMessage(tooltips.emoji_reacts)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/mood-smile.svg')} />
|
||||
</button>}
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
title={intl.formatMessage(tooltips.boosts)}
|
||||
>
|
||||
<Icon src={require('feather-icons/dist/icons/repeat.svg')} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'poll' ? 'active' : ''}
|
||||
onClick={this.onClick('poll')}
|
||||
title={intl.formatMessage(tooltips.polls)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/chart-bar.svg')} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'follow' ? 'active' : ''}
|
||||
onClick={this.onClick('follow')}
|
||||
title={intl.formatMessage(tooltips.follows)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/user-plus.svg')} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'move' ? 'active' : ''}
|
||||
onClick={this.onClick('move')}
|
||||
title={intl.formatMessage(tooltips.moves)}
|
||||
>
|
||||
<Icon src={require('feather-icons/dist/icons/briefcase.svg')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return renderedElement;
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: this.onClick('all'),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
if (!advancedMode) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.mentions),
|
||||
action: this.onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/icons/at.svg')} />,
|
||||
title: intl.formatMessage(messages.mentions),
|
||||
action: this.onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/icons/thumb-up.svg')} />,
|
||||
title: intl.formatMessage(messages.favourites),
|
||||
action: this.onClick('favourite'),
|
||||
name: 'favourite',
|
||||
});
|
||||
if (supportsEmojiReacts) items.push({
|
||||
text: <Icon src={require('@tabler/icons/icons/mood-smile.svg')} />,
|
||||
title: intl.formatMessage(messages.emoji_reacts),
|
||||
action: this.onClick('pleroma:emoji_reaction'),
|
||||
name: 'pleroma:emoji_reaction',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
|
||||
title: intl.formatMessage(messages.boosts),
|
||||
action: this.onClick('reblog'),
|
||||
name: 'reblog',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/icons/chart-bar.svg')} />,
|
||||
title: intl.formatMessage(messages.polls),
|
||||
action: this.onClick('poll'),
|
||||
name: 'poll',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/icons/user-plus.svg')} />,
|
||||
title: intl.formatMessage(messages.follows),
|
||||
action: this.onClick('follow'),
|
||||
name: 'follow',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
|
||||
title: intl.formatMessage(messages.moves),
|
||||
action: this.onClick('move'),
|
||||
name: 'move',
|
||||
});
|
||||
}
|
||||
|
||||
return <FilterBar key={advancedMode} className='notification__filter-bar' items={items} active={selectedFilter} />;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
export default @injectIntl
|
||||
class FilterBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
selectFilter: PropTypes.func.isRequired,
|
||||
selectedFilter: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
onClick(searchType) {
|
||||
return () => this.props.selectFilter(searchType);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedFilter } = this.props;
|
||||
|
||||
return (
|
||||
<div className='search__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'accounts' ? 'active' : ''}
|
||||
onClick={this.onClick('accounts')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.accounts'
|
||||
defaultMessage='People'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'statuses' ? 'active' : ''}
|
||||
onClick={this.onClick('statuses')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'hashtags' ? 'active' : ''}
|
||||
onClick={this.onClick('hashtags')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -9,9 +9,11 @@ import LoadingIndicator from 'soapbox/components/loading_indicator';
|
|||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
|
||||
import FilterBar from 'soapbox/components/filter_bar';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
all: { id: 'reactions.all', defaultMessage: 'All' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
|
@ -62,6 +64,29 @@ class ReactionsModal extends React.PureComponent {
|
|||
this.setState({ reaction });
|
||||
};
|
||||
|
||||
renderFilterBar() {
|
||||
const { intl, reactions } = this.props;
|
||||
const { reaction } = this.state;
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: this.handleFilterChange(''),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
reactions.forEach(reaction => items.push(
|
||||
{
|
||||
text: `${reaction.name} ${reaction.count}`,
|
||||
action: this.handleFilterChange(reaction.name),
|
||||
name: reaction.name,
|
||||
},
|
||||
));
|
||||
|
||||
return <FilterBar className='reaction__filter-bar' items={items} active={reaction || 'all'} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, reactions } = this.props;
|
||||
const { reaction } = this.state;
|
||||
|
@ -78,14 +103,7 @@ class ReactionsModal extends React.PureComponent {
|
|||
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
body = (<>
|
||||
{
|
||||
reactions.size > 0 && (
|
||||
<div className='reaction__filter-bar'>
|
||||
<button className={!reaction ? 'active' : ''} onClick={this.handleFilterChange('')}>All</button>
|
||||
{reactions?.filter(reaction => reaction.count).map(reaction => <button key={reaction.name} className={this.state.reaction === reaction.name ? 'active' : ''} onClick={this.handleFilterChange(reaction.name)}>{reaction.name} {reaction.count}</button>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{reactions.size > 0 && this.renderFilterBar()}
|
||||
<ScrollableList
|
||||
scrollKey='reactions'
|
||||
emptyMessage={emptyMessage}
|
||||
|
|
|
@ -636,10 +636,8 @@ article:last-child > .domain {
|
|||
}
|
||||
}
|
||||
|
||||
.notification__filter-bar,
|
||||
.search__filter-bar,
|
||||
.account__section-headline,
|
||||
.reaction__filter-bar {
|
||||
.filter-bar,
|
||||
.account__section-headline {
|
||||
border-bottom: 1px solid var(--brand-color--faint);
|
||||
cursor: default;
|
||||
display: flex;
|
||||
|
@ -684,15 +682,30 @@ article:last-child > .domain {
|
|||
background-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button .svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0 auto;
|
||||
.svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: relative;
|
||||
|
||||
&__active {
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
bottom: 0;
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.no-reduce-motion .filter-bar__active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.reaction__filter-bar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
|
Ładowanie…
Reference in New Issue