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:
Joakim Jäderberg
2025-04-17 06:39:42 +00:00
parent 8c0597727b
commit b98d6c10c0
35 changed files with 797 additions and 1602 deletions

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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",
},
})

View File

@@ -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;
}
}

View File

@@ -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()
}