From e07f5cf4c838ea796c94aac693987890b8148690 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 25 Oct 2018 13:13:56 +0300 Subject: [PATCH 01/16] TopbarDesktop example didn't work on Styleguide - expects to be full width --- .../TopbarDesktop/TopbarDesktop.example.js | 20 ------------------- src/examples.js | 3 +-- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 src/components/TopbarDesktop/TopbarDesktop.example.js diff --git a/src/components/TopbarDesktop/TopbarDesktop.example.js b/src/components/TopbarDesktop/TopbarDesktop.example.js deleted file mode 100644 index 5d0c8d16..00000000 --- a/src/components/TopbarDesktop/TopbarDesktop.example.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable no-console */ -import { fakeIntl } from '../../util/test-data'; -import TopbarDesktop from './TopbarDesktop'; - -const noop = () => null; - -export const AuthenticatedDesktopTopbar = { - component: TopbarDesktop, - props: { - isAuthenticated: true, - currentUserHasListings: true, - name: 'John Doe', - onSearchSubmit: values => { - console.log('submit search:', values); - }, - intl: fakeIntl, - onLogout: noop, - }, - group: 'navigation', -}; diff --git a/src/examples.js b/src/examples.js index c8c5994e..e7991bdf 100644 --- a/src/examples.js +++ b/src/examples.js @@ -54,7 +54,6 @@ import * as StripeBankAccountTokenInputField from './components/StripeBankAccoun import * as TabNav from './components/TabNav/TabNav.example'; import * as TabNavHorizontal from './components/TabNavHorizontal/TabNavHorizontal.example'; import * as Tabs from './components/Tabs/Tabs.example'; -import * as TopbarDesktop from './components/TopbarDesktop/TopbarDesktop.example'; import * as UserCard from './components/UserCard/UserCard.example'; // forms @@ -140,6 +139,7 @@ export { PasswordResetForm, PayoutDetailsForm, PropertyGroup, + RangeSlider, ResponsiveImage, ReviewForm, ReviewRating, @@ -154,7 +154,6 @@ export { TabNav, TabNavHorizontal, Tabs, - TopbarDesktop, Typography, UserCard, }; From 080f75f14169c501c2a33059fd1c1f8d0ad3d57c Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 25 Oct 2018 13:14:54 +0300 Subject: [PATCH 02/16] Styleguide cleaning: Footer behaved badly --- src/components/Footer/Footer.example.css | 3 +++ src/components/Footer/Footer.example.js | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 src/components/Footer/Footer.example.css diff --git a/src/components/Footer/Footer.example.css b/src/components/Footer/Footer.example.css new file mode 100644 index 00000000..036e3cc1 --- /dev/null +++ b/src/components/Footer/Footer.example.css @@ -0,0 +1,3 @@ +.example { + overflow-x: hidden; +} diff --git a/src/components/Footer/Footer.example.js b/src/components/Footer/Footer.example.js index 8ed62725..4a7da2e8 100644 --- a/src/components/Footer/Footer.example.js +++ b/src/components/Footer/Footer.example.js @@ -1,6 +1,7 @@ import Footer from './Footer'; +import css from './Footer.example.css'; export const Default = { component: Footer, - props: {}, + props: { className: css.example }, }; From 6200e7f593f8aff3f2def849ab800ed3e2618a00 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 25 Oct 2018 13:15:18 +0300 Subject: [PATCH 03/16] Styleguide cleaning: styleguide links didn't break --- src/containers/StyleguidePage/StyleguidePage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/containers/StyleguidePage/StyleguidePage.js b/src/containers/StyleguidePage/StyleguidePage.js index 055cb596..6843dc01 100644 --- a/src/containers/StyleguidePage/StyleguidePage.js +++ b/src/containers/StyleguidePage/StyleguidePage.js @@ -30,8 +30,8 @@ const Example = props => { className={css.link} > {componentName} - - / + {' '} + /{' '} Date: Thu, 25 Oct 2018 13:16:31 +0300 Subject: [PATCH 04/16] Styleguide cleaning: ImageCarousel was using vw units (temp fix for styleguide) --- .../ImageCarousel/ImageCarousel.example.css | 12 ++++++++++++ .../ImageCarousel/ImageCarousel.example.js | 7 ++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 src/components/ImageCarousel/ImageCarousel.example.css diff --git a/src/components/ImageCarousel/ImageCarousel.example.css b/src/components/ImageCarousel/ImageCarousel.example.css new file mode 100644 index 00000000..a4187e9e --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.example.css @@ -0,0 +1,12 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + width: 100%; + height: 100%; + padding: 0; + + @media (--viewportMedium) { + padding: 100px 10vw; + } +} diff --git a/src/components/ImageCarousel/ImageCarousel.example.js b/src/components/ImageCarousel/ImageCarousel.example.js index 94229a49..3831dd8e 100644 --- a/src/components/ImageCarousel/ImageCarousel.example.js +++ b/src/components/ImageCarousel/ImageCarousel.example.js @@ -1,6 +1,7 @@ import React from 'react'; import { types as sdkTypes } from '../../util/sdkLoader'; import ImageCarousel from './ImageCarousel'; +import css from './ImageCarousel.example.css'; const { UUID } = sdkTypes; @@ -122,15 +123,15 @@ const ImageCarouselWrapper = props => { export const NoImages = { component: ImageCarouselWrapper, - props: { images: [] }, + props: { images: [], rootClassName: css.root }, }; export const SingleImage = { component: ImageCarouselWrapper, - props: { images: [imageSquare] }, + props: { images: [imageSquare], rootClassName: css.root }, }; export const MultipleImages = { component: ImageCarouselWrapper, - props: { images: [imageLandscape, imagePortrait, imageSquare] }, + props: { images: [imageLandscape, imagePortrait, imageSquare], rootClassName: css.root }, }; From d81a4b33aef40726cc182b6c4957f01bfa01b4ea Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 25 Oct 2018 13:17:03 +0300 Subject: [PATCH 05/16] Styleguide cleaning: Menu component label didn't have enough space --- src/components/Menu/Menu.example.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Menu/Menu.example.js b/src/components/Menu/Menu.example.js index 7b94ba45..791b9087 100644 --- a/src/components/Menu/Menu.example.js +++ b/src/components/Menu/Menu.example.js @@ -44,7 +44,7 @@ export const MenuBasic = { const MenuOnRight = () => { return ( -
+
); From 8dcd16081777a0e26d6bea2e6c67f254b36a2003 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 31 Oct 2018 00:18:08 +0200 Subject: [PATCH 06/16] withDimensions context helper --- src/util/contextHelpers.js | 106 +++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/util/contextHelpers.js b/src/util/contextHelpers.js index a72b0da6..9f82c906 100644 --- a/src/util/contextHelpers.js +++ b/src/util/contextHelpers.js @@ -69,6 +69,112 @@ export const withViewport = Component => { return WithViewportComponent; }; +/** + * A higher order component (HOC) that provides dimensions to the wrapped component as a + * `dimensions` prop that has the shape `{ width: 600, height: 400}`. + * + * @param {React.Component} Component to be wrapped by this HOC + * @param {Object} options pass in options like maxWidth and maxHeight. + * + * @return {Object} HOC component which knows its dimensions + */ +export const withDimensions = (Component, options = {}) => { + // The resize event is flooded when the browser is resized. We'll + // use a small timeout to throttle changing the viewport since it + // will trigger rerendering. + const THROTTLE_WAIT_MS = 200; + // First render default wait after mounting (small wait for styled paint) + const RENDER_WAIT_MS = 100; + + class WithDimensionsComponent extends ReactComponent { + constructor(props) { + super(props); + this.element = null; + this.defaultRenderTimeout = null; + + this.state = { width: 0, height: 0 }; + + this.handleWindowResize = throttle(this.handleWindowResize.bind(this), THROTTLE_WAIT_MS); + this.setDimensions = this.setDimensions.bind(this); + } + + componentDidMount() { + window.addEventListener('resize', this.handleWindowResize); + window.addEventListener('orientationchange', this.handleWindowResize); + + this.defaultRenderTimeout = window.setTimeout(() => { + this.setDimensions(); + }, RENDER_WAIT_MS); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleWindowResize); + window.removeEventListener('orientationchange', this.handleWindowResize); + window.clearTimeout(this.defaultRenderTimeout); + } + + handleWindowResize() { + window.requestAnimationFrame(() => { + this.setDimensions(); + }); + } + + setDimensions() { + this.setState(prevState => { + const { clientWidth, clientHeight } = this.element || { clientWidth: 0, clientHeight: 0 }; + return { width: clientWidth, height: clientHeight }; + }); + } + + render() { + // Dimensions from state (i.e. dimension after previous resize) + // These are needed for component rerenders + const { width, height } = this.state; + + // Current dimensions from element reference + const { clientWidth, clientHeight } = this.element || { clientWidth: 0, clientHeight: 0 }; + const hasDimensions = + (width !== 0 && height !== 0) || (clientWidth !== 0 && clientHeight !== 0); + + // clientWidth and clientHeight + const currentDimensions = + clientWidth !== 0 && clientHeight !== 0 + ? { width: clientWidth, height: clientHeight } + : width !== 0 && height !== 0 + ? { width, height } + : {}; + + const props = { ...this.props, dimensions: currentDimensions }; + + // lazyLoadWithDimensions HOC needs to take all given space + // unless max dimensions are provided through options. + const { maxWidth, maxHeight } = options; + const maxWidthMaybe = maxWidth ? { maxWidth } : {}; + const maxHeightMaybe = maxHeight ? { maxHeight } : {}; + const style = + maxWidth || maxHeight + ? { width: '100%', height: '100%', ...maxWidthMaybe, ...maxHeightMaybe } + : { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }; + + return ( +
{ + this.element = element; + }} + style={style} + > + {hasDimensions ? : null} +
+ ); + } + } + + WithDimensionsComponent.displayName = `withDimensions(${Component.displayName || + Component.name})`; + + return WithDimensionsComponent; +}; + /** * A higher order component (HOC) that lazy loads the current element and provides * dimensions to the wrapped component as a `dimensions` prop that has From 433002e6371f1bae623421e5aeff387abc8751c3 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 31 Oct 2018 00:18:39 +0200 Subject: [PATCH 07/16] RangeSlider and FieldRangeSlider --- .../FieldRangeSlider.example.js | 73 +++++++++ .../FieldRangeSlider/FieldRangeSlider.js | 32 ++++ src/components/RangeSlider/Handle.css | 40 +++++ src/components/RangeSlider/Handle.js | 141 ++++++++++++++++++ src/components/RangeSlider/RangeSlider.css | 12 ++ .../RangeSlider/RangeSlider.example.js | 54 +++++++ src/components/RangeSlider/RangeSlider.js | 120 +++++++++++++++ src/components/RangeSlider/Track.css | 33 ++++ src/components/RangeSlider/Track.js | 53 +++++++ src/components/index.js | 1 + src/examples.js | 3 + 11 files changed, 562 insertions(+) create mode 100644 src/components/FieldRangeSlider/FieldRangeSlider.example.js create mode 100644 src/components/FieldRangeSlider/FieldRangeSlider.js create mode 100644 src/components/RangeSlider/Handle.css create mode 100644 src/components/RangeSlider/Handle.js create mode 100644 src/components/RangeSlider/RangeSlider.css create mode 100644 src/components/RangeSlider/RangeSlider.example.js create mode 100644 src/components/RangeSlider/RangeSlider.js create mode 100644 src/components/RangeSlider/Track.css create mode 100644 src/components/RangeSlider/Track.js diff --git a/src/components/FieldRangeSlider/FieldRangeSlider.example.js b/src/components/FieldRangeSlider/FieldRangeSlider.example.js new file mode 100644 index 00000000..ed3af2f5 --- /dev/null +++ b/src/components/FieldRangeSlider/FieldRangeSlider.example.js @@ -0,0 +1,73 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { Form as FinalForm, FormSpy } from 'react-final-form'; +import { Button } from '../../components'; +import FieldRangeSlider from './FieldRangeSlider'; + +const formName = 'Styleguide.FieldRangeSlider.Form'; + +const FormComponent = props => ( + { + const { + formId, + handleSubmit, + onChange, + invalid, + pristine, + submitting, + min, + max, + step, + handles, + } = fieldRenderProps; + const submitDisabled = invalid || pristine || submitting; + + return ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + + + + + ); + }} + /> +); + +export const FieldRangeSliderForm = { + component: FormComponent, + props: { + min: 0, + max: 1000, + step: 5, + handles: [333, 666], + onChange: formState => { + if (formState.dirty) { + console.log('form values changed to:', formState.values); + } + }, + onSubmit: values => { + console.log('submit values:', values); + }, + }, + group: 'custom inputs', +}; diff --git a/src/components/FieldRangeSlider/FieldRangeSlider.js b/src/components/FieldRangeSlider/FieldRangeSlider.js new file mode 100644 index 00000000..e18b09b1 --- /dev/null +++ b/src/components/FieldRangeSlider/FieldRangeSlider.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { Field } from 'react-final-form'; +import classNames from 'classnames'; +import { RangeSlider } from '../../components'; + +const RangeSliderInput = props => { + const { input, handles, ...rest } = props; + const { value, ...inputProps } = input; + + const currentHandles = Array.isArray(value) ? value : handles; + return ; +}; + +const FieldRangeSlider = props => { + const { rootClassName, className, id, label, ...rest } = props; + + if (label && !id) { + throw new Error('id required when a label is given'); + } + + const inputProps = { id, ...rest }; + const classes = classNames(rootClassName, className); + + return ( +
+ {label ? : null} + +
+ ); +}; + +export default FieldRangeSlider; diff --git a/src/components/RangeSlider/Handle.css b/src/components/RangeSlider/Handle.css new file mode 100644 index 00000000..25f50f29 --- /dev/null +++ b/src/components/RangeSlider/Handle.css @@ -0,0 +1,40 @@ +@import '../../marketplace.css'; + +.rootTouchBuffer { + /* Position */ + position: absolute; + top: 50%; + margin-top: -22px; + margin-left: -22px; + + /* Layout */ + width: 44px; + height: 44px; + padding: 0; + border: 0; +} + +.visibleHandle { + /* Position */ + position: absolute; + top: 12px; + margin-left: 12px; + + /* Layout */ + width: 20px; + height: 20px; + border: 2px solid var(--marketplaceColor); + border-radius: 50%; + background-color: var(--matterColorLight); + cursor: pointer; + + &:hoverĀ  { + transform: scale(1.1); + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3); + } +} + +.dragged { + transform: scale(1.1); + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3); +} diff --git a/src/components/RangeSlider/Handle.js b/src/components/RangeSlider/Handle.js new file mode 100644 index 00000000..35bba6fa --- /dev/null +++ b/src/components/RangeSlider/Handle.js @@ -0,0 +1,141 @@ +import React, { Component } from 'react'; +import { func, number, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './Handle.css'; + +class Handle extends Component { + constructor(props) { + super(props); + + this.state = { dragging: false, relativePos: null }; + + this.handleRef = React.createRef(); + this._isMounted = false; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + this.onTouchMove = this.onTouchMove.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + + this.onStart = this.onStart.bind(this); + this.onMove = this.onMove.bind(this); + this.onEnd = this.onEnd.bind(this); + } + + componentDidMount() { + this._isMounted = true; + this.onMouseMoveListener = window.addEventListener('mousemove', this.onMouseMove, false); + this.onMouseUpListener = window.addEventListener('mouseup', this.onMouseUp, false); + } + + componentWillUnmount() { + this._isMounted = false; + window.removeEventListener('mousemove', this.onMouseMoveListener, false); + window.removeEventListener('mouseup', this.onMouseUpListener, false); + } + + onMouseDown(e) { + e.stopPropagation(); + e.preventDefault(); + + this.onStart(e.pageX); + } + onMouseMove(e) { + if (!this.state.dragging) return; + + this.onMove(e.pageX); + } + onMouseUp(e) { + e.stopPropagation(); + e.preventDefault(); + + this.onEnd(); + } + + onTouchStart(e) { + e.stopPropagation(); + e.preventDefault(); + + const touchpageX = e.touches[0].pageX; + this.onStart(touchpageX); + } + onTouchMove(e) { + if (!this.state.dragging) return; + + this.onMove(e.touches[0].pageX); + } + onTouchEnd(e) { + e.stopPropagation(); + e.preventDefault(); + + this.onEnd(); + } + + onStart(pagePosition) { + const { offsetLeft, offsetWidth } = this.handleRef.current; + this.setState({ dragging: true, relativePos: pagePosition - offsetLeft - offsetWidth / 2 }); + this.props.changeActive(); + } + onMove(pagePosition) { + const { min, max, positionToValue } = this.props; + const position = pagePosition - this.state.relativePos; + const currentValue = positionToValue(position); + + const value = currentValue < min ? min : currentValue > max ? max : currentValue; + + this.props.onChange(value); + } + onEnd() { + // Ensuring that setState doesn't get called. + // This a strange behaviour since window.removeEventListener is called in componentWillUnmount + if (this._isMounted) { + this.setState({ dragging: false }); + } + } + + render() { + const { rootClassName, className, value, valueToPosition } = this.props; + const position = valueToPosition(value); + const classes = classNames(rootClassName || css.rootTouchBuffer, className); + + return ( +
+
+
+ ); + } +} + +Handle.defaultProps = { + rootClassName: null, + className: null, +}; + +Handle.propTypes = { + rootClassName: string, + className: string, + min: number.isRequired, + max: number.isRequired, + valueToPosition: func.isRequired, + positionToValue: func.isRequired, +}; + +export default Handle; diff --git a/src/components/RangeSlider/RangeSlider.css b/src/components/RangeSlider/RangeSlider.css new file mode 100644 index 00000000..472de977 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.css @@ -0,0 +1,12 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + margin: 0 10px; + width: calc(100% - 20px); + height: 24px; +} + +.activeHandle { + z-index: 1; +} diff --git a/src/components/RangeSlider/RangeSlider.example.js b/src/components/RangeSlider/RangeSlider.example.js new file mode 100644 index 00000000..19a3cf08 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.example.js @@ -0,0 +1,54 @@ +import React, { Component } from 'react'; +import RangeSlider from './RangeSlider'; + +class RangeSliderWrapper extends Component { + constructor(props) { + super(props); + this.state = { handles: props.handles }; + } + + render() { + return ( + { + this.setState({ handles: v }); + }} + /> + ); + } +} + +export const RangeSliderOneHandle = { + component: RangeSliderWrapper, + props: { + min: 0, + max: 1000, + step: 5, + handles: [500], + }, + group: 'custom inputs', +}; + +export const RangeSliderTwoHandles = { + component: RangeSliderWrapper, + props: { + min: 0, + max: 1000, + step: 5, + handles: [333, 666], + }, + group: 'custom inputs', +}; + +export const RangeSliderThreeHandles = { + component: RangeSliderWrapper, + props: { + min: 0, + max: 1000, + step: 5, + handles: [150, 490, 850], + }, + group: 'custom inputs', +}; diff --git a/src/components/RangeSlider/RangeSlider.js b/src/components/RangeSlider/RangeSlider.js new file mode 100644 index 00000000..8e9cb151 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.js @@ -0,0 +1,120 @@ +import React, { Component } from 'react'; +import { arrayOf, number, shape, string } from 'prop-types'; +import classNames from 'classnames'; +import { withDimensions } from '../../util/contextHelpers'; + +import Handle from './Handle'; +import Track from './Track'; +import css from './RangeSlider.css'; + +class RangeSliderComponent extends Component { + constructor(props) { + super(props); + + const { min, max, handles } = props; + handles.forEach((h, index) => { + if (h < min || h > max || (index < handles.length - 1 && h > handles[index + 1])) { + throw new Error( + 'RangeSlider error: handles need to be given in ascending order and they need to be within min and max values' + ); + } + }); + + this.state = { activeHandle: 0 }; + + this.toPosition = this.toPosition.bind(this); + this.toValue = this.toValue.bind(this); + this.changeActive = this.changeActive.bind(this); + this.onChange = this.onChange.bind(this); + } + + toPosition(value) { + const { dimensions, min, max } = this.props; + const width = dimensions.width; + const valueOffset = value - min; + const scale = max - min; + return Math.round(valueOffset / scale * width); + } + + toValue(position) { + const { dimensions, min, max, step } = this.props; + const width = dimensions.width; + const scale = max - min; + const value = Math.round(position / width * scale) + min; + return Math.ceil(value / step) * step; + } + + changeActive(index) { + this.setState({ activeHandle: index }); + } + + onChange(position, handleIndex) { + this.props.onChange(Object.assign([...this.props.handles], { [handleIndex]: position })); + } + + render() { + const { handles, min, max } = this.props; + + return ( + + {handles.map((h, index) => { + const classes = classNames({ [css.activeHandle]: this.state.activeHandle === index }); + return ( + this.changeActive(index)} + onChange={value => this.onChange(value, index)} + /> + ); + })} + + ); + } +} + +RangeSliderComponent.defaultProps = { + min: 0, + max: 10000000, + step: 1, +}; + +RangeSliderComponent.propTypes = { + handles: arrayOf(number), + min: number, + max: number, + step: number, + dimensions: shape({ + height: number.isRequired, + width: number.isRequired, + }).isRequired, +}; + +const RangeSliderComponentWithDimensions = withDimensions(RangeSliderComponent); + +const RangeSlider = props => { + const { rootClassName, className, ...rest } = props; + const classes = classNames(rootClassName || css.root, className); + return ( +
+ +
+ ); +}; + +RangeSlider.defaultProps = { + rootClassName: null, + className: null, +}; + +RangeSlider.propTypes = { + rootClassName: string, + className: string, +}; + +export default RangeSlider; diff --git a/src/components/RangeSlider/Track.css b/src/components/RangeSlider/Track.css new file mode 100644 index 00000000..e55cbe23 --- /dev/null +++ b/src/components/RangeSlider/Track.css @@ -0,0 +1,33 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + width: 100%; + height: 100%; +} + +.track { + /* Position */ + position: absolute; + top: 50%; + margin-top: -4px; + margin-left: -10px; + + /* Layout */ + width: 100%; + height: 8px; + padding: 0 10px; + + box-sizing: content-box; + background-color: var(--matterColorNegative); + border-radius: 4px; + box-shadow: inset 0 1px 3px 0 rgba(0, 0, 0, 0.1); +} + +.range { + position: absolute; + top: 50%; + margin-top: -4px; + height: 8px; + background-color: var(--marketplaceColor); +} diff --git a/src/components/RangeSlider/Track.js b/src/components/RangeSlider/Track.js new file mode 100644 index 00000000..28bc1ecc --- /dev/null +++ b/src/components/RangeSlider/Track.js @@ -0,0 +1,53 @@ +import React, { Component } from 'react'; +import { array, node, string } from 'prop-types'; +import classNames from 'classnames'; + +import css from './Track.css'; + +class Track extends Component { + render() { + const { rootClassName, className, children, handles, valueToPosition } = this.props; + const positionFromIndex = index => valueToPosition(handles[index]); + + const classes = classNames(rootClassName || css.root, className); + return ( +
+
+ + {handles.reduce((ranges, h, index) => { + return index < handles.length - 1 + ? [ + ...ranges, +
, + ] + : ranges; + }, [])} + + {children} +
+ ); + } +} + +Track.defaultProps = { + rootClassName: null, + className: null, + children: null, + handles: [], +}; + +Track.propTypes = { + rootClassName: string, + className: string, + children: node, + handles: array, +}; + +export default Track; diff --git a/src/components/index.js b/src/components/index.js index 57108049..f277d6d6 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -98,6 +98,7 @@ export { default as PaginationLinks } from './PaginationLinks/PaginationLinks'; export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy'; export { default as Promised } from './Promised/Promised'; export { default as PropertyGroup } from './PropertyGroup/PropertyGroup'; +export { default as RangeSlider } from './RangeSlider/RangeSlider'; export { default as ResponsiveImage } from './ResponsiveImage/ResponsiveImage'; export { default as ReviewModal } from './ReviewModal/ReviewModal'; export { default as ReviewRating } from './ReviewRating/ReviewRating'; diff --git a/src/examples.js b/src/examples.js index e7991bdf..1550165e 100644 --- a/src/examples.js +++ b/src/examples.js @@ -13,6 +13,7 @@ import * as FieldCurrencyInput from './components/FieldCurrencyInput/FieldCurren import * as FieldDateInput from './components/FieldDateInput/FieldDateInput.example'; import * as FieldDateRangeInput from './components/FieldDateRangeInput/FieldDateRangeInput.example'; import * as FieldPhoneNumberInput from './components/FieldPhoneNumberInput/FieldPhoneNumberInput.example'; +import * as FieldRangeSlider from './components/FieldRangeSlider/FieldRangeSlider.example'; import * as FieldReviewRating from './components/FieldReviewRating/FieldReviewRating.example'; import * as FieldSelect from './components/FieldSelect/FieldSelect.example'; import * as FieldTextInput from './components/FieldTextInput/FieldTextInput.example'; @@ -44,6 +45,7 @@ import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example import * as NamedLink from './components/NamedLink/NamedLink.example'; import * as PaginationLinks from './components/PaginationLinks/PaginationLinks.example'; import * as PropertyGroup from './components/PropertyGroup/PropertyGroup.example'; +import * as RangeSlider from './components/RangeSlider/RangeSlider.example'; import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example'; import * as ReviewRating from './components/ReviewRating/ReviewRating.example'; import * as Reviews from './components/Reviews/Reviews.example'; @@ -104,6 +106,7 @@ export { FieldDateInput, FieldDateRangeInput, FieldPhoneNumberInput, + FieldRangeSlider, FieldReviewRating, FieldSelect, FieldTextInput, From 4c610a7014e3ea63bc3702aa3d4afb3bd490ae90 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 30 Oct 2018 22:26:38 +0200 Subject: [PATCH 08/16] PriceFilterForm --- src/forms/PriceFilterForm/PriceFilterForm.css | 192 ++++++++++++++++ src/forms/PriceFilterForm/PriceFilterForm.js | 209 ++++++++++++++++++ src/forms/index.js | 1 + src/marketplaceFonts.css | 7 + src/translations/en.json | 4 + 5 files changed, 413 insertions(+) create mode 100644 src/forms/PriceFilterForm/PriceFilterForm.css create mode 100644 src/forms/PriceFilterForm/PriceFilterForm.js diff --git a/src/forms/PriceFilterForm/PriceFilterForm.css b/src/forms/PriceFilterForm/PriceFilterForm.css new file mode 100644 index 00000000..3d9aea45 --- /dev/null +++ b/src/forms/PriceFilterForm/PriceFilterForm.css @@ -0,0 +1,192 @@ +@import '../../marketplace.css'; + +.root { + display: none; + + /* Borders */ + outline: none; +} + +.popup { + /* By default hide the content */ + display: block; + visibility: hidden; + opacity: 0; + pointer-events: none; + + /* Position */ + position: absolute; + z-index: var(--zIndexPopup); + + /* Layout */ + padding: 15px 30px 20px 30px; + margin-top: 7px; + background-color: var(--matterColorLight); + + /* Borders */ + border-top: 1px solid var(--matterColorNegative); + box-shadow: var(--boxShadowPopup); + border-radius: 4px; + transition: var(--transitionStyleButton); +} + +.isOpenAsPopup { + display: block; + visibility: visible; + opacity: 1; + pointer-events: auto; +} + +.plain { + width: 100%; +} + +.isOpen { + display: block; +} + +.contentWrapper { + display: flex; + flex-wrap: wrap; + max-width: 300px; + padding-top: 5px; + padding-bottom: 5px; + margin-top: 0px; +} +@media (--viewportMedium) { + .contentWrapper { + padding-top: 7px; + padding-bottom: 7px; + margin-top: 0px; + } +} + +.label { + @apply --marketplaceSearchFilterSublabelFontStyles; + padding: 8px 0 12px 0; + margin-right: 18px; +} +@media (--viewportMedium) { + .label { + padding: 8px 0 16px 0; + } +} + +.inputsWrapper { + display: flex; +} + +.minPrice { + @apply --marketplaceSearchFilterSublabelFontStyles; + width: 48px; + text-align: center; + border-bottom-color: var(--attentionColor); + border-bottom-width: 3px; + + -moz-appearance: textfield; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } +} +.maxPrice { + @apply --marketplaceSearchFilterSublabelFontStyles; + width: 48px; + text-align: center; + border-bottom-color: var(--attentionColor); + border-bottom-width: 3px; + + -moz-appearance: textfield; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } +} + +.priceSeparator { + margin: 0 8px; +} + +.sliderWrapper { + display: flex; + padding: 17px 0 25px 0; +} +@media (--viewportMedium) { + .sliderWrapper { + padding: 16px 0 24px 0; + } +} + +.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; + + /* clearButton will add all available space between cancelButton, + * but some hard coded margin is still needed + */ + margin-left: 48px; + + &: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/forms/PriceFilterForm/PriceFilterForm.js b/src/forms/PriceFilterForm/PriceFilterForm.js new file mode 100644 index 00000000..7eb68bc3 --- /dev/null +++ b/src/forms/PriceFilterForm/PriceFilterForm.js @@ -0,0 +1,209 @@ +import React from 'react'; +import { bool, func, number, object, string } from 'prop-types'; +import classNames from 'classnames'; +import debounce from 'lodash/debounce'; +import { Field, Form as FinalForm, FormSpy } from 'react-final-form'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; + +import { Form, RangeSlider } from '../../components'; +import css from './PriceFilterForm.css'; + +const DEBOUNCE_WAIT_TIME = 400; + +// Helper function to parse value for min handle +// Value needs to be between slider's minimum value and current maximum value +const parseMin = (min, currentMax) => value => { + if (isNaN(value)) { + return min; + } + const parsedValue = Number.parseInt(value, 10); + return parsedValue < min ? min : parsedValue > currentMax ? currentMax : parsedValue; +}; + +// Helper function to parse value for max handle +// Value needs to be between slider's max value and current minimum value +const parseMax = (max, currentMin) => value => { + if (isNaN(value)) { + return max; + } + const parsedValue = Number.parseInt(value, 10); + return parsedValue < currentMin ? currentMin : parsedValue > max ? max : parsedValue; +}; + +// PriceFilterForm component +const PriceFilterFormComponent = props => { + const { liveEdit, onChange, onSubmit, onCancel, onClear, ...rest } = props; + + if (liveEdit && !onChange) { + throw new Error('PriceFilterForm: if liveEdit is true you need to provide onChange function'); + } + + if (!liveEdit && !(onCancel && onClear && onSubmit)) { + throw new Error( + 'PriceFilterForm: if liveEdit is false you need to provide onCancel, onClear, and onSubmit functions' + ); + } + + const handleChange = debounce( + formState => { + if (formState.dirty) { + onChange(formState.values); + } + }, + DEBOUNCE_WAIT_TIME, + { leading: false, trailing: true } + ); + + const formCallbacks = liveEdit ? { onSubmit: () => null } : { onSubmit, onCancel, onClear }; + return ( + { + const { + form, + handleSubmit, + id, + showAsPopup, + onClear, + onCancel, + isOpen, + contentRef, + style, + intl, + values, + min, + max, + step, + } = formRenderProps; + const { minPrice, maxPrice } = values; + + const handleCancel = () => { + // reset the final form to initialValues + form.reset(); + onCancel(); + }; + + const clear = intl.formatMessage({ id: 'PriceFilterForm.clear' }); + const cancel = intl.formatMessage({ id: 'PriceFilterForm.cancel' }); + const submit = intl.formatMessage({ id: 'PriceFilterForm.submit' }); + + const classes = classNames(css.root, { + [css.popup]: showAsPopup, + [css.isOpenAsPopup]: showAsPopup && isOpen, + [css.plain]: !showAsPopup, + [css.isOpen]: !showAsPopup && isOpen, + }); + + return ( +
+
+ + + +
+ + - + +
+
+ +
+ { + form.change('minPrice', handles[0]); + form.change('maxPrice', handles[1]); + }} + /> +
+ + {liveEdit ? ( + + ) : ( +
+ + + +
+ )} + + ); + }} + /> + ); +}; + +PriceFilterFormComponent.defaultProps = { + liveEdit: false, + showAsPopup: false, + isOpen: false, + contentRef: null, + style: null, + min: 0, + step: 1, + onCancel: null, + onChange: null, + onClear: null, + onSubmit: null, +}; + +PriceFilterFormComponent.propTypes = { + id: string.isRequired, + liveEdit: bool, + showAsPopup: bool, + onCancel: func, + onChange: func, + onClear: func, + onSubmit: func, + isOpen: bool, + contentRef: func, + style: object, + min: number.isRequired, + max: number.isRequired, + step: number, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const PriceFilterForm = injectIntl(PriceFilterFormComponent); + +export default PriceFilterForm; diff --git a/src/forms/index.js b/src/forms/index.js index e25116d7..00f25fd2 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -14,6 +14,7 @@ export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChan export { default as PasswordRecoveryForm } from './PasswordRecoveryForm/PasswordRecoveryForm'; export { default as PasswordResetForm } from './PasswordResetForm/PasswordResetForm'; export { default as PayoutDetailsForm } from './PayoutDetailsForm/PayoutDetailsForm'; +export { default as PriceFilterForm } from './PriceFilterForm/PriceFilterForm'; export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm'; export { default as ReviewForm } from './ReviewForm/ReviewForm'; export { default as SendMessageForm } from './SendMessageForm/SendMessageForm'; diff --git a/src/marketplaceFonts.css b/src/marketplaceFonts.css index 1631adfe..7767fff1 100644 --- a/src/marketplaceFonts.css +++ b/src/marketplaceFonts.css @@ -271,6 +271,13 @@ } } + --marketplaceSearchFilterSublabelFontStyles { + font-family: 'sofiapro', Helvetica, Arial, sans-serif; + font-weight: var(--fontWeightMedium); + font-size: 18px; + line-height: 18px; + } + --marketplaceListingAttributeFontStyles { font-family: 'sofiapro', Helvetica, Arial, sans-serif; font-weight: var(--fontWeightMedium); diff --git a/src/translations/en.json b/src/translations/en.json index edec37eb..da26e74e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -476,6 +476,10 @@ "PayoutPreferencesPage.stripeNotConnected": "Payment information not saved. Please fill in the form to accept payments from your listings.", "PayoutPreferencesPage.submitButtonText": "Save details", "PayoutPreferencesPage.title": "Payment settings", + "PriceFilterForm.cancel": "Cancel", + "PriceFilterForm.clear": "Clear", + "PriceFilterForm.label": "Price range:", + "PriceFilterForm.submit": "Apply", "PrivacyPolicyPage.heading": "Saunatime Privacy Policy", "PrivacyPolicyPage.privacyTabTitle": "Privacy Policy", "PrivacyPolicyPage.schemaTitle": "Privacy Policy | {siteTitle}", From fea522bf5cd36a0e0c309c1ba8148e7e128d7963 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 30 Oct 2018 22:27:55 +0200 Subject: [PATCH 09/16] SelectMultipleFilterForm: fix padding to match design --- src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css index 2d6f57c3..563539f7 100644 --- a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css +++ b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css @@ -12,7 +12,7 @@ /* Layout */ margin-top: 7px; - padding: 15px 40px 20px 30px; + padding: 15px 30px 20px 30px; /* Borders */ background-color: var(--matterColorLight); From 647dee1360e3bbad23d1568152118cf9859db9fd Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 30 Oct 2018 22:38:04 +0200 Subject: [PATCH 10/16] PriceFilter --- .../PriceFilter/PriceFilter.example.js | 77 +++++++ src/components/PriceFilter/PriceFilter.js | 19 ++ .../PriceFilter/PriceFilterPlain.css | 57 ++++++ .../PriceFilter/PriceFilterPlain.js | 124 ++++++++++++ .../PriceFilter/PriceFilterPopup.css | 43 ++++ .../PriceFilter/PriceFilterPopup.js | 190 ++++++++++++++++++ src/components/index.js | 1 + src/examples.js | 2 + src/translations/en.json | 4 + src/util/currency.js | 27 +++ 10 files changed, 544 insertions(+) create mode 100644 src/components/PriceFilter/PriceFilter.example.js create mode 100644 src/components/PriceFilter/PriceFilter.js create mode 100644 src/components/PriceFilter/PriceFilterPlain.css create mode 100644 src/components/PriceFilter/PriceFilterPlain.js create mode 100644 src/components/PriceFilter/PriceFilterPopup.css create mode 100644 src/components/PriceFilter/PriceFilterPopup.js diff --git a/src/components/PriceFilter/PriceFilter.example.js b/src/components/PriceFilter/PriceFilter.example.js new file mode 100644 index 00000000..029ad18d --- /dev/null +++ b/src/components/PriceFilter/PriceFilter.example.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { stringify, parse } from '../../util/urlHelpers'; + +import PriceFilter from './PriceFilter'; + +const URL_PARAM = 'pub_price'; +const RADIX = 10; + +// Helper for submitting example +const handleSubmit = (urlParam, values, history) => { + const { minPrice, maxPrice } = values || {}; + const queryParams = + minPrice != null && maxPrice != null + ? `?${stringify({ [urlParam]: [minPrice, maxPrice].join(',') })}` + : ''; + history.push(`${window.location.pathname}${queryParams}`); +}; + +const PriceFilterWrapper = withRouter(props => { + const { history, location } = props; + + const params = parse(location.search); + const price = params[URL_PARAM]; + const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; + const initialValues = !!price + ? { + minPrice: valuesFromParams[0], + maxPrice: valuesFromParams[1], + } + : null; + + return ( + { + console.log('Submit PriceFilterForm with (unformatted) values:', values); + handleSubmit(urlParam, values, history); + }} + /> + ); +}); + +export const PriceFilterPopup = { + component: PriceFilterWrapper, + props: { + id: 'PriceFilterPopupExample', + urlParam: URL_PARAM, + min: 0, + max: 1000, + step: 5, + liveEdit: false, + showAsPopup: true, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; + +export const PriceFilterPlain = { + component: PriceFilterWrapper, + props: { + id: 'PriceFilterPlainExample', + urlParam: URL_PARAM, + min: 0, + max: 1000, + step: 5, + liveEdit: true, + showAsPopup: false, + contentPlacementOffset: -14, + // initialValues: handled inside wrapper + // onSubmit: handled inside wrapper + }, + group: 'misc', +}; diff --git a/src/components/PriceFilter/PriceFilter.js b/src/components/PriceFilter/PriceFilter.js new file mode 100644 index 00000000..0a165930 --- /dev/null +++ b/src/components/PriceFilter/PriceFilter.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { bool } from 'prop-types'; +import PriceFilterPlain from './PriceFilterPlain'; +import PriceFilterPopup from './PriceFilterPopup'; + +const PriceFilter = props => { + const { showAsPopup, ...rest } = props; + return showAsPopup ? : ; +}; + +PriceFilter.defaultProps = { + showAsPopup: false, +}; + +PriceFilter.propTypes = { + showAsPopup: bool, +}; + +export default PriceFilter; diff --git a/src/components/PriceFilter/PriceFilterPlain.css b/src/components/PriceFilter/PriceFilterPlain.css new file mode 100644 index 00000000..1fae6d29 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPlain.css @@ -0,0 +1,57 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + padding-top: 24px; + padding-bottom: 17px; + border-bottom: 1px solid var(--matterColorNegative); +} + +.filterLabel, +.filterLabelSelected { + @apply --marketplaceH3FontStyles; + + /* Baseline adjustment for label text */ + margin-top: 0; + margin-bottom: 12px; + padding: 4px 0 2px 0; +} + +.filterLabel { + color: var(--matterColorDark); +} + +.filterLabelSelected { + color: var(--marketplaceColor); +} + +.labelButton { + /* Override button styles */ + outline: none; + text-align: left; + border: none; + padding: 0; + cursor: pointer; +} + +.clearButton { + @apply --marketplaceH5FontStyles; + font-weight: var(--fontWeightMedium); + color: var(--matterColorAnti); + + /* Layout */ + display: inline; + float: right; + margin-top: 6px; + padding: 0; + + /* Override button styles */ + outline: none; + text-align: left; + border: none; + + &:focus, + &:hover { + color: var(--matterColor); + } +} diff --git a/src/components/PriceFilter/PriceFilterPlain.js b/src/components/PriceFilter/PriceFilterPlain.js new file mode 100644 index 00000000..89711b22 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPlain.js @@ -0,0 +1,124 @@ +import React, { Component } from 'react'; +import { func, number, shape, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; +import { propTypes } from '../../util/types'; +import { formatCurrencyMajorUnit } from '../../util/currency'; +import config from '../../config'; + +import { PriceFilterForm } from '../../forms'; + +import css from './PriceFilterPlain.css'; + +class PriceFilterPlainComponent extends Component { + constructor(props) { + super(props); + this.state = { isOpen: true }; + + this.handleChange = this.handleChange.bind(this); + this.handleClear = this.handleClear.bind(this); + this.toggleIsOpen = this.toggleIsOpen.bind(this); + } + + handleChange(values) { + const { onSubmit, urlParam } = this.props; + onSubmit(urlParam, values); + } + + handleClear() { + const { onSubmit, urlParam } = this.props; + onSubmit(urlParam, null); + } + + toggleIsOpen() { + this.setState(prevState => ({ isOpen: !prevState.isOpen })); + } + + render() { + const { + rootClassName, + className, + id, + initialValues, + min, + max, + step, + intl, + currencyConfig, + } = this.props; + const classes = classNames(rootClassName || css.root, className); + const { minPrice, maxPrice } = initialValues || {}; + + const hasValue = value => value != null; + const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice); + + const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel; + const labelText = hasInitialValues + ? intl.formatMessage( + { id: 'PriceFilter.labelSelectedPlain' }, + { + minPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, minPrice), + maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice), + } + ) + : intl.formatMessage({ id: 'PriceFilter.label' }); + + return ( +
+
+ + +
+ { + this.filterContent = node; + }} + min={min} + max={max} + step={step} + liveEdit + isOpen={this.state.isOpen} + /> +
+ ); + } +} + +PriceFilterPlainComponent.defaultProps = { + rootClassName: null, + className: null, + initialValues: null, + step: number, + currencyConfig: config.currencyConfig, +}; + +PriceFilterPlainComponent.propTypes = { + rootClassName: string, + className: string, + id: string.isRequired, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: shape({ + minPrice: number.isRequired, + maxPrice: number.isRequired, + }), + min: number.isRequired, + max: number.isRequired, + step: number, + currencyConfig: propTypes.currencyConfig, + + // form injectIntl + intl: intlShape.isRequired, +}; + +const PriceFilterPlain = injectIntl(PriceFilterPlainComponent); + +export default PriceFilterPlain; diff --git a/src/components/PriceFilter/PriceFilterPopup.css b/src/components/PriceFilter/PriceFilterPopup.css new file mode 100644 index 00000000..67812d59 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPopup.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/PriceFilter/PriceFilterPopup.js b/src/components/PriceFilter/PriceFilterPopup.js new file mode 100644 index 00000000..b50e84eb --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPopup.js @@ -0,0 +1,190 @@ +import React, { Component } from 'react'; +import { func, number, shape, string } from 'prop-types'; +import classNames from 'classnames'; +import { injectIntl, intlShape } from 'react-intl'; +import { propTypes } from '../../util/types'; +import { formatCurrencyMajorUnit } from '../../util/currency'; +import config from '../../config'; + +import { PriceFilterForm } from '../../forms'; +import css from './PriceFilterPopup.css'; + +const KEY_CODE_ESCAPE = 27; + +class PriceFilterPopup 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 { onSubmit, urlParam } = this.props; + this.setState({ isOpen: false }); + onSubmit(urlParam, values); + } + + handleClear() { + const { onSubmit, urlParam } = this.props; + this.setState({ isOpen: false }); + onSubmit(urlParam, null); + } + + handleCancel() { + const { onSubmit, initialValues, urlParam } = this.props; + this.setState({ isOpen: false }); + onSubmit(urlParam, 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, + id, + initialValues, + min, + max, + step, + intl, + currencyConfig, + } = this.props; + const classes = classNames(rootClassName || css.root, className); + const { minPrice, maxPrice } = initialValues || {}; + + const hasValue = value => value != null; + const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice); + + const label = hasInitialValues + ? intl.formatMessage( + { id: 'PriceFilter.labelSelectedButton' }, + { + minPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, minPrice), + maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice), + } + ) + : intl.formatMessage({ id: 'PriceFilter.label' }); + + const labelStyles = hasInitialValues ? css.labelSelected : css.label; + const contentStyle = this.positionStyleForContent(); + + return ( +
{ + this.filter = node; + }} + > + + { + this.filterContent = node; + }} + style={contentStyle} + min={min} + max={max} + step={step} + showAsPopup + isOpen={this.state.isOpen} + /> +
+ ); + } +} + +PriceFilterPopup.defaultProps = { + rootClassName: null, + className: null, + initialValues: null, + contentPlacementOffset: 0, + liveEdit: false, + step: number, + currencyConfig: config.currencyConfig, +}; + +PriceFilterPopup.propTypes = { + rootClassName: string, + className: string, + id: string.isRequired, + urlParam: string.isRequired, + onSubmit: func.isRequired, + initialValues: shape({ + minPrice: number.isRequired, + maxPrice: number.isRequired, + }), + contentPlacementOffset: number, + min: number.isRequired, + max: number.isRequired, + step: number, + currencyConfig: propTypes.currencyConfig, + + // form injectIntl + intl: intlShape.isRequired, +}; + +export default injectIntl(PriceFilterPopup); diff --git a/src/components/index.js b/src/components/index.js index f277d6d6..2094d181 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -95,6 +95,7 @@ export { default as NotificationBadge } from './NotificationBadge/NotificationBa export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDiscussionPanel'; export { default as Page } from './Page/Page'; export { default as PaginationLinks } from './PaginationLinks/PaginationLinks'; +export { default as PriceFilter } from './PriceFilter/PriceFilter'; export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy'; export { default as Promised } from './Promised/Promised'; export { default as PropertyGroup } from './PropertyGroup/PropertyGroup'; diff --git a/src/examples.js b/src/examples.js index 1550165e..fe7ec969 100644 --- a/src/examples.js +++ b/src/examples.js @@ -44,6 +44,7 @@ import * as Modal from './components/Modal/Modal.example'; import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example'; import * as NamedLink from './components/NamedLink/NamedLink.example'; import * as PaginationLinks from './components/PaginationLinks/PaginationLinks.example'; +import * as PriceFilter from './components/PriceFilter/PriceFilter.example'; import * as PropertyGroup from './components/PropertyGroup/PropertyGroup.example'; import * as RangeSlider from './components/RangeSlider/RangeSlider.example'; import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example'; @@ -141,6 +142,7 @@ export { PasswordRecoveryForm, PasswordResetForm, PayoutDetailsForm, + PriceFilter, PropertyGroup, RangeSlider, ResponsiveImage, diff --git a/src/translations/en.json b/src/translations/en.json index da26e74e..4a745a12 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -476,6 +476,10 @@ "PayoutPreferencesPage.stripeNotConnected": "Payment information not saved. Please fill in the form to accept payments from your listings.", "PayoutPreferencesPage.submitButtonText": "Save details", "PayoutPreferencesPage.title": "Payment settings", + "PriceFilter.clear": "Clear", + "PriceFilter.label": "Price", + "PriceFilter.labelSelectedPlain": "Price: {minPrice} - {maxPrice}", + "PriceFilter.labelSelectedButton": "{minPrice} - {maxPrice}", "PriceFilterForm.cancel": "Cancel", "PriceFilterForm.clear": "Clear", "PriceFilterForm.label": "Price range:", diff --git a/src/util/currency.js b/src/util/currency.js index fd21af9a..fb09dc3d 100644 --- a/src/util/currency.js +++ b/src/util/currency.js @@ -258,3 +258,30 @@ export const formatMoney = (intl, value) => { return intl.formatNumber(valueAsNumber, numberFormatOptions); }; + +/** + * Format the given major-unit string value as currency. E.g. "10" -> "$10". + * + * NOTE: This function should not be used with listing prices or other Money type. + * This can be used with price filters and other components that doesn't send Money types to API. + * + * @param {Object} intl + * @param {String} value + * + * @return {String} formatted money value + */ +export const formatCurrencyMajorUnit = (intl, currency, valueWithoutSubunits) => { + const valueAsNumber = new Decimal(valueWithoutSubunits).toNumber(); + + // See: https://github.com/yahoo/react-intl/wiki/API#formatnumber + const numberFormatOptions = { + style: 'currency', + currency, + currencyDisplay: 'symbol', + useGrouping: true, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }; + + return intl.formatNumber(valueAsNumber, numberFormatOptions); +}; From 009f14e2c7e591060973246bbcc26b41ae41c01d Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 30 Oct 2018 22:47:04 +0200 Subject: [PATCH 11/16] SearchFilters and SearchFiltersMobile --- src/components/SearchFilters/SearchFilters.js | 44 ++++++++++++++++++- .../SearchFiltersMobile.js | 44 +++++++++++++++++++ src/util/types.js | 12 ++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index 4b0f213b..4bd1906f 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { withRouter } from 'react-router-dom'; import omit from 'lodash/omit'; -import { SelectSingleFilter, SelectMultipleFilter } from '../../components'; +import { SelectSingleFilter, SelectMultipleFilter, PriceFilter } from '../../components'; import routeConfiguration from '../../routeConfiguration'; import { createResourceLocatorString } from '../../util/routes'; import { propTypes } from '../../util/types'; @@ -14,6 +14,7 @@ import css from './SearchFilters.css'; // Dropdown container can have a positional offset (in pixels) const FILTER_DROPDOWN_OFFSET = -14; +const RADIX = 10; // resolve initial value for a single value filter const initialValue = (queryParams, paramName) => { @@ -25,6 +26,18 @@ const initialValues = (queryParams, paramName) => { return !!queryParams[paramName] ? queryParams[paramName].split(',') : []; }; +const initialPriceRangeValue = (queryParams, paramName) => { + const price = queryParams[paramName]; + const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; + + return !!price && valuesFromParams.length === 2 + ? { + minPrice: valuesFromParams[0], + maxPrice: valuesFromParams[1], + } + : null; +}; + const SearchFiltersComponent = props => { const { rootClassName, @@ -35,6 +48,7 @@ const SearchFiltersComponent = props => { searchInProgress, categoryFilter, amenitiesFilter, + priceFilter, isSearchFiltersPanelOpen, toggleSearchFiltersPanel, searchFiltersPanelSelectedCount, @@ -61,6 +75,10 @@ const SearchFiltersComponent = props => { ? initialValue(urlQueryParams, categoryFilter.paramName) : null; + const initialPriceRange = priceFilter + ? initialPriceRangeValue(urlQueryParams, priceFilter.paramName) + : null; + const handleSelectOptions = (urlParam, options) => { const queryParams = options && options.length > 0 @@ -80,6 +98,16 @@ const SearchFiltersComponent = props => { history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); }; + const handlePrice = (urlParam, range) => { + const { minPrice, maxPrice } = range || {}; + const queryParams = + minPrice != null && maxPrice != null + ? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` } + : omit(urlQueryParams, urlParam); + + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + }; + const categoryFilterElement = categoryFilter ? ( { /> ) : null; + const priceFilterElement = priceFilter ? ( + + ) : null; + const toggleSearchFiltersPanelButtonClasses = isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 ? css.searchFiltersPanelOpen @@ -126,6 +166,7 @@ const SearchFiltersComponent = props => {
{categoryFilterElement} {amenitiesFilterElement} + {priceFilterElement} {toggleSearchFiltersPanelButton}
@@ -174,6 +215,7 @@ SearchFiltersComponent.propTypes = { onManageDisableScrolling: func.isRequired, categoriesFilter: propTypes.filterConfig, amenitiesFilter: propTypes.filterConfig, + priceFilter: propTypes.filterConfig, isSearchFiltersPanelOpen: bool, toggleSearchFiltersPanel: func, searchFiltersPanelSelectedCount: number, diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index d175ad9c..a1cfb102 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -11,12 +11,15 @@ import { SecondaryButton, ModalInMobile, Button, + PriceFilter, SelectSingleFilterPlain, SelectMultipleFilterPlain, } from '../../components'; import { propTypes } from '../../util/types'; import css from './SearchFiltersMobile.css'; +const RADIX = 10; + class SearchFiltersMobileComponent extends Component { constructor(props) { super(props); @@ -28,8 +31,10 @@ class SearchFiltersMobileComponent extends Component { this.resetAll = this.resetAll.bind(this); this.handleSelectSingle = this.handleSelectSingle.bind(this); this.handleSelectMultiple = this.handleSelectMultiple.bind(this); + this.handlePrice = this.handlePrice.bind(this); this.initialValue = this.initialValue.bind(this); this.initialValues = this.initialValues.bind(this); + this.initialPriceRangeValue = this.initialPriceRangeValue.bind(this); } // Open filters modal, set the initial parameters to current ones @@ -84,6 +89,17 @@ class SearchFiltersMobileComponent extends Component { history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); } + handlePrice(urlParam, range) { + const { urlQueryParams, history } = this.props; + const { minPrice, maxPrice } = range || {}; + const queryParams = + minPrice != null && maxPrice != null + ? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` } + : omit(urlQueryParams, urlParam); + + history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams)); + } + // Reset all filter query parameters resetAll(e) { const { urlQueryParams, history, filterParamNames } = this.props; @@ -108,6 +124,19 @@ class SearchFiltersMobileComponent extends Component { return !!urlQueryParams[paramName] ? urlQueryParams[paramName].split(',') : []; } + initialPriceRangeValue(paramName) { + const urlQueryParams = this.props.urlQueryParams; + const price = urlQueryParams[paramName]; + const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : []; + + return !!price && valuesFromParams.length === 2 + ? { + minPrice: valuesFromParams[0], + maxPrice: valuesFromParams[1], + } + : null; + } + render() { const { rootClassName, @@ -121,6 +150,7 @@ class SearchFiltersMobileComponent extends Component { selectedFiltersCount, categoryFilter, amenitiesFilter, + priceFilter, intl, } = this.props; @@ -182,6 +212,19 @@ class SearchFiltersMobileComponent extends Component { /> ) : null; + const initialPriceRange = this.initialPriceRangeValue(priceFilter.paramName); + + const priceFilterElement = priceFilter ? ( + + ) : null; + return (
@@ -213,6 +256,7 @@ class SearchFiltersMobileComponent extends Component {
{categoryFilterElement} {amenitiesFilterElement} + {priceFilterElement}