feat: accessible search bar (#36784)

* feat: Passing hits from SearchHits to parent SearchBar to keep track of with the keyboard. Moved all logic for number of hits to WithInstantSearch.js

* Basic functionality working

* Added up/down looping functionality to dropdown

* Set 's' and '/' as shortcuts to focus the search bar

* Moved some things around and added functionality for mouse hovering to change the selected hit. Reworked a bit of the global CSS so mouse hovers don't cause multiple highlights in the dropdown

* Brought back magnifying glass icon

* feat: Switched out onKeyDown and key codes for react-hotkeys

* Refactoring based on review
pull/36922/head^2
Kristofer Koishigawa 2019-10-01 23:43:53 +09:00 committed by mrugesh
parent 8225ce7572
commit 07c552bffe
7 changed files with 291 additions and 147 deletions

View File

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

View File

@ -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,
@ -116,6 +117,7 @@ class InstantSearchRoot extends Component {
render() {
const { query } = this.props;
const MAX_MOBILE_HEIGHT = 768;
return (
<InstantSearch
apiKey='4318af87aa3ce128708f1153556c6108'
@ -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>
);

View File

@ -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,
@ -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,
@ -48,19 +53,25 @@ 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() {
@ -68,53 +79,136 @@ class SearchBar extends Component {
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;
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>
);
}

View File

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

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { Highlight } from 'react-instantsearch-dom';
import { isEmpty } from 'lodash';
const Suggestion = ({ handleSubmit, hit }) => {
const Suggestion = ({ hit, handleMouseEnter, handleMouseLeave }) => {
const dropdownFooter = hit.objectID.includes('default-hit-');
return isEmpty(hit) || isEmpty(hit.objectID) ? null : (
<a
@ -12,8 +12,15 @@ const Suggestion = ({ handleSubmit, hit }) => {
? 'fcc_suggestion_footer fcc_suggestion_item'
: 'fcc_suggestion_item'
}
href={hit.url}
onClick={e => (dropdownFooter ? handleSubmit(e, hit.query) : '')}
href={
dropdownFooter
? `https://freecodecamp.org/news/search/?query=${encodeURIComponent(
hit.query
)}`
: hit.url
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span className='hit-name'>
{dropdownFooter ? (
@ -27,7 +34,8 @@ const Suggestion = ({ handleSubmit, hit }) => {
};
Suggestion.propTypes = {
handleSubmit: PropTypes.func.isRequired,
handleMouseEnter: PropTypes.func.isRequired,
handleMouseLeave: PropTypes.func.isRequired,
hit: PropTypes.object
};

View File

@ -618,7 +618,7 @@ a[class^='ais-'] {
-moz-appearance: none;
appearance: none;
position: absolute;
z-index: 1;
z-index: 100;
width: 20px;
height: 20px;
top: 50%;
@ -628,6 +628,7 @@ a[class^='ais-'] {
}
.ais-SearchBox-submit {
left: 0.3rem;
top: 57%;
}
.ais-SearchBox-reset {
right: 0.3rem;

View File

@ -10,16 +10,16 @@
color: var(--gray-00);
}
.ais-SearchBox-submit,
.ais-SearchBox-reset {
display: none;
}
.ais-SearchBox-input {
padding: 1px 10px;
padding: 1px 10px 1px 30px;
font-size: 18px;
display: inline-block;
width: calc(100vw - 10px);
margin-top: 6px;
}
.fcc_searchBar .ais-SearchBox-input,
@ -43,8 +43,64 @@
left: 5px;
}
#fcc_instantsearch {
margin-top: 6px;
/* hits */
.fcc_searchBar .ais-Highlight-highlighted {
background-color: transparent;
font-style: normal;
font-weight: bold;
}
.ais-Highlight-nonHighlighted {
font-weight: 300;
}
.fcc_hits_wrapper {
display: flex;
justify-content: center;
}
.fcc_suggestion_item {
display: block;
padding: 8px;
color: var(--gray-00) !important;
text-decoration: none;
}
.fcc_suggestion_item [class^='ais-'] {
font-size: 17px;
}
.fcc_suggestion_item:hover {
cursor: pointer;
}
.fcc_sr_only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.ais-Hits-item {
background-color: var(--gray-75);
}
/* Hit selected with arrow keys or mouse */
.selected {
background-color: var(--blue-dark);
}
/* Dropdown footer */
.fcc_suggestion_footer {
border-top: 1.5px solid var(--gray-00);
}
.fcc_suggestion_footer .hit-name .ais-Highlight .ais-Highlight-nonHighlighted {
font-weight: bold;
}
@media (min-width: 380px) {
@ -66,8 +122,6 @@
@media (min-width: 700px) {
.ais-SearchBox-input {
width: 100%;
}
#fcc_instantsearch {
margin-top: 6px;
max-width: 500px;
}
@ -85,78 +139,12 @@
top: auto;
right: 15px;
}
.ais-SearchBox-submit {
left: 0.85rem;
}
}
@media (min-width: 1100px) {
.fcc_searchBar .ais-Hits {
width: calc(100% - 20px);
}
}
/* hits */
.fcc_searchBar .ais-Highlight-highlighted {
background-color: transparent;
font-style: normal;
font-weight: bold;
}
.ais-Highlight-nonHighlighted {
font-weight: 300;
}
.fcc_hits_wrapper {
display: flex;
justify-content: center;
}
.fcc_suggestion_item {
display: block;
padding: 8px;
color: var(--gray-00);
text-decoration: none;
}
.fcc_suggestion_item [class^='ais-'] {
font-size: 17px;
}
.fcc_suggestion_item:hover {
background-color: var(--blue-dark);
cursor: pointer;
}
.fcc_sr_only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Dropdown footer */
.fcc_suggestion_footer {
border-top: 1.5px solid var(--gray-00);
}
.fcc_suggestion_footer .hit-name .ais-Highlight .ais-Highlight-nonHighlighted {
font-weight: bold;
}
/* Show only the first 5 hits on mobile */
.ais-Hits-list .ais-Hits-item:nth-child(n + 6) {
display: none;
}
/* Ensure the dropdown footer is always visible */
.ais-Hits-list .ais-Hits-item:nth-child(9) {
display: block;
}
@media (min-width: 767px) and (min-height: 768px) {
/* Show hits 6-8 on desktop and some tablets */
.ais-Hits-list .ais-Hits-item:nth-child(n + 6) {
display: block;
}
}