From 07c552bffee2dd9cc62ba4cb5a5bf46d48ecfcc0 Mon Sep 17 00:00:00 2001 From: Kristofer Koishigawa Date: Tue, 1 Oct 2019 23:43:53 +0900 Subject: [PATCH] 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 --- client/src/components/layouts/global.css | 3 +- .../components/search/WithInstantSearch.js | 17 +- .../components/search/searchBar/SearchBar.js | 148 ++++++++++++++---- .../components/search/searchBar/SearchHits.js | 113 ++++++++----- .../search/searchBar/SearchSuggestion.js | 16 +- .../search/searchBar/searchbar-base.css | 3 +- .../components/search/searchBar/searchbar.css | 138 ++++++++-------- 7 files changed, 291 insertions(+), 147 deletions(-) diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index b9847dbb0e8..c6c22592571 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -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, diff --git a/client/src/components/search/WithInstantSearch.js b/client/src/components/search/WithInstantSearch.js index 0667fc70687..25211f674a8 100644 --- a/client/src/components/search/WithInstantSearch.js +++ b/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, @@ -116,6 +117,7 @@ class InstantSearchRoot extends Component { render() { const { query } = this.props; + const MAX_MOBILE_HEIGHT = 768; return ( - + {this.isSearchPage() ? ( + + ) : ( + + + + + + + + + )} {this.props.children} ); diff --git a/client/src/components/search/searchBar/SearchBar.js b/client/src/components/search/searchBar/SearchBar.js index e3c3ad080f1..dc2109e2309 100644 --- a/client/src/components/search/searchBar/SearchBar.js +++ b/client/src/components/search/searchBar/SearchBar.js @@ -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 (
-
- - - {isDropdownEnabled && isSearchFocused && ( - - )} -
+ +
+ + + {isDropdownEnabled && isSearchFocused && ( + + )} +
+
); } diff --git a/client/src/components/search/searchBar/SearchHits.js b/client/src/components/search/searchBar/SearchHits.js index bf7d3f52a40..c5abfbc7194 100644 --- a/client/src/components/search/searchBar/SearchHits.js +++ b/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 - ${currentRefinement} + ${searchQuery} ` + } } } - } - ]; - return ( -
- -
- ); -}); + ]; + const allHits = hits.slice(0, 8).concat(footer); + useEffect(() => { + handleHits(allHits); + }); -const SearchHits = connectStateResults(({ handleSubmit, searchState }) => { - return isEmpty(searchState) || !searchState.query ? null : ( - - ); -}); + return ( +
+
    + {allHits.map((hit, i) => ( +
  • + +
  • + ))} +
+
+ ); + } +); + +const SearchHits = connectStateResults( + ({ + searchState, + handleMouseEnter, + handleMouseLeave, + selectedIndex, + handleHits + }) => { + return isEmpty(searchState) || !searchState.query ? null : ( + + ); + } +); + +CustomHits.propTypes = { + handleHits: PropTypes.func.isRequired +}; export default SearchHits; diff --git a/client/src/components/search/searchBar/SearchSuggestion.js b/client/src/components/search/searchBar/SearchSuggestion.js index c6c7e7602aa..184200eb107 100644 --- a/client/src/components/search/searchBar/SearchSuggestion.js +++ b/client/src/components/search/searchBar/SearchSuggestion.js @@ -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 : ( { ? '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} > {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 }; diff --git a/client/src/components/search/searchBar/searchbar-base.css b/client/src/components/search/searchBar/searchbar-base.css index 62e881e9a0f..4bbbc86a45d 100644 --- a/client/src/components/search/searchBar/searchbar-base.css +++ b/client/src/components/search/searchBar/searchbar-base.css @@ -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; diff --git a/client/src/components/search/searchBar/searchbar.css b/client/src/components/search/searchBar/searchbar.css index c9f1be2b622..bd1d76b59b6 100644 --- a/client/src/components/search/searchBar/searchbar.css +++ b/client/src/components/search/searchBar/searchbar.css @@ -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; - } -}