Skip to content

Commit

Permalink
implement basic instant searchbox
Browse files Browse the repository at this point in the history
  • Loading branch information
thundermiracle committed Apr 25, 2021
1 parent b2f4c41 commit b8ac571
Show file tree
Hide file tree
Showing 12 changed files with 532 additions and 9 deletions.
1 change: 1 addition & 0 deletions gatsby-config.js
Expand Up @@ -125,6 +125,7 @@ module.exports = {
queries: require('./src/utils/algoliaQueries'),
},
},
`gatsby-plugin-styled-components`,
// {
// resolve: `gatsby-plugin-html2amp`,
// options: {
Expand Down
5 changes: 5 additions & 0 deletions package.json
Expand Up @@ -7,6 +7,8 @@
"url": "https://github.com/thundermiracle/gatsby-simple-blog/issues"
},
"dependencies": {
"@styled-icons/fa-solid": "^10.32.0",
"algoliasearch": "^4.9.0",
"disqus-react": "^1.0.11",
"dotenv": "^8.2.0",
"escape-string-regexp": "^4.0.0",
Expand All @@ -21,6 +23,7 @@
"gatsby-plugin-offline": "^4.3.0",
"gatsby-plugin-react-helmet": "^4.3.0",
"gatsby-plugin-sharp": "^3.3.1",
"gatsby-plugin-styled-components": "^4.3.0",
"gatsby-plugin-typography": "^3.3.0",
"gatsby-remark-autolink-headers": "^4.0.0",
"gatsby-remark-copy-linked-files": "^4.0.0",
Expand All @@ -38,7 +41,9 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-instantsearch-dom": "^6.10.3",
"react-typography": "^0.16.19",
"styled-components": "^5.2.3",
"typeface-merriweather": "1.1.13",
"typeface-montserrat": "1.1.13",
"typography": "^0.16.19",
Expand Down
1 change: 1 addition & 0 deletions src/components/Layout/LanguageBar.css
Expand Up @@ -5,6 +5,7 @@
border-bottom: 1px solid;
display: flex;
flex-direction: row-reverse;
align-items: center;
}

