diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/ExpandedNav/SearchBar.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/ExpandedNav/SearchBar.tsx index 9b861fe9ebf..7d5275431ea 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/ExpandedNav/SearchBar.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/ExpandedNav/SearchBar.tsx @@ -1,5 +1,12 @@ import { css } from '@emotion/react'; -import { from, space, textSans15 } from '@guardian/source/foundations'; +import { + from, + palette as sourcePalette, + space, + textSans12, + textSans15, + textSansBold14, +} from '@guardian/source/foundations'; import { Button, Label, @@ -7,6 +14,7 @@ import { SvgMagnifyingGlass, TextInput, } from '@guardian/source/react-components'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { nestedOphanComponents } from '../../../../lib/ophan-helpers'; import { palette as themePalette } from '../../../../palette'; @@ -88,20 +96,209 @@ const searchSubmit = css` } `; +const searchResultsDropdown = css` + position: absolute; + top: 42px; + left: 0; + right: 0; + background: ${sourcePalette.neutral[10]}; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + z-index: 1000; + max-height: 400px; + overflow-y: auto; +`; + +const searchResultItem = css` + display: flex; + align-items: flex-start; + padding: ${space[2]}px ${space[3]}px; + text-decoration: none; + border-bottom: 1px solid ${sourcePalette.neutral[20]}; + transition: background-color 0.15s; + + &:hover { + background-color: ${sourcePalette.neutral[20]}; + } + + &:last-child { + border-bottom: none; + } +`; + +const resultThumbnail = css` + width: 80px; + height: 48px; + object-fit: cover; + border-radius: 4px; + margin-right: ${space[3]}px; + flex-shrink: 0; +`; + +const resultContent = css` + flex: 1; + min-width: 0; +`; + +const resultHeadline = css` + ${textSansBold14} + color: ${sourcePalette.neutral[100]}; + margin: 0 0 ${space[1]}px 0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +const resultMeta = css` + ${textSans12} + color: ${sourcePalette.neutral[60]}; + display: flex; + align-items: center; + gap: ${space[2]}px; +`; + +const resultPillar = css` + text-transform: uppercase; + font-weight: bold; +`; + +const loadingText = css` + ${textSans15} + color: ${sourcePalette.neutral[60]}; + padding: ${space[3]}px; + text-align: center; +`; + +const noResultsText = css` + ${textSans15} + color: ${sourcePalette.neutral[60]}; + padding: ${space[3]}px; + text-align: center; +`; + +interface SearchResult { + id: string; + webTitle: string; + webUrl: string; + sectionName?: string; + pillarName?: string; + fields?: { + thumbnail?: string; + trailText?: string; + }; +} + +const pillarColors: Record = { + News: sourcePalette.news[400], + Opinion: sourcePalette.opinion[400], + Sport: sourcePalette.sport[400], + Culture: sourcePalette.culture[400], + Lifestyle: sourcePalette.lifestyle[400], +}; + +interface CAPISearchResponse { + response?: { + results?: SearchResult[]; + }; +} + export const SearchBar = () => { const searchId = 'gu-search'; + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [showResults, setShowResults] = useState(false); + const debounceRef = useRef | null>(null); + const containerRef = useRef(null); + + const searchCAPI = useCallback(async (searchQuery: string) => { + if (searchQuery.length < 2) { + setResults([]); + setShowResults(false); + return; + } + + setIsLoading(true); + setShowResults(true); + + try { + const response = await fetch( + `https://content.guardianapis.com/search?q=${encodeURIComponent( + searchQuery, + )}&show-fields=thumbnail,trailText&page-size=5&api-key=test`, + ); + const data = (await response.json()) as CAPISearchResponse; + + if (data.response?.results) { + setResults(data.response.results); + } else { + setResults([]); + } + } catch { + setResults([]); + } finally { + setIsLoading(false); + } + }, []); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + + // Debounce the search + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + void searchCAPI(value); + }, 300); + }, + [searchCAPI], + ); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowResults(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return ( -
+ { + if (results.length > 0) setShowResults(true); + }} + autoComplete="off" />