Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 7.9 kB view raw
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;