diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css
index b9847dbb0e..c6c2259257 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 0667fc7068..25211f674a 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 e3c3ad080f..dc2109e230 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 bf7d3f52a4..c5abfbc719 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 (
-
-
- {shortenedHits.concat(defaultHit).map(hit => (
- -
-
-
- ))}
-
-
- );
-});
+ ];
+ 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 c6c7e7602a..184200eb10 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 62e881e9a0..4bbbc86a45 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 c9f1be2b62..bd1d76b59b 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;
- }
-}