Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

(docs) add a search input to the sidebar (#1281)

* Add < SidebarSearchInput />
* Filter sidebar headings and subheadings based on input
* While searching, include matching H3 headers in the sidebar
* Highlight matching substrings by wrapping them in <strong>
* Add fuse.js fuzzy-search dep
* Increase sidebar width

Changed files
+223 -31
packages
+1
packages/site/package.json
···
"dependencies": {
"@mdx-js/react": "^1.6.22",
"formidable-oss-badges": "0.3.5",
+
"fuse.js": "^6.4.6",
"history": "^4.7.2",
"path": "^0.12.7",
"preact": "^10.5.7",
+4 -3
packages/site/src/components/navigation.js
···
right: 0;
bottom: 0;
min-height: 100%;
+
width: ${p => p.theme.layout.sidebar};
@media ${({ theme }) => theme.media.sm} {
display: block;
position: relative;
-
width: ${p => p.theme.layout.sidebar};
margin-left: calc(2 * ${p => p.theme.layout.stripes});
}
`;
···
background-color: ${p => p.theme.colors.bg};
border-right: 1px solid ${p => p.theme.colors.border};
border-top: 1px solid ${p => p.theme.colors.border};
+
width: ${p => p.theme.layout.sidebar};
@media ${({ theme }) => theme.media.sm} {
border: none;
background: none;
padding-top: ${p => p.theme.spacing.md};
-
width: ${p => p.theme.layout.sidebar};
}
`;
···
color: ${p => p.theme.colors.passive};
font-weight: ${p => p.theme.fontWeights.body};
text-decoration: none;
-
margin-top: ${p => p.theme.spacing.xs};
+
margin: ${p =>
+
`${p.theme.spacing.xs} 0 0 ${p.nested ? p.theme.spacing.sm : 0}`};
&:first-child {
margin-top: 0;
+40
packages/site/src/components/sidebar-search-input.js
···
+
import React from 'react';
+
import PropTypes from 'prop-types';
+
import styled from 'styled-components';
+
+
const StyledInput = styled.input`
+
background-color: rgba(255, 255, 255, 0.8);
+
border: none;
+
border-radius: 0.5rem;
+
color: ${p => p.theme.colors.text};
+
font-family: ${p => p.theme.fonts.body};
+
font-size: 1.6rem;
+
line-height: 2.3rem;
+
letter-spacing: -0.6px;
+
margin: ${p =>
+
`${p.theme.spacing.sm} 0 ${p.theme.spacing.sm} calc(${p.theme.spacing.xs} * -1.5)`};
+
padding: ${p =>
+
`${p.theme.spacing.xs} calc(${p.theme.spacing.xs} * 1.5) ${p.theme.spacing.xs}`};
+
width: calc(100% + 1.8rem);
+
background-color: ${p => p.theme.colors.passiveBg};
+
+
@media ${p => p.theme.media.sm} {
+
background-color: ${p => p.theme.colors.bg};
+
}
+
`;
+
+
const SidebarSearchInput = ({ value, onHandleInputChange }) => (
+
<StyledInput
+
onChange={onHandleInputChange}
+
placeholder="Filter..."
+
type="search"
+
value={value}
+
/>
+
);
+
+
SidebarSearchInput.propTypes = {
+
value: PropTypes.string,
+
onHandleInputChange: PropTypes.func,
+
};
+
+
export default SidebarSearchInput;
+172 -27
packages/site/src/components/sidebar.js
···
/* eslint-disable react-hooks/rules-of-hooks */
-
import React, { Fragment, useMemo } from 'react';
+
import React, { Fragment, useMemo, useState } from 'react';
import styled from 'styled-components';
+
import Fuse from 'fuse.js';
import { useBasepath } from 'react-static';
import { Link, useLocation } from 'react-router-dom';
import * as path from 'path';
-
import { useMarkdownTree, useMarkdownPage } from 'react-static-plugin-md-pages';
+
import { useMarkdownPage, useMarkdownTree } from 'react-static-plugin-md-pages';
import {
SidebarNavItem,
···
SideBarStripes,
ChevronItem,
} from './navigation';
+
import SidebarSearchInput from './sidebar-search-input';
import logoSidebar from '../assets/sidebar-badge.svg';
const HeroLogoLink = styled(Link)`
-
display: flex;
+
display: none;
flex-direction: row;
justify-content: center;
margin-bottom: ${p => p.theme.spacing.sm};
align-self: center;
+
+
@media ${p => p.theme.media.sm} {
+
display: flex;
+
}
`;
const HeroLogo = styled.img.attrs(() => ({
src: logoSidebar,
alt: 'urql',
}))`
-
display: none;
width: ${p => p.theme.layout.logo};
height: ${p => p.theme.layout.logo};
-
-
@media ${p => p.theme.media.sm} {
-
display: block;
-
}
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
-
padding-top: ${p => p.theme.spacing.xs};
padding-bottom: ${p => p.theme.spacing.lg};
`;
···
return { pathname };
};
-
export const SidebarStyling = ({ children, sidebarOpen, closeSidebar }) => {
+
export const SidebarStyling = ({ children, sidebarOpen }) => {
const basepath = useBasepath() || '';
const homepage = basepath ? `/${basepath}/` : '/';
return (
<>
<SideBarStripes />
-
<SidebarContainer hidden={!sidebarOpen} onClick={closeSidebar}>
+
<SidebarContainer hidden={!sidebarOpen}>
<SidebarWrapper>
<HeroLogoLink to={homepage}>
<HeroLogo />
···
);
};
-
const Sidebar = props => {
+
const getMatchTree = (() => {
+
const sortByRefIndex = (a, b) => a.refIndex - b.refIndex;
+
+
const options = {
+
distance: 100,
+
findAllMatches: true,
+
includeMatches: true,
+
keys: [
+
'frontmatter.title',
+
`children.frontmatter.title`,
+
'children.headings.value',
+
],
+
threshold: 0.2,
+
};
+
+
return (children, pattern) => {
+
// Filter any nested heading with a depth greater than 2
+
const childrenMaxH3 = children.map(child => ({
+
...child,
+
children:
+
child.children &&
+
child.children.map(child => ({
+
...child,
+
headings: child.headings.filter(heading => heading.depth == 2),
+
})),
+
}));
+
+
const fuse = new Fuse(childrenMaxH3, options);
+
let matches = fuse.search(pattern);
+
+
// For every matching section, include only matching headers
+
return matches
+
.reduce((matches, match) => {
+
const matchesMap = new Map();
+
match.matches.forEach(individualMatch => {
+
matchesMap.set(individualMatch.value, {
+
indices: individualMatch.indices,
+
});
+
});
+
+
// Add the top level heading but don't add subheadings unless they match
+
const currentItem = {
+
...match.item,
+
matchedIndices: match.indices,
+
refIndex: match.refIndex,
+
};
+
+
// For every child of the currently matched section, add all appplicable
+
// H2 and H3 headers plus their indices
+
if (currentItem.children) {
+
currentItem.children = currentItem.children.reduce(
+
(children, child) => {
+
const newChild = { ...child };
+
newChild.headings = newChild.headings.reduce(
+
(headings, header) => {
+
const match = matchesMap.get(header.value);
+
if (match) {
+
headings.push({
+
...header,
+
matchedIndices: match.indices,
+
});
+
}
+
return headings;
+
},
+
[]
+
);
+
+
const match = matchesMap.get(newChild.frontmatter.title);
+
if (match) {
+
newChild.matchedIndices = match.indices;
+
}
+
+
if (match || newChild.headings.length > 0) {
+
children.push(newChild);
+
}
+
return children;
+
},
+
[]
+
);
+
}
+
+
return [...matches, currentItem];
+
}, [])
+
.sort(sortByRefIndex);
+
};
+
})();
+
+
// Wrap matching substrings in <strong>
+
const highlightText = (text, indices) => (
+
<>
+
{indices.map(([startIndex, endIndex], i) => {
+
const isLastIndex = !indices[i + 1];
+
const prevEndIndex = indices[i - 1] ? indices[i - 1][1] : -1;
+
+
return (
+
<>
+
{startIndex != 0 ? text.slice(prevEndIndex + 1, startIndex) : ''}
+
<strong>{text.slice(startIndex, endIndex + 1)}</strong>
+
{isLastIndex && endIndex < text.length
+
? text.slice(endIndex + 1, text.length)
+
: ''}
+
</>
+
);
+
})}
+
</>
+
);
+
+
const Sidebar = ({ closeSidebar, ...props }) => {
+
const [filterTerm, setFilterTerm] = useState('');
const location = useLocation();
const tree = useMarkdownTree();
+
const page = useMarkdownPage();
const sidebarItems = useMemo(() => {
let pathname = location.pathname.match(/docs\/?(.+)?/);
···
children = [{ ...tree, children: undefined }, ...children];
}
+
if (filterTerm) {
+
children = getMatchTree(children, filterTerm);
+
}
+
return children.map(page => {
const pageChildren = page.children || [];
···
? trimmedPathname.startsWith(page.path)
: !!page.path.match(new RegExp(`${trimmedPathname}$`, 'g'));
+
const showSubItems = !!filterTerm || (pageChildren.length && isActive);
+
return (
<Fragment key={page.key}>
<SidebarNavItem
to={relative(pathname, page.path)}
+
// If there is an active filter term in place, expand all headings
isActive={() => isActive}
+
onClick={closeSidebar}
>
-
{page.frontmatter.title}
+
{page.matchedIndices
+
? highlightText(page.frontmatter.title, page.matchedIndices)
+
: page.frontmatter.title}
{pageChildren.length ? <ChevronItem /> : null}
</SidebarNavItem>
-
{pageChildren.length && isActive ? (
+
{showSubItems ? (
<SidebarNavSubItemWrapper>
{pageChildren.map(childPage => (
-
<SidebarNavSubItem
-
isActive={() =>
-
!!childPage.path.match(
-
new RegExp(`${trimmedPathname}$`, 'g')
-
)
-
}
-
to={relative(pathname, childPage.path)}
-
key={childPage.key}
-
>
-
{childPage.frontmatter.title}
-
</SidebarNavSubItem>
+
<Fragment key={childPage.key}>
+
<SidebarNavSubItem
+
isActive={() =>
+
!!childPage.path.match(
+
new RegExp(`${trimmedPathname}$`, 'g')
+
)
+
}
+
to={relative(pathname, childPage.path)}
+
>
+
{childPage.matchedIndices
+
? highlightText(
+
childPage.frontmatter.title,
+
childPage.matchedIndices
+
)
+
: childPage.frontmatter.title}
+
</SidebarNavSubItem>
+
{/* Only Show H3 items if there is a search applied */}
+
{filterTerm
+
? childPage.headings.map(heading => (
+
<SidebarNavSubItem
+
to={relative(pathname, childPage.path)}
+
key={heading.value}
+
nested={true}
+
>
+
{highlightText(heading.value, heading.matchedIndices)}
+
</SidebarNavSubItem>
+
))
+
: null}
+
</Fragment>
))}
</SidebarNavSubItemWrapper>
) : null}
</Fragment>
);
});
-
}, [location, tree]);
+
}, [location, tree, filterTerm, closeSidebar]);
-
return <SidebarStyling {...props}>{sidebarItems}</SidebarStyling>;
+
return (
+
<SidebarStyling {...props}>
+
<SidebarSearchInput
+
onHandleInputChange={e => setFilterTerm(e.target.value)}
+
value={filterTerm}
+
/>
+
{sidebarItems}
+
</SidebarStyling>
+
);
};
export default Sidebar;
+1 -1
packages/site/src/styles/theme.js
···
page: '144rem',
header: '4.8rem',
stripes: '0.7rem',
-
sidebar: '26rem',
+
sidebar: '28rem',
legend: '22rem',
logo: '12rem',
};
+5
yarn.lock
···
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c"
integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==
+
fuse.js@^6.4.6:
+
version "6.4.6"
+
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.6.tgz#62f216c110e5aa22486aff20be7896d19a059b79"
+
integrity sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw==
+
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"