Skip to content
This repository has been archived by the owner on Oct 31, 2024. It is now read-only.

feat(default-theme): suggest search styling & debounce #807 #971

Merged
merged 8 commits into from
Aug 3, 2020
3 changes: 3 additions & 0 deletions api/helpers.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface CategoryFilterTermValue {
key: string;
}

// @alpha (undocumented)
export function debounce<T extends (...args: any[]) => any>(fn: T, delay?: number): T;

// @alpha (undocumented)
export function exportUrlQuery(searchCriteria: SearchCriteria): string | undefined;

Expand Down
1 change: 0 additions & 1 deletion packages/default-theme/components/SwHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ export default {
@import "@/assets/scss/variables";

.sw-top-navigation {
--search-bar-width: 100%;
--header-container-padding: 0 var(--spacer-base);
--header-navigation-item-margin: 0 1rem 0 0;

Expand Down
108 changes: 56 additions & 52 deletions packages/default-theme/components/SwSearchBar.vue
Original file line number Diff line number Diff line change
@@ -1,103 +1,107 @@
<template>
<SfSearchBar
:placeholder="$t('Search for products')"
:aria-label="$t('Search for products')"
class="sf-header__search desktop-only"
v-model="typingQuery"
@keyup.native="performSuggestSearch"
@enter="performSearch"
data-cy="search-bar"
/>
<div class="sw-search-bar">
<SfSearchBar
:placeholder="$t('Search for products')"
:aria-label="$t('Search for products')"
class="sf-header__search desktop-only"
v-model="typingQuery"
@keyup.native="performSuggestSearch"
@enter="performSearch"
@focus="isSuggestBoxOpen = true"
data-cy="search-bar"
/>
<SwSuggestSearch
:products="suggestResultProducts"
:total-found="suggestResultTotal"
:search-phrase="typingQuery"
:is-open="isSuggestBoxOpen"
@close="isSuggestBoxOpen = false"
@search="performSearch"
/>
</div>
</template>

<script>
import { ref, reactive, onMounted, watch, computed } from "@vue/composition-api"
import { getSearchPageUrl } from "@shopware-pwa/default-theme/helpers"
import { SfSearchBar } from "@storefront-ui/vue"
import { useProductSearch } from "@shopware-pwa/composables"
import { debounce } from "@shopware-pwa/helpers"
import SwSuggestSearch from "@shopware-pwa/default-theme/components/SwSuggestSearch"

export default {
components: {
SfSearchBar,
SwSuggestSearch,
},
setup(props, { root }) {
const {
currentSearchTerm,
search,
suggestSearch,
suggestionsResult,
resetFilters,
} = useProductSearch(root)

const typingQuery = ref("")
const isSuggestBoxOpen = ref(false)
const suggestResultProducts = computed(
() => suggestionsResult.value && suggestionsResult.value.elements
)
const suggestResultTotal = computed(
() => suggestionsResult.value && suggestionsResult.value.total
)

const performSuggestSearch = debounce((event) => {
if (event && event.key === "Enter") {
return
}
const searchTerm = event.target.value
if (typeof searchTerm === "string" && searchTerm.length > 0) {
suggestSearch(searchTerm)
isSuggestBoxOpen.value = true
} else {
isSuggestBoxOpen.value = false
}
}, 300)

return {
currentSearchTerm,
search,
suggestSearch,
suggestResultTotal,
suggestResultProducts,
isSuggestBoxOpen,
getSearchPageUrl,
typingQuery,
resetFilters,
performSuggestSearch,
}
},
methods: {
performSuggestSearch(event) {
// TODO bring back with debounde when there will be UI preview
// const searchTerm = event.target.value
// if (typeof searchTerm === 'string' && searchTerm.length > 0) {
// try {
// this.suggestSearch(searchTerm)
// } catch (e) {
// console.error('[SwTopNavigation][performSuggestSearch]: ' + e)
// }
// }
},
performSearch(searchTerm) {
if (typeof searchTerm === "string" && searchTerm.length > 0) {
performSearch() {
if (this.typingQuery.length > 0) {
this.resetFilters()
this.$router.push(this.$i18n.path(getSearchPageUrl(searchTerm)))
this.$router.push(this.$i18n.path(getSearchPageUrl(this.typingQuery)))
}
},
},
watch: {
$route(to, from) {
this.isSuggestBoxOpen = false
},
},
}
</script>

<style lang="scss" scoped>
@import "@/assets/scss/variables";

.sw-top-navigation {
::v-deep .sf-search-bar {
--search-bar-width: 100%;
--header-container-padding: 0 var(--spacer-base);
--header-navigation-item-margin: 0 1rem 0 0;
margin-bottom: var(--spacer-sm);
.sw-overlay {
--overlay-z-index: 1;
}

@include for-desktop {
::v-deep .sf-header {
display: flex;
justify-content: space-between;
&__sticky-container {
width: 100%;
}
&__navigation {
flex: 0 0 calc(100% - 20rem);
}
}
}
}
.sw-header {
z-index: 2;
background-color: #fff;
&__icons {
display: flex;
}
&__icon {
cursor: pointer;
}

.sw-search-bar {
width: 100%;
}
</style>
167 changes: 167 additions & 0 deletions packages/default-theme/components/SwSuggestSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<template>
<div
class="search-suggestions"
v-if="isOpen && products"
v-click-outside="close"
>
<div class="search-suggestions__results">
<div class="search-suggestions__results-heading">
<SfHeading :title="title" :level="5" class="sf-heading--left" />
</div>
<SfDivider />
<div class="search-suggestions__results-products">
<SfLink
class="search-suggestions__product"
v-for="product in products.slice(0, 5)"
:key="product.id"
:link="$i18n.path(getProductRouterLink(product))"
>
<SfImage
:src="product.cover.media.url"
:alt="product.label"
class="search-suggestions__product-image"
width="90"
height="90"
/>
<span>
<span class="search-suggestions__product-title">
{{ product.name }}
</span>
<span class="search-suggestions__product-price">{{
product.price[0].gross | price
}}</span>
</span>
</SfLink>
</div>
</div>
<Button
v-if="isShowMoreAvailable"
class="sf-button--secondary sf-button--full-width"
@click="$emit('search')"
>See more</Button
>
</div>
</template>

<script>
import {
SfDivider,
SfHeading,
SfLink,
SfImage,
SfIcon,
} from "@storefront-ui/vue"
import { clickOutside } from "@storefront-ui/vue/src/utilities/directives"
import Button from "@shopware-pwa/default-theme/components/atoms/SwButton"
import {
getProductMainImageUrl,
getProductRegularPrice,
getProductUrl,
getProductSpecialPrice,
getProductName,
} from "@shopware-pwa/helpers"

export default {
components: { SfDivider, SfHeading, SfLink, SfImage, SfIcon, Button },
props: {
isOpen: {
type: Boolean,
default: false,
},
searchPhrase: {
type: String,
default: "",
},
products: {
type: Array,
default: () => [],
},
totalFound: {
type: Number,
default: 0,
},
},
directives: { clickOutside },
computed: {
title() {
return `${this.searchPhrase} (${this.totalFound} found)`
},
isShowMoreAvailable() {
return this.totalFound > 5
},
},
methods: {
getProductRouterLink(product) {
return this.$i18n.path(getProductUrl(product))
},
close() {
this.$emit("close")
},
},
}
</script>

<style lang="scss" scoped>
.search-suggestions {
background-color: var(--c-white);
border: 1px solid var(--c-gray);
padding: 0.625rem;
position: absolute;
width: 18.75rem;
&__results {
padding: var(--spacer-xs) 0;
&-heading {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: var(--space-xs);
.search-suggestions__see-more {
background: transparent;
border: none;
color: var(--c-info);
font-size: var(--font-xs);
padding: 0;
text-decoration: underline;
}
}

&-products {
padding-top: var(--spacer-xs);
}

.sf-heading {
letter-spacing: 0.06rem;
margin: 0;
margin-bottom: var(--spacer-xs);
padding: 0;
::v-deep .sf-heading__title {
--heading-title-font-size: var(--font-sm);
}
}
}
&__product {
border-bottom: 1px solid var(--c-light);
display: flex;
font-family: var(--font-family-primary);
margin-top: var(--spacer-xs);
padding-bottom: var(--spacer-xs);
text-decoration: none;
&-image {
min-width: 5.625rem;
margin-right: var(--spacer-xs);
width: 5.625rem;
object-fit: cover;
}
&-title {
display: block;
font-size: var(--font-sm);
padding-bottom: var(--spacer-xs);
text-decoration: none;
}
&-price {
color: var(--c-primary);
font-size: var(--font-base);
}
}
}
</style>
22 changes: 22 additions & 0 deletions packages/helpers/__tests__/debounce.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { debounce } from "@shopware-pwa/helpers";

describe("Shopware helpers - debounce", () => {
jest.useFakeTimers();
it("should invoke passed function in default timeout", () => {
const func = jest.fn();
const debouncedFunc = debounce(func);
debouncedFunc();
expect(func).not.toBeCalled();
jest.runAllTimers();
expect(func).toHaveBeenCalledTimes(1);
});
it("should invoke debounce two times and clear the previous invokation", () => {
const func = jest.fn();
const debouncedFunc = debounce(func, 500);
debouncedFunc();
expect(func).not.toBeCalled();
debouncedFunc();
jest.runOnlyPendingTimers();
expect(func).toHaveBeenCalledTimes(1);
});
});
18 changes: 18 additions & 0 deletions packages/helpers/src/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @alpha
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300
): T {
let prevTimer: number | null = null;
return ((...args: any[]) => {
if (prevTimer) {
clearTimeout(prevTimer);
}
prevTimer = window.setTimeout(() => {
fn(...args);
prevTimer = null;
}, delay);
}) as any;
}
1 change: 1 addition & 0 deletions packages/helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from "./salutation";
export * from "./error";
export * from "./order";
export * from "./plugins";
export * from "./debounce";