.toggle-content {
Expand Down
9 changes: 8 additions & 1 deletion src/components/Layout/LanguageBar.js
Expand Up @@ -10,6 +10,12 @@ import BalloonField from '../BalloonField';
import LangList from '../LangList/LangList';
import './LanguageBar.css';

import Search from '../Search';

const searchIndices = [
{ name: process.env.GATSBY_ALGOLIA_INDEX_NAME, title: process.env.GATSBY_ALGOLIA_INDEX_NAME },
];

/**
* base MUST include slash (eg: en/)
*
Expand All @@ -36,7 +42,7 @@ function LanguageBar({ lang: langKey }) {
<StaticQuery
// eslint-disable-next-line no-use-before-define
query={supportedLanguagesQuery}
render={data => {
render={(data) => {
const { langsEntries, lang: defaultLang } = data.site.siteMetadata;

if (langsEntries.length < 2) {
Expand All @@ -59,6 +65,7 @@ function LanguageBar({ lang: langKey }) {
>
<div className="bar">
<LangButton lang={language} focused={displayLang} onClick={handleToggleLanguage} />
<Search indices={searchIndices} />
</div>
<div className="toggle-content" style={toggleStyle}>
<BalloonField style={{ padding: 20 }}>
Expand Down
18 changes: 18 additions & 0 deletions src/components/Search/SearchBox.js
@@ -0,0 +1,18 @@
import React from 'react';
import { connectSearchBox } from 'react-instantsearch-dom';
import { Search as SearchIcon } from '@styled-icons/fa-solid/Search';

export default connectSearchBox(({ refine, currentRefinement, className, onFocus }) => (
<form className={className}>
<input
className="SearchInput"
type="text"
placeholder="Search"
aria-label="Search"
onChange={(e) => refine(e.target.value)}
value={currentRefinement}
onFocus={onFocus}
/>
<SearchIcon className="SearchIcon" />
</form>
));
50 changes: 50 additions & 0 deletions src/components/Search/SearchResult.js
@@ -0,0 +1,50 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { Link } from 'gatsby';
import {
connectStateResults,
Highlight,
Hits,
Index,
Snippet,
PoweredBy,
} from 'react-instantsearch-dom';

const HitCount = connectStateResults(({ searchResults }) => {
const hitCount = searchResults && searchResults.nbHits;

return hitCount > 0 ? (
<div className="HitCount">
{hitCount} result{hitCount !== 1 ? `s` : ``}
</div>
) : null;
});

const PageHit = ({ hit }) => (
<div>
<Link to={hit.slug}>
<h4>
<Highlight attribute="title" hit={hit} tagName="mark" />
</h4>
</Link>
<Snippet attribute="excerpt" hit={hit} tagName="mark" />
</div>
);

const HitsInIndex = ({ index }) => (
<Index indexName={index.name}>
<HitCount />
<Hits className="Hits" hitComponent={PageHit} />
</Index>
);

const SearchResult = ({ indices, className }) => (
<div className={className}>
{indices.map((index) => (
<HitsInIndex index={index} key={index.name} />
))}
<PoweredBy />
</div>
);

export default SearchResult;
45 changes: 45 additions & 0 deletions src/components/Search/StyledSearchBox.js
@@ -0,0 +1,45 @@
import styled, { css } from 'styled-components';
import SearchBox from './SearchBox';

const open = css`
width: 10em;
background: ${({ theme }) => theme.background};
cursor: text;
margin-left: -1.6em;
padding-left: 1.6em;
`;

const closed = css`
width: 0;
background: transparent;
cursor: pointer;
margin-left: -1em;
padding-left: 1em;
`;

export default styled(SearchBox)`
display: flex;
flex-direction: row-reverse;
align-items: center;
margin-bottom: 0;
.SearchInput {
outline: none;
border: ${({ hasFocus }) => (hasFocus ? 'auto' : 'none')};
font-size: 1em;
transition: 100ms;
border-radius: 2px;
color: ${({ theme }) => theme.foreground};
::placeholder {
color: ${({ theme }) => theme.faded};
}
${({ hasFocus }) => (hasFocus ? open : closed)}
}
.SearchIcon {
width: 1em;
margin: 0.3em;
color: ${({ theme }) => theme.foreground};
pointer-events: none;
}
`;
58 changes: 58 additions & 0 deletions src/components/Search/StyledSearchResult.js
@@ -0,0 +1,58 @@
import styled, { css } from 'styled-components';
import SearchResult from './SearchResult';

const Popover = css`
max-height: 80vh;
overflow: scroll;
-webkit-overflow-scrolling: touch;
position: absolute;
z-index: 2;
right: 0;
top: 100%;
margin-top: 0.5em;
width: 80vw;
max-width: 30em;
box-shadow: 0 0 5px 0;
padding: 1em;
border-radius: 2px;
background: ${({ theme }) => theme.background};
`;

export default styled(SearchResult)`
display: ${(props) => (props.show ? `block` : `none`)};
${Popover}
.HitCount {
display: flex;
justify-content: flex-end;
}
.Hits {
ul {
list-style: none;
margin-left: 0;
}
li.ais-Hits-item {
margin-bottom: 1em;
a {
color: ${({ theme }) => theme.foreground};
h4 {
margin-bottom: 0.2em;
}
}
}
}
.ais-PoweredBy {
display: flex;
justify-content: flex-end;
font-size: 80%;
svg {
width: 70px;
}
}
`;
6 changes: 6 additions & 0 deletions src/components/Search/StyledSearchRoot.js
@@ -0,0 +1,6 @@
import styled from 'styled-components';

export default styled.div`
position: relative;
margin-right: 1em;
`;
43 changes: 43 additions & 0 deletions src/components/Search/index.js
@@ -0,0 +1,43 @@
/* eslint-disable react/prop-types */
import React, { createRef, useState } from 'react';
import algoliasearch from 'algoliasearch/lite';
import { InstantSearch } from 'react-instantsearch-dom';
import { ThemeProvider } from 'styled-components';
import StyledSearchBox from './StyledSearchBox';
import StyledSearchResult from './StyledSearchResult';
import StyledSearchRoot from './StyledSearchRoot';
import useClickOutside from './useClickOutside';

const theme = {
foreground: '#050505',
background: 'white',
faded: '#888',
};

export default function Search({ indices }) {
const rootRef = createRef();
const [query, setQuery] = useState();
const [hasFocus, setFocus] = useState(false);
const searchClient = algoliasearch(
process.env.GATSBY_ALGOLIA_APP_ID,
process.env.GATSBY_ALGOLIA_SEARCH_KEY,
);

useClickOutside(rootRef, () => setFocus(false));

return (
<ThemeProvider theme={theme}>
<StyledSearchRoot ref={rootRef}>
<InstantSearch
searchClient={searchClient}
indexName={indices[0].name}
// eslint-disable-next-line no-shadow
onSearchStateChange={({ query }) => setQuery(query)}
>
<StyledSearchBox onFocus={() => setFocus(true)} hasFocus={hasFocus} />
<StyledSearchResult show={query && query.length > 0 && hasFocus} indices={indices} />
</InstantSearch>
</StyledSearchRoot>
</ThemeProvider>
);
}
24 changes: 24 additions & 0 deletions src/components/Search/useClickOutside.js
@@ -0,0 +1,24 @@
/* eslint-disable no-restricted-syntax */
import { useEffect } from 'react';

const events = [`mousedown`, `touchstart`];

export default (ref, onClickOutside) => {
const isOutside = (element) => !ref.current || !ref.current.contains(element);

const onClick = (event) => {
if (isOutside(event.target)) {
onClickOutside();
}
};

useEffect(() => {
for (const event of events) {
document.addEventListener(event, onClick);
}

return () => {
for (const event of events) document.removeEventListener(event, onClick);
};
});
};

0 comments on commit b8ac571

Please sign in to comment.