Merge pull request #743 from sharetribe/map-search-hover

Highlight hovered listing on map
This commit is contained in:
Kimmo Puputti 2018-03-09 16:46:55 +02:00 committed by GitHub
commit bba4d8f96c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 144 additions and 31 deletions

View file

@ -50,7 +50,7 @@ const formatTitle = (title, maxLength) => {
};
export const ListingCardComponent = props => {
const { className, rootClassName, intl, listing } = props;
const { className, rootClassName, intl, listing, setActiveListing } = props;
const classes = classNames(rootClassName || css.root, className);
const currentListing = ensureListing(listing);
const id = currentListing.id.uuid;
@ -65,7 +65,11 @@ export const ListingCardComponent = props => {
return (
<NamedLink className={classes} name="ListingPage" params={{ id, slug }}>
<div className={css.threeToTwoWrapper}>
<div
className={css.threeToTwoWrapper}
onMouseEnter={() => setActiveListing(currentListing.id)}
onMouseLeave={() => setActiveListing(null)}
>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}

View file

@ -11,7 +11,10 @@ exports[`ListingCard matches snapshot 1`] = `
}
}
>
<div>
<div
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<div>
<ResponsiveImage
alt="listing1 title"

View file

@ -90,6 +90,7 @@ const withCoordinatesObfuscated = listings => {
*/
const MapWithGoogleMap = withGoogleMap(props => {
const {
activeListingId,
center,
infoCardOpen,
isOpenOnModal,
@ -103,6 +104,10 @@ const MapWithGoogleMap = withGoogleMap(props => {
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
const isActive = activeListingId
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
: false;
// If location contains only one listing, print price label
if (listingArr.length === 1) {
const listing = listingArr[0];
@ -114,6 +119,7 @@ const MapWithGoogleMap = withGoogleMap(props => {
}
return (
<SearchMapPriceLabel
isActive={isActive}
key={listing.id.uuid}
className={LABEL_HANDLE}
listing={listing}
@ -123,6 +129,7 @@ const MapWithGoogleMap = withGoogleMap(props => {
}
return (
<SearchMapGroupLabel
isActive={isActive}
key={listingArr[0].id.uuid}
className={LABEL_HANDLE}
listings={listingArr}
@ -237,6 +244,7 @@ export class SearchMapComponent extends Component {
onIdle,
zoom,
coordinatesConfig,
activeListingId,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const mapClasses = mapRootClassName || css.mapRoot;
@ -255,6 +263,7 @@ export class SearchMapComponent extends Component {
center={center}
isOpenOnModal={isOpenOnModal}
listings={listings}
activeListingId={activeListingId}
infoCardOpen={this.state.infoCardOpen}
onListingClicked={this.onListingClicked}
onMapLoad={this.onMapLoadHandler}
@ -276,6 +285,7 @@ export class SearchMapComponent extends Component {
SearchMapComponent.defaultProps = {
bounds: null,
center: new sdkTypes.LatLng(0, 0),
activeListingId: null,
className: null,
isOpenOnModal: false,
listings: [],
@ -290,6 +300,7 @@ SearchMapComponent.defaultProps = {
SearchMapComponent.propTypes = {
bounds: propTypes.latlngBounds,
center: propTypes.latlng,
activeListingId: propTypes.uuid,
className: string,
isOpenOnModal: bool,
listings: arrayOf(propTypes.listing),

View file

@ -7,29 +7,48 @@
height: auto;
padding: 0;
border: 0;
&:hover {
& .details,
& .caret {
cursor: pointer;
/* Same as active */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
color: var(--matterColorLight);
box-shadow: var(--boxShadowPopup);
}
}
}
.details {
/**
* Since caret is absolutely positioned,
* label must have relative to be included to the same rendering layer
*/
position: relative;
/* Font */
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightSemiBold);
margin-top: 0;
margin-bottom: 0;
/* Coloring */
border: 0;
padding: 6px 14px;
/* Coloring */
background-color: var(--matterColorLight);
color: var(--matterColor);
/* Borders */
border-radius: 17px;
background-color: var(--marketplaceColor);
color: var(--matterColorLight);
border-style: solid;
border-color: var(--matterColorNegative);
border-width: 1px;
box-shadow: var(--boxShadowPopupLight);
transition: var(--transitionStyleButton);
&:hover {
cursor: pointer;
box-shadow: var(--boxShadowPopup);
}
transition: var(--transitionStyleButton);
/* Overwrite dimensions from font styles */
@media (--viewportMedium) {
@ -38,6 +57,14 @@
}
}
.detailsActive {
/* Same as hover */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
color: var(--matterColorLight);
box-shadow: var(--boxShadowPopup);
}
.caretShadow {
/* Caret / arrow dimensions and position */
width: 6px;
@ -63,5 +90,13 @@
transform: rotate(45deg);
/* Caret should have same bg-color as label */
background-color: var(--marketplaceColor);
background-color: var(--matterColorLight);
transition: var(--transitionStyleButton);
}
.caretActive {
/* Same as hover */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
}

View file

@ -15,17 +15,22 @@ const getPixelPositionOffset = (width, height) => {
class SearchMapGroupLabel extends Component {
shouldComponentUpdate(nextProps) {
return nextProps.listings.length > this.props.listings.length;
const hasSameAmountOfListings = nextProps.listings.length === this.props.listings.length;
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
return !(hasSameAmountOfListings && hasSameActiveStatus);
}
render() {
const { className, rootClassName, listings, onListingClicked } = this.props;
const { className, rootClassName, listings, onListingClicked, isActive } = this.props;
const firstListing = ensureListing(listings[0]);
const geolocation = firstListing.attributes.geolocation;
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
const classes = classNames(rootClassName || css.root, className);
const countLabelClasses = classNames(css.details, { [css.detailsActive]: isActive });
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
return (
<OverlayView
@ -35,8 +40,8 @@ class SearchMapGroupLabel extends Component {
>
<button className={classes} onClick={() => onListingClicked(listings)}>
<div className={css.caretShadow} />
<div className={css.details}>{listings.length}</div>
<div className={css.caret} />
<div className={countLabelClasses}>{listings.length}</div>
<div className={caretClasses} />
</button>
</OverlayView>
);

View file

@ -6,6 +6,19 @@
height: auto;
border: 0;
padding: 0;
&:hover {
& .priceLabel,
& .caret {
cursor: pointer;
/* Same as active */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
color: var(--matterColorLight);
box-shadow: var(--boxShadowPopup);
}
}
}
.priceLabel {
@ -36,12 +49,6 @@
margin-bottom: 0;
transition: var(--transitionStyleButton);
&:hover {
color: var(--marketplaceColor);
cursor: pointer;
box-shadow: var(--boxShadowPopup);
}
/* Overwrite dimensions from font styles */
@media (--viewportMedium) {
margin-top: 0;
@ -49,6 +56,14 @@
}
}
.priceLabelActive {
/* Same as hover */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
color: var(--matterColorLight);
box-shadow: var(--boxShadowPopup);
}
.caretShadow {
/* Caret / arrow dimensions and position */
width: 6px;
@ -73,6 +88,8 @@
margin-left: -3px;
transform: rotate(45deg);
transition: var(--transitionStyleButton);
/* Caret should have same bg-color and border as label */
background-color: var(--matterColorLight);
border-right-style: solid;
@ -82,3 +99,9 @@
border-bottom-color: var(--matterColorNegative);
border-bottom-width: 1px;
}
.caretActive {
/* Same as hover */
background-color: var(--marketplaceColor);
border-color: var(--marketplaceColor);
}

View file

@ -22,11 +22,13 @@ class SearchMapPriceLabel extends Component {
const nextListing = ensureListing(nextProps.listing);
const isSameListing = currentListing.id.uuid === nextListing.id.uuid;
const hasSamePrice = currentListing.attributes.price === nextListing.attributes.price;
return !(isSameListing && hasSamePrice);
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
return !(isSameListing && hasSamePrice && hasSameActiveStatus);
}
render() {
const { className, rootClassName, intl, listing, onListingClicked } = this.props;
const { className, rootClassName, intl, listing, onListingClicked, isActive } = this.props;
const currentListing = ensureListing(listing);
const { geolocation, price } = currentListing.attributes;
@ -37,6 +39,8 @@ class SearchMapPriceLabel extends Component {
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
const classes = classNames(rootClassName || css.root, className);
const priceLabelClasses = classNames(css.priceLabel, { [css.priceLabelActive]: isActive });
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
return (
<OverlayView
@ -46,8 +50,8 @@ class SearchMapPriceLabel extends Component {
>
<button className={classes} onClick={() => onListingClicked(currentListing)}>
<div className={css.caretShadow} />
<div className={css.priceLabel}>{formattedPrice}</div>
<div className={css.caret} />
<div className={priceLabelClasses}>{formattedPrice}</div>
<div className={caretClasses} />
</button>
</OverlayView>
);

View file

@ -6,7 +6,7 @@ import { ListingCard, PaginationLinks } from '../../components';
import css from './SearchResultsPanel.css';
const SearchResultsPanel = props => {
const { className, rootClassName, listings, pagination, search } = props;
const { className, rootClassName, listings, pagination, search, setActiveListing } = props;
const classes = classNames(rootClassName || css.root, className);
const paginationLinks =
@ -22,7 +22,14 @@ const SearchResultsPanel = props => {
return (
<div className={classes}>
<div className={css.listingCards}>
{listings.map(l => <ListingCard className={css.listingCard} key={l.id.uuid} listing={l} />)}
{listings.map(l => (
<ListingCard
className={css.listingCard}
key={l.id.uuid}
listing={l}
setActiveListing={setActiveListing}
/>
))}
{props.children}
</div>
{paginationLinks}

View file

@ -12,6 +12,8 @@ export const SEARCH_MAP_LISTINGS_REQUEST = 'app/SearchPage/SEARCH_MAP_LISTINGS_R
export const SEARCH_MAP_LISTINGS_SUCCESS = 'app/SearchPage/SEARCH_MAP_LISTINGS_SUCCESS';
export const SEARCH_MAP_LISTINGS_ERROR = 'app/SearchPage/SEARCH_MAP_LISTINGS_ERROR';
export const SEARCH_MAP_SET_ACTIVE_LISTING = 'app/SearchPage/SEARCH_MAP_SET_ACTIVE_LISTING';
// ================ Reducer ================ //
const initialState = {
@ -70,6 +72,11 @@ const listingPageReducer = (state = initialState, action = {}) => {
console.error(payload);
return { ...state, searchMapListingsError: payload };
case SEARCH_MAP_SET_ACTIVE_LISTING:
return {
...state,
activeListingId: payload,
};
default:
return state;
}
@ -130,6 +137,11 @@ export const searchListings = searchParams => (dispatch, getState, sdk) => {
});
};
export const setActiveListing = listingId => ({
type: SEARCH_MAP_SET_ACTIVE_LISTING,
payload: listingId,
});
export const searchMapListings = searchParams => (dispatch, getState, sdk) => {
dispatch(searchMapListingsRequest(searchParams));

View file

@ -29,7 +29,7 @@ import {
SearchFiltersPanel,
} from '../../components';
import { TopbarContainer } from '../../containers';
import { searchListings, searchMapListings } from './SearchPage.duck';
import { searchListings, searchMapListings, setActiveListing } from './SearchPage.duck';
import css from './SearchPage.css';
@ -260,6 +260,8 @@ export class SearchPageComponent extends Component {
searchParams,
categories,
amenities,
activeListingId,
onActivateListing,
} = this.props;
// eslint-disable-next-line no-unused-vars
const { mapSearch, page, ...searchInURL } = parse(location.search, {
@ -292,6 +294,7 @@ export class SearchPageComponent extends Component {
const searchMap = (
<SearchMap
activeListingId={activeListingId}
bounds={bounds}
center={origin}
listings={mapListings || []}
@ -369,6 +372,7 @@ export class SearchPageComponent extends Component {
listings={listings}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
setActiveListing={onActivateListing}
/>
</div>
);
@ -475,6 +479,7 @@ SearchPageComponent.defaultProps = {
tab: 'listings',
categories: config.custom.categories,
amenities: config.custom.amenities,
activeListingId: null,
};
const { array, bool, func, oneOf, object, shape, string } = PropTypes;
@ -513,6 +518,7 @@ const mapStateToProps = state => {
searchListingsError,
searchParams,
searchMapListingIds,
activeListingId,
} = state.SearchPage;
const pageListings = getListingsById(state, currentPageResultIds);
const mapListings = getListingsById(
@ -528,6 +534,7 @@ const mapStateToProps = state => {
searchInProgress,
searchListingsError,
searchParams,
activeListingId,
};
};
@ -535,6 +542,7 @@ const mapDispatchToProps = dispatch => ({
onManageDisableScrolling: (componentId, disableScrolling) =>
dispatch(manageDisableScrolling(componentId, disableScrolling)),
onSearchMapListings: searchParams => dispatch(searchMapListings(searchParams)),
onActivateListing: listingId => dispatch(setActiveListing(listingId)),
});
// Note: it is important that the withRouter HOC is **outside** the

View file

@ -119,6 +119,7 @@ exports[`SearchPageComponent matches snapshot 1`] = `
>
<div>
<SearchMapComponent
activeListingId={null}
bounds={null}
center={
LatLng {