Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: accessible search bar #36784

3 changes: 2 additions & 1 deletion client/src/components/layouts/global.css
Expand Up @@ -92,7 +92,7 @@ th {
color: var(--secondary-color);
}

a:hover {
a:not(.fcc_suggestion_item):hover {
color: var(--tertiary-color);
background-color: var(--tertiary-background);
}
Expand Down Expand Up @@ -163,6 +163,7 @@ a:focus {
}

.btn:active:hover,
.btn-primary:hover,
.btn-primary:active:hover,
.btn-primary.active:hover,
.open > .dropdown-toggle.btn-primary:hover,
Expand Down
17 changes: 15 additions & 2 deletions client/src/components/search/WithInstantSearch.js
@@ -1,10 +1,11 @@
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { Location } from '@reach/router';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { InstantSearch, Configure } from 'react-instantsearch-dom';
import qs from 'query-string';
import { navigate } from 'gatsby';
import Media from 'react-responsive';

import {
isSearchDropdownEnabledSelector,
Expand Down Expand Up @@ -116,6 +117,7 @@ class InstantSearchRoot extends Component {

render() {
const { query } = this.props;
const MAX_MOBILE_HEIGHT = 768;
return (
<InstantSearch
apiKey='4318af87aa3ce128708f1153556c6108'
Expand All @@ -124,7 +126,18 @@ class InstantSearchRoot extends Component {
onSearchStateChange={this.onSearchStateChange}
searchState={{ query }}
>
<Configure hitsPerPage={15} />
{this.isSearchPage() ? (
<Configure hitsPerPage={15} />
) : (
<Fragment>
<Media maxHeight={MAX_MOBILE_HEIGHT}>
<Configure hitsPerPage={5} />
</Media>
<Media minHeight={MAX_MOBILE_HEIGHT + 1}>
<Configure hitsPerPage={8} />
</Media>
</Fragment>
)}
{this.props.children}
</InstantSearch>
);
Expand Down
148 changes: 121 additions & 27 deletions client/src/components/search/searchBar/SearchBar.js
Expand Up @@ -4,6 +4,8 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { createSelector } from 'reselect';
import { SearchBox } from 'react-instantsearch-dom';
import { HotKeys, configure } from 'react-hotkeys';
import { isEqual } from 'lodash';

import {
isSearchDropdownEnabledSelector,
Expand All @@ -17,6 +19,9 @@ import SearchHits from './SearchHits';
import './searchbar-base.css';
import './searchbar.css';

// Configure react-hotkeys to work with the searchbar
configure({ ignoreTags: ['select', 'textarea'] });

const propTypes = {
isDropdownEnabled: PropTypes.bool,
isSearchFocused: PropTypes.bool,
Expand Down Expand Up @@ -48,73 +53,162 @@ class SearchBar extends Component {

this.searchBarRef = React.createRef();
this.handleChange = this.handleChange.bind(this);
this.handlePageClick = this.handlePageClick.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: []
};
}

componentDidMount() {
const searchInput = document.querySelector('.ais-SearchBox-input');
searchInput.id = 'fcc_instantsearch';

document.addEventListener('click', this.handlePageClick);
document.addEventListener('click', this.handleFocus);
}

componentWillUnmount() {
document.removeEventListener('click', this.handlePageClick);
document.removeEventListener('click', this.handleFocus);
}

handleChange() {
const { isSearchFocused, toggleSearchFocused } = this.props;
if (!isSearchFocused) {
toggleSearchFocused(true);
}

this.setState({
index: -1
});
}

handlePageClick(e) {
handleFocus(e) {
const { toggleSearchFocused } = this.props;
const isSearchFocusedClick = this.searchBarRef.current.contains(e.target);
return toggleSearchFocused(isSearchFocusedClick);
const isSearchFocused = this.searchBarRef.current.contains(e.target);
if (!isSearchFocused) {
// Reset if user clicks outside of
// search bar / closes dropdown
this.setState({ index: -1 });
}
return toggleSearchFocused(isSearchFocused);
}

handleSearch(e, query) {
e.preventDefault();
const { toggleSearchDropdown, updateSearchQuery } = this.props;
// disable the search dropdown
const { index, hits } = this.state;
const selectedHit = hits[index];

// Disable the search dropdown
toggleSearchDropdown(false);
if (query) {
updateSearchQuery(query);
if (selectedHit) {
// Redirect to hit / footer selected by arrow keys
return window.location.assign(selectedHit.url);
} else if (!query) {
// Set query to value in search bar if enter is pressed
query = e.currentTarget.children[0].value;
}
updateSearchQuery(query);

// For Learn search results page
// return navigate('/search');

// Temporary redirect to News search results page
return window.location.assign(
`https://freecodecamp.org/news/search/?query=${query}`
);
// when non-empty search input submitted
return query
? window.location.assign(
`https://freecodecamp.org/news/search/?query=${encodeURIComponent(
query
)}`
)
: false;
}

handleMouseEnter(e) {
e.persist();
const hoveredText = e.currentTarget.innerText;

this.setState(({ hits }) => {
const hitsTitles = hits.map(hit => hit.title);
const hoveredIndex = hitsTitles.indexOf(hoveredText);

return { index: hoveredIndex };
});
}

handleMouseLeave() {
this.setState({
index: -1
});
}

handleHits(currHits) {
const { hits } = this.state;

if (!isEqual(hits, currHits)) {
this.setState({
index: -1,
hits: currHits
});
}
}

keyMap = {
INDEX_UP: ['up'],
INDEX_DOWN: ['down']
};

keyHandlers = {
INDEX_UP: e => {
e.preventDefault();
this.setState(({ index, hits }) => ({
index: index === -1 ? hits.length - 1 : index - 1
}));
},
INDEX_DOWN: e => {
e.preventDefault();
this.setState(({ index, hits }) => ({
index: index === hits.length - 1 ? -1 : index + 1
}));
}
};

render() {
const { isDropdownEnabled, isSearchFocused } = this.props;
const { index } = this.state;

scissorsneedfoodtoo marked this conversation as resolved.
Show resolved Hide resolved
return (
<div
className='fcc_searchBar'
data-testid='fcc_searchBar'
ref={this.searchBarRef}
>
<div className='fcc_search_wrapper'>
<label className='fcc_sr_only' htmlFor='fcc_instantsearch'>
Search
</label>
<SearchBox
onChange={this.handleChange}
onSubmit={this.handleSearch}
showLoadingIndicator={true}
translations={{ placeholder }}
/>
{isDropdownEnabled && isSearchFocused && (
<SearchHits handleSubmit={this.handleSearch} />
)}
</div>
<HotKeys handlers={this.keyHandlers} keyMap={this.keyMap}>
<div className='fcc_search_wrapper'>
<label className='fcc_sr_only' htmlFor='fcc_instantsearch'>
Search
</label>
<SearchBox
focusShortcuts={[83, 191]}
onChange={this.handleChange}
onFocus={this.handleFocus}
onSubmit={this.handleSearch}
showLoadingIndicator={true}
translations={{ placeholder }}
/>
{isDropdownEnabled && isSearchFocused && (
<SearchHits
handleHits={this.handleHits}
handleMouseEnter={this.handleMouseEnter}
handleMouseLeave={this.handleMouseLeave}
selectedIndex={index}
/>
)}
</div>
</HotKeys>
</div>
);
}
Expand Down
113 changes: 76 additions & 37 deletions client/src/components/search/searchBar/SearchHits.js
@@ -1,50 +1,89 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { connectStateResults, connectHits } from 'react-instantsearch-dom';
import isEmpty from 'lodash/isEmpty';
import Suggestion from './SearchSuggestion';

const CustomHits = connectHits(({ hits, currentRefinement, handleSubmit }) => {
const shortenedHits = hits.filter((hit, i) => i < 8);
const defaultHit = [
{
objectID: `default-hit-${currentRefinement}`,
query: currentRefinement,
_highlightResult: {
query: {
value: `
const CustomHits = connectHits(
({
hits,
searchQuery,
handleMouseEnter,
handleMouseLeave,
selectedIndex,
handleHits
}) => {
const footer = [
{
objectID: `default-hit-${searchQuery}`,
query: searchQuery,
url: `https://freecodecamp.org/news/search/?query=${encodeURIComponent(
searchQuery
)}`,
title: `See all results for ${searchQuery}`,
_highlightResult: {
query: {
value: `
See all results for
<ais-highlight-0000000000>
${currentRefinement}
${searchQuery}
</ais-highlight-0000000000>
`
}
}
}
}
];
return (
<div className='ais-Hits'>
<ul className='ais-Hits-list'>
{shortenedHits.concat(defaultHit).map(hit => (
<li
className='ais-Hits-item'
data-fccobjectid={hit.objectID}
key={hit.objectID}
>
<Suggestion handleSubmit={handleSubmit} hit={hit} />
</li>
))}
</ul>
</div>
);
});
];
const allHits = hits.slice(0, 8).concat(footer);
useEffect(() => {
handleHits(allHits);
});

const SearchHits = connectStateResults(({ handleSubmit, searchState }) => {
return isEmpty(searchState) || !searchState.query ? null : (
<CustomHits
currentRefinement={searchState.query}
handleSubmit={handleSubmit}
/>
);
});
return (
<div className='ais-Hits'>
<ul className='ais-Hits-list'>
{allHits.map((hit, i) => (
<li
className={
i === selectedIndex ? 'ais-Hits-item selected' : 'ais-Hits-item'
}
data-fccobjectid={hit.objectID}
key={hit.objectID}
>
<Suggestion
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
hit={hit}
/>
</li>
))}
</ul>
</div>
);
}
);

const SearchHits = connectStateResults(
({
searchState,
handleMouseEnter,
handleMouseLeave,
selectedIndex,
handleHits
}) => {
return isEmpty(searchState) || !searchState.query ? null : (
<CustomHits
handleHits={handleHits}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
searchQuery={searchState.query}
selectedIndex={selectedIndex}
/>
);
}
);

CustomHits.propTypes = {
handleHits: PropTypes.func.isRequired
};

export default SearchHits;