mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix(client): make search bar only highlight footer when it is selectd. (#58506)
This commit is contained in:
committed by
GitHub
parent
8961b6b7f4
commit
795838496b
@@ -6,7 +6,7 @@ import NoHitsSuggestion from './no-hits-suggestion';
|
||||
interface SearchBarFooterProps {
|
||||
hasHits: boolean;
|
||||
query?: string;
|
||||
selectedIndex: number;
|
||||
isSelected: boolean;
|
||||
onMouseEnter: (e: React.SyntheticEvent<HTMLElement, Event>) => void;
|
||||
onMouseLeave: (e: React.SyntheticEvent<HTMLElement, Event>) => void;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface SearchBarFooterProps {
|
||||
const SearchBarFooter = ({
|
||||
hasHits,
|
||||
query,
|
||||
selectedIndex,
|
||||
isSelected,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: SearchBarFooterProps) => {
|
||||
@@ -27,7 +27,7 @@ const SearchBarFooter = ({
|
||||
|
||||
return hasHits ? (
|
||||
<li
|
||||
className={`ais-Hits-item ${selectedIndex === 5 ? 'selected' : ''}`}
|
||||
className={`ais-Hits-item ${isSelected ? 'selected' : ''}`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import Magnifier from '../../../assets/icons/magnifier';
|
||||
import InputReset from '../../../assets/icons/input-reset';
|
||||
import { searchPageUrl } from '../../../utils/algolia-locale-setup';
|
||||
import type { SearchBarProps } from './search-bar';
|
||||
|
||||
const SearchBarOptimized = ({
|
||||
innerRef
|
||||
}: Pick<SearchBarProps, 'innerRef'>): JSX.Element => {
|
||||
}: {
|
||||
innerRef: React.RefObject<HTMLDivElement>;
|
||||
}): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
// TODO: Refactor this fallback when all translation files are synced
|
||||
const searchPlaceholder = t('search-bar:placeholder').startsWith(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import React, { Component } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HotKeys, ObserveKeys } from 'react-hotkeys';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { useTranslation, withTranslation } from 'react-i18next';
|
||||
import { SearchBox } from 'react-instantsearch';
|
||||
import { connect } from 'react-redux';
|
||||
import { AnyAction, bindActionCreators, Dispatch } from 'redux';
|
||||
@@ -35,82 +34,71 @@ const mapStateToProps = createSelector(
|
||||
const mapDispatchToProps = (dispatch: Dispatch<AnyAction>) =>
|
||||
bindActionCreators({ toggleSearchDropdown, toggleSearchFocused }, dispatch);
|
||||
|
||||
export type SearchBarProps = {
|
||||
type SearchBarProps = {
|
||||
innerRef?: React.RefObject<HTMLDivElement>;
|
||||
toggleSearchDropdown: typeof toggleSearchDropdown;
|
||||
toggleSearchFocused: typeof toggleSearchFocused;
|
||||
isDropdownEnabled?: boolean;
|
||||
isSearchFocused?: boolean;
|
||||
t: TFunction;
|
||||
};
|
||||
type SearchBarState = {
|
||||
index: number;
|
||||
hits: Array<Hit>;
|
||||
};
|
||||
|
||||
export class SearchBar extends Component<SearchBarProps, SearchBarState> {
|
||||
static displayName: string;
|
||||
constructor(props: SearchBarProps) {
|
||||
super(props);
|
||||
const keyMap = {
|
||||
indexUp: ['up'],
|
||||
indexDown: ['down']
|
||||
};
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSearch = this.handleSearch.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
this.handleFocus = this.handleFocus.bind(this);
|
||||
this.handleHits = this.handleHits.bind(this);
|
||||
this.state = {
|
||||
index: -1,
|
||||
hits: []
|
||||
};
|
||||
}
|
||||
export function SearchBar({
|
||||
isDropdownEnabled,
|
||||
isSearchFocused,
|
||||
innerRef,
|
||||
toggleSearchDropdown,
|
||||
toggleSearchFocused
|
||||
}: SearchBarProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(-1);
|
||||
const [hits, setHits] = useState<Array<Hit>>([]);
|
||||
// We need a ref because we have to get the current value of hits in handlers
|
||||
const hitsRef = useRef(hits);
|
||||
|
||||
componentDidMount(): void {
|
||||
const { t } = this.props;
|
||||
|
||||
document.addEventListener('click', this.handleFocus);
|
||||
|
||||
const searchInput = document.querySelector('.ais-SearchBox-input');
|
||||
if (searchInput) {
|
||||
searchInput.setAttribute('aria-label', t('search.label'));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
document.removeEventListener('click', this.handleFocus);
|
||||
}
|
||||
|
||||
handleChange = (): void => {
|
||||
const { isSearchFocused, toggleSearchFocused } = this.props;
|
||||
const handleChange = (): void => {
|
||||
if (!isSearchFocused) {
|
||||
toggleSearchFocused(true);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
index: -1
|
||||
});
|
||||
setIndex(-1);
|
||||
};
|
||||
|
||||
handleFocus = (e: React.FocusEvent<Node> | Event): AnyAction | void => {
|
||||
const { toggleSearchFocused } = this.props;
|
||||
const isSearchFocused = this.props.innerRef?.current?.contains(
|
||||
e.target as HTMLElement
|
||||
);
|
||||
if (!isSearchFocused) {
|
||||
// Reset if user clicks outside of
|
||||
// search bar / closes dropdown
|
||||
this.setState({ index: -1 });
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent<Node> | Event): AnyAction | void => {
|
||||
const isSearchFocused = innerRef?.current?.contains(
|
||||
e.target as HTMLElement
|
||||
);
|
||||
if (!isSearchFocused) {
|
||||
// Reset if user clicks outside of
|
||||
// search bar / closes dropdown
|
||||
setIndex(-1);
|
||||
}
|
||||
return toggleSearchFocused(isSearchFocused);
|
||||
},
|
||||
[innerRef, toggleSearchFocused]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleFocus);
|
||||
const searchInput = document.querySelector('.ais-SearchBox-input');
|
||||
if (searchInput) {
|
||||
searchInput.setAttribute('aria-label', t('search.label'));
|
||||
}
|
||||
return toggleSearchFocused(isSearchFocused);
|
||||
};
|
||||
return () => {
|
||||
document.removeEventListener('click', handleFocus);
|
||||
};
|
||||
}, [handleFocus, t]);
|
||||
|
||||
handleSearch = (
|
||||
const handleSearch = (
|
||||
e: React.SyntheticEvent<HTMLFormElement, Event>,
|
||||
query?: string
|
||||
): boolean | void => {
|
||||
e.preventDefault();
|
||||
const { toggleSearchDropdown } = this.props;
|
||||
const { index, hits } = this.state;
|
||||
const selectedHit = hits[index];
|
||||
|
||||
// Disable the search dropdown
|
||||
@@ -142,108 +130,83 @@ export class SearchBar extends Component<SearchBarProps, SearchBarState> {
|
||||
: false;
|
||||
};
|
||||
|
||||
handleMouseEnter = (e: React.SyntheticEvent<HTMLElement, Event>): void => {
|
||||
const handleMouseEnter = (
|
||||
e: React.SyntheticEvent<HTMLElement, Event>
|
||||
): void => {
|
||||
e.persist();
|
||||
|
||||
this.setState(({ hits }) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
const hitsTitles = hits.map(hit => hit.title);
|
||||
const targetText = e.target.textContent;
|
||||
const hoveredIndex = targetText ? hitsTitles.indexOf(targetText) : -1;
|
||||
|
||||
if (e.target instanceof HTMLElement) {
|
||||
const targetText = e.target.textContent;
|
||||
const hoveredIndex = targetText ? hitsTitles.indexOf(targetText) : -1;
|
||||
|
||||
return { index: hoveredIndex };
|
||||
}
|
||||
|
||||
return { index: -1 };
|
||||
});
|
||||
setIndex(hoveredIndex);
|
||||
}
|
||||
setIndex(-1);
|
||||
};
|
||||
|
||||
handleMouseLeave = (): void => {
|
||||
this.setState({
|
||||
index: -1
|
||||
});
|
||||
const handleMouseLeave = () => {
|
||||
setIndex(-1);
|
||||
};
|
||||
|
||||
handleHits = (currHits: Array<Hit>): void => {
|
||||
const { hits } = this.state;
|
||||
|
||||
const handleHits = (currHits: Array<Hit>): void => {
|
||||
if (!isEqual(hits, currHits)) {
|
||||
this.setState({
|
||||
index: -1,
|
||||
hits: currHits
|
||||
});
|
||||
setIndex(-1);
|
||||
hitsRef.current = currHits;
|
||||
setHits(currHits);
|
||||
}
|
||||
};
|
||||
|
||||
keyMap = {
|
||||
indexUp: ['up'],
|
||||
indexDown: ['down']
|
||||
};
|
||||
|
||||
keyHandlers = {
|
||||
const keyHandlers = {
|
||||
indexUp: (e: KeyboardEvent | undefined): void => {
|
||||
e?.preventDefault();
|
||||
this.setState(({ index, hits }) => ({
|
||||
index: index === -1 ? hits.length : index - 1
|
||||
}));
|
||||
setIndex(index => (index === -1 ? hitsRef.current.length : index - 1));
|
||||
},
|
||||
indexDown: (e: KeyboardEvent | undefined): void => {
|
||||
e?.preventDefault();
|
||||
this.setState(({ index, hits }) => ({
|
||||
index: index === hits.length ? -1 : index + 1
|
||||
}));
|
||||
setIndex(index => (index === hitsRef.current.length ? -1 : index + 1));
|
||||
}
|
||||
};
|
||||
|
||||
render(): JSX.Element {
|
||||
const { isDropdownEnabled, isSearchFocused, innerRef, t } = this.props;
|
||||
const { index } = this.state;
|
||||
// TODO: Refactor this fallback when all translation files are synced
|
||||
const searchPlaceholder = t('search-bar:placeholder').startsWith(
|
||||
'search.placeholder.'
|
||||
)
|
||||
? t('search.placeholder')
|
||||
: t('search-bar:placeholder');
|
||||
const searchPlaceholder = t('search-bar:placeholder').startsWith(
|
||||
'search.placeholder.'
|
||||
)
|
||||
? t('search.placeholder')
|
||||
: t('search-bar:placeholder');
|
||||
|
||||
return (
|
||||
<WithInstantSearch>
|
||||
<div
|
||||
className='fcc_searchBar'
|
||||
data-testid='fcc_searchBar'
|
||||
ref={innerRef}
|
||||
>
|
||||
<HotKeys handlers={this.keyHandlers} keyMap={this.keyMap}>
|
||||
<div className='fcc_search_wrapper'>
|
||||
<ObserveKeys except={['Space']}>
|
||||
<SearchBox
|
||||
data-playwright-test-label='header-search'
|
||||
onSubmit={e => {
|
||||
this.handleSearch(e);
|
||||
}}
|
||||
onInput={this.handleChange}
|
||||
translations={{
|
||||
submitButtonTitle: t('icons.input-search'),
|
||||
resetButtonTitle: t('icons.input-reset')
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
onFocus={this.handleFocus}
|
||||
/>
|
||||
</ObserveKeys>
|
||||
{isDropdownEnabled && isSearchFocused && (
|
||||
<SearchHits
|
||||
handleMouseEnter={this.handleMouseEnter}
|
||||
handleMouseLeave={this.handleMouseLeave}
|
||||
handleHits={this.handleHits}
|
||||
selectedIndex={index}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HotKeys>
|
||||
</div>
|
||||
</WithInstantSearch>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WithInstantSearch>
|
||||
<div className='fcc_searchBar' data-testid='fcc_searchBar' ref={innerRef}>
|
||||
<HotKeys handlers={keyHandlers} keyMap={keyMap}>
|
||||
<div className='fcc_search_wrapper'>
|
||||
<ObserveKeys except={['Space']}>
|
||||
<SearchBox
|
||||
data-playwright-test-label='header-search'
|
||||
onSubmit={e => {
|
||||
handleSearch(e);
|
||||
}}
|
||||
onInput={handleChange}
|
||||
translations={{
|
||||
submitButtonTitle: t('icons.input-search'),
|
||||
resetButtonTitle: t('icons.input-reset')
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</ObserveKeys>
|
||||
{isDropdownEnabled && isSearchFocused && (
|
||||
<SearchHits
|
||||
handleMouseEnter={handleMouseEnter}
|
||||
handleMouseLeave={handleMouseLeave}
|
||||
handleHits={handleHits}
|
||||
selectedIndex={index}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HotKeys>
|
||||
</div>
|
||||
</WithInstantSearch>
|
||||
);
|
||||
}
|
||||
|
||||
SearchBar.displayName = 'SearchBar';
|
||||
|
||||
@@ -55,7 +55,7 @@ function SearchHits({
|
||||
<SearchBarFooter
|
||||
hasHits={!noHits}
|
||||
query={results?.query}
|
||||
selectedIndex={selectedIndex}
|
||||
isSelected={selectedIndex === allHits.length}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user