|
1 | 1 | import { isEqual } from 'lodash-es'; |
2 | | -import React, { Component } from 'react'; |
| 2 | +import React, { useCallback, useEffect, useRef, useState } from 'react'; |
3 | 3 | import { HotKeys, ObserveKeys } from 'react-hotkeys'; |
4 | | -import type { TFunction } from 'i18next'; |
5 | | -import { withTranslation } from 'react-i18next'; |
| 4 | +import { useTranslation, withTranslation } from 'react-i18next'; |
6 | 5 | import { SearchBox } from 'react-instantsearch'; |
7 | 6 | import { connect } from 'react-redux'; |
8 | 7 | import { AnyAction, bindActionCreators, Dispatch } from 'redux'; |
@@ -35,82 +34,71 @@ const mapStateToProps = createSelector( |
35 | 34 | const mapDispatchToProps = (dispatch: Dispatch<AnyAction>) => |
36 | 35 | bindActionCreators({ toggleSearchDropdown, toggleSearchFocused }, dispatch); |
37 | 36 |
|
38 | | -export type SearchBarProps = { |
| 37 | +type SearchBarProps = { |
39 | 38 | innerRef?: React.RefObject<HTMLDivElement>; |
40 | 39 | toggleSearchDropdown: typeof toggleSearchDropdown; |
41 | 40 | toggleSearchFocused: typeof toggleSearchFocused; |
42 | 41 | isDropdownEnabled?: boolean; |
43 | 42 | isSearchFocused?: boolean; |
44 | | - t: TFunction; |
45 | 43 | }; |
46 | | -type SearchBarState = { |
47 | | - index: number; |
48 | | - hits: Array<Hit>; |
49 | | -}; |
50 | | - |
51 | | -export class SearchBar extends Component<SearchBarProps, SearchBarState> { |
52 | | - static displayName: string; |
53 | | - constructor(props: SearchBarProps) { |
54 | | - super(props); |
55 | | - |
56 | | - this.handleChange = this.handleChange.bind(this); |
57 | | - this.handleSearch = this.handleSearch.bind(this); |
58 | | - this.handleMouseEnter = this.handleMouseEnter.bind(this); |
59 | | - this.handleMouseLeave = this.handleMouseLeave.bind(this); |
60 | | - this.handleFocus = this.handleFocus.bind(this); |
61 | | - this.handleHits = this.handleHits.bind(this); |
62 | | - this.state = { |
63 | | - index: -1, |
64 | | - hits: [] |
65 | | - }; |
66 | | - } |
67 | | - |
68 | | - componentDidMount(): void { |
69 | | - const { t } = this.props; |
70 | 44 |
|
71 | | - document.addEventListener('click', this.handleFocus); |
72 | | - |
73 | | - const searchInput = document.querySelector('.ais-SearchBox-input'); |
74 | | - if (searchInput) { |
75 | | - searchInput.setAttribute('aria-label', t('search.label')); |
76 | | - } |
77 | | - } |
78 | | - |
79 | | - componentWillUnmount(): void { |
80 | | - document.removeEventListener('click', this.handleFocus); |
81 | | - } |
| 45 | +const keyMap = { |
| 46 | + indexUp: ['up'], |
| 47 | + indexDown: ['down'] |
| 48 | +}; |
82 | 49 |
|
83 | | - handleChange = (): void => { |
84 | | - const { isSearchFocused, toggleSearchFocused } = this.props; |
| 50 | +export function SearchBar({ |
| 51 | + isDropdownEnabled, |
| 52 | + isSearchFocused, |
| 53 | + innerRef, |
| 54 | + toggleSearchDropdown, |
| 55 | + toggleSearchFocused |
| 56 | +}: SearchBarProps): JSX.Element { |
| 57 | + const { t } = useTranslation(); |
| 58 | + const [index, setIndex] = useState(-1); |
| 59 | + const [hits, setHits] = useState<Array<Hit>>([]); |
| 60 | + // We need a ref because we have to get the current value of hits in handlers |
| 61 | + const hitsRef = useRef(hits); |
| 62 | + |
| 63 | + const handleChange = (): void => { |
85 | 64 | if (!isSearchFocused) { |
86 | 65 | toggleSearchFocused(true); |
87 | 66 | } |
88 | 67 |
|
89 | | - this.setState({ |
90 | | - index: -1 |
91 | | - }); |
| 68 | + setIndex(-1); |
92 | 69 | }; |
93 | 70 |
|
94 | | - handleFocus = (e: React.FocusEvent<Node> | Event): AnyAction | void => { |
95 | | - const { toggleSearchFocused } = this.props; |
96 | | - const isSearchFocused = this.props.innerRef?.current?.contains( |
97 | | - e.target as HTMLElement |
98 | | - ); |
99 | | - if (!isSearchFocused) { |
100 | | - // Reset if user clicks outside of |
101 | | - // search bar / closes dropdown |
102 | | - this.setState({ index: -1 }); |
| 71 | + const handleFocus = useCallback( |
| 72 | + (e: React.FocusEvent<Node> | Event): AnyAction | void => { |
| 73 | + const isSearchFocused = innerRef?.current?.contains( |
| 74 | + e.target as HTMLElement |
| 75 | + ); |
| 76 | + if (!isSearchFocused) { |
| 77 | + // Reset if user clicks outside of |
| 78 | + // search bar / closes dropdown |
| 79 | + setIndex(-1); |
| 80 | + } |
| 81 | + return toggleSearchFocused(isSearchFocused); |
| 82 | + }, |
| 83 | + [innerRef, toggleSearchFocused] |
| 84 | + ); |
| 85 | + |
| 86 | + useEffect(() => { |
| 87 | + document.addEventListener('click', handleFocus); |
| 88 | + const searchInput = document.querySelector('.ais-SearchBox-input'); |
| 89 | + if (searchInput) { |
| 90 | + searchInput.setAttribute('aria-label', t('search.label')); |
103 | 91 | } |
104 | | - return toggleSearchFocused(isSearchFocused); |
105 | | - }; |
| 92 | + return () => { |
| 93 | + document.removeEventListener('click', handleFocus); |
| 94 | + }; |
| 95 | + }, [handleFocus, t]); |
106 | 96 |
|
107 | | - handleSearch = ( |
| 97 | + const handleSearch = ( |
108 | 98 | e: React.SyntheticEvent<HTMLFormElement, Event>, |
109 | 99 | query?: string |
110 | 100 | ): boolean | void => { |
111 | 101 | e.preventDefault(); |
112 | | - const { toggleSearchDropdown } = this.props; |
113 | | - const { index, hits } = this.state; |
114 | 102 | const selectedHit = hits[index]; |
115 | 103 |
|
116 | 104 | // Disable the search dropdown |
@@ -142,108 +130,83 @@ export class SearchBar extends Component<SearchBarProps, SearchBarState> { |
142 | 130 | : false; |
143 | 131 | }; |
144 | 132 |
|
145 | | - handleMouseEnter = (e: React.SyntheticEvent<HTMLElement, Event>): void => { |
| 133 | + const handleMouseEnter = ( |
| 134 | + e: React.SyntheticEvent<HTMLElement, Event> |
| 135 | + ): void => { |
146 | 136 | e.persist(); |
147 | 137 |
|
148 | | - this.setState(({ hits }) => { |
| 138 | + if (e.target instanceof HTMLElement) { |
149 | 139 | const hitsTitles = hits.map(hit => hit.title); |
| 140 | + const targetText = e.target.textContent; |
| 141 | + const hoveredIndex = targetText ? hitsTitles.indexOf(targetText) : -1; |
150 | 142 |
|
151 | | - if (e.target instanceof HTMLElement) { |
152 | | - const targetText = e.target.textContent; |
153 | | - const hoveredIndex = targetText ? hitsTitles.indexOf(targetText) : -1; |
154 | | - |
155 | | - return { index: hoveredIndex }; |
156 | | - } |
157 | | - |
158 | | - return { index: -1 }; |
159 | | - }); |
| 143 | + setIndex(hoveredIndex); |
| 144 | + } |
| 145 | + setIndex(-1); |
160 | 146 | }; |
161 | 147 |
|
162 | | - handleMouseLeave = (): void => { |
163 | | - this.setState({ |
164 | | - index: -1 |
165 | | - }); |
| 148 | + const handleMouseLeave = () => { |
| 149 | + setIndex(-1); |
166 | 150 | }; |
167 | 151 |
|
168 | | - handleHits = (currHits: Array<Hit>): void => { |
169 | | - const { hits } = this.state; |
170 | | - |
| 152 | + const handleHits = (currHits: Array<Hit>): void => { |
171 | 153 | if (!isEqual(hits, currHits)) { |
172 | | - this.setState({ |
173 | | - index: -1, |
174 | | - hits: currHits |
175 | | - }); |
| 154 | + setIndex(-1); |
| 155 | + hitsRef.current = currHits; |
| 156 | + setHits(currHits); |
176 | 157 | } |
177 | 158 | }; |
178 | 159 |
|
179 | | - keyMap = { |
180 | | - indexUp: ['up'], |
181 | | - indexDown: ['down'] |
182 | | - }; |
183 | | - |
184 | | - keyHandlers = { |
| 160 | + const keyHandlers = { |
185 | 161 | indexUp: (e: KeyboardEvent | undefined): void => { |
186 | 162 | e?.preventDefault(); |
187 | | - this.setState(({ index, hits }) => ({ |
188 | | - index: index === -1 ? hits.length : index - 1 |
189 | | - })); |
| 163 | + setIndex(index => (index === -1 ? hitsRef.current.length : index - 1)); |
190 | 164 | }, |
191 | 165 | indexDown: (e: KeyboardEvent | undefined): void => { |
192 | 166 | e?.preventDefault(); |
193 | | - this.setState(({ index, hits }) => ({ |
194 | | - index: index === hits.length ? -1 : index + 1 |
195 | | - })); |
| 167 | + setIndex(index => (index === hitsRef.current.length ? -1 : index + 1)); |
196 | 168 | } |
197 | 169 | }; |
198 | 170 |
|
199 | | - render(): JSX.Element { |
200 | | - const { isDropdownEnabled, isSearchFocused, innerRef, t } = this.props; |
201 | | - const { index } = this.state; |
202 | | - // TODO: Refactor this fallback when all translation files are synced |
203 | | - const searchPlaceholder = t('search-bar:placeholder').startsWith( |
204 | | - 'search.placeholder.' |
205 | | - ) |
206 | | - ? t('search.placeholder') |
207 | | - : t('search-bar:placeholder'); |
208 | | - |
209 | | - return ( |
210 | | - <WithInstantSearch> |
211 | | - <div |
212 | | - className='fcc_searchBar' |
213 | | - data-testid='fcc_searchBar' |
214 | | - ref={innerRef} |
215 | | - > |
216 | | - <HotKeys handlers={this.keyHandlers} keyMap={this.keyMap}> |
217 | | - <div className='fcc_search_wrapper'> |
218 | | - <ObserveKeys except={['Space']}> |
219 | | - <SearchBox |
220 | | - data-playwright-test-label='header-search' |
221 | | - onSubmit={e => { |
222 | | - this.handleSearch(e); |
223 | | - }} |
224 | | - onInput={this.handleChange} |
225 | | - translations={{ |
226 | | - submitButtonTitle: t('icons.input-search'), |
227 | | - resetButtonTitle: t('icons.input-reset') |
228 | | - }} |
229 | | - placeholder={searchPlaceholder} |
230 | | - onFocus={this.handleFocus} |
231 | | - /> |
232 | | - </ObserveKeys> |
233 | | - {isDropdownEnabled && isSearchFocused && ( |
234 | | - <SearchHits |
235 | | - handleMouseEnter={this.handleMouseEnter} |
236 | | - handleMouseLeave={this.handleMouseLeave} |
237 | | - handleHits={this.handleHits} |
238 | | - selectedIndex={index} |
239 | | - /> |
240 | | - )} |
241 | | - </div> |
242 | | - </HotKeys> |
243 | | - </div> |
244 | | - </WithInstantSearch> |
245 | | - ); |
246 | | - } |
| 171 | + const searchPlaceholder = t('search-bar:placeholder').startsWith( |
| 172 | + 'search.placeholder.' |
| 173 | + ) |
| 174 | + ? t('search.placeholder') |
| 175 | + : t('search-bar:placeholder'); |
| 176 | + |
| 177 | + return ( |
| 178 | + <WithInstantSearch> |
| 179 | + <div className='fcc_searchBar' data-testid='fcc_searchBar' ref={innerRef}> |
| 180 | + <HotKeys handlers={keyHandlers} keyMap={keyMap}> |
| 181 | + <div className='fcc_search_wrapper'> |
| 182 | + <ObserveKeys except={['Space']}> |
| 183 | + <SearchBox |
| 184 | + data-playwright-test-label='header-search' |
| 185 | + onSubmit={e => { |
| 186 | + handleSearch(e); |
| 187 | + }} |
| 188 | + onInput={handleChange} |
| 189 | + translations={{ |
| 190 | + submitButtonTitle: t('icons.input-search'), |
| 191 | + resetButtonTitle: t('icons.input-reset') |
| 192 | + }} |
| 193 | + placeholder={searchPlaceholder} |
| 194 | + onFocus={handleFocus} |
| 195 | + /> |
| 196 | + </ObserveKeys> |
| 197 | + {isDropdownEnabled && isSearchFocused && ( |
| 198 | + <SearchHits |
| 199 | + handleMouseEnter={handleMouseEnter} |
| 200 | + handleMouseLeave={handleMouseLeave} |
| 201 | + handleHits={handleHits} |
| 202 | + selectedIndex={index} |
| 203 | + /> |
| 204 | + )} |
| 205 | + </div> |
| 206 | + </HotKeys> |
| 207 | + </div> |
| 208 | + </WithInstantSearch> |
| 209 | + ); |
247 | 210 | } |
248 | 211 |
|
249 | 212 | SearchBar.displayName = 'SearchBar'; |
|
0 commit comments