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 reviewpull/36922/head^2
parent
8225ce7572
commit
07c552bffe
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue