Merge pull request #687 from sharetribe/select-multiple-filter-component

Select multiple filter component
This commit is contained in:
Hannu Lyytikäinen 2018-02-05 11:02:56 +02:00 committed by GitHub
commit 4f3652045b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 507 additions and 86 deletions

View file

@ -1,37 +0,0 @@
.filterTitle {
text-align: center;
}
.toListingsButton {
position: fixed;
bottom: 1rem;
left: 50%;
width: 200px;
margin-left: -100px;
display: block;
text-align: center;
text-decoration: none;
font-size: 1.4rem;
padding: 0.5rem;
color: #fff;
background-color: #9b9b9b;
cursor: pointer;
&:hover {
background-color: #888;
}
}
.close {
position: absolute;
top: 0;
left: 0;
height: 3em;
padding: 1em;
color: #999;
text-decoration: none;
&:hover {
color: #000;
}
}

View file

@ -1,17 +0,0 @@
import React from 'react';
import { NamedLink } from '../../components';
import css from './FilterPanel.css';
const FilterPanel = () => (
<div>
<h1 className={css.filterTitle}>Filters</h1>
<NamedLink className={css.toListingsButton} name="SearchListingsPage">
See studios
</NamedLink>
<NamedLink className={css.close} name="SearchListingsPage">
X
</NamedLink>
</div>
);
export default FilterPanel;

View file

