Merged in fix/SW-2253-consolidate-autocomplete-search (pull request #1795)
Consolidate autocomplete search SW-2253 SW-2338 * use fuse.js for fuzzy search * Handle weird behaviour when search field loses focus on destinationPage * Add error logging for JumpTo when no URL was provided * Switch to use <Typography /> over <Caption /> * fix: bookingWidget search label should always be red * fix: searchHistory can no longer add invalid items * fix: list more hits when searching * fix: issue when searchField value was undefined * fix: don't show searchHistory label if no searchHistory items * simplify skeleton for listitems in search Approved-by: Linus Flood
This commit is contained in:
@@ -47,21 +47,13 @@ export function ListItemSkeleton() {
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
>
|
||||
<div style={{ marginBottom: "0.25rem" }}>
|
||||
<SkeletonShimmer width={"200px"} height="18px" display="block" />
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
>
|
||||
<div>
|
||||
<SkeletonShimmer width={"70px"} height="18px" display="block" />
|
||||
</Body>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
@@ -23,6 +23,7 @@ import styles from "./searchList.module.css"
|
||||
import type { SearchListProps } from "@/types/components/search"
|
||||
|
||||
export default function SearchList({
|
||||
searchInputName,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
handleClearSearchHistory,
|
||||
@@ -33,14 +34,14 @@ export default function SearchList({
|
||||
}: SearchListProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitted },
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
const searchError = errors[searchInputName]
|
||||
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 250)
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 300)
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearch(search)
|
||||
@@ -57,14 +58,14 @@ export default function SearchList({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
clearErrors("search")
|
||||
}, [search, clearErrors])
|
||||
clearErrors(searchInputName)
|
||||
}, [search, clearErrors, searchInputName])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError) {
|
||||
timeoutID = setTimeout(() => {
|
||||
clearErrors("search")
|
||||
clearErrors(searchInputName)
|
||||
// magic number originates from animation
|
||||
// 5000ms delay + 120ms exectuion
|
||||
}, 5120)
|
||||
@@ -75,15 +76,7 @@ export default function SearchList({
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
}
|
||||
}, [clearErrors, searchError])
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true)
|
||||
}, [setHasMounted])
|
||||
|
||||
if (!hasMounted) {
|
||||
return null
|
||||
}
|
||||
}, [clearErrors, searchError, searchInputName])
|
||||
|
||||
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
||||
if (searchError.message === "Required") {
|
||||
@@ -166,7 +159,7 @@ export default function SearchList({
|
||||
"We couldn't find a matching location for your search.",
|
||||
})}
|
||||
</Body>
|
||||
{searchHistory && (
|
||||
{searchHistory && searchHistory.length > 0 && (
|
||||
<>
|
||||
<Divider className={styles.noResultsDivider} color="beige" />
|
||||
<Footnote
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { cva } from "class-variance-authority"
|
||||
import Downshift from "downshift"
|
||||
import { type ChangeEvent, type FormEvent } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { type AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import { Input } from "../Input"
|
||||
import SearchList from "./SearchList"
|
||||
@@ -15,15 +20,27 @@ import { useSearchHistory } from "./useSearchHistory"
|
||||
|
||||
import styles from "./search.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import type { SearchProps } from "@/types/components/search"
|
||||
interface SearchProps {
|
||||
className?: string
|
||||
handlePressEnter: () => void
|
||||
inputName: string
|
||||
onSelect?: (selectedItem: AutoCompleteLocation) => void
|
||||
variant?: "rounded" | "default"
|
||||
withSearchButton?: boolean
|
||||
selectOnBlur?: boolean
|
||||
}
|
||||
|
||||
const SEARCH_TERM_NAME = "search"
|
||||
|
||||
export default function Search({ handlePressEnter }: SearchProps) {
|
||||
const { register, setValue } = useFormContext<BookingWidgetSchema>()
|
||||
export function Search({
|
||||
handlePressEnter,
|
||||
inputName: SEARCH_TERM_NAME,
|
||||
onSelect,
|
||||
variant,
|
||||
withSearchButton = false,
|
||||
selectOnBlur = false,
|
||||
}: SearchProps) {
|
||||
const { register, setValue, setFocus } = useFormContext()
|
||||
const intl = useIntl()
|
||||
const searchTerm = useWatch({ name: SEARCH_TERM_NAME })
|
||||
const searchTerm = useWatch({ name: SEARCH_TERM_NAME }) as string
|
||||
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||
useSearchHistory()
|
||||
|
||||
@@ -47,6 +64,7 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
||||
case "cities":
|
||||
setValue("hotel", undefined)
|
||||
setValue("city", selectedItem.name)
|
||||
|
||||
break
|
||||
case "hotels":
|
||||
setValue("hotel", +selectedItem.id)
|
||||
@@ -56,12 +74,21 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
||||
console.error("Unhandled type:", selectedItem.type)
|
||||
break
|
||||
}
|
||||
|
||||
onSelect?.(selectedItem)
|
||||
}
|
||||
|
||||
function handleClearSearchHistory() {
|
||||
clearHistory()
|
||||
}
|
||||
|
||||
const searchInputClassName = searchInputVariants({
|
||||
withSearchButton: withSearchButton,
|
||||
})
|
||||
const clearButtonClassName = clearButtonVariants({
|
||||
visible: !!searchTerm?.trim(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
inputValue={searchTerm}
|
||||
@@ -78,48 +105,95 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
openMenu,
|
||||
selectHighlightedItem,
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
<label
|
||||
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
||||
className={styles.label}
|
||||
>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={isOpen ? "uiTextActive" : "red"}
|
||||
asChild
|
||||
<div className={searchContainerVariants({ variant })}>
|
||||
<div className={styles.inputContainer}>
|
||||
<label
|
||||
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
||||
className={labelVariants({
|
||||
color: !withSearchButton || isOpen ? "red" : "default",
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Where to?",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<label className={styles.searchInput}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: SEARCH_TERM_NAME,
|
||||
onFocus() {
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
defaultMessage: "Hotels & Destinations",
|
||||
}),
|
||||
value: searchTerm,
|
||||
...register(SEARCH_TERM_NAME, {
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && !isOpen) {
|
||||
handlePressEnter()
|
||||
}
|
||||
},
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>
|
||||
{intl.formatMessage({ defaultMessage: "Where to?" })}
|
||||
</span>
|
||||
</Typography>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<div className={searchInputClassName}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: SEARCH_TERM_NAME,
|
||||
onFocus() {
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
defaultMessage: "Hotels & Destinations",
|
||||
}),
|
||||
value: searchTerm,
|
||||
...register(SEARCH_TERM_NAME, {
|
||||
onChange: handleOnChange,
|
||||
onBlur: () => {
|
||||
if (selectOnBlur) {
|
||||
selectHighlightedItem()
|
||||
}
|
||||
},
|
||||
}),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && !isOpen) {
|
||||
handlePressEnter()
|
||||
}
|
||||
},
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{withSearchButton && (
|
||||
<div className={styles.searchButtonContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button
|
||||
variant="Text"
|
||||
size="Small"
|
||||
onPress={() => {
|
||||
setValue(SEARCH_TERM_NAME, "")
|
||||
}}
|
||||
className={clearButtonClassName}
|
||||
>
|
||||
{intl.formatMessage({ defaultMessage: "Clear" })}
|
||||
</Button>
|
||||
</Typography>
|
||||
<Button
|
||||
className={styles.searchButton}
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
type="submit"
|
||||
onPress={() => {
|
||||
if (!searchTerm) {
|
||||
setFocus(SEARCH_TERM_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
openMenu()
|
||||
setTimeout(() => {
|
||||
// This is a workaround to ensure that the menu is open before selecting the highlighted item
|
||||
// Otherwise there is no highlighted item.
|
||||
// Would need to keep track of the last highlighted item otherwise
|
||||
selectHighlightedItem()
|
||||
}, 0)
|
||||
}}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<>
|
||||
<MaterialIcon icon="search" color="CurrentColor" />
|
||||
{intl.formatMessage({ defaultMessage: "Search" })}
|
||||
</>
|
||||
</Typography>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
@@ -129,6 +203,7 @@ export default function Search({ handlePressEnter }: SearchProps) {
|
||||
isOpen={isOpen}
|
||||
search={searchTerm}
|
||||
searchHistory={searchHistory}
|
||||
searchInputName={SEARCH_TERM_NAME}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -141,13 +216,9 @@ export function SearchSkeleton() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.label}>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Where to?",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{intl.formatMessage({ defaultMessage: "Where to?" })}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
||||
@@ -155,3 +226,48 @@ export function SearchSkeleton() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const searchContainerVariants = cva(styles.container, {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
rounded: styles.rounded,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
})
|
||||
|
||||
const searchInputVariants = cva(styles.searchInput, {
|
||||
variants: {
|
||||
withSearchButton: {
|
||||
true: styles.withSearchButton,
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
withSearchButton: false,
|
||||
},
|
||||
})
|
||||
|
||||
const clearButtonVariants = cva(styles.clearButton, {
|
||||
variants: {
|
||||
visible: {
|
||||
true: styles.clearButtonVisible,
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const labelVariants = cva(styles.label, {
|
||||
variants: {
|
||||
color: {
|
||||
default: "",
|
||||
red: styles.red,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: "default",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,34 +6,71 @@
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
position: relative;
|
||||
height: 60px;
|
||||
|
||||
&.rounded {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
||||
border: 1px solid var(--Border-Intense);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
|
||||
&:has(input:active, input:focus, input:focus-within) {
|
||||
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
||||
}
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
.label {
|
||||
flex: 1;
|
||||
&:has(
|
||||
~ .inputContainer input:active,
|
||||
~ .inputContainer input:focus,
|
||||
~ .inputContainer input:focus-within
|
||||
)
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
}
|
||||
|
||||
.container:has(input:active, input:focus, input:focus-within) {
|
||||
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
|
||||
.searchButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.label:has(
|
||||
~ .inputContainer input:active,
|
||||
~ .inputContainer input:focus,
|
||||
~ .inputContainer input:focus-within
|
||||
)
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
.searchButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
||||
|
||||
align-items: center;
|
||||
display: grid;
|
||||
|
||||
@@ -42,4 +79,21 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.withSearchButton {
|
||||
& input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
|
||||
&.clearButtonVisible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
type AutoCompleteLocation,
|
||||
autoCompleteLocationSchema,
|
||||
} from "@/server/routers/autocomplete/schema"
|
||||
|
||||
export const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
export function useSearchHistory() {
|
||||
const MAX_HISTORY_LENGTH = 5
|
||||
const KEY = useSearchHistoryKey()
|
||||
|
||||
function getHistoryFromLocalStorage(): AutoCompleteLocation[] {
|
||||
const stringifiedHistory = localStorage.getItem(
|
||||
SEARCH_HISTORY_LOCALSTORAGE_KEY
|
||||
)
|
||||
const getHistoryFromLocalStorage = useCallback((): AutoCompleteLocation[] => {
|
||||
const stringifiedHistory = localStorage.getItem(KEY)
|
||||
|
||||
try {
|
||||
const parsedHistory = JSON.parse(stringifiedHistory ?? "[]")
|
||||
@@ -26,14 +26,18 @@ export function useSearchHistory() {
|
||||
return existingHistory
|
||||
} catch (error) {
|
||||
console.error("Failed to parse search history:", error)
|
||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
||||
localStorage.removeItem(KEY)
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
}, [KEY])
|
||||
|
||||
function updateSearchHistory(newItem: AutoCompleteLocation) {
|
||||
const existingHistory = getHistoryFromLocalStorage()
|
||||
if (!autoCompleteLocationSchema.safeParse(newItem).success) {
|
||||
return existingHistory
|
||||
}
|
||||
|
||||
const oldSearchHistoryWithoutTheNew = existingHistory.filter(
|
||||
(h) => h.type !== newItem.type || h.id !== newItem.id
|
||||
)
|
||||
@@ -42,10 +46,7 @@ export function useSearchHistory() {
|
||||
newItem,
|
||||
...oldSearchHistoryWithoutTheNew.slice(0, MAX_HISTORY_LENGTH - 1),
|
||||
]
|
||||
localStorage.setItem(
|
||||
SEARCH_HISTORY_LOCALSTORAGE_KEY,
|
||||
JSON.stringify(updatedSearchHistory)
|
||||
)
|
||||
localStorage.setItem(KEY, JSON.stringify(updatedSearchHistory))
|
||||
|
||||
return updatedSearchHistory
|
||||
}
|
||||
@@ -54,17 +55,19 @@ export function useSearchHistory() {
|
||||
|
||||
useEffect(() => {
|
||||
setSearchHistory(getHistoryFromLocalStorage())
|
||||
}, [])
|
||||
}, [KEY, getHistoryFromLocalStorage])
|
||||
|
||||
function clearHistory() {
|
||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
||||
localStorage.removeItem(KEY)
|
||||
setSearchHistory([])
|
||||
}
|
||||
|
||||
function insertSearchHistoryItem(
|
||||
newItem: AutoCompleteLocation
|
||||
): AutoCompleteLocation[] {
|
||||
const updatedHistory = updateSearchHistory(newItem)
|
||||
setSearchHistory(updatedHistory)
|
||||
|
||||
return updatedHistory
|
||||
}
|
||||
|
||||
@@ -74,3 +77,10 @@ export function useSearchHistory() {
|
||||
clearHistory,
|
||||
}
|
||||
}
|
||||
|
||||
function useSearchHistoryKey() {
|
||||
const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
|
||||
const lang = useLang()
|
||||
|
||||
return `${SEARCH_HISTORY_LOCALSTORAGE_KEY}-${lang}`.toLowerCase()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user