├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── demo └── preview.png ├── package.json ├── src ├── Sider.jsx ├── Sider.scss ├── index.js └── utils │ ├── formatMenuPath.js │ ├── formatMenuPath.spec.js │ ├── getFlatMenuKeys.js │ ├── getFlatMenuKeys.spec.js │ ├── getMeunMatchKeys.js │ ├── getMeunMatchKeys.spec.js │ ├── urlToList.js │ └── urlToList.spec.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "presets": [ 4 | "env", 5 | "react" 6 | ], 7 | "plugins": [ 8 | "transform-class-properties", 9 | "transform-object-rest-spread" 10 | ], 11 | "env": { 12 | "production": { 13 | "ignore": [ 14 | "**/*.spec.js" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "plugins": [ 5 | "react", 6 | "jsx-a11y", 7 | "import" 8 | ], 9 | "rules": { 10 | "no-console": 0, 11 | "react/forbid-prop-types": 0 12 | }, 13 | "globals": { 14 | "document": true, 15 | "window": true 16 | }, 17 | "env": { 18 | "es6": true, 19 | "node": true, 20 | "jest": true 21 | } 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | node_modules/ 3 | coverage/ 4 | lib/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-sider 2 | 3 | [![npm v](https://img.shields.io/npm/v/react-sider.svg)](https://www.npmjs.com/package/react-sider) 4 | [![npm dm](https://img.shields.io/npm/dm/react-sider.svg)](https://www.npmjs.com/package/react-sider) 5 | 6 | Inspired by [Ant Design Pro](https://pro.ant.design/). 7 | 8 | Lightweight [Ant Design Pro](https://pro.ant.design/) like `` component integrated with [Ant Design Menu](http://ant.design/components/menu/). 9 | 10 | ## Features 11 | * Zero work on CSS. 12 | * Minimum configuration and 100% data driven. 13 | * Easy integration with any React app architectures. Only depends on `react`, `react-router-dom`, `lodash` & `antd`. 14 | * Native nested menu and pathname support. 15 | * Automatical menu `openKeys` & `selectKeys` match based on current page `pathname`. 16 | 17 | ## Installation 18 | 19 | ```bash 20 | yarn add react-sider react react-router-dom lodash antd 21 | ``` 22 | 23 | ## Preview 24 | 25 | ![](./demo/preview.png) 26 | 27 | ## Usage 28 | 29 | ### Sider 30 | | Property | Description | Type | Default | 31 | | ---------- | --------------------------- | ----------------- | ------- | 32 | | className | className of container | string | '' | 33 | | style | style of container | object | { } | 34 | | appName | name of application | string | '' | 35 | | appLogo | img src of application logo | string | '' | 36 | | appBaseUrl | href of sider header | string | '/' | 37 | | width | sider container width | number | 256 | 38 | | menuData | data of sider menu | arrayOf(MenuItem) | [ ] | 39 | | pathname | current page pathname | string | '/' | 40 | 41 | ### MenuItem 42 | | Property | Description | Type | Default | 43 | | -------- | ---------------------------------------------- | ----------------- | ------- | 44 | | name | menu item name in text | string | - | 45 | | path | menu item path (see below example for details) | string | - | 46 | | icon | menu item antd icon | string | - | 47 | | children | sub menu items | arrayOf(MenuItem) | - | 48 | 49 | ## Example 50 | 51 | ```javascript 52 | import ReactSider from 'react-sider'; 53 | import 'react-sider/lib/index.css'; 54 | import logo from 'assets/logo.svg'; 55 | 56 | const menuData = [{ 57 | // MenuItem name 58 | name: 'Dashboard', 59 | // MenuItem icon (antd icon) 60 | icon: 'dashboard', 61 | // MenuItem relative path 62 | path: 'dashboard', 63 | // SubMenu 64 | children: [{ 65 | name: 'Analysis', 66 | path: 'analysis', 67 | children: [{ 68 | name: 'Real-time', 69 | path: 'realtime', 70 | }, { 71 | name: 'Offline', 72 | path: 'offline', 73 | }], 74 | }, 75 | { 76 | name: 'Monitor', 77 | path: 'monitor', 78 | }, 79 | { 80 | name: 'Workplace', 81 | path: 'workplace', 82 | }], 83 | }, { 84 | name: 'Marketing', 85 | icon: 'table', 86 | path: 'marketing', 87 | }, { 88 | name: 'Settings', 89 | icon: 'setting', 90 | path: 'settings', 91 | children: [{ 92 | name: 'Users Management', 93 | path: 'users', 94 | }], 95 | }]; 96 | 97 | const Sider = () => ( 98 | 105 | ) 106 | 107 | export default Sider; 108 | ``` 109 | 110 | ## Notes 111 | * `react-sider` will automatically format nested menu path with `/` based on `menuData` structure. 112 | * Remember to config `less-loader` within your application building process since `react-sider` directly imports `antd` components styles. -------------------------------------------------------------------------------- /demo/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlanWei/react-sider/3ea710b6d484f8003bacf75990ee4a211727635b/demo/preview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sider", 3 | "version": "0.4.1", 4 | "description": "Flexible Sider component integrated with Ant Design Menu.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "npm run clean && cross-env NODE_ENV=production babel src --out-dir lib && npm run build:css", 8 | "build:css": "cross-env sass src/Sider.scss lib/index.css --no-source-map", 9 | "test": "jest --coverage", 10 | "lint": "cross-env eslint --ext .js --ext .jsx src", 11 | "clean": "rm -rf lib", 12 | "pre:release": "npm run clean && npm run test && npm run lint" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/AlanWei/react-sider.git" 17 | }, 18 | "author": "Alan Wei ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/AlanWei/react-sider/issues" 22 | }, 23 | "homepage": "https://github.com/AlanWei/react-sider#readme", 24 | "devDependencies": { 25 | "babel-cli": "^6.26.0", 26 | "babel-core": "^6.26.3", 27 | "babel-eslint": "^8.2.3", 28 | "babel-jest": "^23.0.1", 29 | "babel-plugin-transform-class-properties": "^6.24.1", 30 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 31 | "babel-preset-env": "^1.7.0", 32 | "babel-preset-react": "^6.24.1", 33 | "cross-env": "^5.2.0", 34 | "eslint": "^4.19.1", 35 | "eslint-config-airbnb": "^16.1.0", 36 | "eslint-plugin-import": "^2.12.0", 37 | "eslint-plugin-jsx-a11y": "^6.0.3", 38 | "eslint-plugin-react": "^7.8.2", 39 | "jest": "^23.6.0", 40 | "react-test-renderer": "^16.6.3", 41 | "sass": "^1.15.0" 42 | }, 43 | "dependencies": { 44 | "antd": "^3.10.7", 45 | "lodash": "^4.17.11", 46 | "memoize-one": "^4.0.3", 47 | "path-to-regexp": "^2.4.0", 48 | "prop-types": "^15.6.2", 49 | "react": "^16.6.3", 50 | "react-router-dom": "^4.3.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Sider.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import memoize from 'memoize-one'; 5 | import map from 'lodash/map'; 6 | import Menu from 'antd/lib/menu'; 7 | import Icon from 'antd/lib/icon'; 8 | import 'antd/lib/menu/style'; 9 | import 'antd/lib/icon/style'; 10 | import formatMenuPath from './utils/formatMenuPath'; 11 | import getFlatMenuKeys from './utils/getFlatMenuKeys'; 12 | import getMeunMatchKeys from './utils/getMeunMatchKeys'; 13 | import urlToList from './utils/urlToList'; 14 | 15 | const { SubMenu } = Menu; 16 | 17 | const propTypes = { 18 | prefixCls: PropTypes.string, 19 | className: PropTypes.string, 20 | style: PropTypes.object, 21 | appName: PropTypes.string, 22 | appLogo: PropTypes.string, 23 | appBaseUrl: PropTypes.string, 24 | width: PropTypes.number, 25 | menuData: PropTypes.arrayOf(PropTypes.shape({ 26 | name: PropTypes.string, 27 | path: PropTypes.string, 28 | icon: PropTypes.string, 29 | children: PropTypes.array, 30 | })), 31 | pathname: PropTypes.string, 32 | }; 33 | 34 | const defaultProps = { 35 | prefixCls: 'react-sider', 36 | className: '', 37 | style: {}, 38 | appName: '', 39 | appLogo: '', 40 | appBaseUrl: '/', 41 | width: 256, 42 | menuData: [], 43 | pathname: '/', 44 | }; 45 | 46 | class Sider extends Component { 47 | constructor(props) { 48 | super(props); 49 | 50 | this.fullPathMenuData = memoize(menuData => formatMenuPath(menuData)); 51 | this.selectedKeys = memoize((pathname, fullPathMenu) => ( 52 | getMeunMatchKeys(getFlatMenuKeys(fullPathMenu), urlToList(pathname)) 53 | )); 54 | 55 | const { pathname, menuData } = props; 56 | 57 | this.state = { 58 | openKeys: this.selectedKeys(pathname, this.fullPathMenuData(menuData)), 59 | }; 60 | } 61 | 62 | handleOpenChange = (openKeys) => { 63 | this.setState({ 64 | openKeys, 65 | }); 66 | }; 67 | 68 | renderMenu = data => ( 69 | map(data, (item) => { 70 | if (item.children) { 71 | return ( 72 | 76 | {item.icon && } 77 | {item.name} 78 | 79 | } 80 | > 81 | {this.renderMenu(item.children)} 82 | 83 | ); 84 | } 85 | 86 | return ( 87 | 88 | 89 | {item.icon && } 90 | {item.name} 91 | 92 | 93 | ); 94 | }) 95 | ) 96 | 97 | renderSiderHeader = () => { 98 | const { 99 | appBaseUrl, 100 | prefixCls, 101 | appLogo, 102 | appName, 103 | } = this.props; 104 | 105 | return ( 106 | 107 |
108 | logo 113 |
114 | {appName} 115 |
116 |
117 | 118 | ); 119 | } 120 | 121 | renderSiderBody = () => { 122 | const { prefixCls, pathname, menuData } = this.props; 123 | const { openKeys } = this.state; 124 | 125 | return ( 126 |
127 | 135 | {this.renderMenu(this.fullPathMenuData(menuData))} 136 | 137 |
138 | ); 139 | } 140 | 141 | render() { 142 | const { 143 | prefixCls, 144 | className, 145 | style, 146 | width, 147 | } = this.props; 148 | 149 | const classes = `${prefixCls} ${className}`; 150 | const styles = { 151 | ...style, 152 | width, 153 | }; 154 | 155 | return ( 156 |
157 | {this.renderSiderHeader()} 158 | {this.renderSiderBody()} 159 |
160 | ); 161 | } 162 | } 163 | 164 | Sider.propTypes = propTypes; 165 | Sider.defaultProps = defaultProps; 166 | export default Sider; 167 | -------------------------------------------------------------------------------- /src/Sider.scss: -------------------------------------------------------------------------------- 1 | .react-sider { 2 | z-index: 1; 3 | display: flex; 4 | display: -webkit-box; 5 | display: -ms-flexbox; 6 | flex-direction: column; 7 | -webkit-box-orient: vertical; 8 | -webkit-box-direction: normal; 9 | -ms-flex-direction: column; 10 | width: 256px; 11 | box-shadow: 2px 0 8px rgba(0, 0, 0, .15); 12 | &-header { 13 | display: flex; 14 | align-items: center; 15 | background: #002140; 16 | height: 64px; 17 | padding-left: 24px; 18 | } 19 | &-body { 20 | flex: 1; 21 | box-flex: 1; 22 | -webkit-box-flex: 1; 23 | -ms-flex: 1; 24 | background: #001529; 25 | } 26 | &-logo { 27 | height: 48px; 28 | width: 48px; 29 | } 30 | &-appName { 31 | color: #ffffff; 32 | font-weight: bold; 33 | font-size: 20px; 34 | } 35 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Sider from './Sider'; 2 | 3 | export default Sider; 4 | -------------------------------------------------------------------------------- /src/utils/formatMenuPath.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map'; 2 | 3 | const formatMenuPath = (data, parentPath = '/') => ( 4 | map(data, (item) => { 5 | const result = { 6 | ...item, 7 | path: `${parentPath}${item.path}`, 8 | }; 9 | if (item.children) { 10 | result.children = formatMenuPath(item.children, `${parentPath}${item.path}/`); 11 | } 12 | return result; 13 | }) 14 | ); 15 | 16 | export default formatMenuPath; 17 | -------------------------------------------------------------------------------- /src/utils/formatMenuPath.spec.js: -------------------------------------------------------------------------------- 1 | import formatMenuPath from './formatMenuPath'; 2 | 3 | test('empty menu', () => { 4 | expect(formatMenuPath([])).toEqual([]); 5 | }); 6 | 7 | test('simple menu', () => { 8 | const simpleMenu = [{ 9 | path: 'dashboard', 10 | }, { 11 | path: 'user', 12 | }, { 13 | path: 'form', 14 | }]; 15 | 16 | expect(formatMenuPath(simpleMenu)).toEqual([{ 17 | path: '/dashboard', 18 | }, { 19 | path: '/user', 20 | }, { 21 | path: '/form', 22 | }]); 23 | }); 24 | 25 | 26 | test('nested menu', () => { 27 | const nestedMenu = [{ 28 | path: 'dashboard', 29 | children: [{ 30 | path: 'analysis', 31 | children: [{ 32 | path: 'realtime', 33 | }, { 34 | path: 'offline', 35 | }], 36 | }, { 37 | path: 'monitor', 38 | }], 39 | }, { 40 | path: 'user', 41 | }, { 42 | path: 'form', 43 | }]; 44 | 45 | expect(formatMenuPath(nestedMenu)).toEqual([{ 46 | path: '/dashboard', 47 | children: [{ 48 | path: '/dashboard/analysis', 49 | children: [{ 50 | path: '/dashboard/analysis/realtime', 51 | }, { 52 | path: '/dashboard/analysis/offline', 53 | }], 54 | }, { 55 | path: '/dashboard/monitor', 56 | }], 57 | }, { 58 | path: '/user', 59 | }, { 60 | path: '/form', 61 | }]); 62 | }); 63 | -------------------------------------------------------------------------------- /src/utils/getFlatMenuKeys.js: -------------------------------------------------------------------------------- 1 | import reduce from 'lodash/reduce'; 2 | 3 | const getFlatMenuKeys = menuData => ( 4 | reduce(menuData, (keys, item) => { 5 | keys.push(item.path); 6 | if (item.children) { 7 | return keys.concat(getFlatMenuKeys(item.children)); 8 | } 9 | return keys; 10 | }, []) 11 | ); 12 | 13 | export default getFlatMenuKeys; 14 | -------------------------------------------------------------------------------- /src/utils/getFlatMenuKeys.spec.js: -------------------------------------------------------------------------------- 1 | import getFlatMenuKeys from './getFlatMenuKeys'; 2 | 3 | test('empty menu', () => { 4 | expect(getFlatMenuKeys([])).toEqual([]); 5 | }); 6 | 7 | test('flat menu', () => { 8 | const simpleMenu = [{ 9 | path: '/dashboard', 10 | }, { 11 | path: '/user', 12 | }, { 13 | path: '/form', 14 | }]; 15 | expect(getFlatMenuKeys(simpleMenu)).toEqual(['/dashboard', '/user', '/form']); 16 | }); 17 | 18 | test('nested menu', () => { 19 | const nestedMenu = [{ 20 | path: '/dashboard', 21 | children: [{ 22 | path: '/dashboard/analysis', 23 | children: [{ 24 | path: '/dashboard/analysis/realtime', 25 | }, { 26 | path: '/dashboard/analysis/offline', 27 | }], 28 | }, { 29 | path: '/dashboard/monitor', 30 | }], 31 | }, { 32 | path: '/user', 33 | }, { 34 | path: '/form', 35 | }]; 36 | expect(getFlatMenuKeys(nestedMenu)).toEqual([ 37 | '/dashboard', 38 | '/dashboard/analysis', 39 | '/dashboard/analysis/realtime', 40 | '/dashboard/analysis/offline', 41 | '/dashboard/monitor', 42 | '/user', 43 | '/form', 44 | ]); 45 | }); 46 | -------------------------------------------------------------------------------- /src/utils/getMeunMatchKeys.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from 'path-to-regexp'; 2 | import reduce from 'lodash/reduce'; 3 | import filter from 'lodash/filter'; 4 | 5 | const getMeunMatchKeys = (flatMenuKeys, paths) => 6 | reduce(paths, (matchKeys, path) => ( 7 | matchKeys.concat(filter(flatMenuKeys, item => pathToRegexp(item).test(path))) 8 | ), []); 9 | 10 | export default getMeunMatchKeys; 11 | -------------------------------------------------------------------------------- /src/utils/getMeunMatchKeys.spec.js: -------------------------------------------------------------------------------- 1 | import getMeunMatchKeys from './getMeunMatchKeys'; 2 | import urlToList from './urlToList'; 3 | 4 | const flatMenuKeys = [ 5 | '/dashboard', 6 | '/dashboard/analysis', 7 | '/dashboard/analysis/realtime', 8 | '/dashboard/analysis/offline', 9 | '/dashboard/monitor', 10 | '/user', 11 | '/form', 12 | ]; 13 | 14 | test('empty path', () => { 15 | expect(getMeunMatchKeys(flatMenuKeys, urlToList(''))).toEqual([]); 16 | }); 17 | 18 | test('simple path', () => { 19 | expect(getMeunMatchKeys(flatMenuKeys, urlToList('/dashboard'))).toEqual(['/dashboard']); 20 | }); 21 | 22 | test('error path', () => { 23 | expect(getMeunMatchKeys(flatMenuKeys, urlToList('/dashboardabc'))).toEqual([]); 24 | }); 25 | 26 | test('nested path', () => { 27 | expect(getMeunMatchKeys(flatMenuKeys, urlToList('/dashboard/analysis/realtime'))).toEqual([ 28 | '/dashboard', 29 | '/dashboard/analysis', 30 | '/dashboard/analysis/realtime', 31 | ]); 32 | }); 33 | -------------------------------------------------------------------------------- /src/utils/urlToList.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map'; 2 | 3 | const urlToList = (url) => { 4 | if (url) { 5 | const urlList = url.split('/').filter(i => i); 6 | return map(urlList, (urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`); 7 | } 8 | return []; 9 | }; 10 | 11 | export default urlToList; 12 | -------------------------------------------------------------------------------- /src/utils/urlToList.spec.js: -------------------------------------------------------------------------------- 1 | import urlToList from './urlToList'; 2 | 3 | test('undefined path', () => { 4 | expect(urlToList(undefined)).toEqual([]); 5 | }); 6 | 7 | test('empty path', () => { 8 | expect(urlToList('')).toEqual([]); 9 | }); 10 | 11 | test('simple path', () => { 12 | expect(urlToList('/dashboard')).toEqual(['/dashboard']); 13 | }); 14 | 15 | test('nested path', () => { 16 | expect(urlToList('/dashboard/analysis/realtime')).toEqual([ 17 | '/dashboard', 18 | '/dashboard/analysis', 19 | '/dashboard/analysis/realtime', 20 | ]); 21 | }); 22 | --------------------------------------------------------------------------------