@ -1,10 +0,0 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import FilterPanel from './FilterPanel';
describe('FilterPanel', () => {
it('matches snapshot', () => {
const tree = renderShallow(<FilterPanel />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,19 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterPanel matches snapshot 1`] = `
<div>
<h1>
Filters
</h1>
<NamedLink
name="SearchListingsPage"
>
See studios
</NamedLink>
<NamedLink
name="SearchListingsPage"
>
X
</NamedLink>
</div>
`;

View file

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
const Form = props => {
const { children, ...restProps } = props;
const { children, contentRef, ...restProps } = props;
const formProps = {
// These are mainly default values for the server
@ -12,6 +12,9 @@ const Form = props => {
method: 'post',
action: '/',
// allow content ref function to be passed to the form
ref: contentRef,
...restProps,
};
return <form {...formProps}>{children}</form>;
@ -19,12 +22,14 @@ const Form = props => {
Form.defaultProps = {
children: null,
contentRef: null,
};
const { node } = PropTypes;
const { func, node } = PropTypes;
Form.propTypes = {
children: node,
contentRef: func,
};
export default Form;

View file

@ -0,0 +1,43 @@
@import '../../marketplace.css';
.root {
position: relative;
display: inline-block;
}
.label {
@apply --marketplaceButtonStylesSecondary;
@apply --marketplaceSearchFilterLabelFontStyles;
padding: 9px 16px 10px 16px;
width: auto;
height: auto;
min-height: 0;
border-radius: 4px;
&:focus {
outline: none;
background-color: var(--matterColorLight);
border-color: transparent;
text-decoration: none;
box-shadow: var(--boxShadowFilterButton);
}
}
.labelSelected {
@apply --marketplaceButtonStyles;
@apply --marketplaceSearchFilterLabelFontStyles;
font-weight: var(--fontWeightSemiBold);
padding: 9px 16px 10px 16px;
width: auto;
height: auto;
min-height: 0;
border-radius: 4px;
border: 1px solid var(--marketplaceColor);
&:hover,
&:focus {
border: 1px solid var(--marketplaceColorDark);
}
}

View file

@ -0,0 +1,67 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import SelectMultipleFilter from './SelectMultipleFilter';
import { stringify, parse } from '../../util/urlHelpers';
const options = [
{
key: 'towels',
label: 'Towels',
},
{
key: 'bathroom',
label: 'Bathroom',
},
{
key: 'swimming_pool',
label: 'Swimming pool',
},
{
key: 'own_drinks',
label: 'Own drinks allowed',
},
{
key: 'jacuzzi',
label: 'Jacuzzi',
},
{
key: 'audiovisual_entertainment',
label: 'Audiovisual entertainment',
},
{
key: 'barbeque',
label: 'Barbeque',
},
{
key: 'own_food_allowed',
label: 'Own food allowed',
},
];
const handleSubmit = (values, history) => {
const queryParams = values ? `?${stringify({ pub_amenities: values.join(',') })}` : '';
history.push(`${window.location.pathname}${queryParams}`);
};
const AmenitiesFilterComponent = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
const amenities = params.pub_amenities;
const initialValues = !!amenities ? amenities.split(',') : [];
return (
<SelectMultipleFilter
onSubmit={values => handleSubmit(values, history)}
options={options}
initialValues={initialValues}
contentPlacementOffset={-14}
/>
);
});
export const AmenitiesFilter = {
component: AmenitiesFilterComponent,
props: {},
group: 'misc',
};

View file

@ -0,0 +1,178 @@
import React, { Component } from 'react';
import { array, arrayOf, func, number, string } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from 'react-intl';
import { toPairs } from 'lodash';
import SelectMultipleFilterForm from './SelectMultipleFilterForm';
import css from './SelectMultipleFilter.css';
const KEY_CODE_ESCAPE = 27;
/**
* Convert an array of option keys into
* an object that can be passed to a redux
* form as initial values.
*/
const keysToValues = keys => {
return keys.reduce((map, key) => {
map[key] = true;
return map;
}, {});
};
/**
* Convert an object containing values received
* from a redux form into an array of values.
*/
const valuesToKeys = values => {
const entries = toPairs(values);
return entries.filter(entry => entry[1] === true).map(entry => entry[0]);
};
class SelectMultipleFilter extends Component {
constructor(props) {
super(props);
this.state = { isOpen: false };
this.filter = null;
this.filterContent = null;
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.toggleOpen = this.toggleOpen.bind(this);
this.positionStyleForContent = this.positionStyleForContent.bind(this);
}
handleSubmit(values) {
const selectedKeys = valuesToKeys(values);
this.setState({ isOpen: false });
this.props.onSubmit(selectedKeys);
}
handleClear() {
this.setState({ isOpen: false });
this.props.onSubmit(null);
}
handleCancel() {
const { onSubmit, initialValues } = this.props;
this.setState({ isOpen: false });
onSubmit(initialValues);
}
handleBlur(event) {
// FocusEvent is fired faster than the link elements native click handler
// gets its own event. Therefore, we need to check the origin of this FocusEvent.
if (!this.filter.contains(event.relatedTarget)) {
this.setState({ isOpen: false });
}
}
handleKeyDown(e) {
// Gather all escape presses to close menu
if (e.keyCode === KEY_CODE_ESCAPE) {
this.toggleOpen(false);
}
}
toggleOpen(enforcedState) {
if (enforcedState) {
this.setState({ isOpen: enforcedState });
} else {
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
}
}
positionStyleForContent() {
if (this.filter && this.filterContent) {
// Render the filter content to the right from the menu
// unless there's no space in which case it is rendered
// to the left
const distanceToRight = window.innerWidth - this.filter.getBoundingClientRect().right;
const labelWidth = this.filter.offsetWidth;
const contentWidth = this.filterContent.offsetWidth;
const contentWidthBiggerThanLabel = contentWidth - labelWidth;
const renderToRight = distanceToRight > contentWidthBiggerThanLabel;
const contentPlacementOffset = this.props.contentPlacementOffset;
const offset = renderToRight
? { left: contentPlacementOffset }
: { right: contentPlacementOffset };
// set a min-width if the content is narrower than the label
const minWidth = contentWidth < labelWidth ? { minWidth: labelWidth } : null;
return { ...offset, ...minWidth };
}
return {};
}
render() {
const { rootClassName, className, options, initialValues, intl } = this.props;
const classes = classNames(rootClassName || css.root, className);
const hasInitialValues = initialValues.length > 0;
const labelStyles = hasInitialValues ? css.labelSelected : css.label;
const label = hasInitialValues
? intl.formatMessage(
{ id: 'SelectMultipleFilterMobile.labelSelected' },
{ count: initialValues.length }
)
: intl.formatMessage({ id: 'SelectMultipleFilterMobile.label' });
const contentStyle = this.positionStyleForContent();
return (
<div
className={classes}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={node => {
this.filter = node;
}}
>
<button className={labelStyles} onClick={() => this.toggleOpen()}>
{label}
</button>
<SelectMultipleFilterForm
onSubmit={this.handleSubmit}
initialValues={keysToValues(initialValues)}
enableReinitialize={true}
onClear={this.handleClear}
onCancel={this.handleCancel}
options={options}
isOpen={this.state.isOpen}
intl={intl}
contentRef={node => {
this.filterContent = node;
}}
style={contentStyle}
/>
</div>
);
}
}
SelectMultipleFilter.defaultProps = {
rootClassName: null,
className: null,
initialValues: [],
contentPlacementOffset: 0,
};
SelectMultipleFilter.propTypes = {
rootClassName: string,
className: string,
onSubmit: func.isRequired,
options: array.isRequired,
initialValues: arrayOf(string),
contentPlacementOffset: number,
// form injectIntl
intl: intlShape.isRequired,
};
export default injectIntl(SelectMultipleFilter);

View file

@ -0,0 +1,102 @@
@import '../../marketplace.css';
.root {
/* By default hide the content */
visibility: hidden;
opacity: 0;
pointer-events: none;
/* Position */
position: absolute;
z-index: var(--zIndexPopup);
/* Layout */
margin-top: 7px;
padding: 15px 40px 20px 30px;
/* Borders */
background-color: var(--matterColorLight);
border-top: 1px solid var(--matterColorNegative);
box-shadow: var(--boxShadowPopup);
border-radius: 4px;
transition: var(--transitionStyleButton);
outline: none;
}
.isOpen {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.fieldGroup {
margin-bottom: 35px;
white-space: nowrap;
}
.buttonsWrapper {
display: flex;
}
.clearButton {
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
margin: 0 auto 0 0;
padding: 0;
/* Override button styles */
outline: none;
border: none;
cursor: pointer;
&:focus,
&:hover {
color: var(--matterColor);
transition: width var(--transitionStyleButton);
}
}
.cancelButton {
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--matterColorAnti);
/* Layout */
margin: 0;
padding: 0;
/* Override button styles */
outline: none;
border: none;
cursor: pointer;
&:focus,
&:hover {
color: var(--matterColor);
transition: width var(--transitionStyleButton);
}
}
.submitButton {
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--marketplaceColor);
/* Layout */
margin: 0 0 0 19px;
padding: 0;
/* Override button styles */
outline: none;
border: none;
cursor: pointer;
&:focus,
&:hover {
color: var(--marketplaceColorDark);
transition: width var(--transitionStyleButton);
}
}

View file

@ -0,0 +1,98 @@
import React from 'react';
import { arrayOf, bool, func, node, object, shape, string } from 'prop-types';
import classNames from 'classnames';
import { compose } from 'redux';
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
import { injectIntl, intlShape } from 'react-intl';
import { FieldGroupCheckbox, Form } from '../../components';
import css from './SelectMultipleFilterForm.css';
const SelectMultipleFilterFormComponent = props => {
const {
form,
destroy,
reset,
handleSubmit,
onClear,
onCancel,
options,
isOpen,
contentRef,
style,
intl,
} = props;
const classes = classNames(css.root, { [css.isOpen]: isOpen });
const handleClear = () => {
// clear the redux form state
destroy();
onClear();
};
const handleCancel = () => {
// reset the redux form to initialValues
reset();
onCancel();
};
const clear = intl.formatMessage({ id: 'SelectMultipleFilterMobile.clear' });
const cancel = intl.formatMessage({ id: 'SelectMultipleFilterMobile.cancel' });
const submit = intl.formatMessage({ id: 'SelectMultipleFilterMobile.submit' });
return (
<Form
className={classes}
onSubmit={handleSubmit}
tabIndex="0"
contentRef={contentRef}
style={style}
>
<FieldGroupCheckbox
className={css.fieldGroup}
id={`${form}.amenitiesFilter`}
options={options}
/>
<div className={css.buttonsWrapper}>
<button className={css.clearButton} type="button" onClick={handleClear}>
{clear}
</button>
<button className={css.cancelButton} type="button" onClick={handleCancel}>
{cancel}
</button>
<button className={css.submitButton} type="submit">
{submit}
</button>
</div>
</Form>
);
};
SelectMultipleFilterFormComponent.defaultProps = {
contentRef: null,
style: null,
};
SelectMultipleFilterFormComponent.propTypes = {
...formPropTypes,
onClear: func.isRequired,
onCancel: func.isRequired,
options: arrayOf(
shape({
key: string.isRequired,
label: node.isRequired,
})
).isRequired,
isOpen: bool.isRequired,
contentRef: func,
style: object,
// form injectIntl
intl: intlShape.isRequired,
};
const defaultFormName = 'SelectMultipleFilterForm';
export default compose(reduxForm({ form: defaultFormName }), injectIntl)(
SelectMultipleFilterFormComponent
);

View file

@ -29,7 +29,6 @@ export {
export { default as EditListingWizard } from './EditListingWizard/EditListingWizard';
export { default as ExpandingTextarea } from './ExpandingTextarea/ExpandingTextarea';
export { default as ExternalLink } from './ExternalLink/ExternalLink';
export { default as FilterPanel } from './FilterPanel/FilterPanel';
export { default as FieldBirthdayInput } from './FieldBirthdayInput/FieldBirthdayInput';
export { default as FieldCheckbox } from './FieldCheckbox/FieldCheckbox';
export { default as FieldCurrencyInput } from './FieldCurrencyInput/FieldCurrencyInput';
@ -108,6 +107,7 @@ export { default as SectionHowItWorks } from './SectionHowItWorks/SectionHowItWo
export { default as SectionLocations } from './SectionLocations/SectionLocations';
export { default as SectionThumbnailLinks } from './SectionThumbnailLinks/SectionThumbnailLinks';
export { default as SelectField } from './SelectField/SelectField';
export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter';
export { default as SelectSingleFilter } from './SelectSingleFilter/SelectSingleFilter';
export {
default as SelectSingleFilterMobile,

View file

@ -3,6 +3,7 @@
exports[`BookingDatesForm matches snapshot without selected dates 1`] = `
<Form
className={null}
contentRef={null}
onSubmit={[Function]}
>
<FieldDateRangeInput

View file

@ -3,6 +3,7 @@
exports[`EditListingLocationForm matches snapshot 1`] = `
<Form
className=""
contentRef={null}
onSubmit={[Function]}
>
<Component

View file

@ -3,6 +3,7 @@
exports[`EditListingPhotosForm matches snapshot 1`] = `
<Form
className=""
contentRef={null}
onSubmit={[Function]}
>
<AddImages

View file

@ -3,6 +3,7 @@
exports[`EditListingPoliciesForm matches snapshot 1`] = `
<Form
className=""
contentRef={null}
onSubmit={[Function]}
>
<TextInputField

View file

@ -45,6 +45,7 @@ import * as ReviewRating from './components/ReviewRating/ReviewRating.example';
import * as Reviews from './components/Reviews/Reviews.example';
import * as SectionThumbnailLinks from './components/SectionThumbnailLinks/SectionThumbnailLinks.example';
import * as SelectField from './components/SelectField/SelectField.example';
import * as SelectMultipleFilter from './components/SelectMultipleFilter/SelectMultipleFilter.example';
import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example';
import * as TabNav from './components/TabNav/TabNav.example';
import * as TabNavHorizontal from './components/TabNavHorizontal/TabNavHorizontal.example';
@ -136,6 +137,7 @@ export {
Reviews,
SectionThumbnailLinks,
SelectField,
SelectMultipleFilter,
SendMessageForm,
SignupForm,
StripeBankAccountTokenInputField,

View file

@ -518,6 +518,11 @@
"SectionLocations.listingsInLocation": "Saunas in {location}",
"SectionLocations.subtitle": "We have more than 1000 saunas around Finland. Here are some of our most popular locations.",
"SectionLocations.title": "We have wooden saunas, electric saunas, and even tent saunas.",
"SelectMultipleFilterMobile.label": "Amenities",
"SelectMultipleFilterMobile.labelSelected": "Amenities • {count}",
"SelectMultipleFilterMobile.clear": "Clear",
"SelectMultipleFilterMobile.cancel": "Cancel",
"SelectMultipleFilterMobile.submit": "Apply",
"SelectSingleFilter.clear": "Clear",
"SelectSingleFilterMobile.clear": "Clear",
"SendMessageForm.sendFailed": "Failed to send. Please try again.",