diff --git a/src/components/FeatherIcon.js b/src/components/FeatherIcon.js index dbf50892a..39e4d8199 100644 --- a/src/components/FeatherIcon.js +++ b/src/components/FeatherIcon.js @@ -83,6 +83,12 @@ const ICONS = { github: ( ), + search: ( + <> + + + + ), 'upload-cloud': ( <> diff --git a/src/components/Navigation.js b/src/components/Navigation.js index 56553b5cd..bf5f31a12 100644 --- a/src/components/Navigation.js +++ b/src/components/Navigation.js @@ -1,127 +1,55 @@ -import React, { Fragment, useState, useContext } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { Link } from 'gatsby'; import cx from 'classnames'; -import { BreadcrumbContext } from './BreadcrumbContext'; -import FeatherIcon from './FeatherIcon'; -import NewRelicIcon from './NewRelicIcon'; +import NavigationItems from './NavigationItems'; import pages from '../data/sidenav.json'; - +import matchSearchString from '../utils/matchSearchString'; import styles from './Navigation.module.scss'; -// recursively create navigation -const renderNav = (pages, depthLevel = 0) => { - // TODO: Refactor this function into a component - // eslint-disable-next-line react-hooks/rules-of-hooks - const crumbs = useContext(BreadcrumbContext).flatMap((x) => x.displayName); - const isHomePage = crumbs.length === 0 && depthLevel === 0; - const iconLibrary = { - 'Collect data': 'collectData', - 'Build apps': 'buildApps', - 'Automate workflows': 'automation', - 'Explore docs': 'developerDocs', - }; - - const groupedPages = pages.reduce((groups, page) => { - const { group = '' } = page; - - return { - ...groups, - [group]: [...(groups[group] || []), page], - }; - }, {}); - - return Object.entries(groupedPages).map(([group, pages]) => ( - - {group && ( -
  • {group}
  • - )} - {pages.map((page) => { - const [isExpanded, setIsExpanded] = useState( - isHomePage || crumbs.includes(page.displayName) - ); - const isCurrentPage = crumbs[crumbs.length - 1] === page.displayName; - const headerIcon = depthLevel === 0 && ( - - ); - - return ( -
  • - {page.url ? ( - - - {headerIcon} - {page.displayName} - - {isCurrentPage && ( - - )} - - ) : ( - - )} - {page.children && ( -
      - {renderNav(page.children, depthLevel + 1)} -
    - )} -
  • - ); - })} -
    - )); +const filterPageNames = (pages, searchTerm, parent = []) => { + return [ + ...new Set( + pages.flatMap((page) => { + if (page.children) { + return filterPageNames(page.children, searchTerm, [ + ...parent, + page.displayName, + ]); + } else if (matchSearchString(page.displayName, searchTerm)) { + return [...parent, page.displayName]; + } else if (parent.some((el) => matchSearchString(el, searchTerm))) { + return [...parent]; + } + }) + ), + ]; }; -const Navigation = ({ className }) => { +const Navigation = ({ className, searchTerm }) => { + const searchTermSanitized = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filteredPageNames = + searchTerm !== '' ? filterPageNames(pages, searchTermSanitized) : undefined; + return ( ); }; Navigation.propTypes = { className: PropTypes.string, + searchTerm: PropTypes.string, }; export default Navigation; diff --git a/src/components/Navigation.module.scss b/src/components/Navigation.module.scss index 408650aaa..57428ada2 100644 --- a/src/components/Navigation.module.scss +++ b/src/components/Navigation.module.scss @@ -2,87 +2,7 @@ font-size: 0.875rem; } -.listNav, -.nestedNav { - list-style: none; -} - -.nestedNav { - padding-left: 1rem; - display: none; - - &.isExpanded { - display: block; - } - - [data-depth] > & { - padding-left: calc(0.5rem + 1em); - } -} - .listNav { + list-style: none; padding-left: 0; } - -.navLink { - color: var(--color-neutrals-900); - margin-bottom: 1rem; - display: flex; - align-items: center; - justify-content: space-between; - text-decoration: none; - transition: 0.1s; - - &:not(.isCurrentPage):hover { - color: var(--color-neutrals-600); - } - - [data-depth='0'] > & { - font-weight: bold; - } -} - -.headerIcon { - margin-right: 0.5rem; -} - -button.navLink { - color: var(--color-neutrals-900); - background: inherit; - border: none; - padding: 0; - font-size: inherit; - font-weight: inherit; - - &:focus { - outline: none; - } -} - -.currentPageIndicator { - stroke-width: 4; -} - -.nestedChevron { - margin-right: 0.5rem; - stroke-width: 4; - transition: 0.2s; - &.isExpanded { - transform: rotate(90deg); - } -} - -.groupName { - color: var(--color-neutrals-600); - font-weight: bold; - font-size: 0.75rem; - text-transform: uppercase; - - &:not(:first-child) { - margin-top: 2rem; - } -} - -.isCurrentPage { - font-weight: bold; -} diff --git a/src/components/NavigationItems.js b/src/components/NavigationItems.js new file mode 100644 index 000000000..ae5cb1323 --- /dev/null +++ b/src/components/NavigationItems.js @@ -0,0 +1,171 @@ +import React, { Fragment, useState, useContext } from 'react'; +import PropTypes from 'prop-types'; +import FeatherIcon from './FeatherIcon'; +import NewRelicIcon from './NewRelicIcon'; +import { Link } from 'gatsby'; +import cx from 'classnames'; +import { BreadcrumbContext } from './BreadcrumbContext'; +import styles from './NavigationItems.module.scss'; +import { link } from '../types'; + +const getHighlightedText = (text, highlight) => { + const parts = text.split(new RegExp(`(${highlight})`, 'gi')); + return ( + + {parts.map((part) => + part.toLowerCase() === highlight.toLowerCase() ? {part} : part + )} + + ); +}; + +const NavigationItems = ({ + pages, + filteredPageNames, + searchTerm, + depthLevel = 0, +}) => { + const groupedPages = pages.reduce((groups, page) => { + const { group = '' } = page; + + return { + ...groups, + [group]: [...(groups[group] || []), page], + }; + }, {}); + + return Object.entries(groupedPages).map(([group, pages]) => { + const showGroup = + (group && !filteredPageNames) || + (group && + filteredPageNames && + pages.some((el) => filteredPageNames.includes(el.displayName))); + return ( + + {showGroup && ( +
  • {group}
  • + )} + {pages.map((page, index) => ( + + ))} +
    + ); + }); +}; + +const NavItem = ({ page, depthLevel, searchTerm, filteredPageNames }) => { + const crumbs = useContext(BreadcrumbContext).flatMap((x) => x.displayName); + const isHomePage = crumbs.length === 0 && depthLevel === 0; + + const [isExpanded, setIsExpanded] = useState( + isHomePage || crumbs.includes(page.displayName) + ); + + const iconLibrary = { + 'Collect data': 'collectData', + 'Build apps': 'buildApps', + 'Automate workflows': 'automation', + 'Explore docs': 'developerDocs', + }; + const isCurrentPage = crumbs[crumbs.length - 1] === page.displayName; + const headerIcon = depthLevel === 0 && ( + + ); + const display = filteredPageNames + ? getHighlightedText(page.displayName, searchTerm) + : page.displayName; + + if (filteredPageNames && !filteredPageNames.includes(page.displayName)) + return null; + + return ( +
  • + {page.url ? ( + + + {headerIcon} + {display} + + {isCurrentPage && ( + + )} + + ) : ( + + )} + {page.children && ( +
      + +
    + )} +
  • + ); +}; + +NavigationItems.propTypes = { + pages: PropTypes.array.isRequired, + filteredPageNames: PropTypes.array, + searchTerm: PropTypes.string, + depthLevel: PropTypes.number, +}; + +NavItem.propTypes = { + page: link, + filteredPageNames: PropTypes.array, + searchTerm: PropTypes.string, + depthLevel: PropTypes.number.isRequired, +}; + +export default NavigationItems; diff --git a/src/components/NavigationItems.module.scss b/src/components/NavigationItems.module.scss new file mode 100644 index 000000000..516dfb096 --- /dev/null +++ b/src/components/NavigationItems.module.scss @@ -0,0 +1,92 @@ +.nestedNav { + list-style: none; +} + +.nestedNav { + padding-left: 1rem; + display: none; + + &.isExpanded { + display: block; + } + + [data-depth] > & { + padding-left: calc(0.5rem + 1em); + } +} + +.navLink { + color: var(--color-neutrals-900); + margin-bottom: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + text-decoration: none; + transition: 0.1s; + + &:not(.isCurrentPage):hover { + color: var(--color-neutrals-600); + } + + [data-depth='0'] > & { + font-weight: 600; + padding: 1em 0; + } +} + +.headerIcon { + margin-right: 0.5rem; +} + +button.navLink { + color: var(--color-neutrals-900); + background: inherit; + border: none; + padding: 0; + font-size: inherit; + font-weight: inherit; + + &:focus { + outline: none; + } +} + +.currentPageIndicator { + stroke-width: 4; +} + +.nestedChevron { + margin-right: 0.5rem; + stroke-width: 4; + transition: 0.2s; + &.isExpanded { + transform: rotate(90deg); + } +} + +.groupName { + color: var(--color-neutrals-600); + font-weight: bold; + font-size: 0.75rem; + text-transform: uppercase; + + &:not(:first-child) { + margin-top: 2rem; + } +} + +.isCurrentPage { + font-weight: bold; +} + +.filterOn { + ul { + display: block; + } + .nestedChevron { + transform: rotate(90deg); + } + .groupName { + margin-top: 0.5rem; + } +} diff --git a/src/components/SearchInput.js b/src/components/SearchInput.js new file mode 100644 index 000000000..bc6dbb4d9 --- /dev/null +++ b/src/components/SearchInput.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import FeatherIcon from './FeatherIcon'; +import styles from './SearchInput.module.scss'; + +const SearchInput = ({ className, ...props }) => ( +
    + + +
    +); + +SearchInput.propTypes = { + className: PropTypes.string, +}; + +export default SearchInput; diff --git a/src/components/SearchInput.module.scss b/src/components/SearchInput.module.scss new file mode 100644 index 000000000..bf48fcaef --- /dev/null +++ b/src/components/SearchInput.module.scss @@ -0,0 +1,19 @@ +.container { + display: inline-flex; + align-items: center; + width: 100%; + position: relative; +} + +.icon { + position: absolute; + right: 0.5rem; + stroke: var(--color-neutrals-700); +} + +.input { + width: 100%; + font-size: 0.875rem; + padding: 0.5rem; + padding-right: calc(1rem + 1em); +} diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index b7c5205dd..524b08a21 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -1,19 +1,30 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'gatsby'; import cx from 'classnames'; import Logo from './Logo'; import Navigation from './Navigation'; +import SearchInput from './SearchInput'; import styles from './Sidebar.module.scss'; -const Sidebar = ({ className }) => ( - -); +const Sidebar = ({ className }) => { + const [searchTerm, setSearchTerm] = useState(''); + + return ( + + ); +}; Sidebar.propTypes = { className: PropTypes.string, diff --git a/src/components/Sidebar.module.scss b/src/components/Sidebar.module.scss index f007215b5..c5a7a93e9 100644 --- a/src/components/Sidebar.module.scss +++ b/src/components/Sidebar.module.scss @@ -19,3 +19,7 @@ .nav { margin-top: 1rem; } + +.searchInput { + margin: 1rem 0; +} diff --git a/src/utils/matchSearchString.js b/src/utils/matchSearchString.js new file mode 100644 index 000000000..bd3063610 --- /dev/null +++ b/src/utils/matchSearchString.js @@ -0,0 +1,8 @@ +const matchSearchString = (str, searchTerm) => { + return new RegExp( + searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'i' + ).test(str); +}; + +export default matchSearchString;