1/* eslint-disable react-hooks/rules-of-hooks */
2
3import React, { Fragment, useMemo, useState } from 'react';
4import styled from 'styled-components';
5import Fuse from 'fuse.js';
6import { Link, useLocation } from 'react-router-dom';
7
8import { useMarkdownTree } from 'react-static-plugin-md-pages';
9
10import {
11 SidebarNavItem,
12 SidebarNavSubItem,
13 SidebarNavSubItemWrapper,
14 SidebarContainer,
15 SidebarWrapper,
16 SideBarStripes,
17 ChevronItem,
18} from './navigation';
19import SidebarSearchInput from './sidebar-search-input';
20
21import logoSidebar from '../assets/sidebar-badge.svg';
22
23const HeroLogoLink = styled(Link)`
24 display: none;
25 flex-direction: row;
26 justify-content: center;
27 margin-bottom: ${p => p.theme.spacing.sm};
28 align-self: center;
29
30 @media ${p => p.theme.media.sm} {
31 display: flex;
32 }
33`;
34
35const HeroLogo = styled.img.attrs(() => ({
36 src: logoSidebar,
37 alt: 'urql',
38}))`
39 width: ${p => p.theme.layout.logo};
40 height: ${p => p.theme.layout.logo};
41`;
42
43const ContentWrapper = styled.div`
44 display: flex;
45 flex-direction: column;
46 padding-bottom: ${p => p.theme.spacing.lg};
47`;
48
49export const SidebarStyling = ({ children, sidebarOpen }) => (
50 <>
51 <SideBarStripes />
52 <SidebarContainer hidden={!sidebarOpen}>
53 <SidebarWrapper>
54 <HeroLogoLink to="/">
55 <HeroLogo />
56 </HeroLogoLink>
57 <ContentWrapper>{children}</ContentWrapper>
58 </SidebarWrapper>
59 </SidebarContainer>
60 </>
61);
62
63const getMatchTree = (() => {
64 const sortByRefIndex = (a, b) => a.refIndex - b.refIndex;
65
66 const options = {
67 distance: 100,
68 findAllMatches: true,
69 includeMatches: true,
70 keys: [
71 'frontmatter.title',
72 `children.frontmatter.title`,
73 'children.headings.value',
74 ],
75 threshold: 0.2,
76 };
77
78 return (children, pattern) => {
79 // Filter any nested heading with a depth greater than 2
80 const childrenMaxH3 = children.map(child => ({
81 ...child,
82 children:
83 child.children &&
84 child.children.map(child => ({
85 ...child,
86 headings: child.headings.filter(heading => heading.depth == 2),
87 })),
88 }));
89
90 const fuse = new Fuse(childrenMaxH3, options);
91 let matches = fuse.search(pattern);
92
93 // For every matching section, include only matching headers
94 return matches
95 .reduce((matches, match) => {
96 const matchesMap = new Map();
97 match.matches.forEach(individualMatch => {
98 matchesMap.set(individualMatch.value, {
99 indices: individualMatch.indices,
100 });
101 });
102
103 // Add the top level heading but don't add subheadings unless they match
104 const currentItem = {
105 ...match.item,
106 matchedIndices: match.indices,
107 refIndex: match.refIndex,
108 };
109
110 // For every child of the currently matched section, add all appplicable
111 // H2 and H3 headers plus their indices
112 if (currentItem.children) {
113 currentItem.children = currentItem.children.reduce(
114 (children, child) => {
115 const newChild = { ...child };
116 newChild.headings = newChild.headings.reduce(
117 (headings, header) => {
118 const match = matchesMap.get(header.value);
119 if (match) {
120 headings.push({
121 ...header,
122 matchedIndices: match.indices,
123 });
124 }
125 return headings;
126 },
127 []
128 );
129
130 const match = matchesMap.get(newChild.frontmatter.title);
131 if (match) {
132 newChild.matchedIndices = match.indices;
133 }
134
135 if (match || newChild.headings.length > 0) {
136 children.push(newChild);
137 }
138 return children;
139 },
140 []
141 );
142 }
143
144 return [...matches, currentItem];
145 }, [])
146 .sort(sortByRefIndex);
147 };
148})();
149
150// Wrap matching substrings in <strong>
151const highlightText = (text, indices) => (
152 <>
153 {indices.map(([startIndex, endIndex], i) => {
154 const isLastIndex = !indices[i + 1];
155 const prevEndIndex = indices[i - 1] ? indices[i - 1][1] : -1;
156
157 return (
158 <>
159 {startIndex != 0 ? text.slice(prevEndIndex + 1, startIndex) : ''}
160 <strong>{text.slice(startIndex, endIndex + 1)}</strong>
161 {isLastIndex && endIndex < text.length
162 ? text.slice(endIndex + 1, text.length)
163 : ''}
164 </>
165 );
166 })}
167 </>
168);
169
170const Sidebar = ({ closeSidebar, ...props }) => {
171 const [filterTerm, setFilterTerm] = useState('');
172 const location = useLocation();
173 const tree = useMarkdownTree();
174
175 const sidebarItems = useMemo(() => {
176 let pathname = location.pathname.match(/docs\/?(.+)?/);
177 if (!pathname || !tree || !tree.children || !location) {
178 return null;
179 }
180
181 pathname = pathname[0];
182 const trimmedPathname = pathname.replace(/(\/$)|(\/#.+)/, '');
183
184 let children = tree.children;
185 if (tree.frontmatter && tree.originalPath) {
186 children = [{ ...tree, children: undefined }, ...children];
187 }
188
189 if (filterTerm) {
190 children = getMatchTree(children, filterTerm);
191 }
192
193 return children.map(page => {
194 const pageChildren = page.children || [];
195
196 const isActive = pageChildren.length
197 ? trimmedPathname.startsWith(page.path)
198 : !!page.path.match(new RegExp(`${trimmedPathname}$`, 'g'));
199
200 const showSubItems = !!filterTerm || (pageChildren.length && isActive);
201
202 return (
203 <Fragment key={page.key}>
204 <SidebarNavItem
205 to={`/${page.path}/`}
206 // If there is an active filter term in place, expand all headings
207 isActive={() => isActive}
208 onClick={closeSidebar}
209 >
210 {page.matchedIndices
211 ? highlightText(page.frontmatter.title, page.matchedIndices)
212 : page.frontmatter.title}
213 {pageChildren.length ? <ChevronItem /> : null}
214 </SidebarNavItem>
215
216 {showSubItems ? (
217 <SidebarNavSubItemWrapper>
218 {pageChildren.map(childPage => (
219 <Fragment key={childPage.key}>
220 <SidebarNavSubItem
221 isActive={() =>
222 !!childPage.path.match(
223 new RegExp(`${trimmedPathname}$`, 'g')
224 )
225 }
226 to={`/${childPage.path}/`}
227 >
228 {childPage.matchedIndices
229 ? highlightText(
230 childPage.frontmatter.title,
231 childPage.matchedIndices
232 )
233 : childPage.frontmatter.title}
234 </SidebarNavSubItem>
235 {/* Only Show H3 items if there is a search applied */}
236 {filterTerm
237 ? childPage.headings.map(heading => (
238 <SidebarNavSubItem
239 to={`/${childPage.path}/#${heading.slug}`}
240 key={heading.value}
241 nested={true}
242 >
243 {highlightText(heading.value, heading.matchedIndices)}
244 </SidebarNavSubItem>
245 ))
246 : null}
247 </Fragment>
248 ))}
249 </SidebarNavSubItemWrapper>
250 ) : null}
251 </Fragment>
252 );
253 });
254 }, [location, tree, filterTerm, closeSidebar]);
255
256 return (
257 <SidebarStyling {...props}>
258 <SidebarSearchInput
259 onHandleInputChange={e => setFilterTerm(e.target.value)}
260 value={filterTerm}
261 />
262 {sidebarItems}
263 </SidebarStyling>
264 );
265};
266
267export default Sidebar;