├── .prettierignore ├── .lintstagedrc.json ├── .husky └── pre-commit ├── .eslintignore ├── storybook ├── icons │ ├── types.ts │ ├── BarChart.tsx │ ├── Book.tsx │ ├── Calendar.tsx │ ├── Diamond.tsx │ ├── InkBottle.tsx │ ├── ShoppingCart.tsx │ ├── Service.tsx │ ├── Global.tsx │ ├── Github.tsx │ └── Icon.tsx ├── stories │ ├── playground.stories.tsx │ ├── MenuItem.stories.tsx │ ├── Menu.stories.tsx │ ├── Sidebar.stories.tsx │ └── SubMenu.stories.tsx ├── components │ ├── SidebarHeader.tsx │ ├── Badge.tsx │ ├── PackageBadges.tsx │ ├── Switch.tsx │ ├── Typography.tsx │ └── SidebarFooter.tsx └── Playground.tsx ├── jest.config.js ├── .storybook ├── main.js ├── preview.js └── preview-head.html ├── .prettierrc ├── src ├── styles │ ├── StyledUl.tsx │ ├── StyledBackdrop.tsx │ ├── StyledMenuLabel.tsx │ ├── StyledMenuSuffix.tsx │ ├── StyledMenuIcon.tsx │ ├── StyledMenuPrefix.tsx │ └── StyledExpandIcon.tsx ├── index.ts ├── hooks │ ├── useLegacySidebar.tsx │ ├── useMenu.tsx │ ├── useMediaQuery.tsx │ ├── usePopper.tsx │ └── useProSidebar.tsx ├── components │ ├── ProSidebarProvider.tsx │ ├── LegacySidebarContext.tsx │ ├── MenuButton.tsx │ ├── SubMenuContent.tsx │ ├── Menu.tsx │ ├── MenuItem.tsx │ ├── Sidebar.tsx │ └── SubMenu.tsx └── utils │ └── utilityClasses.ts ├── .editorconfig ├── babel.config.js ├── setupTests.ts ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ └── release.yml └── stale.yml ├── .gitignore ├── tsconfig.json ├── tests ├── testUtils.tsx ├── Menu.test.tsx └── Sidebar.test.tsx ├── rollup.config.js ├── LICENSE ├── .eslintrc ├── package.json ├── CHANGELOG.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | storybook-static -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": ["eslint --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | storybook-static 4 | rollup.config.js 5 | jest.config.js 6 | babel.config.js 7 | -------------------------------------------------------------------------------- /storybook/icons/types.ts: -------------------------------------------------------------------------------- 1 | export interface IconProps extends React.SVGAttributes { 2 | size?: number; 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-jsdom', 3 | setupFilesAfterEnv: ['/setupTests.ts'], 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../storybook/**/*.stories.@(tsx|mdx)'], 3 | addons: ['@storybook/addon-essentials'], 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/StyledUl.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const StyledUl = styled.ul` 4 | list-style-type: none; 5 | padding: 0; 6 | margin: 0; 7 | `; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | options: { 3 | storySort: { 4 | order: ['Sidebar', 'Menu', 'MenuItem', 'SubMenu', 'Playground'], 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | '@babel/preset-react', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /src/styles/StyledBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const StyledBackdrop = styled.div` 4 | position: fixed; 5 | top: 0px; 6 | right: 0px; 7 | bottom: 0px; 8 | left: 0px; 9 | z-index: 1; 10 | background-color: rgb(0, 0, 0, 0.3); 11 | `; 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/Sidebar'; 2 | export * from './components/Menu'; 3 | export * from './components/SubMenu'; 4 | export * from './components/MenuItem'; 5 | export * from './components/ProSidebarProvider'; 6 | export * from './hooks/useProSidebar'; 7 | export * from './utils/utilityClasses'; 8 | export type { CSSObject } from '@emotion/styled'; 9 | -------------------------------------------------------------------------------- /src/hooks/useLegacySidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LegacySidebarContext, 4 | LegacySidebarContextProps, 5 | } from '../components/LegacySidebarContext'; 6 | 7 | export const useLegacySidebar = (): LegacySidebarContextProps | undefined => { 8 | const context = React.useContext(LegacySidebarContext); 9 | 10 | return context; 11 | }; 12 | -------------------------------------------------------------------------------- /src/styles/StyledMenuLabel.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSObject } from '@emotion/styled'; 2 | 3 | interface StyledMenuLabelProps { 4 | rootStyles?: CSSObject; 5 | } 6 | 7 | export const StyledMenuLabel = styled.span` 8 | flex-grow: 1; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | white-space: nowrap; 12 | 13 | ${({ rootStyles }) => rootStyles}; 14 | `; 15 | -------------------------------------------------------------------------------- /src/hooks/useMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MenuContext, MenuContextProps } from '../components/Menu'; 3 | 4 | export const useMenu = (): MenuContextProps => { 5 | const context = React.useContext(MenuContext); 6 | if (context === undefined) { 7 | //TODO: set better error message 8 | throw new Error('Menu Component is required!'); 9 | } 10 | return context; 11 | }; 12 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | global.ResizeObserver = jest.fn().mockImplementation(() => ({ 7 | observe: jest.fn(), 8 | unobserve: jest.fn(), 9 | disconnect: jest.fn(), 10 | })); 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | - [ ] Bug fix 10 | - [ ] New feature 11 | - [ ] Documentation update 12 | - [ ] Refactoring / enhancement 13 | 14 | ## How Has This Been Tested? 15 | 16 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. 17 | -------------------------------------------------------------------------------- /.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 | 11 | # production 12 | build 13 | dist 14 | storybook-static 15 | 16 | # vs code config 17 | .vscode 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /storybook/icons/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const BarChart: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /storybook/icons/Book.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Book: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "declaration": true, 5 | "declarationDir": "dist", 6 | "module": "esnext", 7 | "target": "es5", 8 | "lib": ["dom", "esnext"], 9 | "sourceMap": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true 15 | }, 16 | "include": ["src", "tests", "storybook", "setupTests.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /storybook/stories/playground.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Playground as PlaygroundComponent } from '../Playground'; 4 | 5 | const StoryParams: ComponentMeta = { 6 | title: 'Playground', 7 | component: PlaygroundComponent, 8 | subcomponents: {}, 9 | argTypes: {}, 10 | }; 11 | 12 | export default StoryParams; 13 | 14 | export const Playground: ComponentStory = () => ; 15 | -------------------------------------------------------------------------------- /storybook/icons/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Calendar: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/ProSidebarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SidebarProvider } from './LegacySidebarContext'; 3 | 4 | interface ProSidebarProviderProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | /** 9 | * @deprecated 10 | * `ProSidebarProvider` is deprecated and will be removed in the next major release. 11 | */ 12 | export const ProSidebarProvider: React.FC = ({ children }) => { 13 | console.warn('ProSidebarProvider is deprecated and will be removed in the next major release.'); 14 | return {children}; 15 | }; 16 | -------------------------------------------------------------------------------- /src/styles/StyledMenuSuffix.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSObject } from '@emotion/styled'; 2 | 3 | interface StyledMenuSuffixProps { 4 | firstLevel?: boolean; 5 | collapsed?: boolean; 6 | transitionDuration?: number; 7 | rootStyles?: CSSObject; 8 | } 9 | 10 | export const StyledMenuSuffix = styled.span` 11 | margin-right: 5px; 12 | margin-left: 5px; 13 | opacity: ${({ firstLevel, collapsed }) => (firstLevel && collapsed ? '0' : '1')}; 14 | transition: opacity ${({ transitionDuration }) => transitionDuration}ms; 15 | 16 | ${({ rootStyles }) => rootStyles}; 17 | `; 18 | -------------------------------------------------------------------------------- /storybook/icons/Diamond.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Diamond: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/styles/StyledMenuIcon.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSObject } from '@emotion/styled'; 2 | 3 | interface StyledMenuIconProps { 4 | rtl?: boolean; 5 | rootStyles?: CSSObject; 6 | } 7 | 8 | export const StyledMenuIcon = styled.span` 9 | width: 35px; 10 | min-width: 35px; 11 | height: 35px; 12 | line-height: 35px; 13 | text-align: center; 14 | display: inline-block; 15 | border-radius: 2px; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | 20 | ${({ rtl }) => (rtl ? 'margin-left: 10px;' : 'margin-right: 10px;')} 21 | 22 | ${({ rootStyles }) => rootStyles}; 23 | `; 24 | -------------------------------------------------------------------------------- /storybook/icons/InkBottle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const InkBottle: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /storybook/icons/ShoppingCart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const ShoppingCart: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/styles/StyledMenuPrefix.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSObject } from '@emotion/styled'; 2 | 3 | interface StyledMenuPrefixProps { 4 | firstLevel?: boolean; 5 | collapsed?: boolean; 6 | transitionDuration?: number; 7 | rtl?: boolean; 8 | rootStyles?: CSSObject; 9 | } 10 | 11 | export const StyledMenuPrefix = styled.span` 12 | ${({ rtl }) => (rtl ? 'margin-left: 5px;' : 'margin-right: 5px;')} 13 | opacity: ${({ firstLevel, collapsed }) => (firstLevel && collapsed ? '0' : '1')}; 14 | transition: opacity ${({ transitionDuration }) => transitionDuration}ms; 15 | 16 | ${({ rootStyles }) => rootStyles}; 17 | `; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /storybook/icons/Service.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Service: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useMediaQuery = (breakpoint?: string): boolean => { 4 | const [matches, setMatches] = React.useState( 5 | !!breakpoint && typeof window !== 'undefined' && window.matchMedia(breakpoint).matches, 6 | ); 7 | 8 | React.useEffect(() => { 9 | if (breakpoint) { 10 | const media = window.matchMedia(breakpoint); 11 | 12 | const handleMatch = () => { 13 | if (media.matches !== matches) { 14 | setMatches(media.matches); 15 | } 16 | }; 17 | 18 | handleMatch(); 19 | 20 | media.addEventListener('change', handleMatch); 21 | return () => media.removeEventListener('change', handleMatch); 22 | } 23 | }, [matches, breakpoint]); 24 | 25 | return matches; 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils/utilityClasses.ts: -------------------------------------------------------------------------------- 1 | export const sidebarClasses = { 2 | root: 'ps-sidebar-root', 3 | container: 'ps-sidebar-container', 4 | image: 'ps-sidebar-image', 5 | backdrop: 'ps-sidebar-backdrop', 6 | collapsed: 'ps-collapsed', 7 | toggled: 'ps-toggled', 8 | rtl: 'ps-rtl', 9 | broken: 'ps-broken', 10 | }; 11 | 12 | export const menuClasses = { 13 | root: 'ps-menu-root', 14 | menuItemRoot: 'ps-menuitem-root', 15 | subMenuRoot: 'ps-submenu-root', 16 | button: 'ps-menu-button', 17 | prefix: 'ps-menu-prefix', 18 | suffix: 'ps-menu-suffix', 19 | label: 'ps-menu-label', 20 | icon: 'ps-menu-icon', 21 | subMenuContent: 'ps-submenu-content', 22 | SubMenuExpandIcon: 'ps-submenu-expand-icon', 23 | disabled: 'ps-disabled', 24 | active: 'ps-active', 25 | open: 'ps-open', 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: npm release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '16.x' 18 | registry-url: https://registry.npmjs.org/ 19 | 20 | - name: Install Dependencies 21 | run: yarn install --frozen-lockfile 22 | 23 | - name: Tests 24 | run: yarn test:ci 25 | 26 | - name: Build 27 | run: yarn build 28 | 29 | - name: Publish 30 | run: yarn publish 31 | env: 32 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /storybook/icons/Global.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Global: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tests/testUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, RenderOptions, RenderResult } from '@testing-library/react'; 3 | import { ProSidebarProvider } from '../src/components/ProSidebarProvider'; 4 | 5 | type CustomRender = ( 6 | ui: React.ReactElement, 7 | options?: Omit, 8 | ) => RenderResult; 9 | 10 | interface AllTheProvidersProps { 11 | children?: React.ReactNode; 12 | } 13 | 14 | const AllTheProviders: React.FC = ({ children }) => { 15 | return {children}; 16 | }; 17 | 18 | const customRender: CustomRender = ( 19 | ui: React.ReactElement, 20 | options?: Omit, 21 | ) => render(ui, { wrapper: AllTheProviders, ...options }); 22 | 23 | export * from '@testing-library/react'; 24 | 25 | export { customRender }; 26 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from 'rollup-plugin-typescript2'; 5 | 6 | import packageJson from './package.json'; 7 | 8 | export default { 9 | input: 'src/index.ts', 10 | output: [ 11 | { 12 | file: packageJson.main, 13 | format: 'cjs', 14 | }, 15 | { 16 | file: packageJson.module, 17 | format: 'esm', 18 | }, 19 | ], 20 | plugins: [ 21 | peerDepsExternal(), 22 | resolve(), 23 | commonjs(), 24 | typescript({ 25 | useTsconfigDeclarationDir: true, 26 | tsconfigOverride: { 27 | exclude: ['tests', '**/*.test.tsx', 'storybook', 'setupTests.ts'], 28 | }, 29 | }), 30 | ], 31 | external: ['react', 'react-dom', 'prop-types'], 32 | }; 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 60 5 | # Number of days of inactivity before a stale Issue or Pull Request is closed 6 | daysUntilClose: 7 7 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 8 | exemptLabels: 9 | - pinned 10 | - security 11 | # Label to use when marking as stale 12 | staleLabel: stale 13 | # Comment to post when marking as stale. Set to `false` to disable 14 | markComment: > 15 | This issue has been automatically marked as stale because it has not had 16 | recent activity. It will be closed if no further activity occurs. Thank you 17 | for your contributions. 18 | # Comment to post when removing the stale label. Set to `false` to disable 19 | unmarkComment: false 20 | # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable 21 | closeComment: false 22 | # Limit to only `issues` or `pulls` 23 | # only: issues 24 | -------------------------------------------------------------------------------- /storybook/icons/Github.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconProps } from './types'; 3 | 4 | export const Github: React.FC = ({ size = 18, ...rest }) => { 5 | return ( 6 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Mohamed Azouaoui 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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2021, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | }, 9 | "project": "./tsconfig.json" 10 | }, 11 | "settings": { 12 | "import/resolver": { 13 | "node": { 14 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 15 | } 16 | } 17 | }, 18 | "plugins": ["react", "@typescript-eslint", "prettier", "import", "testing-library", "jest-dom"], 19 | "extends": [ 20 | "airbnb-typescript", 21 | "airbnb/hooks", 22 | "prettier", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:prettier/recommended", 25 | "plugin:testing-library/react", 26 | "plugin:jest-dom/recommended" 27 | ], 28 | "env": { 29 | "browser": true, 30 | "es2021": true, 31 | "jest": true, 32 | "node": true 33 | }, 34 | "rules": { 35 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], 36 | "react/jsx-props-no-spreading": "off", 37 | "react/prop-types": "off", 38 | "import/prefer-default-export": "off", 39 | "import/no-extraneous-dependencies": "off", 40 | "@typescript-eslint/no-empty-interface": "off" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Menu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { customRender, fireEvent, screen, waitFor } from './testUtils'; 3 | import { Sidebar } from '../src/components/Sidebar'; 4 | import { Menu } from '../src/components/Menu'; 5 | import { SubMenu } from '../src/components/SubMenu'; 6 | import { menuClasses, sidebarClasses } from '../src/utils/utilityClasses'; 7 | 8 | describe('Menu', () => { 9 | it('should display popper on submenu click when collapsed', async () => { 10 | customRender( 11 | 12 | 13 | 14 | 15 | , 16 | ); 17 | 18 | const submenuButton = screen.getByTestId(`${menuClasses.button}-test-id`); 19 | const submenuContent = screen.queryByTestId(`${menuClasses.subMenuContent}-test-id`); 20 | 21 | expect(submenuButton).toBeInTheDocument(); 22 | expect(submenuContent).toBeInTheDocument(); 23 | 24 | fireEvent.click(submenuButton); 25 | 26 | const sidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 27 | expect(sidebarElem).toHaveClass(sidebarClasses.root); 28 | expect(sidebarElem).toHaveStyle({ 29 | width: '80px', 30 | 'min-width': '80px', 31 | }); 32 | await waitFor(() => 33 | expect(submenuContent).toHaveStyle({ 34 | visibility: 'visible', 35 | }), 36 | ); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/styles/StyledExpandIcon.tsx: -------------------------------------------------------------------------------- 1 | import styled, { CSSObject } from '@emotion/styled'; 2 | 3 | interface StyledExpandIconProps { 4 | open?: boolean; 5 | rtl?: boolean; 6 | } 7 | 8 | interface StyledExpandIconWrapperProps { 9 | collapsed?: boolean; 10 | level?: number; 11 | rtl?: boolean; 12 | rootStyles?: CSSObject; 13 | } 14 | 15 | export const StyledExpandIconWrapper = styled.span` 16 | ${({ collapsed, level, rtl }) => 17 | collapsed && 18 | level === 0 && 19 | ` 20 | position: absolute; 21 | ${rtl ? 'left: 10px;' : 'right: 10px;'} 22 | top: 50%; 23 | transform: translateY(-50%); 24 | 25 | `} 26 | 27 | ${({ rootStyles }) => rootStyles}; 28 | `; 29 | 30 | export const StyledExpandIcon = styled.span` 31 | display: inline-block; 32 | transition: transform 0.3s; 33 | ${({ rtl }) => 34 | rtl 35 | ? ` 36 | border-left: 2px solid currentcolor; 37 | border-top: 2px solid currentcolor; 38 | ` 39 | : ` border-right: 2px solid currentcolor; 40 | border-bottom: 2px solid currentcolor; 41 | `} 42 | 43 | width: 5px; 44 | height: 5px; 45 | transform: rotate(${({ open, rtl }) => (open ? (rtl ? '-135deg' : '45deg') : '-45deg')}); 46 | `; 47 | 48 | export const StyledExpandIconCollapsed = styled.span` 49 | width: 5px; 50 | height: 5px; 51 | background-color: currentcolor; 52 | border-radius: 50%; 53 | display: inline-block; 54 | `; 55 | -------------------------------------------------------------------------------- /storybook/components/SidebarHeader.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | import { Typography } from './Typography'; 4 | 5 | interface SidebarHeaderProps extends React.HTMLAttributes { 6 | children?: React.ReactNode; 7 | rtl: boolean; 8 | } 9 | 10 | const StyledSidebarHeader = styled.div` 11 | height: 64px; 12 | min-height: 64px; 13 | display: flex; 14 | align-items: center; 15 | padding: 0 20px; 16 | 17 | > div { 18 | width: 100%; 19 | overflow: hidden; 20 | } 21 | `; 22 | 23 | const StyledLogo = styled.div<{ rtl?: boolean }>` 24 | width: 35px; 25 | min-width: 35px; 26 | height: 35px; 27 | min-height: 35px; 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | border-radius: 8px; 32 | color: white; 33 | font-size: 24px; 34 | font-weight: 700; 35 | background-color: #009fdb; 36 | background: linear-gradient(45deg, rgb(21 87 205) 0%, rgb(90 225 255) 100%); 37 | ${({ rtl }) => 38 | rtl 39 | ? ` 40 | margin-left: 10px; 41 | margin-right: 4px; 42 | ` 43 | : ` 44 | margin-right: 10px; 45 | margin-left: 4px; 46 | `} 47 | `; 48 | 49 | export const SidebarHeader: React.FC = ({ children, rtl, ...rest }) => { 50 | return ( 51 | 52 |
53 | P 54 | 55 | Pro Sidebar 56 | 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /storybook/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | interface BadgeProps extends React.HTMLAttributes { 5 | children?: React.ReactNode; 6 | variant?: 'info' | 'success' | 'warning' | 'danger'; 7 | shape?: 'circle' | 'rounded'; 8 | } 9 | 10 | const StyledBadge = styled.div` 11 | min-width: 18px; 12 | min-height: 18px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | border-radius: ${({ shape }) => (shape === 'circle' ? '50%' : '16px')}; 17 | padding: ${({ shape }) => (shape === 'circle' ? '0' : '0 6px')}; 18 | font-size: 11px; 19 | font-weight: 600; 20 | 21 | ${({ variant }) => { 22 | switch (variant) { 23 | case 'info': 24 | return ` 25 | background-color: #048acd; 26 | color: #fff; 27 | `; 28 | case 'success': 29 | return ` 30 | background-color: #0cbb34; 31 | color: #fff; 32 | 33 | `; 34 | case 'danger': 35 | return ` 36 | background-color: #fb3939; 37 | color: #fff; 38 | 39 | `; 40 | case 'warning': 41 | return ` 42 | background-color: #e25807; 43 | color: #fff; 44 | 45 | `; 46 | } 47 | }} 48 | `; 49 | 50 | export const Badge: React.FC = ({ 51 | children, 52 | variant = 'info', 53 | shape = 'rounded', 54 | ...rest 55 | }) => { 56 | return ( 57 | 58 | {children} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /storybook/components/PackageBadges.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | const StyledPackageBadges = styled.div` 5 | margin: 0 -5px; 6 | a { 7 | margin: 0 5px; 8 | } 9 | `; 10 | 11 | export const PackageBadges = () => { 12 | return ( 13 | 14 |

15 | 16 | License 20 | 21 | 22 | Peer 26 | 27 | 28 | Download 32 | 33 | 34 | Stars 38 | 39 | 40 | Forks 44 | 45 |

46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/LegacySidebarContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface SidebarState { 4 | collapsed?: boolean; 5 | toggled?: boolean; 6 | broken?: boolean; 7 | rtl?: boolean; 8 | transitionDuration?: number; 9 | } 10 | 11 | export interface LegacySidebarContextProps extends SidebarState { 12 | updateSidebarState: (values: SidebarState) => void; 13 | updateCollapseState: () => void; 14 | updateToggleState: () => void; 15 | } 16 | 17 | interface SidebarProviderProps { 18 | children?: React.ReactNode; 19 | } 20 | 21 | export const LegacySidebarContext = React.createContext( 22 | undefined, 23 | ); 24 | 25 | export const SidebarProvider: React.FC = ({ children }) => { 26 | const [sidebarState, setSidebarState] = React.useState({ 27 | collapsed: false, 28 | toggled: false, 29 | broken: false, 30 | rtl: false, 31 | transitionDuration: 300, 32 | }); 33 | 34 | const updateSidebarState = React.useCallback((values: Partial) => { 35 | setSidebarState((prevState) => ({ ...prevState, ...values })); 36 | }, []); 37 | 38 | const updateCollapseState = React.useCallback(() => { 39 | setSidebarState((prevState) => ({ ...prevState, collapsed: !Boolean(prevState?.collapsed) })); 40 | }, []); 41 | 42 | const updateToggleState = React.useCallback(() => { 43 | setSidebarState((prevState) => ({ ...prevState, toggled: !Boolean(prevState?.toggled) })); 44 | }, []); 45 | 46 | const providerValue = React.useMemo( 47 | () => ({ ...sidebarState, updateSidebarState, updateCollapseState, updateToggleState }), 48 | [sidebarState, updateCollapseState, updateSidebarState, updateToggleState], 49 | ); 50 | 51 | return ( 52 | {children} 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /storybook/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | interface SwitchProps extends React.InputHTMLAttributes { 5 | label?: string; 6 | } 7 | 8 | const CheckBoxWrapper = styled.div` 9 | display: flex; 10 | align-items: center; 11 | `; 12 | 13 | const CheckBoxLabel = styled.label` 14 | margin-left: 10px; 15 | margin-right: 10px; 16 | font-size: 13px; 17 | cursor: pointer; 18 | `; 19 | 20 | const CheckBox = styled.div<{ checked?: boolean }>` 21 | position: relative; 22 | cursor: pointer; 23 | width: 32px; 24 | height: 20px; 25 | border-radius: 30px; 26 | 27 | background-color: ${({ checked }) => (checked ? '#0ed693' : '#dde0e7')}; 28 | `; 29 | 30 | const CheckBoxCircle = styled.div<{ checked?: boolean }>` 31 | position: absolute; 32 | top: 3px; 33 | left: 3px; 34 | width: 14px; 35 | height: 14px; 36 | border-radius: 50%; 37 | background-color: #fff; 38 | transition: left 0.2s; 39 | 40 | ${({ checked }) => (checked ? `left: 15px;` : '')} 41 | `; 42 | 43 | export const Switch = ({ id, label, checked, ...rest }: SwitchProps) => { 44 | return ( 45 | 46 | 47 | 65 | 66 | 67 | {label && {label}} 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 73 | -------------------------------------------------------------------------------- /src/hooks/usePopper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createPopper } from '@popperjs/core'; 3 | import { SidebarContext } from '../components/Sidebar'; 4 | 5 | interface PopperOptions { 6 | level: number; 7 | buttonRef: React.RefObject; 8 | contentRef: React.RefObject; 9 | } 10 | 11 | interface PopperResult { 12 | popperInstance?: ReturnType; 13 | } 14 | 15 | export const usePopper = (options: PopperOptions): PopperResult => { 16 | const { level, buttonRef, contentRef } = options; 17 | 18 | const { collapsed, toggled, transitionDuration } = React.useContext(SidebarContext); 19 | const popperInstanceRef = React.useRef | undefined>(); 20 | 21 | /** 22 | * create popper instance only on first level submenu components and when sidebar is collapsed 23 | */ 24 | React.useEffect(() => { 25 | if (level === 0 && collapsed && contentRef.current && buttonRef.current) { 26 | popperInstanceRef.current = createPopper(buttonRef.current, contentRef.current, { 27 | placement: 'right', 28 | strategy: 'fixed', 29 | modifiers: [ 30 | { 31 | name: 'offset', 32 | options: { 33 | offset: [0, 5], 34 | }, 35 | }, 36 | ], 37 | }); 38 | } 39 | 40 | return () => popperInstanceRef.current?.destroy(); 41 | }, [level, collapsed, contentRef, buttonRef]); 42 | 43 | /** 44 | * update popper instance (position) when buttonRef or contentRef changes 45 | */ 46 | React.useEffect(() => { 47 | if (contentRef.current && buttonRef.current) { 48 | const ro = new ResizeObserver(() => { 49 | popperInstanceRef.current?.update(); 50 | }); 51 | 52 | ro.observe(contentRef.current); 53 | ro.observe(buttonRef.current); 54 | } 55 | 56 | setTimeout(() => { 57 | popperInstanceRef.current?.update(); 58 | }, transitionDuration); 59 | }, [transitionDuration, toggled, contentRef, buttonRef]); 60 | 61 | return { popperInstance: popperInstanceRef.current }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/MenuButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | 4 | interface MenuButtonProps extends Omit, 'prefix'> { 5 | component?: string | React.ReactElement; 6 | children?: React.ReactNode; 7 | } 8 | 9 | interface MenuButtonStylesProps { 10 | level: number; 11 | collapsed?: boolean; 12 | rtl?: boolean; 13 | disabled?: boolean; 14 | active?: boolean; 15 | } 16 | 17 | export const menuButtonStyles = (props: MenuButtonStylesProps) => { 18 | const { rtl, level, collapsed, disabled, active } = props; 19 | 20 | return ` 21 | display: flex; 22 | align-items: center; 23 | height: 50px; 24 | text-decoration: none; 25 | color: inherit; 26 | box-sizing: border-box; 27 | cursor: pointer; 28 | 29 | ${ 30 | rtl 31 | ? `padding-left: 20px; 32 | padding-right: ${level === 0 ? 20 : (collapsed ? level : level + 1) * 20}px; 33 | ` 34 | : `padding-right: 20px; 35 | padding-left: ${level === 0 ? 20 : (collapsed ? level : level + 1) * 20}px; 36 | ` 37 | } 38 | 39 | &:hover { 40 | background-color: #f3f3f3; 41 | } 42 | 43 | ${ 44 | disabled && 45 | ` 46 | pointer-events: none; 47 | cursor: default; 48 | color:#adadad; 49 | ` 50 | } 51 | 52 | ${active && 'background-color: #e2eef9;'} 53 | 54 | `; 55 | }; 56 | 57 | export const MenuButtonRef: React.ForwardRefRenderFunction = ( 58 | { className, component, children, ...rest }, 59 | ref, 60 | ) => { 61 | if (component) { 62 | if (typeof component === 'string') { 63 | return React.createElement( 64 | component, 65 | { 66 | className: classNames(className), 67 | ...rest, 68 | ref, 69 | }, 70 | children, 71 | ); 72 | } else { 73 | const { className: classNameProp, ...props } = component.props; 74 | 75 | return React.cloneElement( 76 | component, 77 | { 78 | className: classNames(className, classNameProp), 79 | ...rest, 80 | ...props, 81 | ref, 82 | }, 83 | children, 84 | ); 85 | } 86 | } else { 87 | return ( 88 | 89 | {children} 90 | 91 | ); 92 | } 93 | }; 94 | 95 | export const MenuButton = React.forwardRef(MenuButtonRef); 96 | -------------------------------------------------------------------------------- /src/components/SubMenuContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { CSSObject } from '@emotion/styled'; 3 | import { StyledUl } from '../styles/StyledUl'; 4 | import { menuClasses } from '../utils/utilityClasses'; 5 | import { useMenu } from '../hooks/useMenu'; 6 | 7 | interface SubMenuContentProps extends React.HTMLAttributes { 8 | transitionDuration?: number; 9 | open?: boolean; 10 | openWhenCollapsed?: boolean; 11 | firstLevel?: boolean; 12 | collapsed?: boolean; 13 | defaultOpen?: boolean; 14 | rootStyles?: CSSObject; 15 | children?: React.ReactNode; 16 | } 17 | 18 | const StyledSubMenuContent = styled.div` 19 | height: 0px; 20 | overflow: hidden; 21 | z-index: 999; 22 | transition: height ${({ transitionDuration }) => transitionDuration}ms; 23 | box-sizing: border-box; 24 | background-color: white; 25 | 26 | ${({ firstLevel, collapsed }) => 27 | firstLevel && 28 | collapsed && 29 | ` 30 | background-color: white; 31 | box-shadow: 0 3px 6px -4px #0000001f, 0 6px 16px #00000014, 0 9px 28px 8px #0000000d; 32 | `} 33 | 34 | ${({ defaultOpen }) => defaultOpen && 'height: auto;display: block;'} 35 | 36 | ${({ collapsed, firstLevel, openWhenCollapsed }) => 37 | collapsed && firstLevel 38 | ? ` 39 | position: fixed; 40 | padding-left: 0px; 41 | width: 200px; 42 | border-radius: 4px; 43 | height: auto!important; 44 | display: block!important; 45 | transition: none!important; 46 | visibility: ${openWhenCollapsed ? 'visible' : 'hidden'}; 47 | ` 48 | : ` 49 | position: static!important; 50 | transform: none!important; 51 | `}; 52 | 53 | ${({ rootStyles }) => rootStyles}; 54 | `; 55 | 56 | const SubMenuContentFR: React.ForwardRefRenderFunction = ( 57 | { children, open, openWhenCollapsed, firstLevel, collapsed, defaultOpen, ...rest }, 58 | ref, 59 | ) => { 60 | const { transitionDuration } = useMenu(); 61 | const [defaultOpenState] = React.useState(defaultOpen); 62 | 63 | return ( 64 | 75 | {children} 76 | 77 | ); 78 | }; 79 | 80 | export const SubMenuContent = React.forwardRef(SubMenuContentFR); 81 | -------------------------------------------------------------------------------- /src/hooks/useProSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLegacySidebar } from './useLegacySidebar'; 3 | 4 | interface ProSidebarResult { 5 | /** 6 | * a function that enables you to update the sidebar's collapsed status 7 | */ 8 | collapseSidebar: (collapsed?: boolean) => void; 9 | 10 | /** 11 | * a function that enables you to update the sidebar's toggled status 12 | */ 13 | toggleSidebar: (toggled?: boolean) => void; 14 | 15 | /** 16 | * sidebar breakpoint status 17 | * value is set to true when screen size reaches the breakpoint 18 | */ 19 | broken: boolean; 20 | 21 | /** 22 | * sidebar collapsed status 23 | */ 24 | collapsed: boolean; 25 | 26 | /** 27 | * sidebar toggled status 28 | */ 29 | toggled: boolean; 30 | 31 | /** 32 | * sidebar rtl status 33 | */ 34 | rtl: boolean; 35 | } 36 | 37 | /** 38 | * @deprecated 39 | * `useProSidebar` is deprecated and will be removed in the next major release. 40 | * please use Sidebar props instead. 41 | */ 42 | export const useProSidebar = (): ProSidebarResult => { 43 | const legacySidebarContext = useLegacySidebar(); 44 | 45 | if (legacySidebarContext === undefined) { 46 | throw new Error( 47 | 'useProSidebar must be used within a ProSidebarProvider. Please wrap your component with a ProSidebarProvider to use this hook.', 48 | ); 49 | } 50 | 51 | const collapseSidebar = React.useCallback( 52 | (value?: boolean) => { 53 | if (value === undefined) legacySidebarContext.updateCollapseState(); 54 | else legacySidebarContext.updateSidebarState({ collapsed: value }); 55 | }, 56 | // eslint-disable-next-line react-hooks/exhaustive-deps 57 | [legacySidebarContext.updateCollapseState, legacySidebarContext.updateSidebarState], 58 | ); 59 | 60 | const toggleSidebar = React.useCallback( 61 | (value?: boolean) => { 62 | if (value === undefined) legacySidebarContext.updateToggleState(); 63 | else legacySidebarContext.updateSidebarState({ toggled: value }); 64 | }, 65 | // eslint-disable-next-line react-hooks/exhaustive-deps 66 | [legacySidebarContext.updateToggleState, legacySidebarContext.updateSidebarState], 67 | ); 68 | 69 | React.useEffect(() => { 70 | console.warn( 71 | 'useProSidebar is deprecated and will be removed in the next major release. Please use Sidebar props instead.', 72 | ); 73 | }, []); 74 | 75 | return { 76 | collapseSidebar, 77 | toggleSidebar, 78 | collapsed: !!legacySidebarContext.collapsed, 79 | broken: !!legacySidebarContext.broken, 80 | toggled: !!legacySidebarContext.toggled, 81 | rtl: !!legacySidebarContext.rtl, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /storybook/icons/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props extends React.SVGAttributes { 4 | name: 5 | | 'diamond' 6 | | 'bar-chart' 7 | | 'shopping-cart' 8 | | 'ink-bottle' 9 | | 'book-2' 10 | | 'calendar' 11 | | 'global' 12 | | 'service'; 13 | size?: number; 14 | } 15 | 16 | export const Icon: React.FC = ({ size = '18', name, ...rest }) => { 17 | const getIconPath = (): string => { 18 | switch (name) { 19 | case 'diamond': 20 | return 'M4.873 3h14.254a1 1 0 0 1 .809.412l3.823 5.256a.5.5 0 0 1-.037.633L12.367 21.602a.5.5 0 0 1-.734 0L.278 9.302a.5.5 0 0 1-.037-.634l3.823-5.256A1 1 0 0 1 4.873 3z'; 21 | case 'bar-chart': 22 | return 'M2 13h6v8H2v-8zM9 3h6v18H9V3zm7 5h6v13h-6V8z'; 23 | case 'shopping-cart': 24 | return 'M6 9h13.938l.5-2H8V5h13.72a1 1 0 0 1 .97 1.243l-2.5 10a1 1 0 0 1-.97.757H5a1 1 0 0 1-1-1V4H2V2h3a1 1 0 0 1 1 1v6zm0 14a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm12 0a2 2 0 1 1 0-4 2 2 0 0 1 0 4z'; 25 | case 'ink-bottle': 26 | return 'M16 9l4.371 1.749c.38.151.629.52.629.928V21c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1v-9.323c0-.409.249-.777.629-.928L8 9h8zm4 5H8v5h12v-5zM16 3c.552 0 1 .448 1 1v4H7V4c0-.552.448-1 1-1h8z'; 27 | case 'book-2': 28 | return 'M21 18H6a1 1 0 0 0 0 2h15v2H6a3 3 0 0 1-3-3V4a2 2 0 0 1 2-2h16v16zm-5-9V7H8v2h8z'; 29 | case 'calendar': 30 | return 'M2 11h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-9zm15-8h4a1 1 0 0 1 1 1v5H2V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2z'; 31 | case 'global': 32 | return 'M2.05 13h5.477a17.9 17.9 0 0 0 2.925 8.88A10.005 10.005 0 0 1 2.05 13zm0-2a10.005 10.005 0 0 1 8.402-8.88A17.9 17.9 0 0 0 7.527 11H2.05zm19.9 0h-5.477a17.9 17.9 0 0 0-2.925-8.88A10.005 10.005 0 0 1 21.95 11zm0 2a10.005 10.005 0 0 1-8.402 8.88A17.9 17.9 0 0 0 16.473 13h5.478zM9.53 13h4.94A15.908 15.908 0 0 1 12 20.592 15.908 15.908 0 0 1 9.53 13zm0-2A15.908 15.908 0 0 1 12 3.408 15.908 15.908 0 0 1 14.47 11H9.53z'; 33 | case 'service': 34 | return 'M14.121 10.48a1 1 0 0 0-1.414 0l-.707.706a2 2 0 1 1-2.828-2.828l5.63-5.632a6.5 6.5 0 0 1 6.377 10.568l-2.108 2.135-4.95-4.95zM3.161 4.468a6.503 6.503 0 0 1 8.009-.938L7.757 6.944a4 4 0 0 0 5.513 5.794l.144-.137 4.243 4.242-4.243 4.243a2 2 0 0 1-2.828 0L3.16 13.66a6.5 6.5 0 0 1 0-9.192z'; 35 | default: 36 | return ''; 37 | } 38 | }; 39 | 40 | return React.createElement( 41 | 'svg', 42 | { 43 | xmlns: 'http://www.w3.org/2000/svg', 44 | width: size, 45 | height: size, 46 | viewBox: '0 0 24 24', 47 | fill: 'currentColor', 48 | ...rest, 49 | }, 50 | React.createElement('path', { 51 | d: getIconPath(), 52 | }), 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /storybook/components/Typography.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | interface TypographyProps extends React.HTMLAttributes { 5 | children?: React.ReactNode; 6 | fontWeight?: number | string; 7 | color?: string; 8 | fontSize?: number | string; 9 | variant?: 10 | | 'h1' 11 | | 'h2' 12 | | 'h3' 13 | | 'h4' 14 | | 'h5' 15 | | 'h6' 16 | | 'body1' 17 | | 'body2' 18 | | 'subtitle1' 19 | | 'subtitle2' 20 | | 'caption'; 21 | } 22 | 23 | const StyledTypography = styled.p` 24 | margin: 0; 25 | overflow: hidden; 26 | white-space: nowrap; 27 | text-overflow: ellipsis; 28 | 29 | ${({ variant }) => { 30 | switch (variant) { 31 | case 'h1': 32 | return ` 33 | font-size: 72px; 34 | line-height: 90px; 35 | `; 36 | case 'h2': 37 | return ` 38 | font-size: 60px; 39 | line-height: 72px; 40 | `; 41 | case 'h3': 42 | return ` 43 | font-size: 48px; 44 | line-height: 60px; 45 | `; 46 | case 'h4': 47 | return ` 48 | font-size: 36px; 49 | line-height: 44px; 50 | `; 51 | case 'h5': 52 | return ` 53 | font-size: 30px; 54 | line-height: 38px; 55 | `; 56 | case 'h6': 57 | return ` 58 | font-size: 24px; 59 | line-height: 32px; 60 | `; 61 | case 'subtitle1': 62 | return ` 63 | font-size: 20px; 64 | line-height: 30px; 65 | `; 66 | case 'subtitle2': 67 | return ` 68 | font-size: 18px; 69 | line-height: 28px; 70 | `; 71 | case 'body1': 72 | return ` 73 | font-size: 16px; 74 | line-height: 24px; 75 | `; 76 | 77 | case 'body2': 78 | return ` 79 | font-size: 12px; 80 | line-height: 18px; 81 | `; 82 | case 'caption': 83 | return ` 84 | font-size: 10px; 85 | line-height: 16px; 86 | `; 87 | 88 | default: 89 | return ''; 90 | } 91 | }} 92 | 93 | ${({ fontWeight }) => (fontWeight ? `font-weight: ${fontWeight};` : '')} 94 | ${({ color }) => (color ? `color: ${color};` : '')} 95 | ${({ fontSize }) => (fontSize ? `font-size: ${fontSize};` : '')} 96 | `; 97 | 98 | export const Typography: React.FC = ({ variant = 'body1', children, ...rest }) => { 99 | return ( 100 | 101 | {children} 102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /storybook/components/SidebarFooter.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | import { Github } from '../icons/Github'; 4 | import { Typography } from './Typography'; 5 | import packageJson from '../../package.json'; 6 | 7 | interface SidebarFooterProps extends React.HTMLAttributes { 8 | children?: React.ReactNode; 9 | collapsed?: boolean; 10 | } 11 | 12 | const StyledButton = styled.a` 13 | padding: 5px 16px; 14 | border-radius: 4px; 15 | border: none; 16 | cursor: pointer; 17 | display: inline-block; 18 | background-color: #fff; 19 | color: #484848; 20 | text-decoration: none; 21 | `; 22 | 23 | const StyledSidebarFooter = styled.div` 24 | width: 50%; 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | justify-content: center; 29 | padding: 20px; 30 | border-radius: 8px; 31 | color: white; 32 | background: linear-gradient(45deg, rgb(21 87 205) 0%, rgb(90 225 255) 100%); 33 | /* background: #0098e5; */ 34 | `; 35 | 36 | const StyledCollapsedSidebarFooter = styled.a` 37 | width: 40px; 38 | height: 40px; 39 | display: flex; 40 | flex-direction: column; 41 | align-items: center; 42 | justify-content: center; 43 | cursor: pointer; 44 | border-radius: 50%; 45 | color: white; 46 | background: linear-gradient(45deg, rgb(21 87 205) 0%, rgb(90 225 255) 100%); 47 | /* background: #0098e5; */ 48 | `; 49 | 50 | const codeUrl = 51 | 'https://github.com/azouaoui-med/react-pro-sidebar/blob/master/storybook/Playground.tsx'; 52 | 53 | export const SidebarFooter: React.FC = ({ children, collapsed, ...rest }) => { 54 | return ( 55 |
62 | {collapsed ? ( 63 | 64 | 65 | 66 | ) : ( 67 | 68 |
69 | 70 |
71 | Pro Sidebar 72 | 73 | V {packageJson.version} 74 | 75 |
76 | 77 | 78 | View code 79 | 80 | 81 |
82 |
83 | )} 84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pro-sidebar", 3 | "version": "1.1.0", 4 | "description": "high level and customizable side navigation", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "repository": "https://github.com/azouaoui-med/react-pro-sidebar.git", 11 | "author": "Mohamed Azouaoui ", 12 | "license": "MIT", 13 | "private": false, 14 | "scripts": { 15 | "clean": "rimraf dist", 16 | "build": "yarn clean && rollup -c", 17 | "start": "rollup -c -w", 18 | "storybook": "start-storybook -p 9001", 19 | "build:storybook": "build-storybook", 20 | "test": "jest", 21 | "test:ci": "yarn test --coverage --watchAll=false --runInBand --forceExit", 22 | "test:watch": "jest --watch", 23 | "lint": "eslint . --ext .ts,.tsx", 24 | "lint:fix": "yarn lint --fix", 25 | "format": "prettier --write .", 26 | "prepare": "husky install", 27 | "gh-pages": "yarn build:storybook && gh-pages -d storybook-static" 28 | }, 29 | "peerDependencies": { 30 | "react": ">=16.8.0", 31 | "react-dom": ">=16.8.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.16.0", 35 | "@babel/preset-env": "^7.16.4", 36 | "@babel/preset-react": "^7.16.0", 37 | "@rollup/plugin-commonjs": "^21.1.0", 38 | "@rollup/plugin-node-resolve": "^13.3.0", 39 | "@storybook/addon-essentials": "^6.4.7", 40 | "@storybook/react": "^6.4.7", 41 | "@testing-library/jest-dom": "^5.16.5", 42 | "@testing-library/react": "^13.4.0", 43 | "@testing-library/user-event": "^14.4.3", 44 | "@types/jest": "^27.0.3", 45 | "@types/react": "^18.0.25", 46 | "@types/react-dom": "^18.0.9", 47 | "@typescript-eslint/eslint-plugin": "^5.5.0", 48 | "@typescript-eslint/parser": "^5.5.0", 49 | "babel-jest": "^27.4.2", 50 | "eslint": "^8.4.0", 51 | "eslint-config-airbnb": "^19.0.2", 52 | "eslint-config-airbnb-typescript": "^16.1.0", 53 | "eslint-config-prettier": "^8.3.0", 54 | "eslint-import-resolver-typescript": "^2.5.0", 55 | "eslint-plugin-import": "^2.25.3", 56 | "eslint-plugin-jest-dom": "^3.9.2", 57 | "eslint-plugin-jsx-a11y": "^6.5.1", 58 | "eslint-plugin-prettier": "^4.0.0", 59 | "eslint-plugin-react": "^7.27.1", 60 | "eslint-plugin-react-hooks": "^4.3.0", 61 | "eslint-plugin-testing-library": "^5.0.1", 62 | "gh-pages": "^4.0.0", 63 | "husky": "^7.0.0", 64 | "jest": "^27.4.3", 65 | "lint-staged": ">=10", 66 | "prettier": "^2.5.1", 67 | "react": "^18.2.0", 68 | "react-dom": "^18.2.0", 69 | "rimraf": "^3.0.2", 70 | "rollup": "^2.79.1", 71 | "rollup-plugin-peer-deps-external": "^2.2.4", 72 | "rollup-plugin-typescript2": "^0.31.2", 73 | "typescript": "^4.5.2" 74 | }, 75 | "keywords": [ 76 | "react-component", 77 | "react-sidebar", 78 | "layout", 79 | "sidebar", 80 | "menu", 81 | "submenu", 82 | "component", 83 | "collapsed", 84 | "rtl" 85 | ], 86 | "dependencies": { 87 | "@emotion/react": "^11.10.5", 88 | "@emotion/styled": "^11.10.5", 89 | "@popperjs/core": "^2.11.6", 90 | "classnames": "^2.3.2" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import { StyledUl } from '../styles/StyledUl'; 4 | import styled, { CSSObject } from '@emotion/styled'; 5 | import { menuClasses } from '../utils/utilityClasses'; 6 | 7 | export interface MenuItemStylesParams { 8 | level: number; 9 | disabled: boolean; 10 | active: boolean; 11 | isSubmenu: boolean; 12 | open?: boolean; 13 | } 14 | 15 | export type ElementStyles = CSSObject | ((params: MenuItemStylesParams) => CSSObject | undefined); 16 | 17 | export interface MenuItemStyles { 18 | root?: ElementStyles; 19 | button?: ElementStyles; 20 | label?: ElementStyles; 21 | prefix?: ElementStyles; 22 | suffix?: ElementStyles; 23 | icon?: ElementStyles; 24 | subMenuContent?: ElementStyles; 25 | SubMenuExpandIcon?: ElementStyles; 26 | } 27 | 28 | export interface RenderExpandIconParams { 29 | level: number; 30 | disabled: boolean; 31 | active: boolean; 32 | open: boolean; 33 | } 34 | 35 | export interface MenuContextProps { 36 | /** 37 | * Transition duration in milliseconds 38 | * @default ```300``` 39 | */ 40 | transitionDuration?: number; 41 | 42 | /** 43 | * If set to true, the popper menu will close when a menu item is clicked 44 | * This works on collapsed mode only 45 | * @default ```false``` 46 | */ 47 | closeOnClick?: boolean; 48 | 49 | /** 50 | * Apply styles to MenuItem and SubMenu components and their children 51 | */ 52 | menuItemStyles?: MenuItemStyles; 53 | 54 | /** 55 | * Render a custom expand icon for SubMenu components 56 | */ 57 | renderExpandIcon?: (params: RenderExpandIconParams) => React.ReactNode; 58 | } 59 | 60 | export interface MenuProps extends MenuContextProps, React.MenuHTMLAttributes { 61 | rootStyles?: CSSObject; 62 | children?: React.ReactNode; 63 | } 64 | 65 | const StyledMenu = styled.nav>` 66 | &.${menuClasses.root} { 67 | ${({ rootStyles }) => rootStyles} 68 | } 69 | `; 70 | 71 | export const MenuContext = React.createContext(undefined); 72 | 73 | export const LevelContext = React.createContext(0); 74 | 75 | const MenuFR: React.ForwardRefRenderFunction = ( 76 | { 77 | children, 78 | className, 79 | transitionDuration = 300, 80 | closeOnClick = false, 81 | rootStyles, 82 | menuItemStyles, 83 | renderExpandIcon, 84 | ...rest 85 | }, 86 | ref, 87 | ) => { 88 | const providerValue = React.useMemo( 89 | () => ({ transitionDuration, closeOnClick, menuItemStyles, renderExpandIcon }), 90 | [transitionDuration, closeOnClick, menuItemStyles, renderExpandIcon], 91 | ); 92 | 93 | return ( 94 | 95 | 96 | 102 | {children} 103 | 104 | 105 | 106 | ); 107 | }; 108 | 109 | export const Menu = React.forwardRef(MenuFR); 110 | -------------------------------------------------------------------------------- /storybook/stories/MenuItem.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Menu, menuClasses, MenuItem, Sidebar } from '../../src'; 4 | import { Icon } from '../icons/Icon'; 5 | 6 | const StoryParams: ComponentMeta = { 7 | title: 'MenuItem', 8 | component: MenuItem, 9 | argTypes: {}, 10 | }; 11 | 12 | export default StoryParams; 13 | 14 | export const Basic: ComponentStory = ({ ...props }) => ( 15 |
16 | 17 | 18 | Documentation 19 | Calendar 20 | E-commerce 21 | Examples 22 | 23 | 24 |
25 | ); 26 | 27 | Basic.parameters = { 28 | docs: { 29 | source: { 30 | code: ` 31 | import { Sidebar, Menu, MenuItem } from 'react-pro-sidebar'; 32 | 33 | () => ( 34 |
35 | 36 | 37 | Documentation 38 | Calendar 39 | E-commerce 40 | Examples 41 | 42 | 43 |
44 | )`, 45 | }, 46 | }, 47 | }; 48 | 49 | export const WithIcon: ComponentStory = () => ( 50 |
51 | 52 | 53 | }>Documentation 54 | }> Calendar 55 | }> E-commerce 56 | }> Examples 57 | 58 | 59 |
60 | ); 61 | WithIcon.storyName = 'icon'; 62 | 63 | export const Prefix: ComponentStory = () => ( 64 |
65 | 66 | 67 | Documentation 68 | Calendar 69 | E-commerce 70 | Examples 71 | 72 | 73 |
74 | ); 75 | Prefix.storyName = 'prefix'; 76 | 77 | export const Suffix: ComponentStory = () => ( 78 |
79 | 80 | 81 | Documentation 82 | Calendar 83 | E-commerce 84 | Examples 85 | 86 | 87 |
88 | ); 89 | Suffix.storyName = 'suffix'; 90 | 91 | export const Active: ComponentStory = () => ( 92 |
93 | 94 | 95 | Documentation 96 | Calendar 97 | E-commerce 98 | Examples 99 | 100 | 101 |
102 | ); 103 | Active.storyName = 'active'; 104 | 105 | export const Disabled: ComponentStory = () => ( 106 |
107 | 108 | 109 | Documentation 110 | Calendar 111 | E-commerce 112 | Examples 113 | 114 | 115 |
116 | ); 117 | Disabled.storyName = 'disabled'; 118 | 119 | export const Component: ComponentStory = () => ( 120 |
121 | 122 | 123 | Documentation 124 | Calendar 125 | E-commerce 126 | 127 | 128 |
129 | ); 130 | Component.storyName = 'component'; 131 | 132 | export const RootStyles: ComponentStory = () => ( 133 |
134 | 135 | 136 | 147 | Documentation 148 | 149 | Calendar 150 | E-commerce 151 | Examples 152 | 153 | 154 |
155 | ); 156 | RootStyles.storyName = 'rootStyles'; 157 | -------------------------------------------------------------------------------- /storybook/stories/Menu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Menu, menuClasses, MenuItem, Sidebar, SubMenu } from '../../src'; 4 | import { Icon } from '../icons/Icon'; 5 | 6 | const StoryParams: ComponentMeta = { 7 | title: 'Menu', 8 | component: Menu, 9 | argTypes: {}, 10 | }; 11 | 12 | export default StoryParams; 13 | 14 | export const Basic: ComponentStory = ({ ...props }) => ( 15 |
16 | 17 | 18 | Documentation 19 | Calendar 20 | E-commerce 21 | Examples 22 | 23 | 24 |
25 | ); 26 | 27 | Basic.parameters = { 28 | docs: { 29 | source: { 30 | code: ` 31 | import { Sidebar, Menu, MenuItem } from 'react-pro-sidebar'; 32 | 33 | () => ( 34 |
35 | 36 | 37 | Documentation 38 | Calendar 39 | E-commerce 40 | Examples 41 | 42 | 43 |
44 | )`, 45 | }, 46 | }, 47 | }; 48 | 49 | export const renderExpandIcon: ComponentStory = () => ( 50 |
51 | 52 | {open ? '-' : '+'}}> 53 | 54 | Pie charts 55 | Line charts 56 | Bar charts 57 | 58 | Calendar 59 | E-commerce 60 | Examples 61 | 62 | 63 |
64 | ); 65 | 66 | renderExpandIcon.storyName = 'renderExpandIcon'; 67 | 68 | export const MenuItemStyles: ComponentStory = () => ( 69 |
70 | 71 | { 74 | // only apply styles on first level elements of the tree 75 | if (level === 0) 76 | return { 77 | color: disabled ? '#f5d9ff' : '#d359ff', 78 | backgroundColor: active ? '#eecef9' : undefined, 79 | }; 80 | }, 81 | }} 82 | > 83 | }> 84 | Pie charts 85 | Line charts 86 | Bar charts 87 | 88 | }> 89 | Calendar (active) 90 | 91 | }> 92 | E-commerce (disabled) 93 | 94 | }> Examples 95 | 96 | 97 |
98 | ); 99 | 100 | MenuItemStyles.storyName = 'menuItemStyles'; 101 | 102 | export const TransitionDuration: ComponentStory = () => ( 103 |
104 | 105 | 106 | 107 | Pie charts 108 | Line charts 109 | Bar charts 110 | 111 | Calendar 112 | E-commerce 113 | Examples 114 | 115 | 116 |
117 | ); 118 | 119 | TransitionDuration.storyName = 'transitionDuration'; 120 | 121 | export const CloseOnClick: ComponentStory = () => ( 122 |
123 | 124 | 125 | 126 | Pie charts 127 | Line charts 128 | Bar charts 129 | 130 | Calendar 131 | E-commerce 132 | Examples 133 | 134 | 135 |
136 | ); 137 | 138 | CloseOnClick.storyName = 'closeOnClick'; 139 | 140 | export const RootStyles: ComponentStory = () => ( 141 |
142 | 143 | 151 | }> 152 | Pie charts 153 | Line charts 154 | Bar charts 155 | 156 | }> 157 | Calendar (active) 158 | 159 | }> 160 | E-commerce (disabled) 161 | 162 | }> Examples 163 | 164 | 165 |
166 | ); 167 | 168 | RootStyles.storyName = 'rootStyles'; 169 | -------------------------------------------------------------------------------- /tests/Sidebar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { customRender, screen } from './testUtils'; 3 | import { Sidebar } from '../src/components/Sidebar'; 4 | import * as mediaQueryHook from '../src/hooks/useMediaQuery'; 5 | import { sidebarClasses } from '../src/utils/utilityClasses'; 6 | 7 | describe('Sidebar', () => { 8 | it('should initialize Sidebar correctly', () => { 9 | customRender(Sidebar); 10 | const sidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 11 | const SidebarImgElem = screen.queryByTestId(`${sidebarClasses.image}-test-id`); 12 | 13 | expect(sidebarElem).toBeInTheDocument(); 14 | expect(SidebarImgElem).not.toBeInTheDocument(); 15 | expect(sidebarElem).toHaveClass(sidebarClasses.root); 16 | expect(sidebarElem).toHaveStyle({ 17 | position: 'relative', 18 | width: '250px', 19 | 'min-width': '250px', 20 | transition: 'width,left,right,300ms', 21 | }); 22 | }); 23 | 24 | it('should set the width to 300px ', () => { 25 | customRender(Sidebar); 26 | const sidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 27 | 28 | expect(sidebarElem).toHaveStyle({ 29 | width: '300px', 30 | 'min-width': '300px', 31 | }); 32 | }); 33 | 34 | it('should set the width to 80px when defaultCollapsed is true ', () => { 35 | customRender(Sidebar); 36 | const sidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 37 | expect(sidebarElem).toHaveClass(sidebarClasses.collapsed); 38 | expect(sidebarElem).toHaveStyle({ 39 | width: '80px', 40 | 'min-width': '80px', 41 | }); 42 | }); 43 | 44 | it('should have a width of 100px when collapsedWidth is set ', () => { 45 | customRender( 46 | 47 | Sidebar 48 | , 49 | ); 50 | const sidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 51 | 52 | expect(sidebarElem).toHaveStyle({ 53 | width: '100px', 54 | 'min-width': '100px', 55 | }); 56 | }); 57 | 58 | it('should have apply backgroundColor:black on inner sidebar', () => { 59 | customRender(Sidebar); 60 | const innerSidebarElem = screen.getByTestId(`${sidebarClasses.container}-test-id`); 61 | 62 | expect(innerSidebarElem).toHaveStyle({ 63 | 'background-color': 'black', 64 | }); 65 | }); 66 | 67 | it('should have set transition duration to 0.5s', () => { 68 | customRender(Sidebar); 69 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 70 | 71 | expect(SidebarElem).toHaveStyle({ 72 | transition: 'width,left,right,500ms', 73 | }); 74 | }); 75 | 76 | it('should display a background image', () => { 77 | customRender(Sidebar); 78 | const SidebarImgElem = screen.getByTestId(`${sidebarClasses.image}-test-id`); 79 | 80 | expect(SidebarImgElem).toBeInTheDocument(); 81 | expect(SidebarImgElem).toHaveAttribute('src', 'some-url'); 82 | expect(SidebarImgElem).toHaveClass(sidebarClasses.image); 83 | }); 84 | 85 | it('should sidebar have a correct positioning when broken', () => { 86 | jest.spyOn(mediaQueryHook, 'useMediaQuery').mockImplementation(() => true); 87 | 88 | customRender(Sidebar); 89 | 90 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 91 | 92 | expect(SidebarElem).toHaveStyle({ 93 | position: 'fixed', 94 | height: '100%', 95 | top: '0px', 96 | left: '-250px', 97 | }); 98 | }); 99 | 100 | it('should sidebar have a correct positioning when broken and collapsed', () => { 101 | jest.spyOn(mediaQueryHook, 'useMediaQuery').mockImplementation(() => true); 102 | 103 | customRender( 104 | 105 | Sidebar 106 | , 107 | ); 108 | 109 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 110 | 111 | expect(SidebarElem).toHaveStyle({ 112 | left: '-80px', 113 | }); 114 | }); 115 | 116 | it('should display overlay position sidebar to the left when broken and toggled', () => { 117 | jest.spyOn(mediaQueryHook, 'useMediaQuery').mockImplementation(() => true); 118 | 119 | customRender( 120 | 121 | Sidebar 122 | , 123 | ); 124 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 125 | 126 | expect(screen.getByTestId(`${sidebarClasses.backdrop}-test-id`)).toBeInTheDocument(); 127 | 128 | expect(SidebarElem).toHaveStyle({ 129 | left: '0px', 130 | }); 131 | }); 132 | 133 | it('should position and hide sidebar to the right when rtl is true and broken', () => { 134 | jest.spyOn(mediaQueryHook, 'useMediaQuery').mockImplementation(() => true); 135 | 136 | customRender( 137 | 138 | Sidebar 139 | , 140 | ); 141 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 142 | 143 | expect(SidebarElem).toHaveStyle({ 144 | right: '-250px', 145 | }); 146 | }); 147 | 148 | it('should display and position sidebar to the right when rtl is true and broken and toggled', () => { 149 | jest.spyOn(mediaQueryHook, 'useMediaQuery').mockImplementation(() => true); 150 | 151 | customRender( 152 | 153 | Sidebar 154 | , 155 | ); 156 | const SidebarElem = screen.getByTestId(`${sidebarClasses.root}-test-id`); 157 | 158 | expect(SidebarElem).toHaveStyle({ 159 | right: '0px', 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/components/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { CSSObject } from '@emotion/styled'; 3 | import classnames from 'classnames'; 4 | import { StyledMenuLabel } from '../styles/StyledMenuLabel'; 5 | import { StyledMenuIcon } from '../styles/StyledMenuIcon'; 6 | import { StyledMenuPrefix } from '../styles/StyledMenuPrefix'; 7 | import { useMenu } from '../hooks/useMenu'; 8 | import { StyledMenuSuffix } from '../styles/StyledMenuSuffix'; 9 | import { menuClasses } from '../utils/utilityClasses'; 10 | import { MenuButton, menuButtonStyles } from './MenuButton'; 11 | import { LevelContext } from './Menu'; 12 | import { SidebarContext } from './Sidebar'; 13 | 14 | export interface MenuItemProps 15 | extends Omit, 'prefix'> { 16 | /** 17 | * The icon to be displayed in the menu item 18 | */ 19 | icon?: React.ReactNode; 20 | 21 | /** 22 | * The prefix to be displayed in the menu item 23 | */ 24 | prefix?: React.ReactNode; 25 | 26 | /** 27 | * The suffix to be displayed in the menu item 28 | */ 29 | suffix?: React.ReactNode; 30 | 31 | /** 32 | * If set to true, the menu item will have an active state 33 | * @default ```false``` 34 | */ 35 | active?: boolean; 36 | 37 | /** 38 | * If set to true, the menu item will be disabled 39 | * @default ```false``` 40 | */ 41 | disabled?: boolean; 42 | 43 | /** 44 | * The component to be rendered as the menu item button 45 | */ 46 | component?: string | React.ReactElement; 47 | 48 | /** 49 | * Apply styles from the root element 50 | */ 51 | rootStyles?: CSSObject; 52 | 53 | children?: React.ReactNode; 54 | } 55 | 56 | interface StyledMenuItemProps extends Pick { 57 | level: number; 58 | menuItemStyles?: CSSObject; 59 | collapsed?: boolean; 60 | rtl?: boolean; 61 | buttonStyles?: CSSObject; 62 | } 63 | 64 | type MenuItemElement = 'root' | 'button' | 'label' | 'prefix' | 'suffix' | 'icon'; 65 | 66 | const StyledMenuItem = styled.li` 67 | width: 100%; 68 | position: relative; 69 | 70 | ${({ menuItemStyles }) => menuItemStyles}; 71 | 72 | ${({ rootStyles }) => rootStyles}; 73 | 74 | > .${menuClasses.button} { 75 | ${({ level, disabled, active, collapsed, rtl }) => 76 | menuButtonStyles({ 77 | level, 78 | disabled, 79 | active, 80 | collapsed, 81 | rtl, 82 | })}; 83 | 84 | ${({ buttonStyles }) => buttonStyles}; 85 | } 86 | `; 87 | 88 | export const MenuItemFR: React.ForwardRefRenderFunction = ( 89 | { 90 | children, 91 | icon, 92 | className, 93 | prefix, 94 | suffix, 95 | active = false, 96 | disabled = false, 97 | component, 98 | rootStyles, 99 | ...rest 100 | }, 101 | ref, 102 | ) => { 103 | const level = React.useContext(LevelContext); 104 | const { collapsed, rtl, transitionDuration } = React.useContext(SidebarContext); 105 | const { menuItemStyles } = useMenu(); 106 | 107 | const getMenuItemStyles = (element: MenuItemElement): CSSObject | undefined => { 108 | if (menuItemStyles) { 109 | const params = { level, disabled, active, isSubmenu: false }; 110 | const { 111 | root: rootElStyles, 112 | button: buttonElStyles, 113 | label: labelElStyles, 114 | icon: iconElStyles, 115 | prefix: prefixElStyles, 116 | suffix: suffixElStyles, 117 | } = menuItemStyles; 118 | 119 | switch (element) { 120 | case 'root': 121 | return typeof rootElStyles === 'function' ? rootElStyles(params) : rootElStyles; 122 | 123 | case 'button': 124 | return typeof buttonElStyles === 'function' ? buttonElStyles(params) : buttonElStyles; 125 | 126 | case 'label': 127 | return typeof labelElStyles === 'function' ? labelElStyles(params) : labelElStyles; 128 | 129 | case 'icon': 130 | return typeof iconElStyles === 'function' ? iconElStyles(params) : iconElStyles; 131 | 132 | case 'prefix': 133 | return typeof prefixElStyles === 'function' ? prefixElStyles(params) : prefixElStyles; 134 | 135 | case 'suffix': 136 | return typeof suffixElStyles === 'function' ? suffixElStyles(params) : suffixElStyles; 137 | 138 | default: 139 | return undefined; 140 | } 141 | } 142 | }; 143 | 144 | const sharedClasses = { 145 | [menuClasses.active]: active, 146 | [menuClasses.disabled]: disabled, 147 | }; 148 | 149 | return ( 150 | 162 | 169 | {icon && ( 170 | 175 | {icon} 176 | 177 | )} 178 | 179 | {prefix && ( 180 | 188 | {prefix} 189 | 190 | )} 191 | 192 | 196 | {children} 197 | 198 | 199 | {suffix && ( 200 | 207 | {suffix} 208 | 209 | )} 210 | 211 | 212 | ); 213 | }; 214 | 215 | export const MenuItem = React.forwardRef(MenuItemFR); 216 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.1.0] - 2024-02-03 9 | 10 | ## Fixed 11 | 12 | - Fixed submenu open prop not working properly [issue #210](https://github.com/azouaoui-med/react-pro-sidebar/issues/210) 13 | 14 | ## [1.1.0-alpha.2] - 2024-01-22 15 | 16 | ### Fixed 17 | 18 | - Fixed window is undefined from [@thiagobrito](https://github.com/thiagobrito) 19 | 20 | ## [1.1.0-alpha.1] - 2023-05-20 21 | 22 | ### Added 23 | 24 | - [Sidebar] Added `collapsed` prop 25 | - [Sidebar] Added `toggled` prop 26 | - [Sidebar] Added `onBackdropClick` prop 27 | - [Sidebar] Added `onBreakPoint` prop 28 | 29 | ### Updated 30 | 31 | - Deprecated `ProSidebarProvider` and made it optional 32 | - Deprecated `useProSidebar` 33 | - Updated BreakPoint type to use `all` and deprecate `always` 34 | 35 | ### Fixed 36 | 37 | - Fixed `closeOnClick` triggering close on nested SubMenu click 38 | - Fixed sidebar default states not being applied correctly when using `ProSidebarProvider` and `useProSidebar` 39 | - Fixed Menu's `transitionDuration` collision with Sidebar's `transitionDuration` 40 | 41 | ## [1.0.0] - 2023-01-21 42 | 43 | React Pro Sidebar 1.0.0 is here 🎉 44 | 45 | For full list of changes, browse changelogs matching `1.0.0-alpha.*` and `1.0.0-beta.*` 46 | 47 | ## [1.0.0-beta.3] - 2023-01-20 48 | 49 | - [SubMenu] fixed popper placement on rtl 50 | 51 | ## [1.0.0-beta.2] - 2023-01-14 52 | 53 | - [Menu] Added transitionDuration prop to use when sliding submenu content 54 | 55 | ## [1.0.0-beta.1] - 2023-01-13 56 | 57 | - [MenuItem] Removed `routerLink` prop in favor of `component` 58 | - [SUbMenu] Added `component` prop 59 | 60 | ## [1.0.0-alpha.10] - 2023-01-10 61 | 62 | - [MenuItem] [SubMenu] Apply root classes to child nodes (Button, label, prefix, ...) 63 | - [MenuItem] [SubMenu] Improve accessibility 64 | - [Sidebar] Fix sidebar border not changing when rtl is set to true 65 | 66 | ## [1.0.0-alpha.9] - 2022-11-27 67 | 68 | - Fix build 69 | 70 | ## [1.0.0-alpha.8] - 2022-11-27 71 | 72 | - Added rootStyles to all components 73 | - [Sidebar] Added backdropStyles 74 | - [Sidebar] Removed `overlayColor` prop 75 | - [Menu] Renamed `renderMenuItemStyles` to `menuItemStyles` which now is of type `MenuItemStyles`, the prop now provide a way to apply styles directly to MenuItem/SubMenu component and their children 76 | 77 | **Type definition**: 78 | 79 | ```ts 80 | type ElementStyles = CSSObject | ((params: MenuItemStylesParams) => CSSObject | undefined); 81 | 82 | interface MenuItemStyles { 83 | root?: ElementStyles; 84 | button?: ElementStyles; 85 | label?: ElementStyles; 86 | prefix?: ElementStyles; 87 | suffix?: ElementStyles; 88 | icon?: ElementStyles; 89 | subMenuContent?: ElementStyles; 90 | SubMenuExpandIcon?: ElementStyles; 91 | } 92 | ``` 93 | 94 | - updated classnames, the following are the new names: 95 | 96 | - `ps-sidebar-root` 97 | - `ps-sidebar-container` 98 | - `ps-sidebar-image` 99 | - `ps-sidebar-backdrop` 100 | - `ps-collapsed` 101 | - `ps-toggled` 102 | - `ps-rtl` 103 | - `ps-broken` 104 | - `ps-menu-root` 105 | - `ps-menuitem-root` 106 | - `ps-submenu-root` 107 | - `ps-menu-button` 108 | - `ps-menu-prefix` 109 | - `ps-menu-suffix` 110 | - `ps-menu-label` 111 | - `ps-menu-icon` 112 | - `ps-submenu-content` 113 | - `ps-submenu-expand-icon` 114 | - `ps-disabled` 115 | - `ps-active` 116 | - `ps-open` 117 | 118 | - Added utility classes that can be used to reference used classes 119 | 120 | ## [1.0.0-alpha.7] - 2022-10-24 121 | 122 | - Added support for react router to MenuItem via routerLink prop 123 | 124 | ## [1.0.0-alpha.6] - 2022-10-14 125 | 126 | - Fixed submenu content not displayed when collapsed [issue #124](https://github.com/azouaoui-med/react-pro-sidebar/issues/124) 127 | 128 | ## [1.0.0-alpha.4] - 2022-10-10 129 | 130 | - Build updates 131 | - Upgrade dependencies 132 | - Tests fixes 133 | 134 | ## [1.0.0-alpha.3] - 2022-10-06 135 | 136 | - Fixed children prop typing 137 | 138 | ## [1.0.0-alpha.2] - 2022-10-05 139 | 140 | - Fixed build 141 | 142 | ## [1.0.0-alpha.1] - 2022-10-05 143 | 144 | ### Breaking changes 145 | 146 | - Removed scss in favor of css-in-js(styled component) 147 | - [Sidebar] Rename ProSidebar to Sidebar 148 | 149 | ```diff 150 | - import { ProSidebar } from 'react-pro-sidebar'; 151 | + import { Sidebar } from 'react-pro-sidebar'; 152 | ``` 153 | 154 | - [Sidebar] Removed `collapsed`, `toggled` and `onToggle` props (`useProSidebar` hook will be used instead) 155 | - [Sidebar] Added `defaultCollapsed` prop 156 | - [Sidebar] Added `always` option to breakPoint prop 157 | - [Sidebar] Added `customBreakPoint` prop 158 | - [Sidebar] Added `backgroundColor` prop 159 | - [Sidebar] Added `transitionDuration` prop 160 | - [Sidebar] Added `overlayColor` prop 161 | - [Menu] removed `iconShape`, `popperArrow`, `subMenuBullets` and `innerSubMenuArrows` props 162 | - [Menu] added `renderMenuItemStyles` prop for customizing `MenuItem` & `SubMenu` components 163 | - [Menu] added `renderExpandIcon` prop 164 | - [Menu] added `closeOnClick` prop (useful when wanting to close popper on menuItem click when collapsed is `true`) 165 | - [MenuItem] added `disabled` props 166 | - [SubMenu] added `disabled` props 167 | - [SubMenu] renamed `title` prop to `label` 168 | - Introduced `ProSidebarProvider` component and `useProSidebar` hook to access and manage sidebar state 169 | 170 | ## [0.7.1] - 2021-09-23 171 | 172 | ### Fixed 173 | 174 | - Fix submenu items bullets not showing by adding `subMenuBullets` and `innerSubMenuArrows` props to Menu to choose between arrows and bullets 175 | 176 | ## [0.7.0] - 2021-09-22 177 | 178 | ### Added 179 | 180 | - Add submenu indent variable and update default 181 | - Add exports for all component props 182 | - Add breakpoint-xxl 183 | 184 | ### updated 185 | 186 | - Replace submenu bullets with arrows 187 | - Enable icon use in submenu items 188 | 189 | ### Fixed 190 | 191 | - Fix typescript property collision by Omitting prefix props for li element [@zirho](https://github.com/zirho) 192 | 193 | ## [0.6.0] - 2021-02-11 194 | 195 | ### Added 196 | 197 | - Add width and collapsedWidth props 198 | 199 | ### Fixed 200 | 201 | - Fix dynamically add Menu items depending on certain conditions from [@sergiovhe](https://github.com/sergiovhe) 202 | - Fix use of styles in ProSidebar component from [@sujaysudheenda](https://github.com/sujaysudheenda) 203 | - Fix default props spreading 204 | 205 | ## [0.5.0] - 2020-12-14 206 | 207 | ### Added 208 | 209 | - Add onOpenChange callback function for submenu [issue #23](https://github.com/azouaoui-med/react-pro-sidebar/issues/23) from [@QoobIY](https://github.com/QoobIY) 210 | 211 | ### Fixed 212 | 213 | - Fix prop spreading and update types on submenu 214 | 215 | ## [0.4.4] - 2020-08-16 216 | 217 | ### Fixed 218 | 219 | - Fix SidebarFooter styling [issue #12](https://github.com/azouaoui-med/react-pro-sidebar/issues/12) 220 | - Fix overlapping sidebar image with overlay 221 | 222 | ## [0.4.3] - 2020-07-06 223 | 224 | ### Fixed 225 | 226 | - Fix css build 227 | 228 | ## [0.4.2] - 2020-07-05 229 | 230 | ### Fixed 231 | 232 | - Fix slidedown.css path bug [issue #7](https://github.com/azouaoui-med/react-pro-sidebar/issues/7) 233 | 234 | ## [0.4.1] - 2020-06-12 235 | 236 | ### Fixed 237 | 238 | - Fix Typescript error type [issue #3](https://github.com/azouaoui-med/react-pro-sidebar/issues/3) 239 | 240 | ## [0.4.0] - 2020-06-05 241 | 242 | ### Added 243 | 244 | - Display arrow when hover on top level submenu title 245 | - Add toggle option for sidebar and break points 246 | - Add popperArrow prop in Menu component to specify whether to display an arrow to point to sub-menu wrapper on sidebar collapsed 247 | 248 | ### Changed 249 | 250 | - Use direction:rtl instead of row-reverse 251 | 252 | ### Fixed 253 | 254 | - Fix react-slidedown css import [issue #1](https://github.com/azouaoui-med/react-pro-sidebar/issues/1) from [@metadan](https://github.com/metadan) 255 | - Fix submenu positioning on sidebar collapsed using popperjs and resize-observer-polyfill for resize event listener. 256 | 257 | ## [0.3.0] - 2020-05-11 258 | 259 | ### Added 260 | 261 | - Add suffix prop to menuItem and submenu 262 | - Add prefix prop to menuItem and submenu 263 | - Add sidebar layout 264 | 265 | - Add sidebar header component 266 | - Add sidebar content component 267 | - Add sidebar footer component 268 | 269 | ## [0.2.0] - 2020-05-08 270 | 271 | ### Added 272 | 273 | - Add prop to submenu component to set the open value 274 | - Add prop to menuItem component to set the active value 275 | 276 | ### Fixed 277 | 278 | - Fix sidebar height overflow 279 | 280 | ## [0.1.1] - 2020-05-07 281 | 282 | ### Fixed 283 | 284 | - Fix submenu visibility when collapsed 285 | 286 | ## [0.1.0] - 2020-05-07 287 | 288 | ### Added 289 | 290 | - Add prop to submenu component to set the default open value 291 | 292 | ### Changed 293 | 294 | - Update readme 295 | - Merge API tables into one single table use prop instead of name 296 | - Add description on how to use nested sub-menus 297 | 298 | ### Fixed 299 | 300 | - Fix overflow of the sidebar content 301 | 302 | ## [0.1.0-beta.1] - 2020-05-06 303 | 304 | ### Initial Pre release 305 | 306 | - Initial pre release of the react pro sidebar library 307 | -------------------------------------------------------------------------------- /storybook/stories/Sidebar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Menu, MenuItem, Sidebar } from '../../src'; 4 | 5 | const StoryParams: ComponentMeta = { 6 | title: 'Sidebar', 7 | component: Sidebar, 8 | subcomponents: {}, 9 | argTypes: {}, 10 | }; 11 | 12 | export default StoryParams; 13 | 14 | export const Basic: ComponentStory = ({ ...props }) => ( 15 |
16 | 17 | 18 | Documentation 19 | Calendar 20 | E-commerce 21 | Examples 22 | 23 | 24 |
Main content
25 |
26 | ); 27 | Basic.parameters = { 28 | docs: { 29 | source: { 30 | code: ` 31 | import { Sidebar, Menu, MenuItem } from 'react-pro-sidebar'; 32 | 33 | () => ( 34 |
35 | 36 | 37 | Documentation 38 | Calendar 39 | E-commerce 40 | Examples 41 | 42 | 43 |
Main content
44 |
45 | );`, 46 | }, 47 | }, 48 | }; 49 | 50 | export const Width: ComponentStory = () => ( 51 |
52 | 53 | 54 | Documentation 55 | Calendar 56 | E-commerce 57 | Examples 58 | 59 | 60 |
61 | ); 62 | 63 | Width.storyName = 'width'; 64 | 65 | export const Collapsed: ComponentStory = () => { 66 | const [collapsed, setCollapsed] = React.useState(false); 67 | 68 | return ( 69 |
70 | 71 | 72 | Documentation 73 | Calendar 74 | E-commerce 75 | Examples 76 | 77 | 78 |
79 |
80 | 83 |
84 |
85 |
86 | ); 87 | }; 88 | Collapsed.storyName = 'collapsed'; 89 | 90 | export const CollapsedWidth: ComponentStory = () => ( 91 |
92 | 93 | 94 | Documentation 95 | Calendar 96 | E-commerce 97 | Examples 98 | 99 | 100 |
101 | ); 102 | CollapsedWidth.storyName = 'collapsedWidth'; 103 | 104 | export const Toggled: ComponentStory = () => { 105 | const [toggled, setToggled] = React.useState(false); 106 | 107 | return ( 108 |
109 | setToggled(false)} toggled={toggled} breakPoint="always"> 110 | 111 | Documentation 112 | Calendar 113 | E-commerce 114 | Examples 115 | 116 | 117 |
118 |
119 | 122 |
123 |
124 |
125 | ); 126 | }; 127 | Toggled.storyName = 'toggled'; 128 | 129 | export const BackgroundColor: ComponentStory = () => ( 130 |
131 | 132 | 133 | Documentation 134 | Calendar 135 | E-commerce 136 | Examples 137 | 138 | 139 |
140 | ); 141 | BackgroundColor.storyName = 'backgroundColor'; 142 | 143 | export const Image: ComponentStory = () => ( 144 |
145 | 146 | 147 | Documentation 148 | Calendar 149 | E-commerce 150 | Examples 151 | 152 | 153 |
154 | ); 155 | Image.storyName = 'image'; 156 | 157 | export const BreakPoint: ComponentStory = () => { 158 | const [toggled, setToggled] = React.useState(false); 159 | 160 | return ( 161 |
162 | setToggled(false)} toggled={toggled} breakPoint="always"> 163 | 164 | Documentation 165 | Calendar 166 | E-commerce 167 | Examples 168 | 169 | 170 |
171 |
172 | 175 |
176 |
177 |
178 | ); 179 | }; 180 | BreakPoint.storyName = 'breakPoint'; 181 | 182 | BreakPoint.parameters = { 183 | docs: { 184 | inlineStories: false, 185 | iframeHeight: 500, 186 | }, 187 | }; 188 | 189 | export const CustomBreakPoint: ComponentStory = () => { 190 | const [toggled, setToggled] = React.useState(false); 191 | const [broken, setBroken] = React.useState(window.matchMedia('(max-width: 800px)').matches); 192 | 193 | return ( 194 |
195 | 196 | 197 | Documentation 198 | Calendar 199 | E-commerce 200 | Examples 201 | 202 | 203 |
204 |
205 | {broken && ( 206 | 209 | )} 210 |
211 |
212 |
213 | ); 214 | }; 215 | CustomBreakPoint.storyName = 'customBreakPoint'; 216 | 217 | BreakPoint.parameters = { 218 | docs: { 219 | inlineStories: false, 220 | iframeHeight: 500, 221 | }, 222 | }; 223 | 224 | export const TransitionDuration: ComponentStory = () => { 225 | const [collapsed, setCollapsed] = React.useState(false); 226 | 227 | return ( 228 |
229 | 230 | 231 | Documentation 232 | Calendar 233 | E-commerce 234 | Examples 235 | 236 | 237 |
238 |
239 | 242 |
243 |
244 |
245 | ); 246 | }; 247 | TransitionDuration.storyName = 'transitionDuration'; 248 | 249 | export const RTL: ComponentStory = () => { 250 | return ( 251 |
259 | 260 | 261 | Documentation 262 | Calendar 263 | E-commerce 264 | Examples 265 | 266 | 267 |
268 | ); 269 | }; 270 | RTL.storyName = 'rtl'; 271 | 272 | export const RootStyles: ComponentStory = () => { 273 | return ( 274 |
281 | 287 | 288 | Documentation 289 | Calendar 290 | E-commerce 291 | Examples 292 | 293 | 294 |
295 | ); 296 | }; 297 | RootStyles.storyName = 'rootStyles'; 298 | -------------------------------------------------------------------------------- /storybook/Playground.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Sidebar, Menu, MenuItem, SubMenu, menuClasses, MenuItemStyles } from '../src'; 3 | import { Switch } from './components/Switch'; 4 | import { SidebarHeader } from './components/SidebarHeader'; 5 | import { Diamond } from './icons/Diamond'; 6 | import { BarChart } from './icons/BarChart'; 7 | import { Global } from './icons/Global'; 8 | import { InkBottle } from './icons/InkBottle'; 9 | import { Book } from './icons/Book'; 10 | import { Calendar } from './icons/Calendar'; 11 | import { ShoppingCart } from './icons/ShoppingCart'; 12 | import { Service } from './icons/Service'; 13 | import { SidebarFooter } from './components/SidebarFooter'; 14 | import { Badge } from './components/Badge'; 15 | import { Typography } from './components/Typography'; 16 | import { PackageBadges } from './components/PackageBadges'; 17 | 18 | type Theme = 'light' | 'dark'; 19 | 20 | const themes = { 21 | light: { 22 | sidebar: { 23 | backgroundColor: '#ffffff', 24 | color: '#607489', 25 | }, 26 | menu: { 27 | menuContent: '#fbfcfd', 28 | icon: '#0098e5', 29 | hover: { 30 | backgroundColor: '#c5e4ff', 31 | color: '#44596e', 32 | }, 33 | disabled: { 34 | color: '#9fb6cf', 35 | }, 36 | }, 37 | }, 38 | dark: { 39 | sidebar: { 40 | backgroundColor: '#0b2948', 41 | color: '#8ba1b7', 42 | }, 43 | menu: { 44 | menuContent: '#082440', 45 | icon: '#59d0ff', 46 | hover: { 47 | backgroundColor: '#00458b', 48 | color: '#b6c8d9', 49 | }, 50 | disabled: { 51 | color: '#3e5e7e', 52 | }, 53 | }, 54 | }, 55 | }; 56 | 57 | // hex to rgba converter 58 | const hexToRgba = (hex: string, alpha: number) => { 59 | const r = parseInt(hex.slice(1, 3), 16); 60 | const g = parseInt(hex.slice(3, 5), 16); 61 | const b = parseInt(hex.slice(5, 7), 16); 62 | 63 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 64 | }; 65 | 66 | export const Playground: React.FC = () => { 67 | const [collapsed, setCollapsed] = React.useState(false); 68 | const [toggled, setToggled] = React.useState(false); 69 | const [broken, setBroken] = React.useState(false); 70 | const [rtl, setRtl] = React.useState(false); 71 | const [hasImage, setHasImage] = React.useState(false); 72 | const [theme, setTheme] = React.useState('light'); 73 | 74 | // handle on RTL change event 75 | const handleRTLChange = (e: React.ChangeEvent) => { 76 | setRtl(e.target.checked); 77 | }; 78 | 79 | // handle on theme change event 80 | const handleThemeChange = (e: React.ChangeEvent) => { 81 | setTheme(e.target.checked ? 'dark' : 'light'); 82 | }; 83 | 84 | // handle on image change event 85 | const handleImageChange = (e: React.ChangeEvent) => { 86 | setHasImage(e.target.checked); 87 | }; 88 | 89 | const menuItemStyles: MenuItemStyles = { 90 | root: { 91 | fontSize: '13px', 92 | fontWeight: 400, 93 | }, 94 | icon: { 95 | color: themes[theme].menu.icon, 96 | [`&.${menuClasses.disabled}`]: { 97 | color: themes[theme].menu.disabled.color, 98 | }, 99 | }, 100 | SubMenuExpandIcon: { 101 | color: '#b6b7b9', 102 | }, 103 | subMenuContent: ({ level }) => ({ 104 | backgroundColor: 105 | level === 0 106 | ? hexToRgba(themes[theme].menu.menuContent, hasImage && !collapsed ? 0.4 : 1) 107 | : 'transparent', 108 | }), 109 | button: { 110 | [`&.${menuClasses.disabled}`]: { 111 | color: themes[theme].menu.disabled.color, 112 | }, 113 | '&:hover': { 114 | backgroundColor: hexToRgba(themes[theme].menu.hover.backgroundColor, hasImage ? 0.8 : 1), 115 | color: themes[theme].menu.hover.color, 116 | }, 117 | }, 118 | label: ({ open }) => ({ 119 | fontWeight: open ? 600 : undefined, 120 | }), 121 | }; 122 | 123 | return ( 124 |
125 | setToggled(false)} 129 | onBreakPoint={setBroken} 130 | image="https://user-images.githubusercontent.com/25878302/144499035-2911184c-76d3-4611-86e7-bc4e8ff84ff5.jpg" 131 | rtl={rtl} 132 | breakPoint="md" 133 | backgroundColor={hexToRgba(themes[theme].sidebar.backgroundColor, hasImage ? 0.9 : 1)} 134 | rootStyles={{ 135 | color: themes[theme].sidebar.color, 136 | }} 137 | > 138 |
139 | 140 |
141 |
142 | 147 | General 148 | 149 |
150 | 151 | } 154 | suffix={ 155 | 156 | 6 157 | 158 | } 159 | > 160 | Pie charts 161 | Line charts 162 | Bar charts 163 | 164 | }> 165 | Google maps 166 | Open street maps 167 | 168 | }> 169 | Dark 170 | Light 171 | 172 | }> 173 | Grid 174 | Layout 175 | 176 | Input 177 | Select 178 | 179 | CheckBox 180 | Radio 181 | 182 | 183 | 184 | }> 185 | Product 186 | Orders 187 | Credit card 188 | 189 | 190 | 191 |
192 | 197 | Extra 198 | 199 |
200 | 201 | 202 | } suffix={New}> 203 | Calendar 204 | 205 | }>Documentation 206 | }> 207 | Examples 208 | 209 | 210 |
211 | 212 |
213 |
214 | 215 |
216 |
217 |
218 | {broken && ( 219 | 222 | )} 223 |
224 |
225 | 226 | React Pro Sidebar 227 | 228 | 229 | React Pro Sidebar provides a set of components for creating high level and 230 | customizable side navigation 231 | 232 | 233 |
234 | 235 |
236 |
237 | setCollapsed(!collapsed)} 241 | label="Collapse" 242 | /> 243 |
244 | 245 |
246 | 247 |
248 | 249 |
250 | 256 |
257 | 258 |
259 | 260 |
261 |
262 |
263 |
264 |
265 | ); 266 | }; 267 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { CSSObject } from '@emotion/styled'; 3 | import classnames from 'classnames'; 4 | import { useLegacySidebar } from '../hooks/useLegacySidebar'; 5 | import { useMediaQuery } from '../hooks/useMediaQuery'; 6 | import { sidebarClasses } from '../utils/utilityClasses'; 7 | import { StyledBackdrop } from '../styles/StyledBackdrop'; 8 | 9 | type BreakPoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'always' | 'all'; 10 | 11 | const BREAK_POINTS = { 12 | xs: '480px', 13 | sm: '576px', 14 | md: '768px', 15 | lg: '992px', 16 | xl: '1200px', 17 | xxl: '1600px', 18 | always: 'always', 19 | all: 'all', 20 | }; 21 | 22 | export interface SidebarProps extends React.HTMLAttributes { 23 | /** 24 | * sidebar collapsed status 25 | */ 26 | collapsed?: boolean; 27 | 28 | /** 29 | * width of the sidebar 30 | * @default ```250px``` 31 | */ 32 | width?: string; 33 | 34 | /** 35 | * width of the sidebar when collapsed 36 | * @default ```80px``` 37 | */ 38 | collapsedWidth?: string; 39 | 40 | /** 41 | * initial collapsed status 42 | * @default ```false``` 43 | * 44 | * @deprecated use ```collapsed``` instead 45 | */ 46 | defaultCollapsed?: boolean; 47 | 48 | /** 49 | * set when the sidebar should trigger responsiveness behavior 50 | * @type `xs | sm | md | lg | xl | xxl | all | undefined` 51 | */ 52 | breakPoint?: BreakPoint; 53 | 54 | /** 55 | * alternative breakpoint value that will be used to trigger responsiveness 56 | * 57 | */ 58 | customBreakPoint?: string; 59 | 60 | /** 61 | * sidebar background color 62 | */ 63 | backgroundColor?: string; 64 | 65 | /** 66 | * duration for the transition in milliseconds to be used in collapse and toggle behavior 67 | * @default ```300``` 68 | */ 69 | transitionDuration?: number; 70 | 71 | /** 72 | * sidebar background image 73 | */ 74 | image?: string; 75 | 76 | /** 77 | * sidebar direction 78 | */ 79 | rtl?: boolean; 80 | 81 | /** 82 | * sidebar toggled status 83 | */ 84 | toggled?: boolean; 85 | 86 | /** 87 | * callback function to be called when backdrop is clicked 88 | */ 89 | onBackdropClick?: () => void; 90 | 91 | /** 92 | * callback function to be called when sidebar's broken state changes 93 | */ 94 | onBreakPoint?: (broken: boolean) => void; 95 | 96 | /** 97 | * sidebar styles to be applied from the root element 98 | */ 99 | rootStyles?: CSSObject; 100 | 101 | children?: React.ReactNode; 102 | } 103 | 104 | interface StyledSidebarProps extends Omit { 105 | collapsed?: boolean; 106 | toggled?: boolean; 107 | broken?: boolean; 108 | rtl?: boolean; 109 | } 110 | 111 | type StyledSidebarContainerProps = Pick; 112 | 113 | const StyledSidebar = styled.aside` 114 | position: relative; 115 | border-right-width: 1px; 116 | border-right-style: solid; 117 | border-color: #efefef; 118 | 119 | transition: ${({ transitionDuration }) => `width, left, right, ${transitionDuration}ms`}; 120 | 121 | width: ${({ width }) => width}; 122 | min-width: ${({ width }) => width}; 123 | 124 | &.${sidebarClasses.collapsed} { 125 | width: ${({ collapsedWidth }) => collapsedWidth}; 126 | min-width: ${({ collapsedWidth }) => collapsedWidth}; 127 | } 128 | 129 | &.${sidebarClasses.rtl} { 130 | direction: rtl; 131 | border-right-width: none; 132 | border-left-width: 1px; 133 | border-right-style: none; 134 | border-left-style: solid; 135 | } 136 | 137 | &.${sidebarClasses.broken} { 138 | position: fixed; 139 | height: 100%; 140 | top: 0px; 141 | z-index: 100; 142 | 143 | ${({ rtl, width }) => (!rtl ? `left: -${width};` : '')} 144 | 145 | &.${sidebarClasses.collapsed} { 146 | ${({ rtl, collapsedWidth }) => (!rtl ? `left: -${collapsedWidth}; ` : '')} 147 | } 148 | 149 | &.${sidebarClasses.toggled} { 150 | ${({ rtl }) => (!rtl ? `left: 0;` : '')} 151 | } 152 | 153 | &.${sidebarClasses.rtl} { 154 | right: -${({ width }) => width}; 155 | 156 | &.${sidebarClasses.collapsed} { 157 | right: -${({ collapsedWidth }) => collapsedWidth}; 158 | } 159 | 160 | &.${sidebarClasses.toggled} { 161 | right: 0; 162 | } 163 | } 164 | } 165 | 166 | ${({ rootStyles }) => rootStyles} 167 | `; 168 | 169 | const StyledSidebarContainer = styled.div` 170 | position: relative; 171 | height: 100%; 172 | overflow-y: auto; 173 | overflow-x: hidden; 174 | z-index: 3; 175 | 176 | ${({ backgroundColor }) => (backgroundColor ? `background-color:${backgroundColor};` : '')} 177 | `; 178 | 179 | const StyledSidebarImage = styled.img` 180 | &.${sidebarClasses.image} { 181 | width: 100%; 182 | height: 100%; 183 | object-fit: cover; 184 | object-position: center; 185 | position: absolute; 186 | left: 0; 187 | top: 0; 188 | z-index: 2; 189 | } 190 | `; 191 | 192 | interface SidebarContextProps { 193 | collapsed?: boolean; 194 | toggled?: boolean; 195 | rtl?: boolean; 196 | transitionDuration?: number; 197 | } 198 | 199 | export const SidebarContext = React.createContext({ 200 | collapsed: false, 201 | toggled: false, 202 | rtl: false, 203 | transitionDuration: 300, 204 | }); 205 | 206 | export const Sidebar = React.forwardRef( 207 | ( 208 | { 209 | collapsed, 210 | toggled, 211 | onBackdropClick, 212 | onBreakPoint, 213 | width = '250px', 214 | collapsedWidth = '80px', 215 | defaultCollapsed, 216 | className, 217 | children, 218 | breakPoint, 219 | customBreakPoint, 220 | backgroundColor = 'rgb(249, 249, 249, 0.7)', 221 | transitionDuration = 300, 222 | image, 223 | rtl, 224 | rootStyles, 225 | ...rest 226 | }, 227 | ref, 228 | ) => { 229 | const getBreakpointValue = () => { 230 | if (customBreakPoint) { 231 | return `(max-width: ${customBreakPoint})`; 232 | } 233 | 234 | if (breakPoint) { 235 | if (['xs', 'sm', 'md', 'lg', 'xl', 'xxl'].includes(breakPoint)) { 236 | return `(max-width: ${BREAK_POINTS[breakPoint]})`; 237 | } 238 | 239 | if (breakPoint === 'always' || breakPoint === 'all') { 240 | if (breakPoint === 'always') { 241 | console.warn( 242 | 'The "always" breakPoint is deprecated and will be removed in future release. ' + 243 | 'Please use the "all" breakPoint instead.', 244 | ); 245 | } 246 | return `screen`; 247 | } 248 | 249 | return `(max-width: ${breakPoint})`; 250 | } 251 | }; 252 | 253 | const breakpointCallbackFnRef = React.useRef<(broken: boolean) => void>(); 254 | 255 | breakpointCallbackFnRef.current = (broken: boolean) => { 256 | onBreakPoint?.(broken); 257 | }; 258 | 259 | const broken = useMediaQuery(getBreakpointValue()); 260 | 261 | const [mounted, setMounted] = React.useState(false); 262 | 263 | const legacySidebarContext = useLegacySidebar(); 264 | 265 | const collapsedValue = 266 | collapsed ?? (!mounted && defaultCollapsed ? true : legacySidebarContext?.collapsed); 267 | const toggledValue = toggled ?? legacySidebarContext?.toggled; 268 | 269 | const handleBackdropClick = () => { 270 | onBackdropClick?.(); 271 | legacySidebarContext?.updateSidebarState({ toggled: false }); 272 | }; 273 | 274 | React.useEffect(() => { 275 | breakpointCallbackFnRef.current?.(broken); 276 | }, [broken]); 277 | 278 | // TODO: remove in next major version 279 | React.useEffect(() => { 280 | legacySidebarContext?.updateSidebarState({ broken, rtl, transitionDuration }); 281 | 282 | // eslint-disable-next-line react-hooks/exhaustive-deps 283 | }, [broken, legacySidebarContext?.updateSidebarState, rtl, transitionDuration]); 284 | 285 | // TODO: remove in next major version 286 | React.useEffect(() => { 287 | if (!mounted) { 288 | legacySidebarContext?.updateSidebarState({ 289 | collapsed: defaultCollapsed, 290 | }); 291 | setMounted(true); 292 | } 293 | 294 | // eslint-disable-next-line react-hooks/exhaustive-deps 295 | }, [defaultCollapsed, mounted, legacySidebarContext?.updateSidebarState]); 296 | 297 | return ( 298 | 301 | 321 | 326 | {children} 327 | 328 | 329 | {image && ( 330 | 336 | )} 337 | 338 | {broken && toggledValue && ( 339 | 348 | )} 349 | 350 | 351 | ); 352 | }, 353 | ); 354 | -------------------------------------------------------------------------------- /storybook/stories/SubMenu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Menu, menuClasses, MenuItem, Sidebar, SubMenu } from '../../src'; 4 | import { Icon } from '../icons/Icon'; 5 | 6 | const StoryParams: ComponentMeta = { 7 | title: 'SubMenu', 8 | component: SubMenu, 9 | argTypes: {}, 10 | }; 11 | 12 | export default StoryParams; 13 | 14 | export const Basic: ComponentStory = ({ ...props }) => ( 15 |
16 | 17 | 18 | 19 | Pie charts 20 | Line charts 21 | Bar charts 22 | 23 | 24 | Google maps 25 | Open street maps 26 | 27 | 28 | Dark 29 | Light 30 | 31 | 32 | 33 |
34 | ); 35 | 36 | Basic.parameters = { 37 | docs: { 38 | source: { 39 | code: ` 40 | import { Sidebar, Menu, MenuItem, SubMenu } from 'react-pro-sidebar'; 41 | 42 | () => ( 43 |
44 | 45 | 46 | 47 | Pie charts 48 | Line charts 49 | Bar charts 50 | 51 | 52 | Google maps 53 | Open street maps 54 | 55 | 56 | Dark 57 | Light 58 | 59 | 60 | 61 |
62 | )`, 63 | }, 64 | }, 65 | }; 66 | 67 | export const WithIcon: ComponentStory = () => ( 68 |
69 | 70 | 71 | 72 | } label="Charts"> 73 | Pie charts 74 | Line charts 75 | Bar charts 76 | 77 | } label="Maps"> 78 | Google maps 79 | Open street maps 80 | 81 | } label="Theme"> 82 | Dark 83 | Light 84 | 85 | 86 | 87 | 88 |
89 | ); 90 | 91 | WithIcon.storyName = 'icon'; 92 | 93 | export const Prefix: ComponentStory = () => ( 94 |
95 | 96 | 97 | 98 | 99 | Pie charts 100 | Line charts 101 | Bar charts 102 | 103 | 104 | Google maps 105 | Open street maps 106 | 107 | 108 | Dark 109 | Light 110 | 111 | 112 | 113 | 114 |
115 | ); 116 | Prefix.storyName = 'prefix'; 117 | 118 | export const Suffix: ComponentStory = () => ( 119 |
120 | 121 | 122 | 123 | 124 | Pie charts 125 | Line charts 126 | Bar charts 127 | 128 | 129 | Google maps 130 | Open street maps 131 | 132 | 133 | Dark 134 | Light 135 | 136 | 137 | 138 | 139 |
140 | ); 141 | Suffix.storyName = 'suffix'; 142 | 143 | export const Active: ComponentStory = () => ( 144 |
145 | 146 | 147 | 148 | 149 | Pie charts 150 | Line charts 151 | Bar charts 152 | 153 | 154 | Google maps 155 | Open street maps 156 | 157 | 158 | Dark 159 | Light 160 | 161 | 162 | 163 | 164 |
165 | ); 166 | Active.storyName = 'active'; 167 | 168 | export const Disabled: ComponentStory = () => ( 169 |
170 | 171 | 172 | 173 | 174 | Pie charts 175 | Line charts 176 | Bar charts 177 | 178 | 179 | Google maps 180 | Open street maps 181 | 182 | 183 | Dark 184 | Light 185 | 186 | 187 | 188 | 189 |
190 | ); 191 | Disabled.storyName = 'disabled'; 192 | 193 | export const Component: ComponentStory = () => ( 194 |
195 | 196 | 197 | 198 | Pie charts 199 | Line charts 200 | Bar charts 201 | 202 | 203 | 204 |
205 | ); 206 | Component.storyName = 'component'; 207 | 208 | export const DefaultOpen: ComponentStory = () => ( 209 |
210 | 211 | 212 | 213 | 214 | Pie charts 215 | Line charts 216 | Bar charts 217 | 218 | 219 | Google maps 220 | Open street maps 221 | 222 | 223 | Dark 224 | Light 225 | 226 | 227 | 228 | 229 |
230 | ); 231 | DefaultOpen.storyName = 'defaultOpen'; 232 | 233 | export const Open: ComponentStory = () => { 234 | const [open, setOpen] = React.useState<'charts' | 'maps' | 'theme' | undefined>(); 235 | 236 | const handleOpenSubMenu = (key: 'charts' | 'maps' | 'theme') => { 237 | if (open === key) { 238 | setOpen(undefined); 239 | } else { 240 | setOpen(key); 241 | } 242 | }; 243 | 244 | return ( 245 |
246 | 247 | 248 | 249 | handleOpenSubMenu('charts')} 251 | open={open === 'charts'} 252 | label="Charts" 253 | > 254 | Pie charts 255 | Line charts 256 | Bar charts 257 | 258 | handleOpenSubMenu('maps')} open={open === 'maps'} label="Maps"> 259 | Google maps 260 | Open street maps 261 | 262 | handleOpenSubMenu('theme')} 264 | open={open === 'theme'} 265 | label="Theme" 266 | > 267 | Dark 268 | Light 269 | 270 | 271 | 272 | 273 |
274 | ); 275 | }; 276 | Open.storyName = 'open'; 277 | 278 | export const RootStyles: ComponentStory = () => ( 279 |
280 | 281 | 282 | 283 | .' + menuClasses.button]: { 288 | backgroundColor: '#eaabff', 289 | color: '#9f0099', 290 | '&:hover': { 291 | backgroundColor: '#eecef9', 292 | }, 293 | }, 294 | ['.' + menuClasses.subMenuContent]: { 295 | backgroundColor: '#fbedff', 296 | }, 297 | }} 298 | > 299 | Pie charts 300 | Line charts 301 | Bar charts 302 | 303 | 304 | Google maps 305 | Open street maps 306 | 307 | 308 | Dark 309 | Light 310 | 311 | 312 | 313 | 314 |
315 | ); 316 | RootStyles.storyName = 'rootStyles'; 317 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [React Pro Sidebar](https://www.npmjs.com/package/react-pro-sidebar) 2 | 3 | [![npm][version]][npm-url] 4 | [![License][license]][npm-url] 5 | [![Peer][peer]][npm-url] 6 | [![Download][download]][npm-url] 7 | [![Stars][stars]][github-url] 8 | 9 | [version]: https://img.shields.io/npm/v/react-pro-sidebar.svg?style=flat-square 10 | [license]: https://img.shields.io/github/license/azouaoui-med/react-pro-sidebar?style=flat-square 11 | [peer]: https://img.shields.io/npm/dependency-version/react-pro-sidebar/peer/react?style=flat-square 12 | [download]: https://img.shields.io/npm/dt/react-pro-sidebar?style=flat-square 13 | [stars]: https://img.shields.io/github/stars/azouaoui-med/react-pro-sidebar?style=social 14 | [npm-url]: https://www.npmjs.com/package/react-pro-sidebar 15 | [github-url]: https://github.com/azouaoui-med/react-pro-sidebar 16 | 17 | React Pro Sidebar provides a set of components for creating high level and customizable side navigation 18 | 19 | ## Old versions 20 | 21 | - [v0.x](https://github.com/azouaoui-med/react-pro-sidebar/tree/v0.x) 22 | 23 | ## Live Preview 24 | 25 | - [Demo](https://azouaoui-med.github.io/react-pro-sidebar/iframe.html?id=playground--playground&args=&viewMode=story) 26 | 27 | - [Storybook](https://azouaoui-med.github.io/react-pro-sidebar/?path=/docs/sidebar--basic) 28 | 29 | ## Screenshot 30 | 31 | ![react-pro-sidebar](https://user-images.githubusercontent.com/25878302/212479928-553c2d37-793b-4bcd-ac53-352f26337955.jpg) 32 | 33 | ## Installation 34 | 35 | ### yarn 36 | 37 | ```bash 38 | yarn add react-pro-sidebar 39 | ``` 40 | 41 | ### npm 42 | 43 | ```bash 44 | npm install react-pro-sidebar 45 | ``` 46 | 47 | ## Usage 48 | 49 | ```jsx 50 | import { Sidebar, Menu, MenuItem, SubMenu } from 'react-pro-sidebar'; 51 | 52 | 53 | 54 | 55 | Pie charts 56 | Line charts 57 | 58 | Documentation 59 | Calendar 60 | 61 | ; 62 | ``` 63 | 64 | ## Using React Router 65 | 66 | You can make use of the `component` prop to integrate [React Router](https://reactrouter.com/en/main) link 67 | 68 | **Example Usage** 69 | 70 | ```jsx 71 | import { Sidebar, Menu, MenuItem } from 'react-pro-sidebar'; 72 | import { Link } from 'react-router-dom'; 73 | 74 | 75 | 87 | }> Documentation 88 | }> Calendar 89 | }> E-commerce 90 | 91 | ; 92 | ``` 93 | 94 | ## Customization 95 | 96 | We provide for each component `rootStyles` prop that can be used to customize the styles 97 | 98 | its recommended using utility classes (`sidebarClasses`, `menuClasses`) for selecting target child nodes 99 | 100 | **Example usage** 101 | 102 | ```jsx 103 | 110 | // ... 111 | 112 | ``` 113 | 114 | For `Menu` component, in addition to `rootStyles` you can also use `menuItemStyles` prop for customizing all `MenuItem` & `SubMenu` components and their children 115 | 116 | **Type definition** 117 | 118 | ```jsx 119 | interface MenuItemStyles { 120 | root?: ElementStyles; 121 | button?: ElementStyles; 122 | label?: ElementStyles; 123 | prefix?: ElementStyles; 124 | suffix?: ElementStyles; 125 | icon?: ElementStyles; 126 | subMenuContent?: ElementStyles; 127 | SubMenuExpandIcon?: ElementStyles; 128 | } 129 | 130 | type ElementStyles = CSSObject | ((params: MenuItemStylesParams) => CSSObject | undefined); 131 | ``` 132 | 133 | **Example usage** 134 | 135 | ```jsx 136 | 137 | { 140 | // only apply styles on first level elements of the tree 141 | if (level === 0) 142 | return { 143 | color: disabled ? '#f5d9ff' : '#d359ff', 144 | backgroundColor: active ? '#eecef9' : undefined, 145 | }; 146 | }, 147 | }} 148 | > 149 | //... 150 | 151 | 152 | ``` 153 | 154 | ## API 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 |
ComponentPropTypeDescriptionDefault
SidebardefaultCollapsedbooleanInitial collapsed statusfalse
collapsedbooleanSidebar collapsed statefalse
toggledbooleanSidebar toggled statefalse
widthnumber | stringWidth of the sidebar270px
collapsedWidthnumber | stringWidth of the sidebar on collapsed state80px
backgroundColorstringSet background color for sidebarrgb(249, 249, 249, 0.7)
imagestringUrl of the image to use in the sidebar background, need to apply transparency to background color -
breakPointxs | sm | md | lg | xl | xxl | allSet when the sidebar should trigger responsiveness behavior -
customBreakPointstringSet custom breakpoint value, this will override breakPoint prop -
transitionDurationnumberDuration for the transition in milliseconds to be used in collapse and toggle behavior300
rtlbooleanRTL directionfalse
rootStylesCSSObjectApply styles to sidebar element-
onBackdropClick() => voidCallback function to be called when backdrop is clicked-
MenucloseOnClickbooleanIf true and sidebar is in collapsed state, submenu popper will automatically close on MenuItem clickfalse
menuItemStylesMenuItemStylesApply styles to MenuItem and SubMenu components and their children -
renderExpandIcon(params: { level: number; collapsed: boolean; disabled: boolean; active: boolean; open: boolean; }) => React.ReactNodeRender method for customizing submenu expand icon-
transitionDurationnumberTransition duration in milliseconds to use when sliding submenu content300
rootStylesCSSObjectApply styles from Menu root element-
MenuItemiconReactNodeIcon for the menu item -
activebooleanIf true, the component is activefalse
disabledbooleanIf true, the component is disabled -
prefixReactNodeAdd a prefix to the menuItem -
suffixReactNodeAdd a suffix to the menuItem -
componentstring | ReactElementA component used for menu button node, can be string (ex: 'div') or a component -
rootStylesCSSObjectApply styles to MenuItem element-
SubMenulabelstring | ReactNodeLabel for the submenu -
iconReactNodeIcon for submenu-
defaultOpenbooleanSet if the submenu is open by defaultfalse
openbooleanSet open value if you want to control the state-
activebooleanIf true, the component is activefalse
disabledbooleanIf true, the component is disabled -
prefixReactNodeAdd a prefix to the submenu -
suffixReactNodeAdd a suffix to the submenu -
onOpenChange(open: boolean) => voidCallback function called when submenu state changes-
componentstring | React.ReactElementA component used for menu button node, can be string (ex: 'div') or a component -
rootStylesCSSObjectApply styles to SubMenu element-
390 | 391 | ## License 392 | 393 | MIT © [Mohamed Azouaoui](https://azouaoui.netlify.app) 394 | -------------------------------------------------------------------------------- /src/components/SubMenu.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-expressions */ 2 | import React from 'react'; 3 | import styled, { CSSObject } from '@emotion/styled'; 4 | import classnames from 'classnames'; 5 | import { SubMenuContent } from './SubMenuContent'; 6 | import { StyledMenuLabel } from '../styles/StyledMenuLabel'; 7 | import { StyledMenuIcon } from '../styles/StyledMenuIcon'; 8 | import { StyledMenuPrefix } from '../styles/StyledMenuPrefix'; 9 | import { useMenu } from '../hooks/useMenu'; 10 | import { StyledMenuSuffix } from '../styles/StyledMenuSuffix'; 11 | import { menuClasses } from '../utils/utilityClasses'; 12 | import { 13 | StyledExpandIcon, 14 | StyledExpandIconCollapsed, 15 | StyledExpandIconWrapper, 16 | } from '../styles/StyledExpandIcon'; 17 | import { usePopper } from '../hooks/usePopper'; 18 | import { MenuButton, menuButtonStyles } from './MenuButton'; 19 | import { SidebarContext } from './Sidebar'; 20 | import { LevelContext } from './Menu'; 21 | 22 | export interface SubMenuProps 23 | extends Omit, 'prefix'> { 24 | /** 25 | * The label to be displayed in the menu item 26 | */ 27 | label?: string | React.ReactNode; 28 | 29 | /** 30 | * The icon to be displayed in the menu item 31 | */ 32 | icon?: React.ReactNode; 33 | 34 | /** 35 | * The prefix to be displayed in the menu item 36 | */ 37 | prefix?: React.ReactNode; 38 | 39 | /** 40 | * The suffix to be displayed in the menu item 41 | */ 42 | suffix?: React.ReactNode; 43 | 44 | /** 45 | * set open value to control the open state of the sub menu 46 | */ 47 | open?: boolean; 48 | 49 | /** 50 | * set defaultOpen value to set the initial open state of the sub menu 51 | */ 52 | defaultOpen?: boolean; 53 | 54 | /** 55 | * If set to true, the menu item will have an active state 56 | */ 57 | active?: boolean; 58 | 59 | /** 60 | * If set to true, the menu item will be disabled 61 | */ 62 | disabled?: boolean; 63 | 64 | /** 65 | * The component to be rendered as the menu item button 66 | */ 67 | component?: string | React.ReactElement; 68 | 69 | /** 70 | * Apply styles from the root element 71 | */ 72 | rootStyles?: CSSObject; 73 | 74 | /** 75 | * callback function to be called when the open state of the sub menu changes 76 | * @param open 77 | */ 78 | onOpenChange?: (open: boolean) => void; 79 | 80 | children?: React.ReactNode; 81 | } 82 | 83 | interface StyledSubMenuProps extends Pick { 84 | level: number; 85 | menuItemStyles?: CSSObject; 86 | collapsed?: boolean; 87 | rtl?: boolean; 88 | buttonStyles?: CSSObject; 89 | } 90 | 91 | type MenuItemElement = 92 | | 'root' 93 | | 'button' 94 | | 'label' 95 | | 'prefix' 96 | | 'suffix' 97 | | 'icon' 98 | | 'subMenuContent' 99 | | 'SubMenuExpandIcon'; 100 | 101 | const StyledSubMenu = styled.li` 102 | position: relative; 103 | width: 100%; 104 | 105 | ${({ menuItemStyles }) => menuItemStyles}; 106 | 107 | ${({ rootStyles }) => rootStyles}; 108 | 109 | > .${menuClasses.button} { 110 | ${({ level, disabled, active, collapsed, rtl }) => 111 | menuButtonStyles({ 112 | level, 113 | disabled, 114 | active, 115 | collapsed, 116 | rtl, 117 | })}; 118 | 119 | ${({ buttonStyles }) => buttonStyles}; 120 | } 121 | `; 122 | 123 | export const SubMenuFR: React.ForwardRefRenderFunction = ( 124 | { 125 | children, 126 | className, 127 | label, 128 | icon, 129 | title, 130 | prefix, 131 | suffix, 132 | open: openControlled, 133 | defaultOpen, 134 | active = false, 135 | disabled = false, 136 | rootStyles, 137 | component, 138 | onOpenChange, 139 | onClick, 140 | onKeyUp, 141 | ...rest 142 | }, 143 | ref, 144 | ) => { 145 | const level = React.useContext(LevelContext); 146 | 147 | const { 148 | collapsed, 149 | rtl, 150 | transitionDuration: sidebarTransitionDuration, 151 | } = React.useContext(SidebarContext); 152 | const { renderExpandIcon, closeOnClick, menuItemStyles, transitionDuration } = useMenu(); 153 | 154 | const [open, setOpen] = React.useState(!!defaultOpen); 155 | const [openWhenCollapsed, setOpenWhenCollapsed] = React.useState(false); 156 | const [mounted, setMounted] = React.useState(false); 157 | 158 | const buttonRef = React.useRef(null); 159 | const contentRef = React.useRef(null); 160 | const timer = React.useRef>(); 161 | 162 | const { popperInstance } = usePopper({ 163 | level, 164 | buttonRef, 165 | contentRef, 166 | }); 167 | 168 | const expandContent = React.useCallback(() => { 169 | const target = contentRef.current; 170 | if (target) { 171 | const height = target?.querySelector(`.${menuClasses.subMenuContent} > ul`)?.clientHeight; 172 | target.style.overflow = 'hidden'; 173 | target.style.height = `${height}px`; 174 | 175 | timer.current = setTimeout(() => { 176 | target.style.overflow = 'auto'; 177 | target.style.height = 'auto'; 178 | }, transitionDuration); 179 | } 180 | }, [transitionDuration]); 181 | 182 | const collapseContent = () => { 183 | const target = contentRef.current; 184 | 185 | if (target) { 186 | const height = target?.querySelector(`.${menuClasses.subMenuContent} > ul`)?.clientHeight; 187 | target.style.overflow = 'hidden'; 188 | target.style.height = `${height}px`; 189 | target.offsetHeight; 190 | target.style.height = '0px'; 191 | } 192 | }; 193 | 194 | const handleSlideToggle = (): void => { 195 | if (!(level === 0 && collapsed)) { 196 | if (typeof openControlled === 'undefined') { 197 | clearTimeout(Number(timer.current)); 198 | open ? collapseContent() : expandContent(); 199 | onOpenChange?.(!open); 200 | setOpen(!open); 201 | } else { 202 | onOpenChange?.(!openControlled); 203 | } 204 | } 205 | }; 206 | 207 | React.useEffect(() => { 208 | if (!(level === 0 && collapsed) && typeof openControlled !== 'undefined' && mounted) { 209 | clearTimeout(Number(timer.current)); 210 | !openControlled ? collapseContent() : expandContent(); 211 | } 212 | // eslint-disable-next-line react-hooks/exhaustive-deps 213 | }, [collapsed, expandContent, label, level, onOpenChange, openControlled]); 214 | 215 | const handleOnClick = (event: React.MouseEvent) => { 216 | onClick?.(event); 217 | handleSlideToggle(); 218 | }; 219 | 220 | const handleOnKeyUp = (event: React.KeyboardEvent) => { 221 | onKeyUp?.(event); 222 | if (event.key === 'Enter') { 223 | handleSlideToggle(); 224 | } 225 | }; 226 | 227 | const getSubMenuItemStyles = (element: MenuItemElement): CSSObject | undefined => { 228 | if (menuItemStyles) { 229 | const params = { level, disabled, active, isSubmenu: true, open: openControlled ?? open }; 230 | const { 231 | root: rootElStyles, 232 | button: buttonElStyles, 233 | label: labelElStyles, 234 | icon: iconElStyles, 235 | prefix: prefixElStyles, 236 | suffix: suffixElStyles, 237 | subMenuContent: subMenuContentElStyles, 238 | SubMenuExpandIcon: SubMenuExpandIconElStyles, 239 | } = menuItemStyles; 240 | 241 | switch (element) { 242 | case 'root': 243 | return typeof rootElStyles === 'function' ? rootElStyles(params) : rootElStyles; 244 | 245 | case 'button': 246 | return typeof buttonElStyles === 'function' ? buttonElStyles(params) : buttonElStyles; 247 | 248 | case 'label': 249 | return typeof labelElStyles === 'function' ? labelElStyles(params) : labelElStyles; 250 | 251 | case 'icon': 252 | return typeof iconElStyles === 'function' ? iconElStyles(params) : iconElStyles; 253 | 254 | case 'prefix': 255 | return typeof prefixElStyles === 'function' ? prefixElStyles(params) : prefixElStyles; 256 | 257 | case 'suffix': 258 | return typeof suffixElStyles === 'function' ? suffixElStyles(params) : suffixElStyles; 259 | 260 | case 'SubMenuExpandIcon': 261 | return typeof SubMenuExpandIconElStyles === 'function' 262 | ? SubMenuExpandIconElStyles(params) 263 | : SubMenuExpandIconElStyles; 264 | 265 | case 'subMenuContent': 266 | return typeof subMenuContentElStyles === 'function' 267 | ? subMenuContentElStyles(params) 268 | : subMenuContentElStyles; 269 | 270 | default: 271 | return undefined; 272 | } 273 | } 274 | }; 275 | 276 | React.useEffect(() => { 277 | setTimeout(() => popperInstance?.update(), sidebarTransitionDuration); 278 | if (collapsed && level === 0) { 279 | setOpenWhenCollapsed(false); 280 | // ? if its useful to close first level submenus on collapse sidebar uncomment the code below 281 | // setOpen(false); 282 | } 283 | }, [collapsed, level, rtl, sidebarTransitionDuration, popperInstance]); 284 | 285 | React.useEffect(() => { 286 | const handleTogglePopper = (target: Node) => { 287 | if (!openWhenCollapsed && buttonRef.current?.contains(target)) setOpenWhenCollapsed(true); 288 | else if ( 289 | (closeOnClick && 290 | !(target as HTMLElement) 291 | .closest(`.${menuClasses.menuItemRoot}`) 292 | ?.classList.contains(menuClasses.subMenuRoot)) || 293 | (!contentRef.current?.contains(target) && openWhenCollapsed) 294 | ) { 295 | setOpenWhenCollapsed(false); 296 | } 297 | }; 298 | 299 | const handleDocumentClick = (event: MouseEvent) => { 300 | handleTogglePopper(event.target as Node); 301 | }; 302 | 303 | const handleDocumentKeyUp = (event: KeyboardEvent) => { 304 | if (event.key === 'Enter') { 305 | handleTogglePopper(event.target as Node); 306 | } else if (event.key === 'Escape') { 307 | setOpenWhenCollapsed(false); 308 | } 309 | }; 310 | 311 | const removeEventListeners = () => { 312 | document.removeEventListener('click', handleDocumentClick); 313 | document.removeEventListener('keyup', handleDocumentKeyUp); 314 | }; 315 | 316 | removeEventListeners(); 317 | 318 | if (collapsed && level === 0) { 319 | document.addEventListener('click', handleDocumentClick, false); 320 | document.addEventListener('keyup', handleDocumentKeyUp, false); 321 | } 322 | 323 | return () => { 324 | removeEventListeners(); 325 | }; 326 | }, [collapsed, level, closeOnClick, openWhenCollapsed]); 327 | 328 | React.useEffect(() => { 329 | setMounted(true); 330 | }, []); 331 | 332 | const sharedClasses = { 333 | [menuClasses.active]: active, 334 | [menuClasses.disabled]: disabled, 335 | [menuClasses.open]: openControlled ?? open, 336 | }; 337 | 338 | return ( 339 | 356 | 367 | {icon && ( 368 | 373 | {icon} 374 | 375 | )} 376 | 377 | {prefix && ( 378 | 386 | {prefix} 387 | 388 | )} 389 | 390 | 394 | {label} 395 | 396 | 397 | {suffix && ( 398 | 405 | {suffix} 406 | 407 | )} 408 | 409 | 416 | {renderExpandIcon ? ( 417 | renderExpandIcon({ 418 | level, 419 | disabled, 420 | active, 421 | open: openControlled ?? open, 422 | }) 423 | ) : collapsed && level === 0 ? ( 424 | 425 | ) : ( 426 | 427 | )} 428 | 429 | 430 | 431 | 441 | {children} 442 | 443 | 444 | ); 445 | }; 446 | export const SubMenu = React.forwardRef(SubMenuFR); 447 | --------------------------------------------------------------------------------