diff --git a/src/components/FilterPanel/FilterPanel.css b/src/components/FilterPanel/FilterPanel.css deleted file mode 100644 index c8ac683c..00000000 --- a/src/components/FilterPanel/FilterPanel.css +++ /dev/null @@ -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; - } -} diff --git a/src/components/FilterPanel/FilterPanel.js b/src/components/FilterPanel/FilterPanel.js deleted file mode 100644 index dcc5a762..00000000 --- a/src/components/FilterPanel/FilterPanel.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { NamedLink } from '../../components'; -import css from './FilterPanel.css'; - -const FilterPanel = () => ( -
-

Filters

- - See studios - - - X - -
-); - -export default FilterPanel; diff --git a/src/components/FilterPanel/FilterPanel.test.js b/src/components/FilterPanel/FilterPanel.test.js deleted file mode 100644 index 28ada261..00000000 --- a/src/components/FilterPanel/FilterPanel.test.js +++ /dev/null @@ -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(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/src/components/FilterPanel/__snapshots__/FilterPanel.test.js.snap b/src/components/FilterPanel/__snapshots__/FilterPanel.test.js.snap deleted file mode 100644 index 7ed8959d..00000000 --- a/src/components/FilterPanel/__snapshots__/FilterPanel.test.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FilterPanel matches snapshot 1`] = ` -
-

- Filters -

- - See studios - - - X - -
-`; diff --git a/src/components/Form/Form.js b/src/components/Form/Form.js index 961e205c..07201002 100644 --- a/src/components/Form/Form.js +++ b/src/components/Form/Form.js @@ -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
{children}
; @@ -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; diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.css b/src/components/SelectMultipleFilter/SelectMultipleFilter.css new file mode 100644 index 00000000..67812d59 --- /dev/null +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.css @@ -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); + } +} diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js b/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js new file mode 100644 index 00000000..97ea524f --- /dev/null +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.example.js @@ -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 ( + handleSubmit(values, history)} + options={options} + initialValues={initialValues} + contentPlacementOffset={-14} + /> + ); +}); + +export const AmenitiesFilter = { + component: AmenitiesFilterComponent, + props: {}, + group: 'misc', +}; diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilter.js b/src/components/SelectMultipleFilter/SelectMultipleFilter.js new file mode 100644 index 00000000..627232c2 --- /dev/null +++ b/src/components/SelectMultipleFilter/SelectMultipleFilter.js @@ -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 ( +
{ + this.filter = node; + }} + > + + { + this.filterContent = node; + }} + style={contentStyle} + /> +
+ ); + } +} + +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); diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilterForm.css b/src/components/SelectMultipleFilter/SelectMultipleFilterForm.css new file mode 100644 index 00000000..1fa0e41a --- /dev/null +++ b/src/components/SelectMultipleFilter/SelectMultipleFilterForm.css @@ -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); + } +} diff --git a/src/components/SelectMultipleFilter/SelectMultipleFilterForm.js b/src/components/SelectMultipleFilter/SelectMultipleFilterForm.js new file mode 100644 index 00000000..effcc2d6 --- /dev/null +++ b/src/components/SelectMultipleFilter/SelectMultipleFilterForm.js @@ -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 ( +
+ +
+ + + +
+ + ); +}; + +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 +); diff --git a/src/components/index.js b/src/components/index.js index d75ae34e..b581ac2b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -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, diff --git a/src/containers/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap b/src/containers/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap index 8ca5cdc2..2164cfc9 100644 --- a/src/containers/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap +++ b/src/containers/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap @@ -3,6 +3,7 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = `