├── .storybook ├── addons.js ├── config.js ├── deploy-helper.js └── webpack.config.js ├── .prettierrc ├── src ├── typings │ └── modules.d.ts ├── __tests__ │ ├── __snapshots__ │ │ └── KeyDown.test.tsx.snap │ └── KeyDown.test.tsx ├── index.tsx ├── sass │ └── main.scss ├── KeyDown.tsx └── TreeMenu │ ├── __tests__ │ ├── renderProps.test.tsx │ ├── TreeMenu.test.tsx │ ├── walk.test.tsx │ └── __snapshots__ │ │ ├── renderProps.test.tsx.snap │ │ └── TreeMenu.test.tsx.snap │ ├── renderProps.tsx │ ├── walk.tsx │ └── index.tsx ├── stories ├── assets │ ├── closedIcon.png │ └── openedIcon.png └── index.stories.js ├── .babelrc ├── .prettierignore ├── jest.setup.js ├── .stylelintrc.json ├── .gitignore ├── tsconfig.json ├── jest.config.js ├── rollup.config.js ├── LICENSE ├── .circleci └── config.yml ├── package.json └── README.md /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 90, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /src/typings/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tiny-debounce'; 2 | declare module 'is-empty'; 3 | declare module '*.scss'; 4 | -------------------------------------------------------------------------------- /stories/assets/closedIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iannbing/react-simple-tree-menu/HEAD/stories/assets/closedIcon.png -------------------------------------------------------------------------------- /stories/assets/openedIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iannbing/react-simple-tree-menu/HEAD/stories/assets/openedIcon.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | ["@babel/typescript", { "isTSX": true, "allExtensions": true }], 5 | "@babel/preset-react" 6 | ], 7 | "plugins": ["@babel/proposal-class-properties"] 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/KeyDown.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`KeyDown should render correctly 1`] = ` 4 |
8 | children 9 |
10 | `; 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | 2 | # irrelevant files 3 | public/ 4 | 5 | # system generated files 6 | yarn.lock 7 | package-lock.json 8 | 9 | # not supported by prettier 10 | .DS_Store 11 | .editorconfig 12 | .eslintignore 13 | .gitignore 14 | .prettierignore 15 | *.md -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines the React 16 Adapter for Enzyme. 3 | * 4 | * @link http://airbnb.io/enzyme/docs/installation/#working-with-react-16 5 | * @copyright 2017 Airbnb, Inc. 6 | */ 7 | const enzyme = require('enzyme'); 8 | const Adapter = require('enzyme-adapter-react-16'); 9 | 10 | enzyme.configure({ adapter: new Adapter() }); 11 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["stylelint-scss"], 3 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"], 4 | "rules": { 5 | "indentation": [2, { "severity": "warning" }], 6 | "at-rule-no-unknown": null, 7 | "scss/at-rule-no-unknown": true, 8 | "declaration-empty-line-before": "never", 9 | "comment-empty-line-before": null 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure, addDecorator } from '@storybook/react'; 2 | import { configureActions } from '@storybook/addon-actions'; 3 | 4 | const req = require.context('../stories', true, /.stories.js$/); 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | 11 | configureActions({ 12 | depth: 100, 13 | limit: 20, 14 | }); 15 | -------------------------------------------------------------------------------- /.storybook/deploy-helper.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | 4 | const source = path.join(process.cwd(), '.circleci'); 5 | const storybookStaticFolder = path.join(process.cwd(), 'storybook-static'); 6 | const dest = path.join(process.cwd(), 'storybook-static/.circleci'); 7 | 8 | if (!fs.existsSync(storybookStaticFolder)) { 9 | console.error('You need to build storybook first'); 10 | return; 11 | } 12 | 13 | fs.copySync(source, dest); 14 | console.log('circleci config copied!'); 15 | -------------------------------------------------------------------------------- /src/__tests__/KeyDown.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import KeyDown from '../KeyDown'; 5 | 6 | describe('KeyDown', () => { 7 | it('should render correctly', () => { 8 | const wrapper = shallow( 9 | {}} 11 | down={() => {}} 12 | left={() => {}} 13 | right={() => {}} 14 | enter={() => {}} 15 | > 16 | children 17 | 18 | ); 19 | 20 | expect(wrapper).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /reports 11 | 12 | # production 13 | /build 14 | /dist 15 | /storybook-static 16 | /.rpt2_cache 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | junit.xml 25 | 26 | npm-debug.log* 27 | yarn.lock 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # environment variables 32 | .env.local -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import TreeMenu from './TreeMenu'; 2 | 3 | // export components 4 | export default TreeMenu; 5 | export { defaultChildren, ItemComponent } from './TreeMenu/renderProps'; 6 | 7 | // export definitions 8 | export { TreeMenuProps } from './TreeMenu'; 9 | export { TreeMenuItem, TreeMenuChildren } from './TreeMenu/renderProps'; 10 | export { 11 | TreeNodeObject, 12 | TreeNode, 13 | TreeNodeInArray, 14 | LocaleFunction, 15 | MatchSearchFunction, 16 | Item, 17 | } from './TreeMenu/walk'; 18 | export { default as KeyDown } from './KeyDown'; 19 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = ({ config }) => { 4 | config.module.rules.push({ 5 | test: /\.(ts|tsx)$/, 6 | loader: require.resolve('babel-loader'), 7 | options: { 8 | presets: [['react-app', { flow: false, typescript: true }]], 9 | }, 10 | }); 11 | config.module.rules.push({ 12 | test: /\.scss$/, 13 | use: ['style-loader', 'css-loader', 'sass-loader'], 14 | include: path.resolve(__dirname, '../src/sass/main.scss'), 15 | }); 16 | config.resolve.extensions.push('.ts', '.tsx'); 17 | return config; 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "lib": ["es6", "dom", "es2017"], 5 | "module": "es6", 6 | "jsx": "react", 7 | "target": "es5", 8 | "moduleResolution": "node", 9 | "declarationDir": "dist", 10 | "preserveConstEnums": true, 11 | "sourceMap": true, 12 | "skipLibCheck": true, 13 | "strictNullChecks": true, 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "noImplicitAny": true, 17 | "declaration": true, 18 | "declarationMap": true, 19 | "esModuleInterop": true, 20 | "baseUrl": "./src", 21 | "paths": { 22 | "typings/*": ["typings/*"] 23 | } 24 | }, 25 | "include": ["./src/**/*"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | collectCoverage: true, 5 | coverageDirectory: 'reports', 6 | coverageReporters: ['lcov', 'text'], 7 | reporters: [ 8 | 'default', 9 | [ 10 | 'jest-junit', 11 | { 12 | suiteName: 'jest tests', 13 | suiteNameTemplate: '{filepath}', 14 | output: 'reports/junit.xml', 15 | classNameTemplate: '{filename}', 16 | titleTemplate: '{title}', 17 | ancestorSeparator: ' > ', 18 | }, 19 | ], 20 | ], 21 | snapshotSerializers: ['enzyme-to-json/serializer'], 22 | setupFilesAfterEnv: ['./jest.setup.js'], 23 | modulePathIgnorePatterns: ['/dist/'], 24 | collectCoverageFrom: [ 25 | 'src/**/*.{ts,tsx,js,jsx}', 26 | '!/jest.config', 27 | '!/jest.setup', 28 | '!/src/index.tsx', 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /src/sass/main.scss: -------------------------------------------------------------------------------- 1 | $ICON_SIZE: 2rem; 2 | $DEFAULT_PADDING: 0.75rem; 3 | 4 | .rstm-toggle-icon { 5 | display: inline-block; 6 | &-symbol { 7 | width: $ICON_SIZE; 8 | height: $ICON_SIZE; 9 | text-align: center; 10 | line-height: $ICON_SIZE; 11 | } 12 | } 13 | 14 | .rstm-tree-item-group { 15 | list-style-type: none; 16 | padding-left: 0; 17 | border-top: 1px solid #ccc; 18 | text-align: left; 19 | width: 100%; 20 | } 21 | 22 | .rstm-tree-item { 23 | padding: 0.75rem 1rem; 24 | cursor: pointer; 25 | color: #333; 26 | background: none; 27 | border-bottom: 1px solid #ccc; 28 | box-shadow: none; 29 | z-index: unset; 30 | position: relative; 31 | 32 | &--active { 33 | color: white; 34 | background: #179ed3; 35 | border-bottom: none; 36 | } 37 | 38 | &--focused { 39 | box-shadow: 0 0 5px 0 #222; 40 | z-index: 999; 41 | } 42 | } 43 | 44 | .rstm-search { 45 | padding: 1rem 1.5rem; 46 | border: none; 47 | width: 100%; 48 | } 49 | -------------------------------------------------------------------------------- /src/KeyDown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface KeyDownProps { 4 | children: JSX.Element | string; 5 | up: () => void; 6 | down: () => void; 7 | left: () => void; 8 | right: () => void; 9 | enter: () => void; 10 | } 11 | 12 | const KeyDown = ({ children, up, down, left, right, enter }: KeyDownProps) => { 13 | return ( 14 |
{ 17 | switch (e.key) { 18 | case 'ArrowUp': { 19 | up(); 20 | break; 21 | } 22 | case 'ArrowDown': { 23 | down(); 24 | break; 25 | } 26 | case 'ArrowLeft': { 27 | left(); 28 | break; 29 | } 30 | case 'ArrowRight': { 31 | right(); 32 | break; 33 | } 34 | 35 | case 'Enter': { 36 | enter(); 37 | break; 38 | } 39 | } 40 | }} 41 | > 42 | {children} 43 |
44 | ); 45 | }; 46 | 47 | export default KeyDown; 48 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from 'rollup-plugin-commonjs'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import babel from 'rollup-plugin-babel'; 4 | import typescript from 'rollup-plugin-typescript2'; 5 | import del from 'rollup-plugin-delete'; 6 | import strip from '@rollup/plugin-strip'; 7 | import pkg from './package.json'; 8 | 9 | const extensions = ['.js', '.jsx', '.ts', '.tsx']; 10 | 11 | const name = 'RollupTypeScriptBabel'; 12 | 13 | export default { 14 | input: './src/index.tsx', 15 | external: ['react', 'react-dom'], 16 | 17 | plugins: [ 18 | del({ targets: 'dist/*' }), 19 | // Allows node_modules resolution 20 | resolve({ extensions }), 21 | 22 | // Allow bundling cjs modules. Rollup doesn't understand cjs 23 | commonjs(), 24 | 25 | typescript(), 26 | // Compile TypeScript/JavaScript files 27 | babel({ extensions, include: ['src/**/*'] }), 28 | strip(), 29 | ], 30 | 31 | output: [ 32 | { 33 | file: pkg.module, 34 | format: 'esm', 35 | }, 36 | { 37 | file: pkg.main, 38 | format: 'cjs', 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Huang-Ming Chang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/TreeMenu/__tests__/renderProps.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | 3 | import { defaultChildren } from '../renderProps'; 4 | 5 | const search = (term: string) => { 6 | console.log(`search ${term}`); 7 | }; 8 | 9 | describe('defaultChildren', () => { 10 | it('should render without the toggle icon', () => { 11 | const wrapper = mount( 12 | defaultChildren({ 13 | search, 14 | items: [ 15 | { 16 | hasNodes: false, 17 | label: 'foo', 18 | key: 'key', 19 | onClick: () => {}, 20 | level: 0, 21 | isOpen: false, 22 | }, 23 | ], 24 | }) 25 | ); 26 | 27 | expect(wrapper).toMatchSnapshot(); 28 | expect(wrapper.find('ToggleIcon').exists()).toEqual(false); 29 | }); 30 | 31 | it('should render the toggle icon with "on" equals to true', () => { 32 | const wrapper = mount( 33 | defaultChildren({ 34 | search, 35 | items: [ 36 | { 37 | hasNodes: true, 38 | isOpen: true, 39 | level: 1, 40 | label: 'foo', 41 | key: 'foo', 42 | onClick: () => {}, 43 | }, 44 | { 45 | hasNodes: false, 46 | isOpen: false, 47 | level: 2, 48 | label: 'bar', 49 | key: 'bar', 50 | onClick: () => {}, 51 | }, 52 | { 53 | hasNodes: false, 54 | isOpen: false, 55 | level: 2, 56 | label: 'zoo', 57 | key: 'zoo', 58 | onClick: () => {}, 59 | }, 60 | ], 61 | }) 62 | ); 63 | 64 | expect(wrapper).toMatchSnapshot(); 65 | expect(wrapper.find('ToggleIcon').prop('on')).toEqual(true); 66 | }); 67 | it('should render the toggle icon with "on" equals to false', () => { 68 | const wrapper = mount( 69 | defaultChildren({ 70 | search, 71 | items: [ 72 | { 73 | hasNodes: true, 74 | isOpen: false, 75 | level: 1, 76 | label: 'foo', 77 | key: 'key', 78 | onClick: () => {}, 79 | }, 80 | ], 81 | }) 82 | ); 83 | 84 | expect(wrapper).toMatchSnapshot(); 85 | expect(wrapper.find('ToggleIcon').prop('on')).toEqual(false); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/TreeMenu/renderProps.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import { Item } from './walk'; 5 | 6 | const DEFAULT_PADDING = 0.75; 7 | const ICON_SIZE = 2; 8 | const LEVEL_SPACE = 1.75; 9 | const ToggleIcon = ({ 10 | on, 11 | openedIcon, 12 | closedIcon, 13 | }: { 14 | on: boolean; 15 | openedIcon: ReactNode; 16 | closedIcon: ReactNode; 17 | }) => ( 18 |
19 | {on ? openedIcon : closedIcon} 20 |
21 | ); 22 | 23 | export interface TreeMenuItem extends Item { 24 | active?: boolean; 25 | onClick: (event: React.MouseEvent) => void; 26 | toggleNode?: () => void; 27 | } 28 | 29 | export type TreeMenuChildren = (props: { 30 | search?: (term: string) => void; 31 | searchTerm?: string; 32 | items: TreeMenuItem[]; 33 | resetOpenNodes?: (openNodes?: string[]) => void; 34 | }) => JSX.Element; 35 | 36 | export const ItemComponent: React.FunctionComponent = ({ 37 | hasNodes = false, 38 | isOpen = false, 39 | level = 0, 40 | onClick, 41 | toggleNode, 42 | active, 43 | focused, 44 | openedIcon = '-', 45 | closedIcon = '+', 46 | label = 'unknown', 47 | style = {}, 48 | }) => ( 49 |
  • 66 | {hasNodes && ( 67 |
    { 70 | hasNodes && toggleNode && toggleNode(); 71 | e.stopPropagation(); 72 | }} 73 | > 74 | 75 |
    76 | )} 77 | {label} 78 |
  • 79 | ); 80 | 81 | export const defaultChildren: TreeMenuChildren = ({ search, items }) => { 82 | const onSearch = (e: React.ChangeEvent) => { 83 | const { value } = e.target; 84 | search && search(value); 85 | }; 86 | return ( 87 | <> 88 | {search && ( 89 | 96 | )} 97 |
      98 | {items.map(({ key, ...props }) => ( 99 | 100 | ))} 101 |
    102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/TreeMenu/__tests__/TreeMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import TreeMenu from '../index'; 5 | 6 | const mockData = { 7 | atd: { 8 | label: 'ATS Guide', 9 | key: 'ats', 10 | index: 1, // ATS Guide should be after Release Notes 11 | }, 12 | releasenotes: { 13 | label: 'Release Notes', 14 | key: 'releasenotes', 15 | index: 0, // Release Notes should be first 16 | nodes: { 17 | 'desktop-modeler': { 18 | label: 'Desktop Modeler', 19 | key: 'releasenotes/desktop-modeler', 20 | index: 0, 21 | nodes: { 22 | 7: { 23 | label: '7', 24 | key: 'releasenotes/desktop-modeler/7', 25 | index: 0, 26 | nodes: { 27 | '7.0': { 28 | label: '7.0', 29 | key: 'releasenotes/desktop-modeler/7.0', 30 | index: 0, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }; 39 | 40 | describe('TreeMenu', () => { 41 | it('should render the level-1 nodes by default', () => { 42 | const wrapper = mount(); 43 | 44 | expect(wrapper).toMatchSnapshot(); 45 | expect(wrapper.find('li').length).toEqual(2); 46 | }); 47 | it('should open specified nodes', () => { 48 | const wrapper = mount( 49 | 53 | ); 54 | 55 | expect(wrapper).toMatchSnapshot(); 56 | expect(wrapper.find('li').length).toEqual(4); 57 | }); 58 | it('should highlight the active node', () => { 59 | const activeKey = 'releasenotes/desktop-modeler/7'; 60 | const wrapper = mount( 61 | 66 | ); 67 | const highlightedElement = wrapper 68 | .findWhere(node => node.key() === activeKey) 69 | .childAt(0) 70 | .get(0); 71 | 72 | expect(wrapper).toMatchSnapshot(); 73 | expect(highlightedElement.props.className).toContain('rstm-tree-item--active'); 74 | }); 75 | it('should trigger onClickItem when a node is clicked', () => { 76 | const mockOnClickItem = jest.fn(); 77 | const wrapper = shallow( 78 | 79 | ); 80 | 81 | const targetNode = wrapper.findWhere(node => node.key() === 'releasenotes'); 82 | targetNode.simulate('click'); 83 | expect(mockOnClickItem.mock.calls.length).toEqual(1); 84 | expect(mockOnClickItem).toHaveBeenCalledWith({ 85 | hasNodes: true, 86 | index: 0, 87 | isOpen: false, 88 | key: 'releasenotes', 89 | label: 'Release Notes', 90 | level: 0, 91 | openNodes: [], 92 | parent: '', 93 | searchTerm: '', 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/TreeMenu/__tests__/walk.test.tsx: -------------------------------------------------------------------------------- 1 | import {slowWalk,fastWalk, TreeNode, TreeNodeObject, TreeNodeInArray } from '../walk'; 2 | 3 | const mockDataInObject: TreeNodeObject = { 4 | atd: { 5 | label: 'ATS Guide', 6 | url: 'ats', 7 | index: 1, // ATS Guide should be after Release Notes 8 | }, 9 | releasenotes: { 10 | label: 'Release Notes', 11 | url: 'releasenotes', 12 | index: 0, // Release Notes should be first 13 | nodes: { 14 | 'desktop-modeler': { 15 | label: 'Desktop Modeler', 16 | url: 'releasenotes/desktop-modeler', 17 | index: 0, 18 | nodes: { 19 | 7: { 20 | label: '7', 21 | url: 'releasenotes/desktop-modeler/7', 22 | index: 0, 23 | nodes: { 24 | '7.0': { 25 | label: '7.0', 26 | url: 'releasenotes/desktop-modeler/7.0', 27 | index: 0, 28 | nodes: {}, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }; 37 | 38 | const mockDataInArray: TreeNodeInArray[] = [ 39 | { 40 | key: 'releasenotes', 41 | label: 'Release Notes', 42 | url: 'releasenotes', 43 | nodes: [ 44 | { 45 | key: 'desktop-modeler', 46 | label: 'Desktop Modeler', 47 | url: 'releasenotes/desktop-modeler', 48 | nodes: [ 49 | { 50 | key: '7', 51 | label: '7', 52 | url: 'releasenotes/desktop-modeler/7', 53 | nodes: [ 54 | { 55 | key: '7.0', 56 | label: '7.0', 57 | url: 'releasenotes/desktop-modeler/7.0', 58 | nodes: [], 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | ], 65 | }, 66 | { 67 | key: 'atd', 68 | label: 'ATS Guide', 69 | url: 'ats', 70 | }, 71 | ]; 72 | 73 | const expectedOutcome = [ 74 | { 75 | index: 0, 76 | isOpen: true, 77 | key: 'releasenotes/desktop-modeler/7', 78 | url: 'releasenotes/desktop-modeler/7', 79 | label: '7', 80 | level: 2, 81 | hasNodes: true, 82 | openNodes: [], 83 | parent: 'releasenotes/desktop-modeler', 84 | searchTerm: '7', 85 | }, 86 | { 87 | index: 0, 88 | isOpen: false, 89 | key: 'releasenotes/desktop-modeler/7/7.0', 90 | url: 'releasenotes/desktop-modeler/7.0', 91 | label: '7.0', 92 | level: 3, 93 | hasNodes: false, 94 | openNodes: [], 95 | parent: 'releasenotes/desktop-modeler/7', 96 | searchTerm: '7', 97 | }, 98 | ]; 99 | 100 | describe('slowWalk', () => { 101 | it('should transpose the data object to a desired shape', () => { 102 | const result = slowWalk({ data: mockDataInObject, openNodes: [], searchTerm: '7' }); 103 | expect(result).toEqual(expectedOutcome); 104 | }); 105 | it('should transpose the data array to a desired shape', () => { 106 | const result = slowWalk({ data: mockDataInArray, openNodes: [], searchTerm: '7' }); 107 | expect(result).toEqual(expectedOutcome); 108 | }); 109 | }); 110 | 111 | describe('fastWalk', () => { 112 | it('should transpose the data object to a desired shape', () => { 113 | const result = fastWalk({ data: mockDataInObject, openNodes: [], searchTerm: '7' }); 114 | expect(result).toEqual(expectedOutcome); 115 | }); 116 | it('should transpose the data array to a desired shape', () => { 117 | const result = fastWalk({ data: mockDataInArray, openNodes: [], searchTerm: '7' }); 118 | expect(result).toEqual(expectedOutcome); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | commands: 4 | install: 5 | description: 'Install packages' 6 | steps: 7 | - checkout 8 | - run: 9 | name: 'Update NPM' 10 | command: sudo npm install -g npm@latest 11 | - restore_cache: 12 | keys: 13 | - dependency-cache-{{ checksum "package.json" }} 14 | - run: 15 | name: 'Install NPM' 16 | command: npm install 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "package.json" }} 19 | paths: 20 | - node_modules 21 | 22 | run_tests: 23 | description: 'Run tests' 24 | steps: 25 | - run: 26 | name: 'Run tests' 27 | command: npm run test:ci 28 | environment: 29 | JEST_JUNIT_OUTPUT: 'reports/junit/js-test-results.xml' 30 | - store_test_results: 31 | path: reports/junit 32 | - store_artifacts: 33 | path: reports/junit 34 | 35 | build: 36 | description: 'Build the bundle' 37 | steps: 38 | - run: npm run build 39 | - save_cache: 40 | key: repo-{{ .Branch }}-{{ .Revision }} 41 | paths: 42 | - ./dist 43 | - ./package.json 44 | - ./README.md 45 | 46 | authenticate: 47 | description: 'Authenticate with registry' 48 | steps: 49 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 50 | 51 | publish_beta: 52 | description: 'Publish beta version to npmjs' 53 | steps: 54 | - restore_cache: 55 | keys: 56 | - repo-{{ .Branch }}-{{ .Revision }} 57 | - run: 58 | name: 'Bump Version' 59 | command: npm run bump-version:beta 60 | - run: 61 | name: 'Publish package' 62 | command: npm run publish:beta 63 | 64 | publish: 65 | description: 'Publish to npmjs' 66 | steps: 67 | - restore_cache: 68 | keys: 69 | - repo-{{ .Branch }}-{{ .Revision }} 70 | - run: 71 | name: 'Publish package' 72 | command: npm publish 73 | 74 | build_story: 75 | description: 'Build storybook' 76 | steps: 77 | - run: npm run build-storybook 78 | 79 | deploy_story: 80 | description: 'Deploy storybook' 81 | steps: 82 | - run: npm run deploy-storybook 83 | 84 | jobs: 85 | build: 86 | docker: 87 | - image: circleci/node:10.15 88 | working_directory: ~/react-simple-tree-menu 89 | steps: 90 | - install 91 | - run_tests 92 | - build_story 93 | - build 94 | 95 | release_beta: 96 | docker: 97 | - image: circleci/node:10.15 98 | working_directory: ~/react-simple-tree-menu 99 | steps: 100 | - authenticate 101 | - publish_beta 102 | 103 | release: 104 | docker: 105 | - image: circleci/node:10.15 106 | working_directory: ~/react-simple-tree-menu 107 | steps: 108 | - authenticate 109 | - publish 110 | 111 | workflows: 112 | version: 2. 113 | build: 114 | jobs: 115 | - build: 116 | filters: 117 | branches: 118 | ignore: 119 | - gh-pages 120 | release: 121 | jobs: 122 | - build: 123 | filters: 124 | tags: 125 | only: /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/ 126 | branches: 127 | ignore: /.*/ 128 | - release_beta: 129 | requires: 130 | - build 131 | filters: 132 | tags: 133 | only: /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*$/ 134 | branches: 135 | ignore: /.*/ 136 | - release: 137 | requires: 138 | - build 139 | filters: 140 | tags: 141 | only: /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ 142 | branches: 143 | ignore: /.*/ 144 | -------------------------------------------------------------------------------- /src/TreeMenu/walk.tsx: -------------------------------------------------------------------------------- 1 | import isEmpty from 'is-empty'; 2 | import memoize from 'fast-memoize'; 3 | 4 | export interface TreeNodeObject { 5 | [name: string]: TreeNode; 6 | } 7 | 8 | interface LocaleFunctionProps { 9 | label: string; 10 | [name: string]: any; 11 | } 12 | 13 | interface MatchSearchFunctionProps extends LocaleFunctionProps { 14 | searchTerm: string; 15 | } 16 | 17 | export interface TreeNode extends LocaleFunctionProps { 18 | index: number; 19 | nodes?: TreeNodeObject; 20 | } 21 | 22 | export interface TreeNodeInArray extends LocaleFunctionProps { 23 | key: string; 24 | nodes?: TreeNodeInArray[]; 25 | } 26 | 27 | export type LocaleFunction = (localeFunctionProps: LocaleFunctionProps) => string; 28 | export type MatchSearchFunction = ( 29 | matchSearchFunctionProps: MatchSearchFunctionProps 30 | ) => boolean; 31 | 32 | type Data = TreeNodeObject | TreeNodeInArray[]; 33 | interface WalkProps { 34 | data: Data | undefined; 35 | parent?: string; 36 | level?: number; 37 | openNodes: string[]; 38 | searchTerm: string; 39 | locale?: LocaleFunction; 40 | matchSearch?: MatchSearchFunction; 41 | } 42 | 43 | interface BranchProps { 44 | parent: string; 45 | level: number; 46 | openNodes: string[]; 47 | searchTerm: string; 48 | node: TreeNode | TreeNodeInArray; 49 | nodeName: string; 50 | index?: number; 51 | locale?: LocaleFunction; 52 | matchSearch?: MatchSearchFunction; 53 | } 54 | 55 | export interface Item { 56 | hasNodes: boolean; 57 | isOpen: boolean; 58 | level: number; 59 | key: string; 60 | label: string; 61 | [name: string]: any; 62 | } 63 | 64 | const validateData = (data: Data | undefined): boolean => !!data && !isEmpty(data); 65 | const getValidatedData = (data: Data | undefined) => 66 | validateData(data) ? (data as Data) : []; 67 | 68 | const walk = ({ data, ...props }: WalkProps): Item[] => { 69 | const validatedData = getValidatedData(data); 70 | 71 | const propsWithDefaultValues = { parent: '', level: 0, ...props }; 72 | const handleArray = (dataAsArray: TreeNodeInArray[]) => 73 | dataAsArray.reduce((all: Item[], node: TreeNodeInArray, index) => { 74 | const branchProps = { node, index, nodeName: node.key, ...propsWithDefaultValues }; 75 | const branch = generateBranch(branchProps); 76 | return [...all, ...branch]; 77 | }, []); 78 | 79 | const handleObject = (dataAsObject: TreeNodeObject) => 80 | Object.entries(dataAsObject) 81 | .sort((a, b) => a[1].index - b[1].index) // sorted by index 82 | .reduce((all: Item[], [nodeName, node]: [string, TreeNode]) => { 83 | const branchProps = { node, nodeName, ...propsWithDefaultValues }; 84 | const branch = generateBranch(branchProps); 85 | return [...all, ...branch]; 86 | }, []); 87 | 88 | return Array.isArray(validatedData) 89 | ? handleArray(validatedData) 90 | : handleObject(validatedData); 91 | }; 92 | 93 | const defaultMatchSearch = ({ label, searchTerm }: MatchSearchFunctionProps) => { 94 | const processString = (text: string): string => text.trim().toLowerCase(); 95 | return processString(label).includes(processString(searchTerm)); 96 | }; 97 | 98 | const defaultLocale = ({ label }: LocaleFunctionProps): string => label; 99 | 100 | const generateBranch = ({ 101 | node, 102 | nodeName, 103 | matchSearch = defaultMatchSearch, 104 | locale = defaultLocale, 105 | ...props 106 | }: BranchProps): Item[] => { 107 | const { parent, level, openNodes, searchTerm } = props; 108 | 109 | const { nodes, label: rawLabel = 'unknown', ...nodeProps } = node; 110 | const key = [parent, nodeName].filter(x => x).join('/'); 111 | const hasNodes = validateData(nodes); 112 | const isOpen = hasNodes && (openNodes.includes(key) || !!searchTerm); 113 | 114 | const label = locale({ label: rawLabel, ...nodeProps }); 115 | const isVisible = !searchTerm || matchSearch({ label, searchTerm, ...nodeProps }); 116 | const currentItem = { ...props, ...nodeProps, label, hasNodes, isOpen, key }; 117 | 118 | const data = getValidatedData(nodes); 119 | const nextLevelItems = isOpen 120 | ? walk({ data, locale, matchSearch, ...props, parent: key, level: level + 1 }) 121 | : []; 122 | 123 | return isVisible ? [currentItem, ...nextLevelItems] : nextLevelItems; 124 | }; 125 | 126 | export const fastWalk = memoize(walk); 127 | export const slowWalk = walk; 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simple-tree-menu", 3 | "version": "1.1.18", 4 | "description": "A simple React tree menu component", 5 | "keywords": [ 6 | "react", 7 | "tree", 8 | "menu", 9 | "react-component", 10 | "tree menu", 11 | "render-props", 12 | "control-props", 13 | "downshift" 14 | ], 15 | "main": "dist/main.cjs.js", 16 | "module": "dist/main.esm.js", 17 | "types": "dist/index.d.ts", 18 | "files": [ 19 | "dist/" 20 | ], 21 | "scripts": { 22 | "test": "jest", 23 | "test:ci": "jest --ci --runInBand --reporters=jest-junit", 24 | "build:types": "tsc --emitDeclarationOnly", 25 | "build:js": "rollup -c", 26 | "build": "npm run build:types && npm run build:js && npm run sass-build", 27 | "style:compile-sass": "node-sass-chokidar --source-map true src/sass/ -o dist", 28 | "style:autoprefixer": "postcss dist/*.css --use autoprefixer -d dist", 29 | "sass-build": "npm-run-all -p style:*", 30 | "storybook": "start-storybook -p 9001 -c .storybook", 31 | "build-storybook": "build-storybook && node .storybook/deploy-helper.js", 32 | "deploy-storybook": "storybook-to-ghpages -e storybook-static", 33 | "publish:beta": "npm publish --tag beta", 34 | "bump-version": "npm version", 35 | "bump-version:beta": "npm version prerelease --preid=rc" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/iannbing/react-simple-tree-menu.git" 40 | }, 41 | "author": "Huang-Ming Chang ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/iannbing/react-simple-tree-menu/issues" 45 | }, 46 | "homepage": "https://github.com/iannbing/react-simple-tree-menu#readme", 47 | "devDependencies": { 48 | "@babel/core": "^7.10.2", 49 | "@babel/plugin-proposal-class-properties": "^7.7.4", 50 | "@babel/preset-env": "^7.7.6", 51 | "@babel/preset-react": "^7.7.4", 52 | "@babel/preset-typescript": "^7.7.4", 53 | "@rollup/plugin-strip": "^1.3.3", 54 | "@storybook/addon-actions": "^5.2.8", 55 | "@storybook/addon-info": "^5.2.8", 56 | "@storybook/addon-links": "^5.2.8", 57 | "@storybook/addons": "^5.2.8", 58 | "@storybook/react": "^5.2.8", 59 | "@storybook/storybook-deployer": "^2.8.1", 60 | "@storybook/theming": "^5.2.8", 61 | "@types/classnames": "^2.2.8", 62 | "@types/enzyme": "^3.1.15", 63 | "@types/jest": "^24.0.15", 64 | "@types/react": "^16.7.18", 65 | "@types/react-dom": "^16.0.11", 66 | "autoprefixer": "^9.6.0", 67 | "awesome-typescript-loader": "^5.2.1", 68 | "babel-loader": "^8.1.0", 69 | "babel-plugin-import": "^1.11.2", 70 | "babel-preset-react-app": "^9.1.2", 71 | "bootstrap": "^4.3.1", 72 | "chokidar-cli": "^2.1.0", 73 | "clean-webpack-plugin": "^1.0.0", 74 | "cross-env": "^5.2.0", 75 | "css-loader": "^1.0.1", 76 | "enzyme": "^3.11.0", 77 | "enzyme-adapter-react-16": "^1.15.2", 78 | "enzyme-to-json": "^3.4.3", 79 | "escape-string-regexp": "^2.0.0", 80 | "fs-extra": "^8.0.1", 81 | "jest": "^26.0.1", 82 | "jest-junit": "^6.4.0", 83 | "jquery": "^3.5.1", 84 | "mini-css-extract-plugin": "^0.7.0", 85 | "node-sass": "^4.12.0", 86 | "node-sass-chokidar": "^1.5.0", 87 | "npm-run-all": "^4.1.5", 88 | "postcss-cli": "^7.1.1", 89 | "prettier": "^1.15.2", 90 | "react": "^16.6.3", 91 | "react-dom": "^16.6.3", 92 | "react-test-renderer": "^16.7.0", 93 | "reactstrap": "^8.0.0", 94 | "rollup": "^1.27.10", 95 | "rollup-plugin-babel": "^4.3.3", 96 | "rollup-plugin-commonjs": "^10.1.0", 97 | "rollup-plugin-delete": "^1.1.0", 98 | "rollup-plugin-jsx": "^1.0.3", 99 | "rollup-plugin-node-resolve": "^5.2.0", 100 | "rollup-plugin-typescript2": "^0.25.3", 101 | "sass-loader": "^7.1.0", 102 | "source-map-loader": "^0.2.4", 103 | "style-loader": "^0.23.1", 104 | "stylelint": "^13.6.1", 105 | "stylelint-config-prettier": "^5.2.0", 106 | "stylelint-config-standard": "^18.3.0", 107 | "stylelint-scss": "^3.8.0", 108 | "ts-jest": "^26.1.0", 109 | "typescript": "^3.9.5" 110 | }, 111 | "peerDependencies": { 112 | "react": ">=16.6.3", 113 | "react-dom": ">=16.6.3" 114 | }, 115 | "dependencies": { 116 | "classnames": "^2.2.6", 117 | "fast-memoize": "^2.5.1", 118 | "is-empty": "^1.2.0", 119 | "tiny-debounce": "^0.1.1" 120 | }, 121 | "publishConfig": { 122 | "access": "public" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/TreeMenu/__tests__/__snapshots__/renderProps.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`defaultChildren should render the toggle icon with "on" equals to false 1`] = ` 4 | Array [ 5 | , 12 |
      15 | 23 |
    • 33 |
      37 | 42 |
      47 | + 48 |
      49 |
      50 |
      51 | foo 52 |
    • 53 |
      54 |
    , 55 | ] 56 | `; 57 | 58 | exports[`defaultChildren should render the toggle icon with "on" equals to true 1`] = ` 59 | Array [ 60 | , 67 |
      70 | 78 |
    • 88 |
      92 | 97 |
      102 | - 103 |
      104 |
      105 |
      106 | foo 107 |
    • 108 |
      109 | 117 |
    • 127 | bar 128 |
    • 129 |
      130 | 138 |
    • 148 | zoo 149 |
    • 150 |
      151 |
    , 152 | ] 153 | `; 154 | 155 | exports[`defaultChildren should render without the toggle icon 1`] = ` 156 | Array [ 157 | , 164 |
      167 | 175 |
    • 185 | foo 186 |
    • 187 |
      188 |
    , 189 | ] 190 | `; 191 | -------------------------------------------------------------------------------- /src/TreeMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import debounce from 'tiny-debounce'; 3 | 4 | import { 5 | fastWalk, 6 | slowWalk, 7 | TreeNode, 8 | Item, 9 | TreeNodeInArray, 10 | LocaleFunction, 11 | MatchSearchFunction, 12 | } from './walk'; 13 | import { defaultChildren, TreeMenuChildren, TreeMenuItem } from './renderProps'; 14 | import KeyDown from '../KeyDown'; 15 | 16 | export type TreeMenuProps = { 17 | data: { [name: string]: TreeNode } | TreeNodeInArray[]; 18 | activeKey?: string; 19 | focusKey?: string; 20 | initialActiveKey?: string; 21 | initialFocusKey?: string; 22 | initialOpenNodes?: string[]; 23 | openNodes?: string[]; 24 | resetOpenNodesOnDataUpdate?: boolean; 25 | hasSearch?: boolean; 26 | cacheSearch?: boolean; 27 | onClickItem?: (props: Item) => void; 28 | debounceTime?: number; 29 | children?: TreeMenuChildren; 30 | locale?: LocaleFunction; 31 | matchSearch?: MatchSearchFunction; 32 | disableKeyboard?: boolean; 33 | }; 34 | 35 | type TreeMenuState = { 36 | openNodes: string[]; 37 | searchTerm: string; 38 | activeKey: string; 39 | focusKey: string; 40 | }; 41 | 42 | const defaultOnClick = (props: Item) => console.log(props); // eslint-disable-line no-console 43 | 44 | class TreeMenu extends React.Component { 45 | static defaultProps: TreeMenuProps = { 46 | data: {}, 47 | onClickItem: defaultOnClick, 48 | debounceTime: 125, 49 | children: defaultChildren, 50 | hasSearch: true, 51 | cacheSearch:true, 52 | resetOpenNodesOnDataUpdate: false, 53 | disableKeyboard: false, 54 | }; 55 | 56 | state: TreeMenuState = { 57 | openNodes: this.props.initialOpenNodes || [], 58 | searchTerm: '', 59 | activeKey: this.props.initialActiveKey || '', 60 | focusKey: this.props.initialFocusKey || '', 61 | }; 62 | 63 | componentDidUpdate(prevProps: TreeMenuProps) { 64 | const { data, initialOpenNodes, resetOpenNodesOnDataUpdate } = this.props; 65 | if (prevProps.data !== data && resetOpenNodesOnDataUpdate && initialOpenNodes) { 66 | this.setState({ openNodes: initialOpenNodes }); 67 | } 68 | } 69 | 70 | resetOpenNodes = (newOpenNodes?: string[], activeKey?: string, focusKey?: string) => { 71 | const { initialOpenNodes } = this.props; 72 | const openNodes = 73 | (Array.isArray(newOpenNodes) && newOpenNodes) || initialOpenNodes || []; 74 | this.setState({ openNodes, searchTerm: '', activeKey: activeKey || '', focusKey: focusKey || activeKey || '' }); 75 | }; 76 | 77 | search = (value: string) => { 78 | const { debounceTime } = this.props; 79 | const search = debounce( 80 | (searchTerm: string) => this.setState({ searchTerm }), 81 | debounceTime 82 | ); 83 | search(value); 84 | }; 85 | 86 | toggleNode = (node: string) => { 87 | if (!this.props.openNodes) { 88 | const { openNodes } = this.state; 89 | const newOpenNodes = openNodes.includes(node) 90 | ? openNodes.filter(openNode => openNode !== node) 91 | : [...openNodes, node]; 92 | this.setState({ openNodes: newOpenNodes }); 93 | } 94 | }; 95 | 96 | generateItems = (): TreeMenuItem[] => { 97 | const { data, onClickItem, locale, matchSearch } = this.props; 98 | const { searchTerm } = this.state; 99 | const openNodes = this.props.openNodes || this.state.openNodes; 100 | const activeKey = this.props.activeKey || this.state.activeKey; 101 | const focusKey = this.props.focusKey || this.state.focusKey; 102 | const defaultSearch = this.props.cacheSearch ? fastWalk : slowWalk; 103 | const items: Item[] = data 104 | ? defaultSearch({ data, openNodes, searchTerm, locale, matchSearch }) 105 | : []; 106 | 107 | return items.map(item => { 108 | const focused = item.key === focusKey; 109 | const active = item.key === activeKey; 110 | const onClick = () => { 111 | const newActiveKey = this.props.activeKey || item.key; 112 | this.setState({ activeKey: newActiveKey, focusKey: newActiveKey }); 113 | onClickItem && onClickItem(item); 114 | }; 115 | 116 | const toggleNode = item.hasNodes ? () => this.toggleNode(item.key) : undefined; 117 | return { ...item, focused, active, onClick, toggleNode }; 118 | }); 119 | }; 120 | 121 | getKeyDownProps = (items: TreeMenuItem[]) => { 122 | const { onClickItem } = this.props; 123 | const { focusKey, activeKey, searchTerm } = this.state; 124 | 125 | const focusIndex = items.findIndex(item => item.key === (focusKey || activeKey)); 126 | const getFocusKey = (item: TreeMenuItem) => { 127 | const keyArray = item.key.split('/'); 128 | 129 | return keyArray.length > 1 130 | ? keyArray.slice(0, keyArray.length - 1).join('/') 131 | : item.key; 132 | }; 133 | 134 | return { 135 | up: () => { 136 | this.setState(({ focusKey }) => ({ 137 | focusKey: focusIndex > 0 ? items[focusIndex - 1].key : focusKey, 138 | })); 139 | }, 140 | down: () => { 141 | this.setState(({ focusKey }) => ({ 142 | focusKey: focusIndex < items.length - 1 ? items[focusIndex + 1].key : focusKey, 143 | })); 144 | }, 145 | left: () => { 146 | const item = items[focusIndex]; 147 | if (item) { 148 | this.setState(({ openNodes, ...rest }) => { 149 | const newOpenNodes = openNodes.filter(node => node !== item.key); 150 | return item.isOpen 151 | ? { ...rest, openNodes: newOpenNodes, focusKey: item.key } 152 | : { ...rest, focusKey: getFocusKey(item) }; 153 | }); 154 | } 155 | }, 156 | right: () => { 157 | const { hasNodes, key } = items[focusIndex]; 158 | if (hasNodes) 159 | this.setState(({ openNodes }) => ({ openNodes: [...openNodes, key] })); 160 | }, 161 | enter: () => { 162 | this.setState(({ focusKey }) => ({ activeKey: focusKey })); 163 | onClickItem && onClickItem(items[focusIndex]); 164 | }, 165 | }; 166 | }; 167 | 168 | render() { 169 | const { children, hasSearch, disableKeyboard } = this.props; 170 | const { searchTerm } = this.state; 171 | 172 | const search = this.search; 173 | const items = this.generateItems(); 174 | const resetOpenNodes = this.resetOpenNodes; 175 | const render = children || defaultChildren; 176 | 177 | const renderProps = hasSearch 178 | ? { 179 | search, 180 | resetOpenNodes, 181 | items, 182 | searchTerm, 183 | } 184 | : { items, resetOpenNodes }; 185 | 186 | return disableKeyboard ? ( 187 | render(renderProps) 188 | ) : ( 189 | {render(renderProps)} 190 | ); 191 | } 192 | } 193 | 194 | export default TreeMenu; 195 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | import { action, withActions } from '@storybook/addon-actions'; 5 | import { linkTo } from '@storybook/addon-links'; 6 | import { withInfo } from '@storybook/addon-info'; 7 | 8 | import { ListGroupItem, Input, ListGroup } from 'reactstrap'; 9 | import TreeMenu, { defaultChildren, ItemComponent } from '../src/index'; 10 | import closedIconImg from './assets/closedIcon.png'; 11 | import openedIconImg from './assets/openedIcon.png'; 12 | 13 | import 'bootstrap/dist/css/bootstrap.min.css'; 14 | import '../src/sass/main.scss'; 15 | 16 | const DEFAULT_PADDING = 16; 17 | const ICON_SIZE = 8; 18 | const LEVEL_SPACE = 16; 19 | 20 | // Icon example 21 | const iconStyle = { 22 | verticalAlign: 'text-bottom', 23 | }; 24 | const openedIcon = -; 25 | const closedIcon = +; 26 | 27 | const ToggleIcon = ({ on }) => {on ? '-' : '+'}; 28 | const ListItem = ({ 29 | level = 0, 30 | hasNodes, 31 | isOpen, 32 | label, 33 | searchTerm, 34 | openNodes, 35 | toggleNode, 36 | matchSearch, 37 | focused, 38 | ...props 39 | }) => ( 40 | 50 | {hasNodes && ( 51 |
    { 54 | hasNodes && toggleNode && toggleNode(); 55 | e.stopPropagation(); 56 | }} 57 | > 58 | 59 |
    60 | )} 61 | {label} 62 |
    63 | ); 64 | 65 | const dataInArray = [ 66 | { 67 | key: 'mammal', 68 | label: 'Mammal', 69 | url: 'https://www.google.com/search?q=mammal', 70 | nodes: [ 71 | { 72 | key: 'canidae', 73 | label: 'Canidae', 74 | url: 'https://www.google.com/search?q=canidae', 75 | nodes: [ 76 | { 77 | key: 'dog', 78 | label: 'Dog', 79 | url: 'https://www.google.com/search?q=dog', 80 | nodes: [], 81 | }, 82 | { 83 | key: 'fox', 84 | label: 'Fox', 85 | url: 'https://www.google.com/search?q=fox', 86 | nodes: [], 87 | }, 88 | { 89 | key: 'wolf', 90 | label: 'Wolf', 91 | url: 'https://www.google.com/search?q=wolf', 92 | nodes: [], 93 | }, 94 | ], 95 | }, 96 | ], 97 | }, 98 | { 99 | key: 'reptile', 100 | label: 'Reptile', 101 | url: 'https://www.google.com/search?q=reptile', 102 | nodes: [ 103 | { 104 | key: 'squamata', 105 | label: 'Squamata', 106 | url: 'https://www.google.com/search?q=squamata', 107 | nodes: [ 108 | { 109 | key: 'lizard', 110 | label: 'Lizard', 111 | url: 'https://www.google.com/search?q=lizard', 112 | }, 113 | { 114 | key: 'snake', 115 | label: 'Snake', 116 | url: 'https://www.google.com/search?q=snake', 117 | }, 118 | { 119 | key: 'gekko', 120 | label: 'Gekko', 121 | url: 'https://www.google.com/search?q=gekko', 122 | }, 123 | ], 124 | }, 125 | ], 126 | }, 127 | ]; 128 | 129 | const translations = { 130 | Mammal: 'Mamífero', 131 | Canidae: 'Canidae', 132 | Dog: 'Perro', 133 | Fox: 'Zorro', 134 | Wolf: 'Lobo', 135 | Reptile: 'Reptil', 136 | Squamata: 'Squamata', 137 | Lizard: 'Lagartija', 138 | Snake: 'Serpiente', 139 | Gekko: 'Gekko', 140 | }; 141 | 142 | storiesOf('TreeMenu', module) 143 | .addDecorator(withInfo) 144 | .add('default usage', () => ( 145 | 146 | )) 147 | .add('without search', () => ( 148 | 153 | )) 154 | .add('has initial states', () => ( 155 | 161 | )) 162 | .add('set initial state when data is updated', () => { 163 | class TreeMenuWrapper extends React.Component { 164 | state = { data: dataInArray }; 165 | updateData = () => 166 | this.setState(({ data }) => ({ 167 | data: [ 168 | ...data, 169 | { 170 | key: 'foo', 171 | label: 'Foo', 172 | url: 'https://www.google.com/search?q=foo', 173 | }, 174 | ], 175 | })); 176 | render() { 177 | const { data } = this.state; 178 | return ( 179 | <> 180 |
    181 | 184 |
    185 | 196 | 197 | ); 198 | } 199 | } 200 | return ; 201 | }) 202 | .add('control TreeMenu only from its parent', () => { 203 | class TreeMenuWrapper extends React.Component { 204 | state = { openNodes: [] }; 205 | render() { 206 | return ( 207 | <> 208 |
    209 | 217 | 225 | 236 |
    237 | 243 | 244 | ); 245 | } 246 | } 247 | return ; 248 | }) 249 | .add('control TreeMenu from both its parent and openNodes', () => { 250 | class TreeMenuWrapper extends React.Component { 251 | state = { openNodes: [] }; 252 | childRef = React.createRef(); 253 | render() { 254 | return ( 255 | <> 256 |
    257 | 265 | 273 | 281 |
    282 | 287 | 288 | ); 289 | } 290 | } 291 | return ; 292 | }) 293 | .add('translate to Spanish', () => ( 294 | { 298 | console.log('label: ' + label); 299 | console.log(translations[label]); 300 | return translations[label]; 301 | }} 302 | /> 303 | )) 304 | .add('apply other UI framework, e.g. bootstrap', () => ( 305 | 306 | {({ search, items }) => ( 307 | <> 308 | search(e.target.value)} placeholder="Type and search" /> 309 | 310 | {items.map(({ reset, ...props }) => ( 311 | 312 | ))} 313 | 314 | 315 | )} 316 | 317 | )) 318 | .add('reset openNodes', () => { 319 | return ( 320 | 325 | {({ search, items, resetOpenNodes }) => ( 326 | <> 327 | 334 | {defaultChildren({ search, items })} 335 | 336 | )} 337 | 338 | ); 339 | }) 340 | .add('Opened/Closed Icon', () => ( 341 | 342 | {({ items }) => ( 343 |
      344 | {items.map(({ key, ...props }) => ( 345 | 351 | ))} 352 |
    353 | )} 354 |
    355 | )); 356 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Simple Tree Menu 2 | 3 | [![npm version](https://badge.fury.io/js/react-simple-tree-menu.svg)](https://badge.fury.io/js/react-simple-tree-menu) 4 | [![CircleCI](https://circleci.com/gh/iannbing/react-simple-tree-menu/tree/master.svg?style=shield)](https://circleci.com/gh/iannbing/react-simple-tree-menu/tree/master) 5 | [![Storybook](https://cdn.jsdelivr.net/gh/storybooks/brand@master/badge/badge-storybook.svg)](https://iannbing.github.io/react-simple-tree-menu/) 6 | 7 | Inspired by [Downshift](https://github.com/downshift-js/downshift), a simple, data-driven, light-weight React Tree Menu component that: 8 | 9 | - does not depend on any UI framework 10 | - fully customizable with `render props` and `control props` 11 | - allows search 12 | - supports keyboard browsing 13 | 14 | Check [Storybook Demo](https://iannbing.github.io/react-simple-tree-menu/). 15 | 16 | ## Usage 17 | 18 | Install with the following command in your React app: 19 | 20 | ```bash 21 | npm i react-simple-tree-menu 22 | // or 23 | yarn add react-simple-tree-menu 24 | ``` 25 | 26 | To generate a `TreeMenu`, you need to provide data in the following structure. 27 | 28 | ```js 29 | // as an array 30 | const treeData = [ 31 | { 32 | key: 'first-level-node-1', 33 | label: 'Node 1 at the first level', 34 | ..., // any other props you need, e.g. url 35 | nodes: [ 36 | { 37 | key: 'second-level-node-1', 38 | label: 'Node 1 at the second level', 39 | nodes: [ 40 | { 41 | key: 'third-level-node-1', 42 | label: 'Last node of the branch', 43 | nodes: [] // you can remove the nodes property or leave it as an empty array 44 | }, 45 | ], 46 | }, 47 | ], 48 | }, 49 | { 50 | key: 'first-level-node-2', 51 | label: 'Node 2 at the first level', 52 | }, 53 | ]; 54 | // or as an object 55 | const treeData = { 56 | 'first-level-node-1': { // key 57 | label: 'Node 1 at the first level', 58 | index: 0, // decide the rendering order on the same level 59 | ..., // any other props you need, e.g. url 60 | nodes: { 61 | 'second-level-node-1': { 62 | label: 'Node 1 at the second level', 63 | index: 0, 64 | nodes: { 65 | 'third-level-node-1': { 66 | label: 'Node 1 at the third level', 67 | index: 0, 68 | nodes: {} // you can remove the nodes property or leave it as an empty array 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | 'first-level-node-2': { 75 | label: 'Node 2 at the first level', 76 | index: 1, 77 | }, 78 | }; 79 | 80 | ``` 81 | 82 | And then import `TreeMenu` and use it. By default you only need to provide `data`. You can have more control over the behaviors of the components using the provided API. 83 | 84 | ```jsx 85 | import TreeMenu from 'react-simple-tree-menu'; 86 | ... 87 | // import default minimal styling or your own styling 88 | import '../node_modules/react-simple-tree-menu/dist/main.css'; 89 | // Use the default minimal UI 90 | 91 | 92 | // Use any third-party UI framework 93 | { 96 | this.navigate(props.url); // user defined prop 97 | }} 98 | initialActiveKey='first-level-node-1/second-level-node-1' // the path to the active node 99 | debounceTime={125}> 100 | {({ search, items }) => ( 101 | <> 102 | search(e.target.value)} placeholder="Type and search" /> 103 | 104 | {items.map(props => ( 105 | // You might need to wrap the third-party component to consume the props 106 | // check the story as an example 107 | // https://github.com/iannbing/react-simple-tree-menu/blob/master/stories/index.stories.js 108 | 109 | ))} 110 | 111 | 112 | )} 113 | 114 | 115 | ``` 116 | 117 | If you want to extend the minial UI components, they are exported at your disposal. 118 | 119 | ``` jsx 120 | // you can import and extend the default minial UI 121 | import TreeMenu, { defaultChildren, ItemComponent } from 'react-simple-tree-menu'; 122 | 123 | // add custom styling to the list item 124 | 125 | {({ search, items }) => ( 126 |
      127 | {items.map(({key, ...props}) => ( 128 | 129 | ))} 130 |
    131 | )} 132 |
    133 | 134 | // add a button to do resetOpenNodes 135 | 136 | {({ search, items, resetOpenNodes }) => ( 137 |
    138 |
    141 | )} 142 |
    143 | 144 | ``` 145 | 146 | ### Keyboard browsing 147 | 148 | When the tree menu is focused, you can use your keyboard to browse the tree. 149 | 150 | - UP: move the focus onto the previous node 151 | - DOWN: move the focus onto the next node 152 | - LEFT: close the current node if it has children and it is open; otherwise move the focus to the parent node 153 | - RIGHT: open the current node if it has children 154 | - ENTER: fire `onClick` function and set `activeKey` to current node 155 | 156 | Note the difference between the state `active` and `focused`. ENTER is equivalent to the `onClick` event, but focus does not fire `onClick`. 157 | 158 | ## API 159 | 160 | ### TreeMenu 161 | 162 | | props | description | type | default | 163 | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------- | 164 | | data | Data that defines the structure of the tree. You can nest it as many levels as you want, but note that it might cause performance issue. | {[string]:TreeNode} \| TreeNodeInArray[] | - | 165 | | activeKey | the node matching this key will be active. Note that you need to provide the complete path (e.g. node-level-1/node-level-2/target-node).| string | '' | 166 | | focusKey | the node matching this key will be focused. Note that you need to provide the complete path (e.g. node-level-1/node-level-2/target-node)| string | '' | 167 | | initialActiveKey | set initial state of `activeKey`. Note that you need to provide the complete path (e.g. node-level-1/node-level-2/target-node). | string | - | 168 | | initialFocusKey | set initial state of `focusKey`. Note that you need to provide the complete path (e.g. node-level-1/node-level-2/target-node). | string | - | 169 | | onClickItem | A callback function that defines the behavior when user clicks on an node | (Item): void | `console.warn` | 170 | | debounceTime | debounce time for searching | number | 125 | 171 | | openNodes | you can pass an array of node names to control the open state of certain branches | string[] | - | 172 | | initialOpenNodes | you can pass an array of node names to set some branches open as initial state | string[] | - | 173 | | locale | you can provide a function that converts `label` into `string` | ({label, ...other}) => string | ({label}) => label | 174 | | hasSearch | Set to `false` then `children` will not have the prop `search` | boolean | true | 175 | | cacheSearch | Enable/Disable cache on search | boolean | true | 176 | | matchSearch | you can define your own search function | ({label, searchTerm, ...other}) => boolean | ({label, searchTerm}) => isVisible | 177 | | disableKeyboard | Disable keyboard navigation | boolean | false | 178 | | children | a render props that provdes two props: `search`, `items` and `resetOpenNodes` | (ChildrenProps) => React.ReactNode | - | 179 | 180 | ### TreeNode 181 | 182 | | props | description | type | default | 183 | | -------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------- | 184 | | label | the rendered text of a Node | string | '' | 185 | | index | a number that defines the rendering order of this node on the same level; this is not needed if `data` is `TreeNode[]` | number | - | 186 | | nodes | a node without this property means that it is the last child of its branch | {[string]:TreeNode} \| TreeNode[] | - | 187 | | ...other | User defined props | any | - | 188 | 189 | ### TreeNodeInArray 190 | 191 | | props | description | type | default | 192 | | -------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------- | 193 | | key | Node name | string | - | 194 | | label | the rendered text of a Node | string | '' | 195 | | nodes | a node without this property means that it is the last child of its branch | {[string]:TreeNode} \| TreeNode[] | - | 196 | | ...other | User defined props | any | - | 197 | 198 | ### Item 199 | 200 | | props | description | type | default | 201 | | -------- | ---------------------------------------------- | ------------------------- | ------- | 202 | | hasNodes | if a `TreeNode` is the last node of its branch | boolean | false | 203 | | isOpen | if it is showing its children | boolean | false | 204 | | level | the level of the current node (root is zero) | number | 0 | 205 | | key | key of a `TreeNode` | string | - | 206 | | label | `TreeNode` `label` | string | - | 207 | | ...other | User defined props | any | - | 208 | 209 | ### ChildrenProps 210 | 211 | | props | description | type | default | 212 | | -------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------- | 213 | | search | A function that takes a string to filter the label of the item (only available if `hasSearch` is `true`) | (value: string) => void | - | 214 | | searchTerm | the search term that is currently applied (only available if `hasSearch` is `true`) | string | - | 215 | | items | An array of `TreeMenuItem` | TreeMenuItem[] | [] | 216 | | resetOpenNodes | A function that resets the `openNodes`, by default it will close all nodes. `activeKey` is an optional parameter that will highlight the node at the given path. `focusKey` is also an optional parameter that will set the focus (for keyboard control) to the given path. Both activeKey/focusKey must be provided with the complete path (e.g. node-level-1/node-level-2/target-node). activeKey will not highlight any nodes if not provided. focusKey will default to activeKey if not provided. | (openNodes: string[], activeKey?: string, focusKey?: string) => void | [],'','' | 217 | 218 | ### TreeMenuItem 219 | 220 | | props | description | type | default | 221 | | ---------------- | --------------------------------------------------------------------- | ------------------------- | ------- | 222 | | hasNodes | if a `TreeNode` is the last node of its branch | boolean | false | 223 | | isOpen | if it is showing its children | boolean | false | 224 | | openNodes | an array of all the open node names | string[] | - | 225 | | level | the level of the current node (root is zero) | number | 0 | 226 | | key | key of a `TreeNode` | string | - | 227 | | parent | key of the parent node | string | - | 228 | | searchTerm | user provided search term | string | - | 229 | | label | `TreeNode` `label` | string | - | 230 | | active | if current node is being selected | boolean | - | 231 | | focused | if current node is being focused | boolean | - | 232 | | onClick | a callback function that is run when the node is clicked | Function | - | 233 | | toggleNode | a function that toggles the node (only availavble if it has children) | Function | - | 234 | | ...other | User defined props | {[string]: any} | - | 235 | -------------------------------------------------------------------------------- /src/TreeMenu/__tests__/__snapshots__/TreeMenu.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TreeMenu should highlight the active node 1`] = ` 4 | 54 | 61 |
    65 | 72 |
      75 | 95 |
    • 106 |
      110 | 115 |
      120 | - 121 |
      122 |
      123 |
      124 | Release Notes 125 |
    • 126 |
      127 | 147 |
    • 158 |
      162 | 167 |
      172 | - 173 |
      174 |
      175 |
      176 | Desktop Modeler 177 |
    • 178 |
      179 | 199 |
    • 210 |
      214 | 219 |
      224 | + 225 |
      226 |
      227 |
      228 | 7 229 |
    • 230 |
      231 | 250 |
    • 261 | ATS Guide 262 |
    • 263 |
      264 |
    265 |
    266 |
    267 |
    268 | `; 269 | 270 | exports[`TreeMenu should open specified nodes 1`] = ` 271 | 320 | 327 |
    331 | 338 |
      341 | 361 |
    • 372 |
      376 | 381 |
      386 | - 387 |
      388 |
      389 |
      390 | Release Notes 391 |
    • 392 |
      393 | 413 |
    • 424 |
      428 | 433 |
      438 | - 439 |
      440 |
      441 |
      442 | Desktop Modeler 443 |
    • 444 |
      445 | 465 |
    • 476 |
      480 | 485 |
      490 | + 491 |
      492 |
      493 |
      494 | 7 495 |
    • 496 |
      497 | 516 |
    • 527 | ATS Guide 528 |
    • 529 |
      530 |
    531 |
    532 |
    533 |
    534 | `; 535 | 536 | exports[`TreeMenu should render the level-1 nodes by default 1`] = ` 537 | 580 | 587 |
    591 | 598 |
      601 | 616 |
    • 627 |
      631 | 636 |
      641 | + 642 |
      643 |
      644 |
      645 | Release Notes 646 |
    • 647 |
      648 | 662 |
    • 673 | ATS Guide 674 |
    • 675 |
      676 |
    677 |
    678 |
    679 |
    680 | `; 681 | --------------------------------------------------------------------------------