diff --git a/packages/styleguide/package.json b/packages/styleguide/package.json index cf2e1556db3..ae47abf66e7 100644 --- a/packages/styleguide/package.json +++ b/packages/styleguide/package.json @@ -15,12 +15,14 @@ "homepage": "/", "dependencies": { "@mdx-js/react": "~1.5.0", + "accessible-autocomplete": "~2.0.1", "array-includes": "~3.0.3", "chroma-js": "~2.0.2", "classnames": "~2.2.5", "common-tags": "~1.8.0", "core-js": "~2.6.0", "firacode": "~1.205.0", + "fuzzyjs": "~4.0.3", "object.entries": "~1.1.0", "polished": "~2.3.1", "pretty": "~2.0.0", @@ -33,7 +35,7 @@ "react-frame-component": "~4.0.0", "react-ga": "~2.6.0", "react-responsive": "~6.0.1", - "react-router-dom": "~5.0.0", + "react-router-dom": "~5.1.2", "react-select": "~3.0.8", "styled-components": "~4.1.2", "svg4everybody": "~2.1.9", diff --git a/packages/styleguide/src/components/App/App.js b/packages/styleguide/src/components/App/App.js index 960746781e1..9325c0449f7 100644 --- a/packages/styleguide/src/components/App/App.js +++ b/packages/styleguide/src/components/App/App.js @@ -14,6 +14,7 @@ import Sidebar from './../Sidebar'; import Navigation from './../Navigation'; import NavigationBar from './../NavigationBar'; import Sitemap from './../Sitemap'; +import Search from './../Search'; class App extends Component { static displayName = 'App'; @@ -118,14 +119,15 @@ class App extends Component { }> + + - this.handleNavLinkClick()} diff --git a/packages/styleguide/src/components/Navigation/index.js b/packages/styleguide/src/components/Navigation/index.js index 2b3fa7032a0..c177b728057 100644 --- a/packages/styleguide/src/components/Navigation/index.js +++ b/packages/styleguide/src/components/Navigation/index.js @@ -1,152 +1,119 @@ -import React from 'react'; -import { array, bool, func, string } from 'prop-types'; +import React, { useState, useEffect } from 'react'; +import { array, bool, func } from 'prop-types'; import cx from 'classnames'; import styled from 'styled-components'; -import { withRouter } from 'react-router-dom'; +import { withRouter, useLocation } from 'react-router-dom'; import { rem } from './../../style/utils'; - import Category from './Category'; import NavLink from './NavLink'; const CLASS_ROOT = 'sg-nav'; -class Navigation extends React.Component { - static displayName = 'Navigation'; - - static propTypes = { - isMain: bool, - routes: array, - pathname: string, - onNavLinkClick: func, +const Navigation = ({ + className, + routes = [], + isMain, + onNavLinkClick, + ...other +}) => { + const classes = cx(CLASS_ROOT, className); + let location = useLocation(); + + const [activeCategories, setActiveCategories] = useState({}); + + /** + * Set category active state + */ + const setCategoryActiveState = (id, value = true) => { + setActiveCategories({ + ...activeCategories, + [id]: value, + }); }; - constructor(props) { - super(props); - this.state = { - activeLinks: [], - }; - this.handleClick = this.handleClick.bind(this); - } - - componentWillMount() { - const path = this.props.pathname; - - let pathArray = []; - pathArray = path.split('/'); - pathArray = pathArray.filter(e => String(e).trim()); - - const activeLinks = this.copyActiveLinks(pathArray.length - 1); + /** + * This method sets every category state (from pathname) + * from first parent to current category to active + */ + const setActiveCategoriesFromPathname = () => { + let url = ''; + const locPath = location.pathname; + const categories = locPath + // remove first '/' and last item of the path + .substring(1, locPath.lastIndexOf('/')) + .split('/') + .reduce((acc, curr) => { + url += '/' + curr; + return { ...acc, [url]: true }; + }, {}); + + // set all new active categories + setActiveCategories({ ...activeCategories, ...categories }); + }; - pathArray.forEach((element, i) => { - activeLinks[i].push(`/${element}`); - }); + useEffect( + () => { + setActiveCategoriesFromPathname(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [location.pathname] + ); + + const getNavList = (nodes = [], path = '') => ( + + {nodes.map(node => { + let item = null; + let nestedList = null; + + const url = path + node.path; + if (node.nodes) { + item = ( + { + setCategoryActiveState(url, !activeCategories[url]); + }} + isActive={activeCategories[url]} + > + {node.title} + + ); - this.setState({ - activeLinks, - }); - } - - handleClick(activeLink, depthLevel) { - const activeLinks = this.copyActiveLinks(depthLevel); - - if (activeLinks[depthLevel].indexOf(activeLink) === -1) { - activeLinks[depthLevel].push(activeLink); - } else { - activeLinks[depthLevel] = this.removeActive( - activeLinks[depthLevel], - activeLink - ); - } - - this.setState({ - activeLinks, - }); - } - - // eslint-disable-next-line class-methods-use-this - removeActive(arr, element) { - return arr.filter(e => e !== element); - } - - copyActiveLinks(depthLevel) { - const activeLinks = this.state.activeLinks.slice(0); - - for (let i = 0; i <= depthLevel; i += 1) { - activeLinks[i] = activeLinks[i] || []; - } - - return activeLinks; - } - - isActive(element, depthLevel) { - const activeLinks = this.copyActiveLinks(depthLevel); - - return Array.isArray(activeLinks) || activeLinks.length - ? activeLinks[depthLevel].includes(element) - : false; - } - - render() { - const { - className, - routes = [], - isMain, - onNavLinkClick, - ...other - } = this.props; - - const classes = cx(CLASS_ROOT, className); - - const getNavList = (nodes = [], path = '', depthLevel = 0) => ( - - {nodes.map(node => { - let item = null; - let nestedList = null; - - if (node.nodes) { - item = ( - this.handleClick(node.path, depthLevel)} - isActive={this.isActive(node.path, depthLevel)} - > - {node.title} - - ); - - const depthLevelUpdated = depthLevel + 1; - nestedList = getNavList( - node.nodes, - path + node.path, - depthLevelUpdated - ); - } else { - item = ( - - {node.title} - - ); - } - - return ( - - {item} - {nestedList} - + nestedList = getNavList(node.nodes, url); + } else { + item = ( + + {node.title} + ); - })} - - ); - - // div has to wrapp Nav because of nice layout - return ( - - {getNavList(routes)} - - ); - } -} + } + + return ( + + {item} + {nestedList} + + ); + })} + + ); + + // div has to wrapp Nav because of nice layout + return ( + + {getNavList(routes)} + + ); +}; + +Navigation.displayName = 'Navigation'; + +Navigation.propTypes = { + isMain: bool, + routes: array, + onNavLinkClick: func, +}; const StyledNav = styled.nav` width: ${props => rem(props.theme.sizes.menuWidth)}; @@ -174,6 +141,6 @@ const ListItem = styled.li` export default withRouter( ({ location, match, history, staticContext, ...other }) => ( - + ) ); diff --git a/packages/styleguide/src/components/NavigationBar/index.js b/packages/styleguide/src/components/NavigationBar/index.js index 81a0e9219f6..a36481c095a 100644 --- a/packages/styleguide/src/components/NavigationBar/index.js +++ b/packages/styleguide/src/components/NavigationBar/index.js @@ -8,7 +8,7 @@ const propTypes = { onButtonClick: func, }; -const NavigationBar = ({ className, isActive,onButtonClick, ...other }) => { +const NavigationBar = ({ className, isActive, onButtonClick, ...other }) => { const classes = cx({ 'is-active': isActive }, 'navigation-bar', className); return ( diff --git a/packages/styleguide/src/components/Search/Search.js b/packages/styleguide/src/components/Search/Search.js new file mode 100644 index 00000000000..57fd9891ab5 --- /dev/null +++ b/packages/styleguide/src/components/Search/Search.js @@ -0,0 +1,187 @@ +import React from 'react'; +import { array, string } from 'prop-types'; +import { withRouter, useHistory } from 'react-router-dom'; +import styled from 'styled-components'; +/** https://www.npmjs.com/package/fuzzyjs */ +import { match, surround } from 'fuzzyjs'; +import Autocomplete from 'accessible-autocomplete/react'; + +import { rem } from './../../style/utils'; + +const propTypes = { + list: array.isRequired, + placeholder: string, +}; + +const defaultProps = { + placeholder: 'Search', +}; + +const Search = ({ list, placeholder }) => { + let history = useHistory(); + + const formatMatches = (matches, parentPath, parentTitle) => + matches.map(match => ({ + title: match.original.title, + path: parentPath + match.original.path, + string: match.string, + isHidden: match.original.hasNodes, + })); + + const parseFullPathList = (items, parentPath = '') => { + return items.reduce((acc, curr) => { + const path = parentPath + curr.path; + + return [ + ...acc, + { title: curr.title, path: path, hasNodes: curr.nodes !== undefined }, + ...(curr.nodes ? parseFullPathList(curr.nodes, path) : []), + ]; + }, []); + }; + + const surroundMatch = (string, result) => + surround(string, { + result, + prefix: '', + suffix: '', + }); + + const getMatch = (value, string) => + match(value, string, { + withScore: true, + withRanges: true, + }); + + const search = (value, list, parentPath = '', parentTitle = '') => { + if (value === '') { + return []; + } + + const fullPathsList = parseFullPathList(list); + // search the first level children for match + // const parentMatches = fuzzy.filter(value, fullPathsList, options); + const parentMatches = fullPathsList + .reduce((acc, curr) => { + const result = { + // search in title + title: getMatch(value, curr.title), + // search in path + path: getMatch(value, curr.path), + }; + + return result.path.match + ? [ + ...acc, + { + original: curr, + result: result, + score: { + title: result.title.score, + path: result.path.score, + // get the highest score from the two + full: Math.max( + result.title.score !== 0 ? result.title.score : -1000, + result.path.score !== 0 ? result.path.score : -1000 + ), + }, + string: { + title: surroundMatch(curr.title, result.title), + path: surroundMatch(curr.path, result.path), + }, + }, + ] + : acc; + }, []) + // sort from high to low + .sort((a, b) => b.score.full - a.score.full); + // format the matched items for later use + return formatMatches(parentMatches, parentPath, parentTitle); + }; + + return ( + + { + const filteredResults = search(value, list); + syncResults(filteredResults.filter(result => !result.isHidden)); + }} + templates={{ + inputValue: () => '', + suggestion: result => + result && + `
${result.string.title}
+ ${result.string.path}`, + }} + onConfirm={confirmed => { + if (!confirmed) return false; + const path = confirmed.path; + history.push(path); + }} + /> +
+ ); +}; + +const StyledAutocompleteWrapper = styled.div` + width: ${props => rem(props.theme.sizes.menuWidth)}; + box-sizing: content-box; + font-family: ${props => props.theme.fontFamily}; + font-size: ${props => rem(props.theme.fontSizes.base)}; + line-height: ${props => props.theme.lineHeights.base}; + + .autocomplete__input { + width: 100%; + line-height: 20px; + border: 1px solid black; + padding: 5px 10px; + } + + .autocomplete__option { + padding: ${props => rem(props.theme.spaces.tiny)} + ${props => rem(props.theme.spaces.small)}; + line-height: 1.2em; + } + + .autocomplete__option--focused { + background: #eee; + } + + .autocomplete__menu { + &--hidden { + display: none; + } + &--visible { + position: absolute; + z-index: 10000; + white-space: nowrap; + background: white; + border: 1px solid black; + padding: 0; + width: 94%; + max-height: calc(8.25 * 52px); + overflow: auto; + left: 50%; + transform: translate(-50%, -17px); + } + } + + ul { + padding: 5px; + li { + list-style: none; + } + } +`; + +Search.displayName = 'Search'; +Search.propTypes = propTypes; +Search.defaultProps = defaultProps; + +export default withRouter(Search); diff --git a/packages/styleguide/src/components/Search/index.js b/packages/styleguide/src/components/Search/index.js new file mode 100644 index 00000000000..85bb434b226 --- /dev/null +++ b/packages/styleguide/src/components/Search/index.js @@ -0,0 +1 @@ +export { default } from './Search';