Filter bar animation

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-01-04 22:53:15 +01:00
parent cd4e33a8ed
commit b06596bbb6
6 changed files with 293 additions and 161 deletions

View File

@ -0,0 +1,145 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
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);
this.setState({ mounted: true });
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleKeyDown, false);
}
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;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = 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);
}
}
renderActiveTabIndicator() {
const { active, items } = this.props;
if (!active || !this.node) return null;
const index = items.findIndex(({ name }) => name === active);
const elements = Array.from(this.node.getElementsByTagName('a'));
const element = elements[index];
if (!element) return null;
const offsetLeft = element.offsetLeft;
const { width } = element.getBoundingClientRect();
return (
<div className='filter-bar__active' style={{ left: offsetLeft, width }} />
);
}
renderItem(option, i) {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href, to, title } = option;
return (
<a
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>
);
}
}

View File

@ -2,18 +2,26 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag'; import Hashtag from '../../../components/hashtag';
import FilterBar from '../../search/components/filter_bar';
import ScrollableList from 'soapbox/components/scrollable_list'; import ScrollableList from 'soapbox/components/scrollable_list';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag'; import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import Pullable from 'soapbox/components/pullable'; 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 = { static propTypes = {
value: PropTypes.string, value: PropTypes.string,
@ -25,12 +33,37 @@ export default class SearchResults extends ImmutablePureComponent {
features: PropTypes.object.isRequired, features: PropTypes.object.isRequired,
suggestions: ImmutablePropTypes.list, suggestions: ImmutablePropTypes.list,
trends: ImmutablePropTypes.list, trends: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
}; };
handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter); handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter);
handleSelectFilter = newActiveFilter => this.props.selectFilter(newActiveFilter); 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() { render() {
const { value, results, submitted, selectedFilter, suggestions, trends } = this.props; const { value, results, submitted, selectedFilter, suggestions, trends } = this.props;
@ -105,7 +138,7 @@ export default class SearchResults extends ImmutablePureComponent {
return ( return (
<> <>
<FilterBar selectedFilter={selectedFilter} selectFilter={this.handleSelectFilter} /> {this.renderFilterBar()}
{noResultsMessage || ( {noResultsMessage || (
<Pullable> <Pullable>

View File

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 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' }, mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
@ -14,7 +16,7 @@ const tooltips = defineMessages({
}); });
export default @injectIntl export default @injectIntl
class FilterBar extends React.PureComponent { class NotificationFilterBar extends React.PureComponent {
static propTypes = { static propTypes = {
selectFilter: PropTypes.func.isRequired, selectFilter: PropTypes.func.isRequired,
@ -30,90 +32,67 @@ class FilterBar extends React.PureComponent {
render() { render() {
const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props; const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props;
const renderedElement = !advancedMode ? (
<div className='notification__filter-bar'> const items = [
<button {
className={selectedFilter === 'all' ? 'active' : ''} text: intl.formatMessage(messages.all),
onClick={this.onClick('all')} action: this.onClick('all'),
> name: 'all',
<FormattedMessage },
id='notifications.filter.all' ];
defaultMessage='All'
/> if (!advancedMode) {
</button> items.push({
<button text: intl.formatMessage(messages.mentions),
className={selectedFilter === 'mention' ? 'active' : ''} action: this.onClick('mention'),
onClick={this.onClick('mention')} name: 'mention',
> });
<FormattedMessage } else {
id='notifications.filter.mentions' items.push({
defaultMessage='Mentions' text: <Icon src={require('@tabler/icons/icons/at.svg')} />,
/> title: intl.formatMessage(messages.mentions),
</button> action: this.onClick('mention'),
</div> name: 'mention',
) : ( });
<div className='notification__filter-bar'> items.push({
<button text: <Icon src={require('@tabler/icons/icons/thumb-up.svg')} />,
className={selectedFilter === 'all' ? 'active' : ''} title: intl.formatMessage(messages.favourites),
onClick={this.onClick('all')} action: this.onClick('favourite'),
> name: 'favourite',
<FormattedMessage });
id='notifications.filter.all' if (supportsEmojiReacts) items.push({
defaultMessage='All' text: <Icon src={require('@tabler/icons/icons/mood-smile.svg')} />,
/> title: intl.formatMessage(messages.emoji_reacts),
</button> action: this.onClick('pleroma:emoji_reaction'),
<button name: 'pleroma:emoji_reaction',
className={selectedFilter === 'mention' ? 'active' : ''} });
onClick={this.onClick('mention')} items.push({
title={intl.formatMessage(tooltips.mentions)} text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
> title: intl.formatMessage(messages.boosts),
<Icon src={require('@tabler/icons/icons/at.svg')} /> action: this.onClick('reblog'),
</button> name: 'reblog',
<button });
className={selectedFilter === 'favourite' ? 'active' : ''} items.push({
onClick={this.onClick('favourite')} text: <Icon src={require('@tabler/icons/icons/chart-bar.svg')} />,
title={intl.formatMessage(tooltips.favourites)} title: intl.formatMessage(messages.polls),
> action: this.onClick('poll'),
<Icon src={require('@tabler/icons/icons/thumb-up.svg')} /> name: 'poll',
</button> });
{supportsEmojiReacts && <button items.push({
className={selectedFilter === 'pleroma:emoji_reaction' ? 'active' : ''} text: <Icon src={require('@tabler/icons/icons/user-plus.svg')} />,
onClick={this.onClick('pleroma:emoji_reaction')} title: intl.formatMessage(messages.follows),
title={intl.formatMessage(tooltips.emoji_reacts)} action: this.onClick('follow'),
> name: 'follow',
<Icon src={require('@tabler/icons/icons/mood-smile.svg')} /> });
</button>} items.push({
<button text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
className={selectedFilter === 'reblog' ? 'active' : ''} title: intl.formatMessage(messages.moves),
onClick={this.onClick('reblog')} action: this.onClick('move'),
title={intl.formatMessage(tooltips.boosts)} name: 'move',
> });
<Icon src={require('feather-icons/dist/icons/repeat.svg')} /> }
</button>
<button return <FilterBar key={advancedMode} className='notification__filter-bar' items={items} active={selectedFilter} />;
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;
} }
} }

View File

@ -13,9 +13,11 @@ import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import { makeGetStatus } from '../../selectors'; import { makeGetStatus } from '../../selectors';
import FilterBar from 'soapbox/components/filter_bar';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.reactions', defaultMessage: 'Reactions' }, heading: { id: 'column.reactions', defaultMessage: 'Reactions' },
all: { id: 'reactions.all', defaultMessage: 'All' },
}); });
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
@ -82,8 +84,31 @@ class Reactions extends ImmutablePureComponent {
this.context.router.history.replace(`/@${username}/posts/${statusId}/reactions/${reaction}`); this.context.router.history.replace(`/@${username}/posts/${statusId}/reactions/${reaction}`);
}; };
renderFilterBar() {
const { intl, params, reactions } = this.props;
const { reaction } = params;
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() { render() {
const { intl, params, reactions, accounts, status } = this.props; const { intl, reactions, accounts, status } = this.props;
if (!accounts) { if (!accounts) {
return ( return (
@ -105,14 +130,7 @@ class Reactions extends ImmutablePureComponent {
return ( return (
<Column heading={intl.formatMessage(messages.heading)}> <Column heading={intl.formatMessage(messages.heading)}>
{ {reactions.size > 0 && this.renderFilterBar()}
reactions.size > 0 && (
<div className='reaction__filter-bar'>
<button className={!params.reaction ? 'active' : ''} onClick={this.handleFilterChange('')}>All</button>
{reactions?.filter(reaction => reaction.count).map(reaction => <button key={reaction.name} className={params.reaction === reaction.name ? 'active' : ''} onClick={this.handleFilterChange(reaction.name)}>{reaction.name} {reaction.count}</button>)}
</div>
)
}
<ScrollableList <ScrollableList
scrollKey='reactions' scrollKey='reactions'
emptyMessage={emptyMessage} emptyMessage={emptyMessage}

View File

@ -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>
);
}
}

View File

@ -636,10 +636,8 @@ article:last-child > .domain {
} }
} }
.notification__filter-bar, .filter-bar,
.search__filter-bar, .account__section-headline {
.account__section-headline,
.reaction__filter-bar {
border-bottom: 1px solid var(--brand-color--faint); border-bottom: 1px solid var(--brand-color--faint);
cursor: default; cursor: default;
display: flex; display: flex;
@ -684,13 +682,25 @@ article:last-child > .domain {
background-color: var(--accent-color); background-color: var(--accent-color);
} }
} }
}
button .svg-icon { .svg-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
margin: 0 auto; margin: 0 auto;
} }
}
}
.filter-bar {
position: relative;
&__active {
position: absolute;
height: 3px;
bottom: 0;
background-color: var(--accent-color);
transition: all 0.3s;
}
} }
.reaction__filter-bar { .reaction__filter-bar {